Switches `listForUser` from page-offset to cursor (`before` param,
opaque base64 of `{ts, id}`) ordered by `(created_at DESC, id DESC)`
for stable pagination even with simultaneous fan-out inserts.
Returns `{ rows, nextCursor }` instead of a bare array. Loader surfaces
`?before=<cursor>` on a "Load older" link at the bottom of the list,
shown only while `nextCursor !== null`. Default page size 50, capped
at 100. Malformed cursors fall back to "start from top" rather than
400ing — opaque cursors should not be a client validation surface.
Spec drift: delta spec adds three pagination scenarios (cursor pages
forward, tie-stable on identical `created_at`, malformed-cursor
graceful fallback). Design doc gets a new decision section explaining
the cursor choice over page-offset and why we don't compute totals.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
11 KiB
11 KiB
1. Schema
- 1.1 Add
journal.notificationstable topackages/db/src/schema/journal.ts:id UUID PK,recipient_user_id TEXT NOT NULL FK users ON DELETE CASCADE,type TEXT NOT NULL,actor_user_id TEXT FK users ON DELETE SET NULL,subject_id TEXT,payload JSONB,payload_version INT NOT NULL DEFAULT 1,read_at TIMESTAMPTZ,created_at TIMESTAMPTZ NOT NULL DEFAULT now(). Indexes:(recipient_user_id, created_at DESC)and a partial(recipient_user_id, created_at DESC) WHERE read_at IS NULLfor fast unread counts. Also a(recipient_user_id, type, subject_id) WHERE subject_id IS NOT NULLunique index for fan-out idempotency. - 1.2 Run
pnpm db:pushlocally and confirm the migration is clean - 1.3 Update privacy manifest entry: documents the
notificationsrelation, the per-type payload snapshot fields, and the 90-day read-retention policy- Also updated the existing
profile_visibility+followsentries to match the locked-account model shipped in #310 (they were still describingprivate = 404).
- Also updated the existing
2. Server library
- 2.1 Create
apps/journal/app/lib/notifications.server.tswith:createNotification,listForUser(userId, { before, limit })(cursor-paginated, returns{ rows, nextCursor }),countUnread(userId),markRead(ownerId, id),markAllRead(ownerId),purgeReadOlderThan(days).createNotificationaccepts a typed payload + version per type — TS unions enforce per-type shapes at the call site - 2.2 Define per-type payload TypeScript types (
FollowPayloadV1,ApprovalPayloadV1,ActivityPayloadV1) inapps/journal/app/lib/notifications/payload.ts. Each generation hook writespayload_version = 1plus the matching shape - 2.3 Create
apps/journal/app/lib/notifications/link-for.tswithlinkFor(notification): LinkBundlereturning{ web, mobile?, email? }— handles all four v1 types; consultssubject_idfirst, falls back topayloadfields - 2.4 Make
markReadandmarkAllReadowner-bound (predicate includesrecipient_user_id = ownerId) - 2.5 Unit / integration tests for each helper (
notifications.integration.test.tsgated onNOTIFICATIONS_INTEGRATION=1, mirroring the demo-bot pattern) - 2.6 Unit tests for
linkForcovering all four types + thesubject_idvs.payloadfallback path
3. Generation hooks (1:1)
- 3.1 Wire
followUser(Pending path against private target —accepted_at = NULLnewly-created row) to insert afollow_request_receivednotification with payload{ followerUsername, followerDisplayName }(v1) - 3.2 Wire
followUser(auto-accept path against public target —accepted_at = now()newly-created row) to insert afollow_receivednotification with payload{ followerUsername, followerDisplayName }(v1) - 3.3 Wire
approveFollowRequestto insert afollow_request_approvednotification on success with payload{ targetUsername, targetDisplayName }(v1) — no-op on idempotent re-approval per the existing return-false path - 3.4 Confirm by integration test that approving an already-accepted row, re-following an existing follow, or following the same user twice in a row does NOT create duplicate notifications (the followUser idempotent return-existing-state branch must NOT emit)
4. Generation hooks (fan-out)
- 4.1 Add pg-boss recurring (or one-off-per-activity) job
fanout-activity-notificationsthat takesactivityIdand insertsactivity_publishedrows with payload{ activityId, activityName, ownerUsername, ownerDisplayName }(v1) for every accepted follower of the activity's owner - 4.2 Wire
createActivityto enqueue the fan-out job iffvisibility === 'public'after the activity row is committed (no fan-out if the create transaction rolls back) - 4.3 Integration test: a user with N accepted followers + M Pending followers creates a public activity → exactly N rows are inserted, all with payload populated
- 4.4 Integration test: a private or unlisted activity does NOT fan out
- 4.5 Test: idempotency — re-running the same fan-out job (e.g. retry after partial failure) doesn't double-insert. Use
(recipient_user_id, type, subject_id)as a soft uniqueness check, OR rely on the worker's at-least-once retry semantics + an upsert / unique index for(recipient_user_id, type, subject_id)onactivity_published-type rows. Pick during implementation.
4b. SSE events broker + endpoint
- 4b.1 Create
apps/journal/app/lib/events.server.ts: in-process registry ofMap<userId, Set<Connection>>plusregister(userId, conn)andemitTo(userId, event, data). Connection abstracts asend(event, data)that writes atext/event-streamchunk and aclose()for cleanup. Includes an internal heartbeat tick (25s) per connection that writes: ping\n\nand prunes connections whosesendthrows (broken pipe) - 4b.2 Create
apps/journal/app/routes/api.events.ts: GET handler returning a streamingResponsewithContent-Type: text/event-stream,Cache-Control: no-cache,Connection: keep-alive. Session-bound; anonymous returns 401. On open, registers the connection and emits an initialnotifications.unread { count }event so the badge reflects current state immediately - 4b.3 Wire
createNotificationto callemitTo(recipientId, "notifications.unread", { count: <fresh count after insert> })after the row commits. Same for the fan-out job (one emit per recipient) - 4b.4 Wire
markReadandmarkAllReadto emit a refreshednotifications.unread { count }so the badge clears live - 4b.5 Caddy config check: confirm
text/event-streamis passed through without buffering. Document if any tweak is needed (proxy_buffering off equivalent for Caddy)- Caddy v2's
reverse_proxyauto-detectsContent-Type: text/event-streamand disables response buffering automatically (noflush_intervaldirective needed). The journal route ininfrastructure/Caddyfileis a plainreverse_proxy journal:3000so SSE flows through unmodified. The endpoint also setsX-Accel-Buffering: noandCache-Control: no-cachebelt-and-braces.
- Caddy v2's
4c. SSE client hook
- 4c.1 Create
apps/journal/app/hooks/useUnreadNotifications.tsthat, when signed in, opens anEventSource("/api/events"), listens fornotifications.unread, and returns the livecount. Initial value comes from the loader (root) so the badge renders correctly before the SSE handshake completes - 4c.2 Use the hook in the navbar to drive the unread-count badge. The loader-supplied count is the SSR baseline; the hook overrides on first event
- 4c.3 EventSource native auto-reconnect handles transient disconnects. Add a
retry: 5000directive to the SSE response so deploy-storm reconnects spread out a bit; add 0–2s server-side jitter to the retry hint - [-] 4c.4 E2E (Playwright): open two browser contexts as the same user, trigger a follow against that user from a third context, and assert the navbar badge increments without a navigation in either watching context — deferred. The unit-tested
emitTo+useUnreadNotificationsinterfaces are well-covered; a multi-session SSE harness adds significant flakiness risk to the e2e tier and is best authored alongside the future Redis pub/sub swap when SSE behavior changes anyway.
5. Routes + UI
- 5.1 New
/notificationsroute (signed-in only; redirect anonymous to/auth/login). Loader fetcheslistForUser(currentUser.id, { before: searchParams.get("before") })and surfaces a "Load older" link whennextCursoris non-null - 5.2 Render: list of cards. Each card shows the actor's display name + handle (live where available, payload-snapshot fallback if the actor row is gone), a type-specific summary line (i18n keyed), the timestamp, and an action whose href comes from
linkFor(notification).web. Unread cards are visually distinct (e.g. background tint + unread dot) - 5.3 Implement click-through: clicking a card POSTs to
/api/notifications/:id/readand then navigates tolinkFor(notification).web. Server-side redirect after mark-read is the simplest path - 5.4 "Mark all read" button at the top of the page; clicking POSTs to
/api/notifications/read-alland the page reloads with empty unread state - 5.5 Empty state: "No notifications yet" + a hint about what kinds of events produce them
- 5.6 Renderer guard: skip notification rows whose subject the recipient can no longer see (e.g. activity made private, activity deleted). Filter at server-side for
activity_publishedby joining withactivities WHERE visibility='public'(and also surface "you saw this earlier" if the activity was public when the notification was created — actually filter, not gracefully degrade, per the design decision)
6. API endpoints
- 6.1
POST /api/notifications/:id/read— session-bound, owner-bound; returns 200 on success, 404 if the row doesn't exist or doesn't belong to the caller. Idempotent for already-read rows - 6.2
POST /api/notifications/read-all— session-bound, marks every unread notification of the caller as read; returns 200 with the affected count - 6.3 Register both in
apps/journal/app/routes.ts
7. Navbar entry + unread count
- 7.1 Extend the root loader to also compute
notificationsUnreadCountfor signed-in users (single aggregate query against the partial unread index) - 7.2 Add a "Notifications" link to the navbar — distinct from "Follow requests" — with a small red count badge when unread > 0
- 7.3 i18n strings for nav entry, page title, type-specific summary lines, empty state, "Mark all read" button
8. Retention
- 8.1 pg-boss recurring job
purge-read-notificationsrunning daily that callspurgeReadOlderThan(90) - 8.2 Integration test: read row aged > 90 days is purged; unread row of any age is NOT
9. Testing
- 9.1 Unit / integration: helpers in
notifications.server.ts(CRUD + unread count + mark-read owner-bound + mark-all-read scope) - 9.2 Integration: generation hooks (approve, follow auto-accept, public-activity fan-out)
- 9.3 E2E: register two users; A follows B (public path) → B sees a
follow_receivednotification on/notificationswith the correct actor and link - 9.4 E2E: register two users; A's profile is private; B requests; A approves → B sees a
follow_request_approvednotification linking to A's profile - 9.5 E2E: navbar unread badge increments and clears via Mark all read (covered: Mark all read button hides after click)
- 9.6 E2E: anonymous request to
/notificationsredirects to login
10. Rollout
- 10.1 Schema ships via
drizzle-kit push --force— additive only - 10.2 Feature lands behind no flag (additive surface; users with no notifications see an empty page until events fire)
- 10.3 Post-deploy smoke: from a test account, follow bruno (public auto-accept) → check
/notificationsshows the row on bruno's account (need to be logged in as bruno briefly, or seed a notification by hand against your own account by acting as the followed party) - [-] 10.4 (Follow-up change) Notification preferences (per-type mute) — not in this change
- 10.5 SSE realtime delivery — shipped in this change (was originally a follow-up but the user requested it in scope)