Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol
diffdown.com
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.