Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com
at main 482 lines 15 kB view raw view rendered
1# Shared Documents Dashboard Section Implementation Plan 2 3> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 5**Goal:** Add a "Documents Shared with You" section to the dashboard that lists documents the current user is a collaborator on. 6 7**Architecture:** Collaborator relationships are stored as a `collaborators []string` (DIDs) array inside each document record on the owner's PDS. There is no central index of "documents I collaborate on" — we need to persist that mapping in the local SQLite DB when a collaborator accepts an invite. The dashboard then queries that local table to get the list of (ownerDID, rkey) pairs, fetches each document title from the owner's PDS via XRPC, and renders them alongside the user's own documents. 8 9**Tech Stack:** Go 1.22, SQLite (WAL), ATProto XRPC client, `html/template`, gorilla/sessions 10 11--- 12 13### Task 1: Add `collaborations` table via migration 14 15**Files:** 16- Create: `migrations/008_collaborations.sql` 17 18**Step 1: Write the migration** 19 20```sql 21CREATE TABLE IF NOT EXISTS collaborations ( 22 collaborator_did TEXT NOT NULL, 23 owner_did TEXT NOT NULL, 24 document_rkey TEXT NOT NULL, 25 added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 26 PRIMARY KEY (collaborator_did, owner_did, document_rkey) 27); 28``` 29 30**Step 2: Verify migration runs** 31 32Start the server (`go run ./cmd/server`) and confirm in logs: `Applied migration: 008_collaborations.sql`. Stop server. 33 34**Step 3: Commit** 35 36```bash 37git add migrations/008_collaborations.sql 38git commit -m "Add collaborations table to track shared documents" 39``` 40 41--- 42 43### Task 2: Add DB methods for collaborations 44 45**Files:** 46- Modify: `internal/db/db.go` 47 48**Step 1: Write the failing test** 49 50In `internal/db/db_test.go` (create if absent): 51 52```go 53func TestCollaborations(t *testing.T) { 54 d := openTestDB(t) 55 const collabDID = "did:plc:collab" 56 const ownerDID = "did:plc:owner" 57 const rkey = "abc123" 58 59 if err := d.AddCollaboration(collabDID, ownerDID, rkey); err != nil { 60 t.Fatalf("AddCollaboration: %v", err) 61 } 62 // idempotent 63 if err := d.AddCollaboration(collabDID, ownerDID, rkey); err != nil { 64 t.Fatalf("AddCollaboration (dup): %v", err) 65 } 66 67 rows, err := d.GetCollaborations(collabDID) 68 if err != nil { 69 t.Fatalf("GetCollaborations: %v", err) 70 } 71 if len(rows) != 1 { 72 t.Fatalf("want 1 row, got %d", len(rows)) 73 } 74 if rows[0].OwnerDID != ownerDID || rows[0].DocumentRKey != rkey { 75 t.Errorf("unexpected row: %+v", rows[0]) 76 } 77} 78``` 79 80You'll need an `openTestDB` helper — look for one in the existing test files. If absent, create: 81 82```go 83func openTestDB(t *testing.T) *DB { 84 t.Helper() 85 dir := t.TempDir() 86 SetMigrationsDir("../../migrations") 87 d, err := Open(filepath.Join(dir, "test.db")) 88 if err != nil { 89 t.Fatal(err) 90 } 91 if err := d.Migrate(); err != nil { 92 t.Fatal(err) 93 } 94 t.Cleanup(func() { d.Close() }) 95 return d 96} 97``` 98 99**Step 2: Run test to confirm it fails** 100 101```bash 102go test ./internal/db/... -run TestCollaborations -v 103``` 104 105Expected: compile error — `AddCollaboration` and `GetCollaborations` undefined. 106 107**Step 3: Add `CollaborationRow` type and DB methods to `internal/db/db.go`** 108 109```go 110type CollaborationRow struct { 111 CollaboratorDID string 112 OwnerDID string 113 DocumentRKey string 114 AddedAt time.Time 115} 116 117func (db *DB) AddCollaboration(collabDID, ownerDID, rkey string) error { 118 _, err := db.Exec( 119 `INSERT INTO collaborations (collaborator_did, owner_did, document_rkey, added_at) 120 VALUES (?, ?, ?, ?) 121 ON CONFLICT DO NOTHING`, 122 collabDID, ownerDID, rkey, time.Now(), 123 ) 124 return err 125} 126 127func (db *DB) GetCollaborations(collabDID string) ([]CollaborationRow, error) { 128 rows, err := db.Query( 129 `SELECT collaborator_did, owner_did, document_rkey, added_at 130 FROM collaborations WHERE collaborator_did = ? ORDER BY added_at DESC`, 131 collabDID, 132 ) 133 if err != nil { 134 return nil, err 135 } 136 defer rows.Close() 137 var result []CollaborationRow 138 for rows.Next() { 139 var r CollaborationRow 140 if err := rows.Scan(&r.CollaboratorDID, &r.OwnerDID, &r.DocumentRKey, &r.AddedAt); err != nil { 141 return nil, err 142 } 143 result = append(result, r) 144 } 145 return result, rows.Err() 146} 147``` 148 149**Step 4: Run test to confirm it passes** 150 151```bash 152go test ./internal/db/... -run TestCollaborations -v 153``` 154 155Expected: PASS. 156 157**Step 5: Commit** 158 159```bash 160git add internal/db/db.go internal/db/db_test.go 161git commit -m "Add AddCollaboration and GetCollaborations DB methods" 162``` 163 164--- 165 166### Task 3: Record collaboration on invite acceptance 167 168**Files:** 169- Modify: `internal/handler/handler.go``AcceptInvite` function (~line 586) 170 171**Step 1: After `PutDocument` succeeds in `AcceptInvite`, insert the collaboration row** 172 173Find the block: 174```go 175h.DB.DeleteInvite(invite.Token) 176// Redirect to owner-scoped document URL... 177http.Redirect(w, r, "/docs/"+invite.CreatedBy+"/"+rKey, http.StatusSeeOther) 178``` 179 180Replace with: 181```go 182h.DB.DeleteInvite(invite.Token) 183 184if err := h.DB.AddCollaboration(collabSession.DID, invite.CreatedBy, rKey); err != nil { 185 log.Printf("AcceptInvite: record collaboration: %v", err) 186 // Non-fatal: collaborator is already on the document; just redirect. 187} 188 189http.Redirect(w, r, "/docs/"+invite.CreatedBy+"/"+rKey, http.StatusSeeOther) 190``` 191 192Also handle the "already a collaborator" early-return — add the same `AddCollaboration` call there (idempotent, safe to call again): 193 194```go 195for _, c := range doc.Collaborators { 196 if c == collabSession.DID { 197 h.DB.AddCollaboration(collabSession.DID, invite.CreatedBy, rKey) // ensure row exists 198 http.Redirect(w, r, "/docs/"+invite.CreatedBy+"/"+rKey, http.StatusSeeOther) 199 return 200 } 201} 202``` 203 204**Step 2: Build** 205 206```bash 207go build ./... 208``` 209 210Expected: no errors. 211 212**Step 3: Commit** 213 214```bash 215git add internal/handler/handler.go 216git commit -m "Record collaboration row when invite is accepted" 217``` 218 219--- 220 221### Task 4: Fetch shared documents in the Dashboard handler 222 223**Files:** 224- Modify: `internal/handler/handler.go``Dashboard` function (~line 134) 225 226**Step 1: Add `DashboardContent` struct to hold both document lists** 227 228Near the top of `handler.go`, alongside `PageData` and `DocumentEditData`: 229 230```go 231type DashboardContent struct { 232 OwnDocs []*model.Document 233 SharedDocs []*SharedDocument 234} 235 236type SharedDocument struct { 237 RKey string 238 OwnerDID string 239 Title string 240 UpdatedAt string 241 CreatedAt string 242} 243``` 244 245**Step 2: Update `Dashboard` to populate both lists** 246 247Replace the existing `Dashboard` handler body (after the user/client setup) with logic that: 248 2491. Lists the user's own records (existing logic, unchanged). 2502. Calls `h.DB.GetCollaborations(session.DID)` to get shared doc references. 2513. For each collaboration row, uses `h.xrpcClient(ownerUser.ID)` to call `GetRecord` on the owner's PDS and extract title/dates. 2524. Passes a `DashboardContent` as `Content`. 253 254```go 255func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { 256 user, userHandle := h.currentUser(r) 257 if user == nil { 258 h.render(w, "landing.html", PageData{Title: "Diffdown"}) 259 return 260 } 261 262 empty := DashboardContent{OwnDocs: []*model.Document{}, SharedDocs: []*SharedDocument{}} 263 264 client, err := h.xrpcClient(user.ID) 265 if err != nil { 266 log.Printf("Dashboard: xrpc client: %v", err) 267 h.render(w, "documents.html", PageData{Title: "Documents", User: user, UserHandle: userHandle, Content: empty}) 268 return 269 } 270 271 // Own documents 272 records, _, err := client.ListRecords(client.DID(), collectionDocument, 100, "") 273 if err != nil { 274 log.Printf("Dashboard: list records: %v", err) 275 h.render(w, "documents.html", PageData{Title: "Documents", User: user, UserHandle: userHandle, Content: empty}) 276 return 277 } 278 var ownDocs []*model.Document 279 for _, rec := range records { 280 doc := &model.Document{} 281 if err := json.Unmarshal(rec.Value, doc); err != nil { 282 continue 283 } 284 doc.URI = rec.URI 285 doc.CID = rec.CID 286 doc.RKey = model.RKeyFromURI(rec.URI) 287 ownDocs = append(ownDocs, doc) 288 } 289 290 // Shared documents 291 session, _ := h.DB.GetATProtoSession(user.ID) 292 var sharedDocs []*SharedDocument 293 if session != nil { 294 collabs, err := h.DB.GetCollaborations(session.DID) 295 if err != nil { 296 log.Printf("Dashboard: get collaborations: %v", err) 297 } 298 for _, c := range collabs { 299 ownerUser, err := h.DB.GetUserByDID(c.OwnerDID) 300 if err != nil { 301 log.Printf("Dashboard: owner not found %s: %v", c.OwnerDID, err) 302 continue 303 } 304 ownerClient, err := h.xrpcClient(ownerUser.ID) 305 if err != nil { 306 log.Printf("Dashboard: owner xrpc client %s: %v", c.OwnerDID, err) 307 continue 308 } 309 value, _, err := ownerClient.GetRecord(c.OwnerDID, collectionDocument, c.DocumentRKey) 310 if err != nil { 311 log.Printf("Dashboard: get shared record %s/%s: %v", c.OwnerDID, c.DocumentRKey, err) 312 continue 313 } 314 var doc model.Document 315 if err := json.Unmarshal(value, &doc); err != nil { 316 continue 317 } 318 sharedDocs = append(sharedDocs, &SharedDocument{ 319 RKey: c.DocumentRKey, 320 OwnerDID: c.OwnerDID, 321 Title: doc.Title, 322 UpdatedAt: doc.UpdatedAt, 323 CreatedAt: doc.CreatedAt, 324 }) 325 } 326 } 327 328 h.render(w, "documents.html", PageData{ 329 Title: "Documents", 330 User: user, 331 UserHandle: userHandle, 332 Content: DashboardContent{OwnDocs: ownDocs, SharedDocs: sharedDocs}, 333 }) 334} 335``` 336 337**Step 3: Build** 338 339```bash 340go build ./... 341``` 342 343Expected: no errors. (Template will need updating in the next task.) 344 345**Step 4: Commit** 346 347```bash 348git add internal/handler/handler.go 349git commit -m "Fetch shared documents for dashboard" 350``` 351 352--- 353 354### Task 5: Update the documents template 355 356**Files:** 357- Modify: `templates/documents.html` 358 359**Step 1: Update the template to use `DashboardContent`** 360 361The template currently iterates over `.Content` directly. Change it to use `.Content.OwnDocs` and add a new section for `.Content.SharedDocs`. The shared doc link format is `/docs/{ownerDID}/{rkey}` (same as the collaborator view URL established in existing routes). 362 363```html 364{{template "base" .}} 365{{define "content"}} 366<div class="dashboard"> 367 <div class="dashboard-header"> 368 <h2>Your Documents</h2> 369 <div style="display:flex;align-items:center;gap:1rem"> 370 <div class="view-toggle"> 371 <button id="btn-cards" title="Card view" onclick="setView('cards')"></button> 372 <button id="btn-list" title="List view" onclick="setView('list')"></button> 373 </div> 374 <a href="/docs/new" class="btn">New Document</a> 375 </div> 376 </div> 377 <div class="repo-list" id="doc-list"> 378 {{range .Content.OwnDocs}} 379 <a href="/docs/{{.RKey}}" class="repo-card"> 380 <h3>{{.Title}}</h3> 381 {{if .UpdatedAt}} 382 <time>Updated {{.UpdatedAt | fmtdate}}</time> 383 {{else}} 384 <time>Created {{.CreatedAt | fmtdate}}</time> 385 {{end}} 386 </a> 387 {{else}} 388 <div class="empty-state"> 389 <p>No documents yet.</p> 390 <a href="/docs/new" class="btn">Create your first document</a> 391 </div> 392 {{end}} 393 </div> 394 395 {{if .Content.SharedDocs}} 396 <div class="dashboard-header" style="margin-top:2rem"> 397 <h2>Documents Shared with You</h2> 398 </div> 399 <div class="repo-list" id="shared-list"> 400 {{range .Content.SharedDocs}} 401 <a href="/docs/{{.OwnerDID}}/{{.RKey}}" class="repo-card"> 402 <h3>{{.Title}}</h3> 403 {{if .UpdatedAt}} 404 <time>Updated {{.UpdatedAt | fmtdate}}</time> 405 {{else}} 406 <time>Created {{.CreatedAt | fmtdate}}</time> 407 {{end}} 408 </a> 409 {{end}} 410 </div> 411 {{end}} 412</div> 413{{end}} 414{{define "scripts"}} 415<script> 416(function() { 417 function setView(v) { 418 var lists = [document.getElementById('doc-list'), document.getElementById('shared-list')]; 419 var btnCards = document.getElementById('btn-cards'); 420 var btnList = document.getElementById('btn-list'); 421 lists.forEach(function(list) { 422 if (!list) return; 423 if (v === 'list') { 424 list.classList.add('list-view'); 425 } else { 426 list.classList.remove('list-view'); 427 } 428 }); 429 if (v === 'list') { 430 btnList.classList.add('active'); 431 btnCards.classList.remove('active'); 432 } else { 433 btnCards.classList.add('active'); 434 btnList.classList.remove('active'); 435 } 436 localStorage.setItem('docView', v); 437 } 438 window.setView = setView; 439 setView(localStorage.getItem('docView') || 'cards'); 440})(); 441</script> 442{{end}} 443``` 444 445**Step 2: Build and run** 446 447```bash 448go build ./... && go run ./cmd/server 449``` 450 451Visit `http://localhost:8080` while logged in. Verify "Your Documents" section still renders. If you have an accepted collaboration, verify it appears under "Documents Shared with You". 452 453**Step 3: Commit** 454 455```bash 456git add templates/documents.html 457git commit -m "Add 'Documents Shared with You' section to dashboard" 458``` 459 460--- 461 462### Task 6: Check existing routes cover the collaborator view URL 463 464**Files:** 465- Read: `cmd/server/main.go` 466 467The collaborator view URL `/docs/{ownerDID}/{rkey}` must be registered. Confirm `CollaboratorDocumentView` and `CollaboratorDocumentEdit` are wired up. If not, add them. No code change expected here — this was built in a prior sprint — but verify before closing out. 468 469```bash 470grep -n "Collaborator" cmd/server/main.go 471``` 472 473Expected: entries for `CollaboratorDocumentView` and `CollaboratorDocumentEdit`. 474 475--- 476 477## Notes 478 479- **No backfill needed for existing collaborators** — the `collaborations` table will be empty until invites are accepted post-deploy. This is acceptable; collaborators can re-use an existing invite link or the owner can send a new one. 480- **PDS fetch errors are non-fatal** — if a shared document's owner PDS is unreachable, that card is silently skipped. Add a log line if debugging is needed. 481- **`fmtdate` template func** — already registered in `cmd/server/main.go`; no changes needed. 482- **List/card view toggle** — updated in Task 5 to apply to both lists so both respond to the toggle buttons.