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>
8.8 KiB
8.8 KiB
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
notificationstable on the Journal:recipient_user_id,type,actor_user_id,subject_id,payload JSONB,payload_version INT,read_at,created_at. Unread isread_at IS NULL. Thepayloadsnapshots 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_versionlets 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 apublicactivity, every accepted follower of theirs gets a notification (server-side fan-out at write time).
- New
/notificationspage — 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/requestslink 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
/notificationsclears the badge. - Live unread-count updates via Server-Sent Events. A
/api/eventsendpoint streams a smallnotifications.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) → insertfollow_request_receivedfor the target.followUser(auto-accepted public path) → insertfollow_receivedfor the target.approveFollowRequest→ insertfollow_request_approvedfor the follower.createActivity(whenvisibility = 'public') → enqueue a pg-boss fan-out job that insertsactivity_publishedrows 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
/notificationspage 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
followstable. Whensocial-federationlands, the remote half is its own design problem (push to remote inboxes vs. let remote instances poll us — handled there).
Capabilities
New Capabilities
notifications: thenotificationstable, the/notificationspage, the unread badge, and the generation hooks for the three v1 types.
Modified Capabilities
social-follows: existingapproveFollowRequestandfollowUser(public-path) calls emit notifications. Existing follow lifecycle is unchanged.activity-feed: existingcreateActivityflow emits the fan-out notifications whenvisibility = 'public'. The notification rows live innotifications, separate from the/feedquery.journal-landing: navbar gets a bell entry alongside the existing follow-requests badge.
Impact
- Code: new
notificationstable (Drizzle + migration),notifications.server.ts(createNotification/listForUser/markRead/markAllRead/countUnread) plus alinkFor(notification): { web, mobile, email }helper consulted by every renderer. Generation hooks wired intofollow.server.tsandactivities.server.tspopulatesubject_id+ a versionedpayloadsnapshot at create time. New/notificationsroute. pg-boss recurring + on-demand jobs for activity fan-out. Plus an in-process events broker (events.server.ts) — aMap<userId, Set<{controller, lastEventId}>>plusemitTo(userId, event)— that the generation hooks call to broadcast over open SSE connections. - API:
POST /api/notifications/:id/read,POST /api/notifications/read-all. NewGET /api/events(SSE, session-bound) streamingnotifications.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
useEventStreamhook wired into the root layout. Opens anEventSourcefor signed-in users, listens fornotifications.unreadevents, and updates the navbar badge in place. Auto-reconnects (browser native EventSource behavior + a server-suggestedretry:interval). - UI:
/notificationspage 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
notificationsrelation 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-federationlands, remoteAccept(Follow)activities can emit a localfollow_request_approvednotification using the same row shape —actor_user_idwould become nullable and the IRI of the remote actor goes intosubject_id(or a denormalizedactor_iricolumn added at that time). Schema is reasonably stable. The events broker stays single-process; when the Journal goes multi-process we swap the in-processMapfor a Redis pub/sub adapter behind the sameemitTo(userId, event)interface.