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>
This commit is contained in:
parent
937bde3b0b
commit
95ac79b093
8 changed files with 591 additions and 0 deletions
16
openspec/changes/notifications/specs/activity-feed/spec.md
Normal file
16
openspec/changes/notifications/specs/activity-feed/spec.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Public activity creation fans out notifications
|
||||
Creating an activity with `visibility = 'public'` SHALL enqueue a fan-out job that inserts an `activity_published` notification for every accepted follower of the activity owner. The fan-out SHALL run asynchronously so the activity-creation request returns immediately.
|
||||
|
||||
#### Scenario: Public activity fans out
|
||||
- **WHEN** a user with N accepted followers creates an activity with `visibility = 'public'`
|
||||
- **THEN** a pg-boss job is enqueued, and on completion N notifications exist with `type = 'activity_published'`, `recipient_user_id` ∈ accepted-followers, `actor_user_id` = activity owner, `subject_id` = activity id
|
||||
|
||||
#### Scenario: Private or unlisted activity does not fan out
|
||||
- **WHEN** a user creates an activity with `visibility = 'private'` or `'unlisted'`
|
||||
- **THEN** no fan-out job is enqueued and no notifications are created
|
||||
|
||||
#### Scenario: No accepted followers means no notifications
|
||||
- **WHEN** a user with zero accepted followers creates a public activity
|
||||
- **THEN** the fan-out job runs and inserts zero rows
|
||||
12
openspec/changes/notifications/specs/journal-landing/spec.md
Normal file
12
openspec/changes/notifications/specs/journal-landing/spec.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Notifications entry in the navbar
|
||||
The navbar SHALL render a "Notifications" entry for signed-in users, linking to `/notifications`. It SHALL render an unread count badge when the user has at least one unread notification, and no badge when the count is zero. This entry SHALL be distinct from the existing "Follow requests" entry — they cover different categories (informational vs. actionable).
|
||||
|
||||
#### Scenario: Signed-in user sees the entry
|
||||
- **WHEN** a signed-in user loads any page
|
||||
- **THEN** the navbar includes a "Notifications" link to `/notifications`, and a count badge if the user has unread rows
|
||||
|
||||
#### Scenario: Anonymous user does not see the entry
|
||||
- **WHEN** an unauthenticated visitor loads any page
|
||||
- **THEN** the navbar does not include the Notifications entry (the route requires authentication anyway)
|
||||
127
openspec/changes/notifications/specs/notifications/spec.md
Normal file
127
openspec/changes/notifications/specs/notifications/spec.md
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
## ADDED 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** `followUser` creates a Pending row against a private target
|
||||
- **THEN** a `follow_request_received` notification is created with `recipient_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** `approveFollowRequest` succeeds against a Pending row
|
||||
- **THEN** a `follow_request_approved` notification is created with `recipient_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** `followUser` creates an accepted row against a public target (auto-accept path)
|
||||
- **THEN** a `follow_received` notification is created with `recipient_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_published` notification per accepted follower with `recipient_user_id` = follower, `actor_user_id` = activity owner, `subject_id` = activity id
|
||||
|
||||
#### Scenario: Re-following does not duplicate the notification
|
||||
- **WHEN** a user calls `followUser` against a target they already follow (returns existing state without creating a new row)
|
||||
- **THEN** no new `follow_received` or `follow_request_received` notification 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_received` notification 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 links to /follows/requests, not inline Approve/Reject
|
||||
- **WHEN** a recipient renders a `follow_request_received` notification on `/notifications`
|
||||
- **THEN** the card shows a "Review request" link that navigates to `/follows/requests`; Approve / Reject buttons are NOT rendered on `/notifications`
|
||||
|
||||
#### Scenario: Read-state is independent from request resolution
|
||||
- **WHEN** a recipient marks a `follow_request_received` notification 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`, or `follow_request_approved` notification is created
|
||||
- **THEN** the row's `payload` records the relevant party's `username` and `displayName`, and `payload_version = 1`
|
||||
|
||||
#### Scenario: activity_published snapshots the activity + owner
|
||||
- **WHEN** an `activity_published` notification is created
|
||||
- **THEN** the row's `payload` records `activityId`, `activityName`, `ownerUsername`, and `ownerDisplayName`, and `payload_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 `payload` fields (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 `/notifications` loader prepares a row for render
|
||||
- **THEN** the row carries `linkFor(row).web` so the card's anchor can navigate to the right page (`/activities/...`, `/users/...`, or `/follows/requests`)
|
||||
|
||||
#### Scenario: Helper builds links from subject_id with payload fallback
|
||||
- **WHEN** `linkFor` is invoked on a notification whose `subject_id` is non-null
|
||||
- **THEN** the path is built from `subject_id`; if `subject_id` is null, the helper falls back to the relevant `payload` field (e.g. `payload.activityId`)
|
||||
|
||||
#### Scenario: Helper outputs a `trails://` mobile scheme
|
||||
- **WHEN** `linkFor` is invoked for any v1 notification type
|
||||
- **THEN** the returned `LinkBundle.mobile` is a `trails://` URL pointing at the same logical destination as `web`
|
||||
|
||||
#### Scenario: Activity changing to private after publish
|
||||
- **WHEN** an activity that already triggered fan-out notifications is later changed from `public` to `private`
|
||||
- **THEN** the existing notification rows remain in the database but are filtered out of the recipient's `/notifications` listing (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 SHALL list the user's 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 navbar SHALL surface an unread count badge linking to `/notifications`; the count is `notifications.read_at IS NULL` for the current user. Logged-out visitors requesting `/notifications` SHALL be redirected to `/auth/login`.
|
||||
|
||||
#### Scenario: Logged-in user with notifications
|
||||
- **WHEN** a signed-in user with N notifications loads `/notifications`
|
||||
- **THEN** the page lists all N rows reverse-chronological by `created_at`, with unread rows visually distinct from read rows
|
||||
|
||||
#### Scenario: Logged-in user with no notifications
|
||||
- **WHEN** a signed-in user with zero notifications loads `/notifications`
|
||||
- **THEN** the page renders an empty-state message
|
||||
|
||||
#### Scenario: Anonymous request
|
||||
- **WHEN** an unauthenticated visitor requests `/notifications`
|
||||
- **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 "Notifications" navbar entry renders with a count badge showing K
|
||||
- **AND** when K = 0, the entry renders without a badge
|
||||
|
||||
### 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_at` is set to `now()` 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 NULL` is updated to `read_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 NULL` AND `read_at < now() - interval '90 days'`
|
||||
- **THEN** those rows are deleted
|
||||
|
||||
#### Scenario: Long-unread notification is preserved
|
||||
- **WHEN** a notification has `read_at IS NULL` for 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 user` is deleted
|
||||
|
||||
#### Scenario: Actor deletes account
|
||||
- **WHEN** a user who has caused notifications deletes their account
|
||||
- **THEN** the recipient's notifications remain, with `actor_user_id` set to NULL; the renderer surfaces a generic "Someone" attribution
|
||||
24
openspec/changes/notifications/specs/social-follows/spec.md
Normal file
24
openspec/changes/notifications/specs/social-follows/spec.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Follow lifecycle emits notifications
|
||||
The follow lifecycle SHALL produce notifications for the recipient of the social event (see `notifications` spec):
|
||||
|
||||
- Auto-accepted public follow → `follow_received` to the followed user.
|
||||
- Approved Pending request → `follow_request_approved` to the follower (now accepted).
|
||||
- Reject and unfollow do NOT produce notifications.
|
||||
|
||||
#### Scenario: Public auto-accept notifies the target
|
||||
- **WHEN** a user follows another user whose `profile_visibility = 'public'` (auto-accept path)
|
||||
- **THEN** a `follow_received` notification is created for the followed user
|
||||
|
||||
#### Scenario: Approval notifies the requester
|
||||
- **WHEN** a private user approves a Pending follow request
|
||||
- **THEN** a `follow_request_approved` notification is created for the follower
|
||||
|
||||
#### Scenario: Reject does not notify
|
||||
- **WHEN** a private user rejects a Pending follow request
|
||||
- **THEN** no notification is created (silent rejection — the follower can re-request later if they want)
|
||||
|
||||
#### Scenario: Unfollow does not notify
|
||||
- **WHEN** a follower unfollows or cancels a Pending request
|
||||
- **THEN** no notification is created on the followed side
|
||||
Loading…
Add table
Add a link
Reference in a new issue