From 5e61c14a51abe8e11278f72eb318ea509efe2cd9 Mon Sep 17 00:00:00 2001 From: Brittany Ellich Date: Mon, 1 Jun 2026 07:55:03 -0700 Subject: [PATCH] docs: implementation plan for current-event connection association Co-Authored-By: Claude Opus 4.8 --- ...associate-connection-with-current-event.md | 540 ++++++++++++++++++ 1 file changed, 540 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-01-associate-connection-with-current-event.md diff --git a/docs/superpowers/plans/2026-06-01-associate-connection-with-current-event.md b/docs/superpowers/plans/2026-06-01-associate-connection-with-current-event.md new file mode 100644 index 0000000..b543ef4 --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-associate-connection-with-current-event.md @@ -0,0 +1,540 @@ +# Associate a Connection With Current Event — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** On a connection's profile page, let a checked-in ATProto user tag that person's `quest.atmo.connection` PDS record with the event they're currently checked into, reversibly and in place. + +**Architecture:** A new `connection.SetEvent` updates an existing connection record in place via `com.atproto.repo.putRecord` (reusing the rkey, mirroring `event.Update`). The connection `View` handler resolves the current check-in and the target's most-recent connection record, passing both to the template, which renders a checkbox. A new `POST /connections/{did}/event` endpoint, driven by a small client script, performs the toggle — re-deriving the event server-side and never trusting the client for anything but the boolean. + +**Tech Stack:** Go, chi router, templ, indigo atproto OAuth client, SQLite, vanilla JS. + +--- + +### Task 1: `connection.SetEvent` + record-finding helpers + +**Files:** +- Modify: `internal/connection/connection.go` +- Modify: `internal/connection/list.go` +- Test: `internal/connection/connection_test.go` +- Test: `internal/connection/list_test.go` (create if absent) + +- [ ] **Step 1: Write failing tests for the record-value helper and target finder** + +Add to `internal/connection/connection_test.go`: + +```go +func TestBuildConnectionValue_OmitsEmptyEvent(t *testing.T) { + v := buildConnectionValue(syntax.DID(didA), time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC), "") + if v["$type"] != NSID { + t.Errorf("missing $type, got %v", v["$type"]) + } + if v["with"] != didA { + t.Errorf("with = %v, want %s", v["with"], didA) + } + if _, ok := v["event"]; ok { + t.Error("event should be omitted when empty") + } +} + +func TestBuildConnectionValue_IncludesEvent(t *testing.T) { + v := buildConnectionValue(syntax.DID(didA), time.Now().UTC(), "at://did:plc:x/quest.atmo.event/abc") + if v["event"] != "at://did:plc:x/quest.atmo.event/abc" { + t.Errorf("event = %v", v["event"]) + } +} + +func TestSetEvent_RejectsNilSession(t *testing.T) { + err := SetEvent(context.Background(), nil, "at://did:plc:x/quest.atmo.connection/r1", syntax.DID(didA), time.Now(), "") + if err == nil || !strings.Contains(err.Error(), "nil oauth session") { + t.Fatalf("expected nil session error, got %v", err) + } +} + +func TestSetEvent_RejectsBadURI(t *testing.T) { + sess := &oauth.ClientSession{Data: &oauth.ClientSessionData{AccountDID: syntax.DID(didB)}} + err := SetEvent(context.Background(), sess, "", syntax.DID(didA), time.Now(), "") + if err == nil || !strings.Contains(err.Error(), "rkey") { + t.Fatalf("expected rkey error, got %v", err) + } +} +``` + +Add to `internal/connection/list_test.go` (create the file with the same package and imports if it does not exist — `package connection`, importing `testing`, `time`, and `github.com/bluesky-social/indigo/atproto/syntax`): + +```go +func TestFindForTarget_PicksMostRecent(t *testing.T) { + target := syntax.DID(didA) + older := ListEntry{URI: "at://x/c/old", With: target, ConnectedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), EventURI: "at://x/e/1"} + newer := ListEntry{URI: "at://x/c/new", With: target, ConnectedAt: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), EventURI: "at://x/e/2"} + other := ListEntry{URI: "at://x/c/z", With: syntax.DID(didB), ConnectedAt: time.Now()} + + got, ok := FindForTarget([]ListEntry{older, other, newer}, target) + if !ok { + t.Fatal("expected to find target") + } + if got.URI != "at://x/c/new" { + t.Errorf("URI = %s, want at://x/c/new", got.URI) + } +} + +func TestFindForTarget_NotFound(t *testing.T) { + _, ok := FindForTarget([]ListEntry{{With: syntax.DID(didB)}}, syntax.DID(didA)) + if ok { + t.Error("expected not found") + } +} +``` + +Note: `didA` and `didB` are already defined as test constants in the `connection` package (used by `connection_test.go`). If `list_test.go` is a new file in the same package they are visible; do not redefine them. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd /Users/brittany/Documents/Collective/atmoquest && go test ./internal/connection/ -run 'BuildConnectionValue|SetEvent|FindForTarget' -v` +Expected: FAIL — `undefined: buildConnectionValue`, `undefined: SetEvent`, `undefined: FindForTarget`. + +- [ ] **Step 3: Implement `FindForTarget` in `list.go`** + +Append to `internal/connection/list.go`: + +```go +// FindForTarget returns the most-recent connection entry for the given target +// DID. When several records exist (e.g. across multiple events), the one with +// the latest ConnectedAt wins — matching the dedup the list/profile pages show. +// Returns ok=false when the target has no connection record. +func FindForTarget(entries []ListEntry, target syntax.DID) (ListEntry, bool) { + var best ListEntry + found := false + for _, e := range entries { + if e.With != target { + continue + } + if !found || e.ConnectedAt.After(best.ConnectedAt) { + best = e + found = true + } + } + return best, found +} +``` + +- [ ] **Step 4: Implement `buildConnectionValue` and `SetEvent` in `connection.go`** + +Add the `nsidPutRecord` constant alongside the existing constants in `connection.go`: + +```go + // putRecord is the XRPC procedure for in-place updates. + nsidPutRecord = "com.atproto.repo.putRecord" +``` + +Append to `internal/connection/connection.go`: + +```go +// buildConnectionValue assembles the record body for a quest.atmo.connection +// record. The event field is included only when eventURI is non-empty. +func buildConnectionValue(with syntax.DID, connectedAt time.Time, eventURI string) map[string]any { + value := map[string]any{ + "$type": NSID, + "with": with.String(), + "connectedAt": connectedAt.UTC().Format(time.RFC3339), + } + if eventURI != "" { + value["event"] = eventURI + } + return value +} + +// SetEvent rewrites an existing connection record in place (putRecord, reusing +// the record's rkey) so its event association becomes eventURI. Passing an +// empty eventURI clears the association. The caller carries the record's +// current `with` and `connectedAt` (from the list entry) so no fields are +// dropped. The at-uri stays stable because the rkey is reused. +// +// Caller must hold the repo:quest.atmo.connection OAuth scope. +func SetEvent(ctx context.Context, sess *oauth.ClientSession, recordURI string, with syntax.DID, connectedAt time.Time, eventURI string) error { + if sess == nil { + return errors.New("connection: nil oauth session") + } + rkey := rkeyFromURI(recordURI) + if rkey == "" { + return fmt.Errorf("connection: cannot parse rkey from %q", recordURI) + } + if connectedAt.IsZero() { + connectedAt = time.Now().UTC() + } + + input := map[string]any{ + "repo": sess.Data.AccountDID.String(), + "collection": NSID, + "rkey": rkey, + // `validate` omitted — see Put for rationale. + "record": buildConnectionValue(with, connectedAt, eventURI), + } + if err := sess.APIClient().Post(ctx, syntax.NSID(nsidPutRecord), input, nil); err != nil { + return fmt.Errorf("putRecord %s: %w", NSID, err) + } + return nil +} + +// rkeyFromURI extracts the record key (last path segment) from an at:// URI. +func rkeyFromURI(uri string) string { + if uri == "" { + return "" + } + parts := strings.Split(uri, "/") + return parts[len(parts)-1] +} +``` + +Add `"strings"` to the import block in `connection.go` if it is not already imported. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `cd /Users/brittany/Documents/Collective/atmoquest && go test ./internal/connection/ -v` +Expected: PASS (all connection tests, including the new ones). + +- [ ] **Step 6: Commit** + +```bash +cd /Users/brittany/Documents/Collective/atmoquest +git add internal/connection/ +git commit -m "feat(connection): SetEvent for in-place event tagging + FindForTarget" +``` + +--- + +### Task 2: Add fields to `ProfileView` and render the checkbox + +**Files:** +- Modify: `features/connections/pages/profile.templ` + +- [ ] **Step 1: Add the new fields to the `ProfileView` struct** + +In `features/connections/pages/profile.templ`, extend the struct (after `FollowUp bool`): + +```go + FollowUp bool // follow-up flag from SQLite + // Current-event association (only populated for ATProto viewers who are + // checked into an ongoing event AND have a PDS connection record here). + CurrentEventURI string // at-uri of the ongoing event, "" if not checked in + CurrentEventName string // display name for the checkbox label + ConnRecordURI string // at-uri (rkey) of the record to update, "" if none + AssociatedWithCurrent bool // initial checked state +``` + +- [ ] **Step 2: Render the checkbox inside the notes section** + +In the same file, immediately after the `` that closes the follow-up toggle (around the `pv-followup-toggle` block) and before the closing `` of `pv-notes-section`, add: + +```go + if v.CurrentEventURI != "" && v.ConnRecordURI != "" { + + } +``` + +- [ ] **Step 3: Add the script include** + +In the same file, next to the existing `` line, add below it: + +```go + +``` + +- [ ] **Step 4: Regenerate the templ Go file** + +Run: `cd /Users/brittany/Documents/Collective/atmoquest && go tool templ generate -f features/connections/pages/profile.templ` +Expected: clean output, `features/connections/pages/profile_templ.go` updated. + +- [ ] **Step 5: Verify it builds** + +Run: `cd /Users/brittany/Documents/Collective/atmoquest && go build ./...` +Expected: silent success. + +- [ ] **Step 6: Commit** + +```bash +cd /Users/brittany/Documents/Collective/atmoquest +git add features/connections/pages/profile.templ features/connections/pages/profile_templ.go +git commit -m "feat(connections): render current-event association checkbox" +``` + +--- + +### Task 3: Populate the new fields in the `View` handler + +**Files:** +- Modify: `features/connections/handlers.go` (the `View` method, around lines 420–445) + +- [ ] **Step 1: Wire current-event + record lookup into `View`** + +In `features/connections/handlers.go`, the `View` method already computes +`viewerPDS := h.lookupPDSForDID(r, viewerDID)` and +`entries, err := connection.List(r.Context(), viewerPDS, viewerDID)` and then +loops over `entries` to set `view.ConnectedAt` / `view.EventName`. + +Immediately **after** that existing loop (after the `for _, e := range entries { ... }` block that sets `ConnectedAt`), add: + +```go + // Current-event association: only for ATProto viewers checked into an + // ongoing event who have a PDS connection record with this target. + if evURI, ok, cErr := checkin.Current(r.Context(), h.DB, viewerDID); cErr == nil && ok && evURI != "" { + if rec, found := connection.FindForTarget(entries, target); found { + view.CurrentEventURI = evURI + view.ConnRecordURI = rec.URI + view.AssociatedWithCurrent = rec.EventURI == evURI + if ev, gErr := event.Get(r.Context(), h.DB, evURI); gErr == nil { + view.CurrentEventName = ev.Name + } + } + } +``` + +`checkin`, `connection`, and `event` are already imported in this file. No new imports. + +- [ ] **Step 2: Verify it builds** + +Run: `cd /Users/brittany/Documents/Collective/atmoquest && go build ./...` +Expected: silent success. + +- [ ] **Step 3: Commit** + +```bash +cd /Users/brittany/Documents/Collective/atmoquest +git add features/connections/handlers.go +git commit -m "feat(connections): pass current-event association data to profile view" +``` + +--- + +### Task 4: `POST /connections/{did}/event` endpoint + route + +**Files:** +- Modify: `features/connections/handlers.go` (new `SetConnectionEvent` method) +- Modify: `features/connections/routes.go` + +- [ ] **Step 1: Add the handler method** + +Append a new method to `features/connections/handlers.go`: + +```go +// SetConnectionEvent handles POST /connections/{did}/event — toggles whether +// the viewer's connection record with {did} is tagged with the event the +// viewer is currently checked into. Body: {"associate": bool}. +// +// The event is always re-derived server-side from checkin.Current; the client +// only sends the boolean. Updates the most-recent connection record in place. +func (h *Handlers) SetConnectionEvent(w http.ResponseWriter, r *http.Request) { + viewerDID, sess, ok := h.Auth.RequireSession(w, r) + if !ok { + return // RequireSession wrote the redirect/response + } + + targetStr, err := url.PathUnescape(chi.URLParam(r, "did")) + if err != nil { + http.Error(w, "invalid DID", http.StatusBadRequest) + return + } + target, err := syntax.ParseDID(targetStr) + if err != nil { + http.Error(w, "invalid DID", http.StatusBadRequest) + return + } + + var body struct { + Associate bool `json:"associate"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4*1024)).Decode(&body); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + + // Re-derive the current event server-side. + eventURI := "" + if body.Associate { + evURI, checked, cErr := checkin.Current(r.Context(), h.DB, viewerDID) + if cErr != nil { + slog.Warn("set connection event: checkin.Current", "err", cErr) + http.Error(w, "failed to resolve current event", http.StatusInternalServerError) + return + } + if !checked || evURI == "" { + http.Error(w, "not checked into an ongoing event", http.StatusBadRequest) + return + } + eventURI = evURI + } + + // Find the most-recent connection record for this target. + viewerPDS := h.lookupPDSForDID(r, viewerDID) + entries, err := connection.List(r.Context(), viewerPDS, viewerDID) + if err != nil { + slog.Warn("set connection event: list connections", "err", err) + http.Error(w, "failed to load connection", http.StatusInternalServerError) + return + } + rec, found := connection.FindForTarget(entries, target) + if !found { + http.Error(w, "no connection record found", http.StatusNotFound) + return + } + + if err := connection.SetEvent(r.Context(), sess, rec.URI, rec.With, rec.ConnectedAt, eventURI); err != nil { + slog.Warn("set connection event: putRecord", "uri", rec.URI, "err", err) + http.Error(w, "failed to update record", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} +``` + +All referenced packages (`url`, `json`, `slog`, `http`, `chi`, `syntax`, `checkin`, `connection`) are already imported in this file. + +- [ ] **Step 2: Register the route** + +In `features/connections/routes.go`, add inside `SetupRoutes` after the notes route: + +```go + router.Post("/connections/{did}/event", h.SetConnectionEvent) +``` + +Update the doc comment above `SetupRoutes` to list the new route: + +```go +// - POST /connections/{did}/event — associate/clear the current event tag +``` + +- [ ] **Step 3: Verify it builds** + +Run: `cd /Users/brittany/Documents/Collective/atmoquest && go build ./...` +Expected: silent success. + +- [ ] **Step 4: Commit** + +```bash +cd /Users/brittany/Documents/Collective/atmoquest +git add features/connections/handlers.go features/connections/routes.go +git commit -m "feat(connections): POST /connections/{did}/event endpoint" +``` + +--- + +### Task 5: Client script + +**Files:** +- Create: `web/resources/static/js/connection-event.js` + +- [ ] **Step 1: Write the script** + +Create `web/resources/static/js/connection-event.js`: + +```javascript +// atmo.quest — toggle whether a connection is tagged with the event you're +// currently checked into. POSTs {associate: bool} to +// /connections/{did}/event. The server re-derives which event; we only send +// the boolean. On failure we revert the checkbox so the UI matches the PDS. +(function () { + "use strict"; + + var toggle = document.querySelector(".pv-event-toggle"); + if (!toggle) return; + + var targetDID = toggle.getAttribute("data-target-did"); + var checkbox = document.getElementById("pv-event-associate"); + var status = document.getElementById("pv-event-status"); + if (!targetDID || !checkbox) return; + + function showStatus(msg) { + if (!status) return; + status.textContent = msg; + clearTimeout(status._timer); + status._timer = setTimeout(function () { status.textContent = ""; }, 2000); + } + + checkbox.addEventListener("change", function () { + var desired = checkbox.checked; + checkbox.disabled = true; + fetch("/connections/" + encodeURI(targetDID) + "/event", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ associate: desired }) + }) + .then(function (resp) { + if (resp.ok) { + showStatus("✓ saved"); + } else { + checkbox.checked = !desired; // revert + showStatus("save failed"); + } + }) + .catch(function () { + checkbox.checked = !desired; // revert + showStatus("save failed"); + }) + .finally(function () { + checkbox.disabled = false; + }); + }); +})(); +``` + +- [ ] **Step 2: Confirm the file is syntactically valid** + +Run: `node --check /Users/brittany/Documents/Collective/atmoquest/web/resources/static/js/connection-event.js` +Expected: silent success (exit 0). + +- [ ] **Step 3: Commit** + +```bash +cd /Users/brittany/Documents/Collective/atmoquest +git add web/resources/static/js/connection-event.js +git commit -m "feat(connections): client toggle for current-event association" +``` + +--- + +### Task 6: Full build, tests, and manual verification + +**Files:** none (verification only) + +- [ ] **Step 1: Build, vet, and test everything** + +Run: `cd /Users/brittany/Documents/Collective/atmoquest && go tool templ generate && go build ./... && go vet ./... && go test ./...` +Expected: templ clean, build silent, vet silent, all tests `ok`. + +- [ ] **Step 2: Manual verification (requires a running instance + checked-in account)** + +1. Start the app: `go tool task live` and sign in as an ATProto user. +2. Check into an ongoing event (so `checkin.Current` returns it). +3. Open `/connections/{did}` for a person you have a `quest.atmo.connection` record with. Confirm the `📍 met at ` checkbox appears. +4. Check the box → confirm `✓ saved`. Verify on the PDS: + `com.atproto.repo.getRecord` for that record now shows the `event` field set to the current event's at-uri. +5. Uncheck the box → confirm `✓ saved` and that the record's `event` field is gone. +6. Sign out / use a local account → confirm the checkbox does **not** appear. +7. Open a connection profile while **not** checked into any ongoing event → confirm the checkbox does **not** appear. + +- [ ] **Step 3: Final commit (if templ regeneration changed anything)** + +```bash +cd /Users/brittany/Documents/Collective/atmoquest +git add -A +git commit -m "chore(connections): regenerate templ for current-event association" || echo "nothing to commit" +``` + +--- + +## Notes for the implementer + +- **In-place update is intentional.** When a connection record is already tagged with a *different* event, checking the box overwrites that event with the current one. This was an explicit product decision (see the design doc). +- **Server is the source of truth for the event.** The client only sends `{associate: bool}`; the handler re-derives the event from `checkin.Current`. Never accept an event URI from the client. +- **Scope:** local accounts and local-only connections are out of scope — they have no PDS record, so the checkbox is never rendered and the endpoint will 404 for them. +- **Spec:** `docs/superpowers/specs/2026-06-01-associate-connection-with-current-event-design.md`. -- 2.51.2