Folds the actionable follow-requests surface into the Notifications page as a Requests tab (alongside the existing Activity tab), so the navbar exposes a single bell instead of two adjacent inboxes. The Requests tab shows a count badge for pending rows regardless of read state, while the bell badge keeps reflecting the unread-notifications count (which already covers `follow_request_received` rows). The standalone /follows/requests URL is preserved as a 301 redirect so prior notification deep-links, emails, and bookmarks still resolve. Driven by the IA review captured in docs/information-architecture.md. Specs (notifications, social-follows, journal-landing) are updated in the same change to reflect the new structure. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
14 KiB
notifications Specification
Purpose
In-app notifications for things that happened to a user that they didn't trigger themselves: their follow request was approved, someone followed them, a friend posted a public activity. The /notifications page is a tabbed inbox with two surfaces — the Activity tab (the read-only event log) and the Requests tab (the actionable surface for incoming Pending follow requests, where Approve / Reject lives). /feed is a separate destination for content from people you follow. Includes the notifications table, generation hooks, the /notifications page, mark-read APIs, retention, and the SSE-driven live unread badge (the SSE transport itself lives in sse-broker).
Requirements
Requirement: Notification rows for follow + activity events
The Journal SHALL maintain a notifications table where each row records a single event the recipient should be informed about. Four event types SHALL be produced in v1: follow_request_received, follow_request_approved, follow_received, and activity_published. Each row SHALL include the recipient user, the actor (the user who caused the event, nullable for system or deleted-actor cases), a subject_id whose meaning depends on type (follow row id for follow events; activity id for activity events), a payload JSONB snapshot of the renderer-friendly fields at create time, and a payload_version (INT, default 1) that documents the per-type payload schema version. Rows SHALL track read state via a nullable read_at timestamp.
Scenario: Pending follow request emits a notification to the target
- WHEN
followUsercreates a Pending row against a private target - THEN a
follow_request_receivednotification is created withrecipient_user_id= the followed user,actor_user_id= the requester,subject_id= the follow row id
Scenario: Approval emits a notification to the requester
- WHEN
approveFollowRequestsucceeds against a Pending row - THEN a
follow_request_approvednotification is created withrecipient_user_id= the follower,actor_user_id= the followed user,subject_id= the follow row id
Scenario: Auto-accepted public follow emits a notification to the target
- WHEN
followUsercreates an accepted row against a public target (auto-accept path) - THEN a
follow_receivednotification is created withrecipient_user_id= the followed user,actor_user_id= the follower,subject_id= the follow row id
Scenario: Public activity creation fans out notifications to followers
- WHEN a user creates an activity with
visibility = 'public' - THEN a pg-boss job is enqueued that inserts an
activity_publishednotification per accepted follower withrecipient_user_id= follower,actor_user_id= activity owner,subject_id= activity id
Scenario: Re-following does not duplicate the notification
- WHEN a user calls
followUseragainst a target they already follow (returns existing state without creating a new row) - THEN no new
follow_receivedorfollow_request_receivednotification is created
Scenario: follow_request_received survives request resolution
- WHEN a target approves, rejects, or the requester cancels a Pending follow that previously emitted
follow_request_received - THEN the
follow_request_receivednotification row stays in the database with its existing read-state intact (the event happened; the historical record is preserved independently of the request's eventual outcome)
Scenario: follow_request_received card on the Activity tab links to the Requests tab, not inline Approve/Reject
- WHEN a recipient renders a
follow_request_receivednotification on the Activity tab of/notifications - THEN the card shows a "Review request" link that navigates to
/notifications?tab=requests; Approve / Reject buttons are NOT rendered on the Activity tab
Scenario: Read-state is independent from request resolution
- WHEN a recipient marks a
follow_request_receivednotification read OR approves/rejects the request - THEN the OTHER surface is unaffected — marking read does not approve/reject; approve/reject does not mark the notification read
Requirement: Versioned payload snapshot for offline renderers
Each notification row SHALL include a payload (JSONB) capturing the denormalized fields needed to render the row without a live DB lookup, and a payload_version (INT) recording the per-type schema version of payload. The web renderer SHALL prefer live data when the subject is still reachable and SHALL fall back to the payload when the subject has been deleted, gone private, or is otherwise unreachable. Future renderers (mobile push, email) SHALL read the payload directly.
Scenario: follow notifications snapshot the follower / target
- WHEN a
follow_request_received,follow_received, orfollow_request_approvednotification is created - THEN the row's
payloadrecords the relevant party'susernameanddisplayName, andpayload_version = 1
Scenario: activity_published snapshots the activity + owner
- WHEN an
activity_publishednotification is created - THEN the row's
payloadrecordsactivityId,activityName,ownerUsername, andownerDisplayName, andpayload_version = 1
Scenario: Web renderer falls back to snapshot if subject is unreachable
- WHEN a recipient renders a notification whose subject (e.g. activity) has been deleted or made private since creation
- THEN the renderer uses the
payloadfields (e.g. activity name from the snapshot) so the row still has meaningful copy; click-through degrades gracefully (the link target may 404, which the user sees once they click)
Requirement: Single linkFor helper produces per-platform deep links
The Journal SHALL expose a single server-side helper linkFor(notification) returning a LinkBundle with at minimum a web path and (for forward-compat) mobile and email URL variants. Every renderer (web loader, future mobile push formatter, future email formatter) SHALL use this helper so the type-to-URL mapping lives in exactly one file.
Scenario: Web loader resolves a click-through link
- WHEN the
/notificationsloader prepares a row for render - THEN the row carries
linkFor(row).webso the card's anchor can navigate to the right page (/activities/...,/users/..., or/notifications?tab=requests)
Scenario: Helper builds links from subject_id with payload fallback
- WHEN
linkForis invoked on a notification whosesubject_idis non-null - THEN the path is built from
subject_id; ifsubject_idis null, the helper falls back to the relevantpayloadfield (e.g.payload.activityId)
Scenario: Helper outputs a trails:// mobile scheme
- WHEN
linkForis invoked for any v1 notification type - THEN the returned
LinkBundle.mobileis atrails://URL pointing at the same logical destination asweb
Scenario: Activity changing to private after publish
- WHEN an activity that already triggered fan-out notifications is later changed from
publictoprivate - THEN the existing notification rows remain in the database but are filtered out of the recipient's
/notificationslisting (the renderer skips rows whose subject the recipient can no longer see)
Requirement: Notifications page and unread count
The Journal SHALL expose /notifications to signed-in users only. The page is the user's single inbox surface and SHALL render two tabs selectable via ?tab=: Activity (default, the read-only event log) and Requests (the actionable list of incoming Pending follow requests with Approve / Reject controls — the same surface previously at /follows/requests, which now 301-redirects here). The Activity tab SHALL list notifications reverse-chronological with each row showing the actor's display name + handle, a type-specific summary line, the timestamp, and a clickable link to the relevant subject. The Activity tab SHALL paginate via opaque cursor (a before query parameter); when more rows exist past the current page, it SHALL surface a "Load older" affordance that fetches the next page using the cursor. Page size defaults to 50 and is capped at 100. The Requests tab SHALL list pending rows reverse-chronological by request creation time and SHALL render Approve / Reject buttons per row (delegated to /api/follows/:id/approve|reject). The navbar SHALL surface a single bell entry with an unread count badge linking to /notifications; the count is notifications.read_at IS NULL for the current user. The Requests tab itself SHALL render its own count indicator (the pending-request count, regardless of read state) so the user can see they still have action items even if the underlying notifications have been read. Logged-out visitors requesting /notifications (any tab) SHALL be redirected to /auth/login. The live-update transport for the unread badge is sse-broker (/api/events); this spec only requires the badge to reflect the count.
Scenario: Logged-in user with notifications (Activity tab)
- WHEN a signed-in user with N notifications (N ≤ page size) loads
/notifications(or/notifications?tab=activity) - THEN the Activity tab is selected and lists all N rows reverse-chronological by
created_at, with unread rows visually distinct from read rows
Scenario: Logged-in user with no notifications (Activity tab)
- WHEN a signed-in user with zero notifications loads
/notifications - THEN the Activity tab renders an empty-state message; the Requests tab remains selectable
Scenario: Logged-in user with pending follow requests (Requests tab)
- WHEN a signed-in user with M Pending incoming follow requests loads
/notifications?tab=requests - THEN the Requests tab is selected and lists all M rows reverse-chronological by request creation time, with Approve and Reject buttons per row
Scenario: Logged-in user with no pending follow requests (Requests tab)
- WHEN a signed-in user with zero pending requests loads
/notifications?tab=requests - THEN the Requests tab renders the empty-state message
Scenario: /follows/requests still resolves to the Requests tab
- WHEN any visitor (anonymous or signed-in) requests
/follows/requests - THEN the server responds with HTTP 301 to
/notifications?tab=requests, preserving deep-links from existing notifications, emails, and external bookmarks
Scenario: Anonymous request
- WHEN an unauthenticated visitor requests
/notifications(any tab) - THEN they are redirected to
/auth/login
Scenario: Navbar badge reflects unread count
- WHEN a signed-in user has K > 0 unread notifications
- THEN the bell entry in the navbar renders with a count badge showing K
- AND when K = 0, the entry renders without a badge
Scenario: Requests tab badge reflects pending count regardless of read state
- WHEN a signed-in user has M > 0 pending follow requests, even if all corresponding
follow_request_receivednotifications have been read - THEN the Requests tab in the page header renders with a count badge showing M (the user still has actions to take)
- AND when M = 0, the tab renders without a badge
Scenario: Paginates older notifications via cursor
- WHEN a signed-in user has more notifications than fit in one page and clicks "Load older"
- THEN the next request includes the previous response's cursor as
?before=<cursor>and the page renders the next batch, strictly older than the cursor's(created_at, id)position - AND when the final page has been reached the response no longer surfaces "Load older"
Scenario: Cursor is stable across rows with identical timestamps
- WHEN two notification rows share the exact same
created_at - THEN pagination orders them deterministically by
id(descending) as a tiebreaker, so neither page omits nor duplicates them
Scenario: Malformed cursor is ignored, not surfaced as an error
- WHEN a request reaches
/notificationswith abeforevalue that doesn't decode to a valid cursor - THEN the page renders from the top (newest rows) instead of returning a 400, since the cursor is opaque and clients should not need to validate it
Requirement: Mark-as-read controls
A signed-in user SHALL be able to mark an individual notification as read via POST /api/notifications/:id/read, and to mark all of their unread notifications as read via POST /api/notifications/read-all. Both endpoints are owner-bound: only the recipient may mark their own notifications.
Scenario: Click-through marks the row read and navigates to the subject
- WHEN a user clicks an unread notification on
/notifications - THEN the row's
read_atis set tonow()and the user is navigated to the subject page (e.g., the activity, or the follower's profile)
Scenario: Mark-all-read clears the unread badge
- WHEN a user invokes "Mark all read"
- THEN every notification belonging to that user with
read_at IS NULLis updated toread_at = now(), and the navbar unread count drops to 0
Scenario: Owner-bound enforcement
- WHEN a user attempts to mark another user's notification read via the API
- THEN the API returns 404 (no leak of recipient identity), and the row is unchanged
Requirement: Retention of read notifications
The Journal SHALL retain unread notifications indefinitely and SHALL delete read notifications older than 90 days via a daily pg-boss job, to bound table growth without losing actionable history.
Scenario: Read notification past retention window
- WHEN the retention job runs and finds notifications with
read_at IS NOT NULLANDread_at < now() - interval '90 days' - THEN those rows are deleted
Scenario: Long-unread notification is preserved
- WHEN a notification has
read_at IS NULLfor more than 90 days - THEN the retention job does NOT delete it (only read notifications expire)
Requirement: Cascade on user deletion
Deleting a user account SHALL cascade-delete all notifications where they are the recipient. Notifications where they are the actor_user_id SHALL set actor_user_id to NULL and remain so the recipient's history is preserved with a generic actor reference.
Scenario: Recipient deletes account
- WHEN a user deletes their own account
- THEN every notification with
recipient_user_id = that useris deleted
Scenario: Actor deletes account
- WHEN a user who has caused notifications deletes their account
- THEN the recipient's notifications remain, with
actor_user_idset to NULL; the renderer surfaces a generic "Someone" attribution