trails/openspec/changes/notifications/proposal.md
Ullrich Schäfer 95ac79b093 Propose: notifications
Closes the social loop opened by social-feed: a Pending follower has no
way to know their request was approved, and a follower has no signal at
all that someone they follow just posted. Adds a notifications surface
for three v1 event types — follow_request_approved, follow_received,
activity_published — plus a /notifications page, navbar unread count,
and mark-as-read controls.

Capabilities:
- New: notifications (table, page, badge, generation hooks)
- Modified: social-follows (approve + auto-accept emit)
- Modified: activity-feed (public create fans out)
- Modified: journal-landing (nav entry alongside follow-requests)

Design picks:
- Fan-out-on-write for activity_published (1:N) so /notifications is a
  flat single-table query and "mark read" composes trivially. 1:1
  events insert directly. Cost ceiling documented at 10k followers ×
  50 activities/day = 500k/day, still trivial; revisit only if hot.
- Single notifications table with loose subject_id (no per-type FK);
  renderer dereferences by type. Mastodon-style.
- Two distinct nav entries (Follow requests + Notifications). Pending
  is "act on this", notifications is "this happened" — different
  semantics, kept separate.
- Loader-driven unread count (no real-time channel). Real-time is
  deferred.
- 90-day retention for read rows; unread kept indefinitely.

Out of scope: per-type mute preferences, email/push, real-time,
notifications about routes/replies/mentions, federated notifications.
Each tracked as follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:12:35 +02:00

58 lines
8.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

## Why
The locked-account model in `social-feed` introduced a real "needs your attention" surface (Pending follow requests) and the social feed introduced an "interesting things you might want to know about" surface (a friend posted a new public activity). Today both are invisible unless the user proactively browses the right page — a pending requester has no way to know their request was approved without checking the followed profile, and a follow has no signal at all that someone they follow just posted.
Without a notifications surface, the social loop stays cold: users miss approvals, miss new content, and the locked-account flow feels broken because Pending feels like a one-way drop. A small, focused notifications layer closes the loop.
## What Changes
- New `notifications` table on the Journal: `recipient_user_id`, `type`, `actor_user_id`, `subject_id`, `payload JSONB`, `payload_version INT`, `read_at`, `created_at`. Unread is `read_at IS NULL`. The `payload` snapshots the renderer-friendly fields (display name, activity name, etc.) at creation time so future mobile/email/push consumers can render notifications without a fresh DB lookup; `payload_version` lets us evolve the per-type payload schema without breaking old rows.
- Four notification types in v1:
- `follow_request_received`: when someone requests to follow a private user, the followed user gets a notification. The notification card links to `/follows/requests` (where Approve / Reject lives); marking the notification read does NOT change the request state — they are independent surfaces, mirroring Mastodon's pattern. The existing follow-requests count badge stays as the actionable surface; this notification is the historical "this happened" record.
- `follow_request_approved`: when a private user approves an incoming Pending follow, the follower (now accepted) gets a notification.
- `follow_received`: when a public user is followed (auto-accept path), the followed user gets a notification.
- `activity_published`: when a user publishes a `public` activity, every accepted follower of theirs gets a notification (server-side fan-out at write time).
- New `/notifications` page — signed-in only — listing the user's notifications reverse-chronological with read/unread state. Inline actions where they make sense (e.g. "View profile" / "View activity").
- New navbar bell-style entry (or repurposed badge) showing the unread count, linking to `/notifications`. The existing `/follows/requests` link stays distinct because Pending requests want their own dedicated surface — the bell tracks "things that have already happened to you," follow-requests is "things you can act on right now."
- Mark-as-read: clicking a notification marks that item read; an explicit "Mark all read" action on `/notifications` clears the badge.
- **Live unread-count updates via Server-Sent Events.** A `/api/events` endpoint streams a small `notifications.unread { count }` event to the user's open browser tabs whenever their unread count changes — generation hooks broadcast through an in-process registry. The navbar badge listens for these events and updates without a page reload. Loader-driven count remains the source of truth on initial render and after navigation; SSE is the "keep it fresh between navigations" channel.
- Generation hooks:
- `followUser` (Pending path, private target) → insert `follow_request_received` for the target.
- `followUser` (auto-accepted public path) → insert `follow_received` for the target.
- `approveFollowRequest` → insert `follow_request_approved` for the follower.
- `createActivity` (when `visibility = 'public'`) → enqueue a pg-boss fan-out job that inserts `activity_published` rows for all accepted followers.
- Privacy manifest entry documenting the new relation: who got notified about what, retained for read-state and recency only.
## Out of scope (tracked as follow-ups)
- **Notification preferences / per-type mutes.** Single global on/off would already be wider than this change wants to be. Default is "all types on"; a future change adds a settings toggle.
- **Email digest** of unread notifications.
- **WebSocket delivery / bidirectional real-time channel.** v1 ships SSE for one-way badge updates. Bidirectional (chat-style features, presence, typing indicators) is a separate transport with separate concerns and not needed for the notifications use case.
- **Multi-process broadcast (Redis pub/sub).** SSE in v1 is in-process — fine while we run a single Journal container. When we go multi-process we'll need Redis (or equivalent) to fan out events across processes; tracked as a follow-up.
- **Live updates on the `/notifications` page itself.** SSE drives only the navbar badge in v1. If you're sitting on `/notifications`, new rows show up on next navigation/refresh. Live-prepending rows is straightforward to add later but not core to closing the social loop.
- **Notifications about routes.** Activities are the primary social-feed object; routes get linked from activities, so route-level notifications would mostly duplicate.
- **Notifications about replies / mentions / reactions.** trails.cool doesn't have any of these surfaces yet.
- **Cross-instance (federated) notifications.** The fan-out runs only against the local `follows` table. When `social-federation` lands, the remote half is its own design problem (push to remote inboxes vs. let remote instances poll us — handled there).
## Capabilities
### New Capabilities
- `notifications`: the `notifications` table, the `/notifications` page, the unread badge, and the generation hooks for the three v1 types.
### Modified Capabilities
- `social-follows`: existing `approveFollowRequest` and `followUser` (public-path) calls emit notifications. Existing follow lifecycle is unchanged.
- `activity-feed`: existing `createActivity` flow emits the fan-out notifications when `visibility = 'public'`. The notification rows live in `notifications`, separate from the `/feed` query.
- `journal-landing`: navbar gets a bell entry alongside the existing follow-requests badge.
## Impact
- **Code**: new `notifications` table (Drizzle + migration), `notifications.server.ts` (`createNotification` / `listForUser` / `markRead` / `markAllRead` / `countUnread`) plus a `linkFor(notification): { web, mobile, email }` helper consulted by every renderer. Generation hooks wired into `follow.server.ts` and `activities.server.ts` populate `subject_id` + a versioned `payload` snapshot at create time. New `/notifications` route. pg-boss recurring + on-demand jobs for activity fan-out. Plus an in-process **events broker** (`events.server.ts`) — a `Map<userId, Set<{controller, lastEventId}>>` plus `emitTo(userId, event)` — that the generation hooks call to broadcast over open SSE connections.
- **API**: `POST /api/notifications/:id/read`, `POST /api/notifications/read-all`. New `GET /api/events` (SSE, session-bound) streaming `notifications.unread { count }` events. Loader-driven counts in the navbar (root loader extended) remain the source of truth on initial render and after navigation.
- **Client**: a small `useEventStream` hook wired into the root layout. Opens an `EventSource` for signed-in users, listens for `notifications.unread` events, and updates the navbar badge in place. Auto-reconnects (browser native EventSource behavior + a server-suggested `retry:` interval).
- **UI**: `/notifications` page with notification cards, "Mark all read" control, bell-style nav entry with unread count badge.
- **Operational**: fan-out-on-write for activity_published creates one row per accepted follower at activity-create time. At trails.cool's current scale this is trivial; if a user with 10k followers posts, that's 10k inserts in a single pg-boss job. Documented as the scaling line we'd revisit before we ever cross it. SSE adds a long-lived HTTP connection per signed-in tab; ~510 KB memory per connection. Caddy needs no special config beyond enabling text/event-stream pass-through (it already does). On deploy every connection drops and reconnects with jittered backoff; no special handling needed.
- **Privacy manifest**: documents the new `notifications` relation and the retention policy (default: keep until user marks read or 90 days, whichever later — implementation detail in design.md).
- **Dependencies**: none new; pg-boss is already in the stack.
- **Forward-compat**: when `social-federation` lands, remote `Accept(Follow)` activities can emit a local `follow_request_approved` notification using the same row shape — `actor_user_id` would become nullable and the IRI of the remote actor goes into `subject_id` (or a denormalized `actor_iri` column added at that time). Schema is reasonably stable. The events broker stays single-process; when the Journal goes multi-process we swap the in-process `Map` for a Redis pub/sub adapter behind the same `emitTo(userId, event)` interface.