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
2
openspec/changes/notifications/.openspec.yaml
Normal file
2
openspec/changes/notifications/.openspec.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-04-25
|
||||
265
openspec/changes/notifications/design.md
Normal file
265
openspec/changes/notifications/design.md
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
## Context
|
||||
|
||||
After `social-feed` (PR #310, locked-account model), the Journal has two distinct surfaces for "user owes attention to something":
|
||||
|
||||
- **`/follows/requests`** — Pending incoming follows the user can act on. The navbar already shows a count badge.
|
||||
- **No surface yet** — anything that already happened (your follow request was approved, someone followed you, a friend posted a public activity).
|
||||
|
||||
The follow-request badge covers the "act on this" case. This change adds the "be informed about this" case — historical, read-once notifications for events the user couldn't have triggered themselves.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- A user can see a chronological list of recent things that happened to them on the instance (follow approved/received, friend posted public activity).
|
||||
- The navbar surfaces an unread count so users know there's something new without visiting `/notifications` first.
|
||||
- Generation is centralized — three call sites (approve, follow public, create public activity) own the entire notification stream in v1.
|
||||
- Mark-as-read is per-row and bulk; once read, the row stays around for ~90 days for context but doesn't contribute to the unread badge.
|
||||
- The `/feed` page and the `/notifications` page have **distinct** purposes — feed is "content from people I follow," notifications are "events that happened to me."
|
||||
|
||||
**Non-Goals:**
|
||||
- Per-type notification preferences (mute "follow received" only). Single global default-on. A future change adds settings.
|
||||
- Email digests / push notifications / real-time WebSocket delivery.
|
||||
- Notifications about routes (activities are the unit), replies, mentions, reactions — none of those surfaces exist yet.
|
||||
- Federation. v1 fans out only against local `follows`; remote inbound is `social-federation`'s job.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision: Fan-out-on-write for `activity_published`, single row for the others
|
||||
|
||||
Two notification origins:
|
||||
|
||||
- **`follow_request_approved`** and **`follow_received`** are 1:1 events — a single row goes to a specific user at the moment of the action. Direct insert in the same request as the follow API.
|
||||
- **`activity_published`** is 1:N — when a user publishes a public activity, every accepted follower needs a row. Fan-out happens in a pg-boss job (`fanout-activity-notifications`) so the API request that created the activity returns immediately; the job inserts N notification rows asynchronously.
|
||||
|
||||
**Why fan-out-on-write rather than read-time join:** read-time would mean every load of `/notifications` joins `follows × activities × marks-read`, which is doable but doesn't compose with bulk "mark all read" (you'd need a per-(follower, activity) read-state row anyway). Fan-out gives us simple per-row semantics and reuses one query path for all notification types. At trails.cool's scale (hundreds of users, occasional public activities), the cost is dwarfed by what the activity-creation handler already does.
|
||||
|
||||
**Cost ceiling:** at 10k followers × 50 activities/day = 500k inserts/day. Still trivial for Postgres; if it ever becomes hot, batch insert or move to a per-user "last-seen-cursor" model. Not a concern in this change; called out so future-us has the explicit waiver.
|
||||
|
||||
### Decision: Schema — single table, loose `subject_id`, versioned `payload` snapshot
|
||||
|
||||
```sql
|
||||
CREATE TABLE journal.notifications (
|
||||
id UUID PRIMARY KEY,
|
||||
recipient_user_id TEXT NOT NULL REFERENCES journal.users(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL, -- 'follow_request_received' | 'follow_request_approved' | 'follow_received' | 'activity_published'
|
||||
actor_user_id TEXT REFERENCES journal.users(id) ON DELETE SET NULL,
|
||||
subject_id TEXT, -- type-dependent: follow row id, activity id
|
||||
payload JSONB, -- denormalized snapshot for offline renderers (mobile push, email)
|
||||
payload_version INT NOT NULL DEFAULT 1, -- per-type payload schema version
|
||||
read_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX notifications_recipient_unread_idx
|
||||
ON journal.notifications(recipient_user_id, created_at DESC)
|
||||
WHERE read_at IS NULL;
|
||||
|
||||
CREATE INDEX notifications_recipient_created_idx
|
||||
ON journal.notifications(recipient_user_id, created_at DESC);
|
||||
```
|
||||
|
||||
**Why one table, not per-type:** all four v1 types have small, similar shapes (IDs + a tiny payload). One table keeps the unread count + listing query trivial (`SELECT … WHERE recipient_user_id = ? ORDER BY created_at DESC`). When/if types diverge (rich payload, scheduled notifications, etc.), we re-evaluate.
|
||||
|
||||
**Why loose `subject_id` (not a discriminator + per-type FK):** the renderer dereferences by `type` anyway. A typed FK would force NULL columns for every notification of the wrong type and add four FK constraints we can't fully populate. Loose is simpler. Renderer is responsible for graceful "missing referent" (activity deleted, follow undone) — same pattern Mastodon uses.
|
||||
|
||||
**Why `actor_user_id` nullable + ON DELETE SET NULL:** if the actor account is deleted, we keep the notification row but show "someone" rather than dropping the user's history. This is a small detail but matters for "someone followed you 6 months ago" types of records.
|
||||
|
||||
### Decision: Versioned per-type `payload` snapshot for offline renderers
|
||||
|
||||
`payload` (JSONB, nullable) holds a small bag of denormalized fields needed to render the notification *without* a live DB lookup. `payload_version` (INT, default 1) lets us evolve the per-type payload schema additively without breaking old rows.
|
||||
|
||||
**Per-type v1 payload shapes:**
|
||||
|
||||
```ts
|
||||
// follow_request_received | follow_received
|
||||
type FollowPayloadV1 = {
|
||||
followerUsername: string; // e.g., "alice"
|
||||
followerDisplayName: string | null;
|
||||
};
|
||||
|
||||
// follow_request_approved
|
||||
type ApprovalPayloadV1 = {
|
||||
targetUsername: string;
|
||||
targetDisplayName: string | null;
|
||||
};
|
||||
|
||||
// activity_published
|
||||
type ActivityPayloadV1 = {
|
||||
activityId: string;
|
||||
activityName: string;
|
||||
ownerUsername: string;
|
||||
ownerDisplayName: string | null;
|
||||
};
|
||||
```
|
||||
|
||||
The web renderer prefers **live data** when the subject is still reachable (current display name, current activity title) and falls back to the snapshot when the subject is missing. Mobile push / email renderers (later) read the snapshot directly — they don't have a session to dereference live data anyway, and a slightly stale display is better than a refetch round-trip per push.
|
||||
|
||||
**Why a separate `payload_version` column** rather than embedding `_v` in the JSON:
|
||||
- Cheap to query "anything still on v1" without JSONB extraction.
|
||||
- Indexable if we ever need to.
|
||||
- Explicit at the schema layer: it's clearly a versioning concern, not application data.
|
||||
- Cost: one INT column. Trivial.
|
||||
|
||||
**Forward path for schema evolution (e.g., bumping `activity_published` to v2 with a `distance` field):**
|
||||
1. Bump `payload_version` to 2 in the generation hook for new rows.
|
||||
2. Update renderer to handle both v1 (no `distance`) and v2 (with `distance`).
|
||||
3. Optionally backfill — usually unnecessary for notifications because retention ages out v1 rows naturally (read rows in 90 days; unread rarely hangs around that long).
|
||||
|
||||
If at some point we want to *remove* fields, that's a v3 bump and a renderer that handles all three. Same pattern as any forward-compatible API.
|
||||
|
||||
### Decision: A single `linkFor(notification)` helper produces per-platform deep links
|
||||
|
||||
Every renderer (web loader, future mobile push formatter, future email formatter) calls one helper:
|
||||
|
||||
```ts
|
||||
// apps/journal/app/lib/notifications/link-for.ts (sketch)
|
||||
export type LinkBundle = {
|
||||
web: string; // path-relative for in-app navigation
|
||||
mobile?: string; // trails:// scheme for native deep linking
|
||||
email?: string; // absolute https:// URL for email click-through
|
||||
};
|
||||
|
||||
export function linkFor(n: NotificationRow): LinkBundle {
|
||||
const origin = process.env.ORIGIN ?? "https://trails.cool";
|
||||
switch (n.type) {
|
||||
case "follow_received":
|
||||
case "follow_request_received":
|
||||
case "follow_request_approved": {
|
||||
const username = n.payload?.followerUsername ?? n.payload?.targetUsername;
|
||||
const path = n.type === "follow_request_received" ? "/follows/requests" : `/users/${username}`;
|
||||
return { web: path, mobile: `trails:/${path}`, email: `${origin}${path}` };
|
||||
}
|
||||
case "activity_published": {
|
||||
const activityId = n.subject_id ?? n.payload?.activityId;
|
||||
const path = `/activities/${activityId}`;
|
||||
return { web: path, mobile: `trails://activities/${activityId}`, email: `${origin}${path}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why one helper, called from many renderers:** keeps the type→URL mapping in exactly one place. Adding a new platform (mobile, email, RSS, ActivityPub) means adding a new field to `LinkBundle` and a new branch — but the type discrimination stays in one file.
|
||||
|
||||
**Why the helper consults both `subject_id` and `payload`:** subject_id is the canonical reference; payload is the fallback when subject_id is null or the live record is gone. The helper's behavior is "use whichever is available" so the URL always builds.
|
||||
|
||||
**Why URL paths and not pre-rendered URLs:** route renames in the future can update the helper in one place; old notification rows continue to deep-link correctly because they don't store the URL string.
|
||||
|
||||
### Decision: Retention — keep read notifications for 90 days
|
||||
|
||||
Notifications older than 90 days AND already read are deleted by a daily pg-boss job. Unread notifications are kept indefinitely (so they don't silently disappear from the user's "needs attention" view). This bounds table growth without surprising users.
|
||||
|
||||
**Alternative considered:** keep everything forever. Rejected — at activity-fan-out scale the table grows fast and we don't need a permanent log; if we ever need notification history beyond 90 days it'd be surfacing a different thing (an audit log, not a user inbox).
|
||||
|
||||
### Decision: Two distinct nav entries — Follow requests + Notifications
|
||||
|
||||
Pending follow requests stay on their own `/follows/requests` page with their own count badge; this change adds a separate Notifications entry with a separate unread count.
|
||||
|
||||
**Why not unify:** they're semantically different — one is "you can take an action right now (approve/reject)," the other is "this already happened, no action required from you." Merging would conflate them and force us to handle approve/reject inline in the notifications view, which is more UX surface than the value of one fewer nav entry.
|
||||
|
||||
**Alternative considered:** one bell that combines both counts. Rejected for the reason above; revisit if user feedback pushes for it.
|
||||
|
||||
### Decision: `follow_request_received` lives in *both* surfaces with independent read-state
|
||||
|
||||
A new pending follow request is both an "act on this" event (for `/follows/requests`) and a "this happened" event (for `/notifications`). We surface it on both: the request appears on `/follows/requests` with Approve / Reject buttons (count badge increments), AND a `follow_request_received` notification is created (notifications-unread count also increments).
|
||||
|
||||
The two surfaces have **independent read-state**:
|
||||
- Marking the notification read does NOT approve / reject / acknowledge the request.
|
||||
- Approving or rejecting the request does NOT mark the notification read. The notification stays as a historical record ("Alice asked to follow you on April 24") regardless of how you handled the request.
|
||||
- If the requester cancels their pending follow before action, the notification still stays — the event is a true historical fact.
|
||||
|
||||
**Why both:** users who only check `/notifications` (informational inbox) shouldn't miss requests; users who only check `/follows/requests` (actionable queue) shouldn't see redundant cruft. Surfacing in both with independent read-states is the lowest-surprise behavior — same pattern Mastodon uses.
|
||||
|
||||
**Why independent state:** approving a request is its own user intent; reading the notification card is its own user intent. Coupling them ("approve auto-clears the notification") would feel magical and would prevent users from approving the request now and then reviewing the historical record later.
|
||||
|
||||
**Renderer responsibility:** the notification card for `follow_request_received` shows a "Review request" CTA that links to `/follows/requests`, not Approve / Reject inline. Approve / Reject lives in exactly one place.
|
||||
|
||||
### Decision: Notifications surface only what already happened — no preview content snapshotting
|
||||
|
||||
A notification stores `(recipient, type, actor, subject)` and the renderer fetches the live referent at display time (e.g., the activity's name). We don't snapshot the activity title into the notification row.
|
||||
|
||||
**Why:** if the activity is later renamed or made private, we want the notifications view to reflect current state. The renderer should gracefully handle "activity is now private and recipient can't see it" (filter from the list, or render a generic "an activity" line — pick at implementation time, lean filter).
|
||||
|
||||
**Side effect:** if a user makes their activity `unlisted` after publishing, fan-out notifications still exist as rows, but renders skip those rows for visibility-gated viewers. Acceptable v1 behavior.
|
||||
|
||||
### Decision: Loader-driven count + Server-Sent Events for between-navigation freshness
|
||||
|
||||
The unread count is computed in the root loader on every navigation (one cheap aggregate query against the partial `read_at IS NULL` index) — that's the source of truth on initial render. On top, a simple SSE channel pushes count updates to open browser tabs so the badge stays current while the user sits on a page.
|
||||
|
||||
**Why SSE, not WebSockets:** notifications are a one-way push from server → client. SSE is exactly that. It rides over plain HTTP (no protocol upgrade), browsers ship native `EventSource` with built-in reconnect, and Caddy proxies `text/event-stream` without any special config. WebSockets would buy us bidirectionality we don't need plus more reconnection plumbing on the client.
|
||||
|
||||
**Why both loader + SSE, not SSE alone:** loader-driven gives us a deterministic count at every page render and a clean fallback when SSE is unavailable (proxies, locked-down networks, browser tab put-to-sleep). SSE just keeps the value fresh in between. If the SSE connection dies, the next navigation refreshes the badge — no broken-state recovery needed.
|
||||
|
||||
**Transport details:**
|
||||
|
||||
- **Endpoint**: `GET /api/events` returning `Content-Type: text/event-stream` with `Cache-Control: no-cache`. Session-auth via cookie; anonymous returns 401 (no event stream).
|
||||
- **Heartbeat**: server emits `: ping\n\n` comments every 25 s to keep idle connections alive through any intermediate proxy timeouts.
|
||||
- **Reconnect hint**: server sends `retry: 5000\n` so EventSource backs off 5 s on disconnect (browser default is ~3s). Server adds 0–2 s jitter on the *server side* by spacing out reconnect-suggested retry hints during a deploy storm.
|
||||
- **Event format**:
|
||||
```
|
||||
event: notifications.unread
|
||||
data: {"count": 7}
|
||||
|
||||
```
|
||||
Single event type in v1; client just sets the navbar badge to `count`. New event types extend the protocol additively.
|
||||
|
||||
**Server registry (events broker):**
|
||||
|
||||
```ts
|
||||
// apps/journal/app/lib/events.server.ts
|
||||
type Connection = { send: (event: string, data: unknown) => void; close: () => void };
|
||||
const connections = new Map<string /* userId */, Set<Connection>>();
|
||||
export function register(userId: string, conn: Connection) { /* add + setup teardown */ }
|
||||
export function emitTo(userId: string, event: string, data: unknown) {
|
||||
for (const c of connections.get(userId) ?? []) c.send(event, data);
|
||||
}
|
||||
```
|
||||
|
||||
Generation hooks (`createNotification`, the fan-out job) call `emitTo(recipientId, "notifications.unread", { count })` after the row insert commits. The aggregate count query is small enough to run per-emit; if it ever becomes hot we cache the count per user in memory and increment on emit.
|
||||
|
||||
**Client hook:**
|
||||
|
||||
```ts
|
||||
// app/hooks/useUnreadNotifications.ts (sketch)
|
||||
const [count, setCount] = useState(initialFromLoader);
|
||||
useEffect(() => {
|
||||
if (!signedIn) return;
|
||||
const es = new EventSource("/api/events");
|
||||
es.addEventListener("notifications.unread", (e) => setCount(JSON.parse(e.data).count));
|
||||
return () => es.close();
|
||||
}, [signedIn]);
|
||||
```
|
||||
|
||||
EventSource auto-reconnects on transient disconnects; we just open and close.
|
||||
|
||||
**Cleanup**: when the response stream ends (client navigates away, tab closes, network drops), we remove the connection from the registry. We also run a periodic `setInterval` GC pass that drops connections whose last heartbeat write failed (defensive; ideally not needed).
|
||||
|
||||
**Single-process today, Redis-pub/sub when we go multi-process.** The `emitTo(userId, event, data)` interface is the swap point — the in-process `Map` becomes a Redis subscriber that listens to per-user channels, and `emitTo` becomes a publish. The hooks don't change.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Activity fan-out spam**: a user with thousands of followers posting a public activity creates thousands of rows. → At our scale this is invisible; the cost ceiling is documented above. If we ever need to soften, batch the inserts or move to read-time.
|
||||
- **Stale referents**: notification points at an activity that's since been deleted or made private. → Renderer filters out rows whose referent the viewer can't see; absolute deletes (the activity is hard-deleted) leave a row that the renderer turns into a generic "an activity" line or just skips. Implementation tweak.
|
||||
- **Notification storm on first follow** (auto-accept of a public profile that subsequently posts a flurry): expected behavior; users can mark all read.
|
||||
- **Privacy concern**: notifications retain that "User A followed User B at time T" beyond what the public follower lists already show. → Documented in privacy manifest. The retention window is 90 days (read) / indefinite (unread); user can mark all read to age them out.
|
||||
- **Duplicate suppression**: re-following someone after unfollowing should not produce a duplicate `follow_received`. → followUser is idempotent at the DB level (returns existing state), so the notification path only fires on actual transitions; documented in tasks.
|
||||
- **SSE deploy storm**: every deploy disconnects all open connections; clients reconnect at once. → EventSource reconnects with the server-suggested `retry:` interval (5 s) plus 0–2 s server-jitter. At trails.cool's connected-tab count this is fine; revisit with a `Connection: close + Retry-After` header strategy if we ever see a thundering-herd issue.
|
||||
- **SSE memory leak from forgotten connections**: a connection stuck open with no heartbeat reaching the client wastes memory. → Server emits `: ping` every 25 s; if the write fails (broken pipe), we drop the connection from the registry. Periodic GC pass as defense in depth.
|
||||
- **Multi-process scale ceiling**: in-process broadcast means a user connected to process A can't be reached from process B once we go multi-process. → Today we're single-process, so it's a non-issue. When that changes, swap the registry's `Map` for a Redis pub/sub adapter behind the same `emitTo` interface — additive, no caller changes. Explicitly tracked as a follow-up.
|
||||
- **SSE blocked by intermediate proxies / corporate networks**: some networks kill long-lived HTTP. → Falls back gracefully — the loader-driven count refreshes on every navigation, so the user just sees a slightly-stale badge until they click a link. Acceptable v1 behavior.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Land schema additively via `drizzle-kit push --force` (new table, no row changes).
|
||||
2. Wire the three generation hooks. New code paths only — no behavior change for existing flows besides the side-effect of inserting rows.
|
||||
3. Add `/notifications` route + nav entry + unread count in root loader.
|
||||
4. Add the daily retention pg-boss job.
|
||||
5. Update privacy manifest.
|
||||
6. Rollback: revert PR. Drop `journal.notifications` and remove the column-less hooks. No data dependencies.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **Bell icon vs. labelled "Notifications" link in nav**: minor UX call; lean labelled-link to match the existing nav style (no icons elsewhere). Implementation phase decides.
|
||||
- **Click-through behavior for `activity_published`**: clicking should both mark the row read and navigate to the activity. A single anchor with a server-side "redirect after mark-read" works; or a client-side fetch + navigation. Tasks phase picks.
|
||||
- **What happens to a user's notifications when they delete their account?** Cascade-delete via the recipient FK — implemented automatically. Documented.
|
||||
- **Should `/notifications` itself live-update via SSE?** Out of scope for v1 (the badge is the only thing that updates live; the page reloads on navigation). Worth revisiting if users complain that they sit on the page and miss new rows. Adding it later is a small follow-up — the same SSE event fires; the `/notifications` route subscribes and prepends the row.
|
||||
- **Event payload size**: v1 sends just `{ count }`. If we want richer payloads later (the new notification's preview text, type, actor name) we extend the event format additively — no protocol break. Lean: keep it small for v1.
|
||||
58
openspec/changes/notifications/proposal.md
Normal file
58
openspec/changes/notifications/proposal.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
## 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.
|
||||
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
|
||||
87
openspec/changes/notifications/tasks.md
Normal file
87
openspec/changes/notifications/tasks.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
## 1. Schema
|
||||
|
||||
- [ ] 1.1 Add `journal.notifications` table to `packages/db/src/schema/journal.ts`: `id UUID PK`, `recipient_user_id TEXT NOT NULL FK users ON DELETE CASCADE`, `type TEXT NOT NULL`, `actor_user_id TEXT FK users ON DELETE SET NULL`, `subject_id TEXT`, `payload JSONB`, `payload_version INT NOT NULL DEFAULT 1`, `read_at TIMESTAMPTZ`, `created_at TIMESTAMPTZ NOT NULL DEFAULT now()`. Indexes: `(recipient_user_id, created_at DESC)` and a partial `(recipient_user_id, created_at DESC) WHERE read_at IS NULL` for fast unread counts
|
||||
- [ ] 1.2 Run `pnpm db:push` locally and confirm the migration is clean
|
||||
- [ ] 1.3 Update privacy manifest entry: documents the `notifications` relation, the per-type payload snapshot fields, and the 90-day read-retention policy
|
||||
|
||||
## 2. Server library
|
||||
|
||||
- [ ] 2.1 Create `apps/journal/app/lib/notifications.server.ts` with: `createNotification`, `listForUser(userId, { page })`, `countUnread(userId)`, `markRead(ownerId, id)`, `markAllRead(ownerId)`, `purgeReadOlderThan(days)`. `createNotification` accepts a typed payload + version per type — TS unions enforce per-type shapes at the call site
|
||||
- [ ] 2.2 Define per-type payload TypeScript types (`FollowPayloadV1`, `ApprovalPayloadV1`, `ActivityPayloadV1`) in `apps/journal/app/lib/notifications/payload.ts`. Each generation hook writes `payload_version = 1` plus the matching shape
|
||||
- [ ] 2.3 Create `apps/journal/app/lib/notifications/link-for.ts` with `linkFor(notification): LinkBundle` returning `{ web, mobile?, email? }` — handles all four v1 types; consults `subject_id` first, falls back to `payload` fields
|
||||
- [ ] 2.4 Make `markRead` and `markAllRead` owner-bound (predicate includes `recipient_user_id = ownerId`)
|
||||
- [ ] 2.5 Unit / integration tests for each helper (`notifications.integration.test.ts` gated on `NOTIFICATIONS_INTEGRATION=1`, mirroring the demo-bot pattern)
|
||||
- [ ] 2.6 Unit tests for `linkFor` covering all four types + the `subject_id` vs. `payload` fallback path
|
||||
|
||||
## 3. Generation hooks (1:1)
|
||||
|
||||
- [ ] 3.1 Wire `followUser` (Pending path against private target — `accepted_at = NULL` newly-created row) to insert a `follow_request_received` notification with payload `{ followerUsername, followerDisplayName }` (v1)
|
||||
- [ ] 3.2 Wire `followUser` (auto-accept path against public target — `accepted_at = now()` newly-created row) to insert a `follow_received` notification with payload `{ followerUsername, followerDisplayName }` (v1)
|
||||
- [ ] 3.3 Wire `approveFollowRequest` to insert a `follow_request_approved` notification on success with payload `{ targetUsername, targetDisplayName }` (v1) — no-op on idempotent re-approval per the existing return-false path
|
||||
- [ ] 3.4 Confirm by integration test that approving an already-accepted row, re-following an existing follow, or following the same user twice in a row does NOT create duplicate notifications (the followUser idempotent return-existing-state branch must NOT emit)
|
||||
|
||||
## 4. Generation hooks (fan-out)
|
||||
|
||||
- [ ] 4.1 Add pg-boss recurring (or one-off-per-activity) job `fanout-activity-notifications` that takes `activityId` and inserts `activity_published` rows with payload `{ activityId, activityName, ownerUsername, ownerDisplayName }` (v1) for every accepted follower of the activity's owner
|
||||
- [ ] 4.2 Wire `createActivity` to enqueue the fan-out job iff `visibility === 'public'` after the activity row is committed (no fan-out if the create transaction rolls back)
|
||||
- [ ] 4.3 Integration test: a user with N accepted followers + M Pending followers creates a public activity → exactly N rows are inserted, all with payload populated
|
||||
- [ ] 4.4 Integration test: a private or unlisted activity does NOT fan out
|
||||
- [ ] 4.5 Test: idempotency — re-running the same fan-out job (e.g. retry after partial failure) doesn't double-insert. Use `(recipient_user_id, type, subject_id)` as a soft uniqueness check, OR rely on the worker's at-least-once retry semantics + an upsert / unique index for `(recipient_user_id, type, subject_id)` on `activity_published`-type rows. Pick during implementation.
|
||||
|
||||
## 4b. SSE events broker + endpoint
|
||||
|
||||
- [ ] 4b.1 Create `apps/journal/app/lib/events.server.ts`: in-process registry of `Map<userId, Set<Connection>>` plus `register(userId, conn)` and `emitTo(userId, event, data)`. Connection abstracts a `send(event, data)` that writes a `text/event-stream` chunk and a `close()` for cleanup. Includes an internal heartbeat tick (25s) per connection that writes `: ping\n\n` and prunes connections whose `send` throws (broken pipe)
|
||||
- [ ] 4b.2 Create `apps/journal/app/routes/api.events.ts`: GET handler returning a streaming `Response` with `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`. Session-bound; anonymous returns 401. On open, registers the connection and emits an initial `notifications.unread { count }` event so the badge reflects current state immediately
|
||||
- [ ] 4b.3 Wire `createNotification` to call `emitTo(recipientId, "notifications.unread", { count: <fresh count after insert> })` after the row commits. Same for the fan-out job (one emit per recipient)
|
||||
- [ ] 4b.4 Wire `markRead` and `markAllRead` to emit a refreshed `notifications.unread { count }` so the badge clears live
|
||||
- [ ] 4b.5 Caddy config check: confirm `text/event-stream` is passed through without buffering. Document if any tweak is needed (proxy_buffering off equivalent for Caddy)
|
||||
|
||||
## 4c. SSE client hook
|
||||
|
||||
- [ ] 4c.1 Create `apps/journal/app/hooks/useUnreadNotifications.ts` that, when signed in, opens an `EventSource("/api/events")`, listens for `notifications.unread`, and returns the live `count`. Initial value comes from the loader (root) so the badge renders correctly before the SSE handshake completes
|
||||
- [ ] 4c.2 Use the hook in the navbar to drive the unread-count badge. The loader-supplied count is the SSR baseline; the hook overrides on first event
|
||||
- [ ] 4c.3 EventSource native auto-reconnect handles transient disconnects. Add a `retry: 5000` directive to the SSE response so deploy-storm reconnects spread out a bit; add 0–2s server-side jitter to the retry hint
|
||||
- [ ] 4c.4 E2E (Playwright): open two browser contexts as the same user, trigger a follow against that user from a third context, and assert the navbar badge increments without a navigation in either watching context
|
||||
|
||||
## 5. Routes + UI
|
||||
|
||||
- [ ] 5.1 New `/notifications` route (signed-in only; redirect anonymous to `/auth/login`). Loader fetches `listForUser(currentUser.id, { page: 1 })` and `countUnread(currentUser.id)`
|
||||
- [ ] 5.2 Render: list of cards. Each card shows the actor's display name + handle (live where available, payload-snapshot fallback if the actor row is gone), a type-specific summary line (i18n keyed), the timestamp, and an action whose href comes from `linkFor(notification).web`. Unread cards are visually distinct (e.g. background tint + unread dot)
|
||||
- [ ] 5.3 Implement click-through: clicking a card POSTs to `/api/notifications/:id/read` and then navigates to `linkFor(notification).web`. Server-side redirect after mark-read is the simplest path
|
||||
- [ ] 5.4 "Mark all read" button at the top of the page; clicking POSTs to `/api/notifications/read-all` and the page reloads with empty unread state
|
||||
- [ ] 5.5 Empty state: "No notifications yet" + a hint about what kinds of events produce them
|
||||
- [ ] 5.6 Renderer guard: skip notification rows whose subject the recipient can no longer see (e.g. activity made private, activity deleted). Filter at server-side for `activity_published` by joining with `activities WHERE visibility='public'` (and also surface "you saw this earlier" if the activity was public when the notification was created — actually filter, not gracefully degrade, per the design decision)
|
||||
|
||||
## 6. API endpoints
|
||||
|
||||
- [ ] 6.1 `POST /api/notifications/:id/read` — session-bound, owner-bound; returns 200 on success, 404 if the row doesn't exist or doesn't belong to the caller. Idempotent for already-read rows
|
||||
- [ ] 6.2 `POST /api/notifications/read-all` — session-bound, marks every unread notification of the caller as read; returns 200 with the affected count
|
||||
- [ ] 6.3 Register both in `apps/journal/app/routes.ts`
|
||||
|
||||
## 7. Navbar entry + unread count
|
||||
|
||||
- [ ] 7.1 Extend the root loader to also compute `notificationsUnreadCount` for signed-in users (single aggregate query against the partial unread index)
|
||||
- [ ] 7.2 Add a "Notifications" link to the navbar — distinct from "Follow requests" — with a small red count badge when unread > 0
|
||||
- [ ] 7.3 i18n strings for nav entry, page title, type-specific summary lines, empty state, "Mark all read" button
|
||||
|
||||
## 8. Retention
|
||||
|
||||
- [ ] 8.1 pg-boss recurring job `purge-read-notifications` running daily that calls `purgeReadOlderThan(90)`
|
||||
- [ ] 8.2 Integration test: read row aged > 90 days is purged; unread row of any age is NOT
|
||||
|
||||
## 9. Testing
|
||||
|
||||
- [ ] 9.1 Unit / integration: helpers in `notifications.server.ts` (CRUD + unread count + mark-read owner-bound + mark-all-read scope)
|
||||
- [ ] 9.2 Integration: generation hooks (approve, follow auto-accept, public-activity fan-out)
|
||||
- [ ] 9.3 E2E: register two users; A follows B (public path) → B sees a `follow_received` notification on `/notifications` with the correct actor and link
|
||||
- [ ] 9.4 E2E: register two users; A's profile is private; B requests; A approves → B sees a `follow_request_approved` notification linking to A's profile
|
||||
- [ ] 9.5 E2E: navbar unread badge increments and clears via Mark all read
|
||||
- [ ] 9.6 E2E: anonymous request to `/notifications` redirects to login
|
||||
|
||||
## 10. Rollout
|
||||
|
||||
- [ ] 10.1 Schema ships via `drizzle-kit push --force` — additive only
|
||||
- [ ] 10.2 Feature lands behind no flag (additive surface; users with no notifications see an empty page until events fire)
|
||||
- [ ] 10.3 Post-deploy smoke: from a test account, follow bruno (public auto-accept) → check `/notifications` shows the row on bruno's account (need to be logged in as bruno briefly, or seed a notification by hand against your own account by acting as the followed party)
|
||||
- [ ] 10.4 (Follow-up change) Notification preferences (per-type mute) — not in this change
|
||||
- [ ] 10.5 (Follow-up change) Real-time delivery via SSE / WebSocket — not in this change
|
||||
Loading…
Add table
Add a link
Reference in a new issue