# Shared Documents Dashboard Section Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add a "Documents Shared with You" section to the dashboard that lists documents the current user is a collaborator on. **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. **Tech Stack:** Go 1.22, SQLite (WAL), ATProto XRPC client, `html/template`, gorilla/sessions --- ### Task 1: Add `collaborations` table via migration **Files:** - Create: `migrations/008_collaborations.sql` **Step 1: Write the migration** ```sql CREATE TABLE IF NOT EXISTS collaborations ( collaborator_did TEXT NOT NULL, owner_did TEXT NOT NULL, document_rkey TEXT NOT NULL, added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (collaborator_did, owner_did, document_rkey) ); ``` **Step 2: Verify migration runs** Start the server (`go run ./cmd/server`) and confirm in logs: `Applied migration: 008_collaborations.sql`. Stop server. **Step 3: Commit** ```bash git add migrations/008_collaborations.sql git commit -m "Add collaborations table to track shared documents" ``` --- ### Task 2: Add DB methods for collaborations **Files:** - Modify: `internal/db/db.go` **Step 1: Write the failing test** In `internal/db/db_test.go` (create if absent): ```go func TestCollaborations(t *testing.T) { d := openTestDB(t) const collabDID = "did:plc:collab" const ownerDID = "did:plc:owner" const rkey = "abc123" if err := d.AddCollaboration(collabDID, ownerDID, rkey); err != nil { t.Fatalf("AddCollaboration: %v", err) } // idempotent if err := d.AddCollaboration(collabDID, ownerDID, rkey); err != nil { t.Fatalf("AddCollaboration (dup): %v", err) } rows, err := d.GetCollaborations(collabDID) if err != nil { t.Fatalf("GetCollaborations: %v", err) } if len(rows) != 1 { t.Fatalf("want 1 row, got %d", len(rows)) } if rows[0].OwnerDID != ownerDID || rows[0].DocumentRKey != rkey { t.Errorf("unexpected row: %+v", rows[0]) } } ``` You'll need an `openTestDB` helper — look for one in the existing test files. If absent, create: ```go func openTestDB(t *testing.T) *DB { t.Helper() dir := t.TempDir() SetMigrationsDir("../../migrations") d, err := Open(filepath.Join(dir, "test.db")) if err != nil { t.Fatal(err) } if err := d.Migrate(); err != nil { t.Fatal(err) } t.Cleanup(func() { d.Close() }) return d } ``` **Step 2: Run test to confirm it fails** ```bash go test ./internal/db/... -run TestCollaborations -v ``` Expected: compile error — `AddCollaboration` and `GetCollaborations` undefined. **Step 3: Add `CollaborationRow` type and DB methods to `internal/db/db.go`** ```go type CollaborationRow struct { CollaboratorDID string OwnerDID string DocumentRKey string AddedAt time.Time } func (db *DB) AddCollaboration(collabDID, ownerDID, rkey string) error { _, err := db.Exec( `INSERT INTO collaborations (collaborator_did, owner_did, document_rkey, added_at) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING`, collabDID, ownerDID, rkey, time.Now(), ) return err } func (db *DB) GetCollaborations(collabDID string) ([]CollaborationRow, error) { rows, err := db.Query( `SELECT collaborator_did, owner_did, document_rkey, added_at FROM collaborations WHERE collaborator_did = ? ORDER BY added_at DESC`, collabDID, ) if err != nil { return nil, err } defer rows.Close() var result []CollaborationRow for rows.Next() { var r CollaborationRow if err := rows.Scan(&r.CollaboratorDID, &r.OwnerDID, &r.DocumentRKey, &r.AddedAt); err != nil { return nil, err } result = append(result, r) } return result, rows.Err() } ``` **Step 4: Run test to confirm it passes** ```bash go test ./internal/db/... -run TestCollaborations -v ``` Expected: PASS. **Step 5: Commit** ```bash git add internal/db/db.go internal/db/db_test.go git commit -m "Add AddCollaboration and GetCollaborations DB methods" ``` --- ### Task 3: Record collaboration on invite acceptance **Files:** - Modify: `internal/handler/handler.go` — `AcceptInvite` function (~line 586) **Step 1: After `PutDocument` succeeds in `AcceptInvite`, insert the collaboration row** Find the block: ```go h.DB.DeleteInvite(invite.Token) // Redirect to owner-scoped document URL... http.Redirect(w, r, "/docs/"+invite.CreatedBy+"/"+rKey, http.StatusSeeOther) ``` Replace with: ```go h.DB.DeleteInvite(invite.Token) if err := h.DB.AddCollaboration(collabSession.DID, invite.CreatedBy, rKey); err != nil { log.Printf("AcceptInvite: record collaboration: %v", err) // Non-fatal: collaborator is already on the document; just redirect. } http.Redirect(w, r, "/docs/"+invite.CreatedBy+"/"+rKey, http.StatusSeeOther) ``` Also handle the "already a collaborator" early-return — add the same `AddCollaboration` call there (idempotent, safe to call again): ```go for _, c := range doc.Collaborators { if c == collabSession.DID { h.DB.AddCollaboration(collabSession.DID, invite.CreatedBy, rKey) // ensure row exists http.Redirect(w, r, "/docs/"+invite.CreatedBy+"/"+rKey, http.StatusSeeOther) return } } ``` **Step 2: Build** ```bash go build ./... ``` Expected: no errors. **Step 3: Commit** ```bash git add internal/handler/handler.go git commit -m "Record collaboration row when invite is accepted" ``` --- ### Task 4: Fetch shared documents in the Dashboard handler **Files:** - Modify: `internal/handler/handler.go` — `Dashboard` function (~line 134) **Step 1: Add `DashboardContent` struct to hold both document lists** Near the top of `handler.go`, alongside `PageData` and `DocumentEditData`: ```go type DashboardContent struct { OwnDocs []*model.Document SharedDocs []*SharedDocument } type SharedDocument struct { RKey string OwnerDID string Title string UpdatedAt string CreatedAt string } ``` **Step 2: Update `Dashboard` to populate both lists** Replace the existing `Dashboard` handler body (after the user/client setup) with logic that: 1. Lists the user's own records (existing logic, unchanged). 2. Calls `h.DB.GetCollaborations(session.DID)` to get shared doc references. 3. For each collaboration row, uses `h.xrpcClient(ownerUser.ID)` to call `GetRecord` on the owner's PDS and extract title/dates. 4. Passes a `DashboardContent` as `Content`. ```go func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { user, userHandle := h.currentUser(r) if user == nil { h.render(w, "landing.html", PageData{Title: "Diffdown"}) return } empty := DashboardContent{OwnDocs: []*model.Document{}, SharedDocs: []*SharedDocument{}} client, err := h.xrpcClient(user.ID) if err != nil { log.Printf("Dashboard: xrpc client: %v", err) h.render(w, "documents.html", PageData{Title: "Documents", User: user, UserHandle: userHandle, Content: empty}) return } // Own documents records, _, err := client.ListRecords(client.DID(), collectionDocument, 100, "") if err != nil { log.Printf("Dashboard: list records: %v", err) h.render(w, "documents.html", PageData{Title: "Documents", User: user, UserHandle: userHandle, Content: empty}) return } var ownDocs []*model.Document for _, rec := range records { doc := &model.Document{} if err := json.Unmarshal(rec.Value, doc); err != nil { continue } doc.URI = rec.URI doc.CID = rec.CID doc.RKey = model.RKeyFromURI(rec.URI) ownDocs = append(ownDocs, doc) } // Shared documents session, _ := h.DB.GetATProtoSession(user.ID) var sharedDocs []*SharedDocument if session != nil { collabs, err := h.DB.GetCollaborations(session.DID) if err != nil { log.Printf("Dashboard: get collaborations: %v", err) } for _, c := range collabs { ownerUser, err := h.DB.GetUserByDID(c.OwnerDID) if err != nil { log.Printf("Dashboard: owner not found %s: %v", c.OwnerDID, err) continue } ownerClient, err := h.xrpcClient(ownerUser.ID) if err != nil { log.Printf("Dashboard: owner xrpc client %s: %v", c.OwnerDID, err) continue } value, _, err := ownerClient.GetRecord(c.OwnerDID, collectionDocument, c.DocumentRKey) if err != nil { log.Printf("Dashboard: get shared record %s/%s: %v", c.OwnerDID, c.DocumentRKey, err) continue } var doc model.Document if err := json.Unmarshal(value, &doc); err != nil { continue } sharedDocs = append(sharedDocs, &SharedDocument{ RKey: c.DocumentRKey, OwnerDID: c.OwnerDID, Title: doc.Title, UpdatedAt: doc.UpdatedAt, CreatedAt: doc.CreatedAt, }) } } h.render(w, "documents.html", PageData{ Title: "Documents", User: user, UserHandle: userHandle, Content: DashboardContent{OwnDocs: ownDocs, SharedDocs: sharedDocs}, }) } ``` **Step 3: Build** ```bash go build ./... ``` Expected: no errors. (Template will need updating in the next task.) **Step 4: Commit** ```bash git add internal/handler/handler.go git commit -m "Fetch shared documents for dashboard" ``` --- ### Task 5: Update the documents template **Files:** - Modify: `templates/documents.html` **Step 1: Update the template to use `DashboardContent`** The 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). ```html {{template "base" .}} {{define "content"}}
No documents yet.
Create your first document