Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com

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.goAcceptInvite function (~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.goDashboard function (~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:

  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.
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 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.
  • 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.
  • fmtdate template func — already registered in cmd/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.