diff --git a/docs/superpowers/specs/2026-05-31-event-links-design.md b/docs/superpowers/specs/2026-05-31-event-links-design.md new file mode 100644 index 0000000..7f82e25 --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-event-links-design.md @@ -0,0 +1,125 @@ +# Design: Optional Links on Events + +**Date:** 2026-05-31 +**Status:** Approved, pending implementation plan + +## Summary + +Let event organizers attach up to 5 optional links (label + URL) to an event, +mirroring profile links. Links are edited in the admin event create/edit forms, +stored both in the canonical `quest.atmo.event` PDS record and the local cache, +and displayed on the event detail page (`/events/{token}`) directly above the +"collective progress" bar. + +## Motivation + +Events often have associated resources (schedule, venue map, Discord, sponsor +page). Profiles already support a `{label, url}` link list capped at 5; this +brings the same affordance to events. + +## Data model + +- New type `event.Link{ Label, URL string }`. +- New constant `event.MaxEventLinks = 5` (mirrors `profile.MaxLinks`). +- `Links []Link` added to `event.Record`, `event.CreateInput`, and + `event.UpdateInput`. + +## Storage (local cache) + +- New migration `internal/db/migrations/022_event_links.sql` adds a + `links TEXT NOT NULL DEFAULT ''` column to the `events` table, holding a JSON + array: `[{"label":"…","url":"…"}]`. Inline JSON mirrors how the profile + record stores links and avoids a join table. +- `scanRow` decodes the JSON column into `Record.Links` (tolerant of empty + string → nil slice). +- `event.Put`, `event.Cache`, and the new `event.Update` encode `Links` to JSON + when writing the row. + +## PDS record (synced on create AND edit) + +Event edits must keep the canonical PDS record in lockstep with the app — not +just for links, but for all fields. + +- **Create** (`event.Put`): the `quest.atmo.event` record value written to the + organizer's PDS includes a `links` array. +- **Edit** (new `event.Update(ctx, sess, db, uri, in)`): + 1. Builds the full record value (`name`, `startTime`, `endTime`, `location`, + `expectedAttendees`, `geofence`, `links`) and calls + `com.atproto.repo.putRecord` with the existing rkey (via the existing + `rkeyFromURI` helper) to overwrite the record in the organizer's PDS. + 2. Updates the local cache (the current `UpdateLocal` logic). + +This replaces the current cache-only `UpdateLocal` edit path. As a direct +consequence, **name / location / time / attendee edits now also sync to the +PDS**, which they previously did not. This is intended (the user explicitly +wants the PDS kept in line with the app). + +**Shared helper:** `event.Put` and `event.Update` build an identical record- +value map, so that construction is factored into one `recordValue(...)` helper +used by both — a single source of truth for the record shape. + +## Admin editing UI + +- Add a `links (up to 5)` fieldset to both `AdminEventNew` and `AdminEventEdit` + forms, mirroring profile's `profileLinkRow` (a label input + URL input per + row). +- `AdminEventFormView` gains a `Links []EventLink` field; the edit page + pre-fills it from the cached event's links. +- `parseEventForm` parses up to 5 link rows, trims empty rows, and accepts + `http(s)` URLs only (mirroring profile validation). The cap is enforced + defensively at parse time. +- `AdminEventEditSave` acquires the admin's session via + `h.Auth.RequireSession(w, r)` (as `AdminEventCreate` already does) and passes + it to `event.Update`. If the PDS write fails, the edit fails loudly with an + error rendered on the form, rather than letting the cache diverge from the + PDS. + +## Display + +- On the event detail page (`features/events/pages/detail.templ`), a new links + section is rendered directly above `@eventDetailProgress` (currently + detail.templ:146). +- Links render as `.pill-link` pills, matching the profile look. +- The section is shown only when at least one link exists (links are optional). +- Display text is the link label; if the label is blank, fall back to the URL's + host (mirroring profile behavior). +- `EventDetailView` gains a `Links []EventLink` field, populated by the detail + handler from the cached event. + +## Validation and edge cases + +- Empty rows (both label and URL blank) are dropped at parse time. +- A row with a URL but no label is kept; the label falls back to the URL host + at render. +- Non-`http(s)` URLs are rejected with a form error. +- More than 5 rows are truncated to 5. +- Events with no links render no links section and write `links: []` (or omit) + to the record. + +## Testing + +- `internal/event`: JSON round-trip of `Links` through `scanRow` / cache write; + `event.Update` builds and `putRecord`s the full record value including links + (using a stub/fake oauth session); `recordValue` produces the expected map. +- `features/admin`: `parseEventForm` caps at 5, trims empties, rejects non-http + URLs. +- View render: detail page omits the links section when there are no links and + renders pills when present. + +## Files touched + +- `internal/event/put.go` — `Link` type, `MaxEventLinks`, `Links` on inputs, + `recordValue` helper, `event.Update` (replacing cache-only edit path), + links in `Put`/`Cache`. +- `internal/event/event.go` — `Links` on `Record`, decode in `scanRow`. +- `internal/db/migrations/022_event_links.sql` — new `links` column. +- `features/admin/pages/events.templ` — links fieldset on new + edit forms; + `Links` on `AdminEventFormView`. +- `features/admin/handlers.go` — parse links in `parseEventForm`; pre-fill on + edit page; session + `event.Update` in `AdminEventEditSave`. +- `features/events/pages/detail.templ` — links section above progress bar; + `Links` on `EventDetailView`. +- `features/events/handlers.go` — populate `Links` in the detail view. +- Regenerated `*_templ.go` files. + +No new dependencies.