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>
58 lines
8.8 KiB
Markdown
58 lines
8.8 KiB
Markdown
## 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; ~5–10 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.
|