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
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
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):
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:
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
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
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
go test ./internal/db/... -run TestCollaborations -v
Expected: PASS.
Step 5: Commit
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—AcceptInvitefunction (~line 586)
Step 1: After PutDocument succeeds in AcceptInvite, insert the collaboration row
Find the block:
h.DB.DeleteInvite(invite.Token)
// Redirect to owner-scoped document URL...
http.Redirect(w, r, "/docs/"+invite.CreatedBy+"/"+rKey, http.StatusSeeOther)
Replace with:
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):
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
go build ./...
Expected: no errors.
Step 3: Commit
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—Dashboardfunction (~line 134)
Step 1: Add DashboardContent struct to hold both document lists
Near the top of handler.go, alongside PageData and DocumentEditData:
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:
- Lists the user's own records (existing logic, unchanged).
- Calls
h.DB.GetCollaborations(session.DID)to get shared doc references. - For each collaboration row, uses
h.xrpcClient(ownerUser.ID)to callGetRecordon the owner's PDS and extract title/dates. - Passes a
DashboardContentasContent.
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
go build ./...
Expected: no errors. (Template will need updating in the next task.)
Step 4: Commit
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).
{{template "base" .}}
{{define "content"}}
<div class="dashboard">
<div class="dashboard-header">
<h2>Your Documents</h2>
<div style="display:flex;align-items:center;gap:1rem">
<div class="view-toggle">
<button id="btn-cards" title="Card view" onclick="setView('cards')">⊞</button>
<button id="btn-list" title="List view" onclick="setView('list')">≡</button>
</div>
<a href="/docs/new" class="btn">New Document</a>
</div>
</div>
<div class="repo-list" id="doc-list">
{{range .Content.OwnDocs}}
<a href="/docs/{{.RKey}}" class="repo-card">
<h3>{{.Title}}</h3>
{{if .UpdatedAt}}
<time>Updated {{.UpdatedAt | fmtdate}}</time>
{{else}}
<time>Created {{.CreatedAt | fmtdate}}</time>
{{end}}
</a>
{{else}}
<div class="empty-state">
<p>No documents yet.</p>
<a href="/docs/new" class="btn">Create your first document</a>
</div>
{{end}}
</div>
{{if .Content.SharedDocs}}
<div class="dashboard-header" style="margin-top:2rem">
<h2>Documents Shared with You</h2>
</div>
<div class="repo-list" id="shared-list">
{{range .Content.SharedDocs}}
<a href="/docs/{{.OwnerDID}}/{{.RKey}}" class="repo-card">
<h3>{{.Title}}</h3>
{{if .UpdatedAt}}
<time>Updated {{.UpdatedAt | fmtdate}}</time>
{{else}}
<time>Created {{.CreatedAt | fmtdate}}</time>
{{end}}
</a>
{{end}}
</div>
{{end}}
</div>
{{end}}
{{define "scripts"}}
<script>
(function() {
function setView(v) {
var lists = [document.getElementById('doc-list'), document.getElementById('shared-list')];
var btnCards = document.getElementById('btn-cards');
var btnList = document.getElementById('btn-list');
lists.forEach(function(list) {
if (!list) return;
if (v === 'list') {
list.classList.add('list-view');
} else {
list.classList.remove('list-view');
}
});
if (v === 'list') {
btnList.classList.add('active');
btnCards.classList.remove('active');
} else {
btnCards.classList.add('active');
btnList.classList.remove('active');
}
localStorage.setItem('docView', v);
}
window.setView = setView;
setView(localStorage.getItem('docView') || 'cards');
})();
</script>
{{end}}
Step 2: Build and run
go build ./... && go run ./cmd/server
Visit 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".
Step 3: Commit
git add templates/documents.html
git commit -m "Add 'Documents Shared with You' section to dashboard"
Task 6: Check existing routes cover the collaborator view URL#
Files:
- Read:
cmd/server/main.go
The 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.
grep -n "Collaborator" cmd/server/main.go
Expected: entries for CollaboratorDocumentView and CollaboratorDocumentEdit.
Notes#
- No backfill needed for existing collaborators — the
collaborationstable 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. - 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.
fmtdatetemplate func — already registered incmd/server/main.go; no changes needed.- List/card view toggle — updated in Task 5 to apply to both lists so both respond to the toggle buttons.