Files
solopm-server/plans/09-notifications-realtime.md

48 lines
3.7 KiB
Markdown

# 09 — Notifications & Real-time (SSE)
Three delivery surfaces from one fan-out pass in the event dispatcher ([03-domain-events.md](03-domain-events.md)): the **in-app inbox/bell**, **email**, and **SSE live updates**.
## Fan-out rules (recipient computation per event)
- [ ] `issue.assigned` → notification type `assigned` to the new assignee
- [ ] mention parsed in issue/epic/project/wiki description or comment → type `mentioned` to each mentioned user (must be a project member; non-members are ignored)
- [ ] `comment.created` → type `commented` to all unmuted watchers of the issue
- [ ] `project.member_added` → type `added_to_project` to the added user
- [ ] `issue.status_changed` → type `status_changed` to all unmuted watchers
- [ ] Never notify the actor about their own action
- [ ] Precedence per event per user (no double-notify): `mentioned` > `assigned` > `commented` > `status_changed`
- [ ] Dedupe on redelivery: skip insert if a notification for the same (user, event id) already exists
## In-app inbox
- [ ] `notifications` rows carry a denormalized `payload` (actor name, project title, issue `#N` + title, comment snippet ≤ 140 chars) so the inbox renders without joins
- [ ] Bell shows unread count (from `/notifications/unread_count`, kept live via SSE)
- [ ] Inbox page: paged list, unread highlighted, click → mark read + navigate to resource; "mark all read"
- [ ] Notifications are exempt from soft-delete conventions: hard-deleted by cull after 90 days regardless of read state
## Email
- [ ] Per-user `email_mode`: `off | instant | daily_digest` (settings page; default instant)
- [ ] Instant: `send_email` job per notification; plain, single-purpose emails (subject `[SoloPM] {actor} {verb} {resource}`, body = snippet + deep link via `BASE_URL`); text/plain + minimal HTML
- [ ] Daily digest: nightly `daily_digest` job batches the last 24h of unread notifications per user into one email grouped by project
- [ ] Email silently disabled when SMTP env vars are unset (log once at startup)
- [ ] Retry via job queue backoff; failures land in `jobs.last_error`
## SSE
- [ ] Endpoint `GET /api/v1/stream` (session or PAT auth): `Content-Type: text/event-stream`, `X-Accel-Buffering: no`, heartbeat `ping` frame every 25s
- [ ] **One firehose stream per user** ([17-decisions.md](17-decisions.md)): the hub delivers every event from projects the user is a member of; the client decides what's relevant to the current page
- [ ] Frame types (SSE `event:` field, JSON `data:`):
- [ ] `notification``{id, type, payload, created_at}` → bell increments, toast shown
- [ ] `data``{event_type, project_id, resource_type, resource_id, issue_num?}` — a *pointer*, not the changed data; client stores refetch or patch state for the affected resource if it's on screen
- [ ] `ping` — keepalive, no body
- [ ] Hub: in-memory `map[userID][]chan frame`; register on connect, deregister on disconnect; membership resolved at connect time and re-resolved on `project.member_added/removed` frames for that user; non-blocking sends (drop frame to a slow client — they self-heal on reconnect)
- [ ] Client contract ([13-frontend.md](13-frontend.md)): auto-reconnect with backoff; **on reconnect, refetch the current page's data and the unread count** (missed frames are lost by design — no replay)
- [ ] Graceful shutdown closes all streams (clients reconnect to the new process)
## Acceptance sketch
- [ ] Two browsers, same project: assign an issue in A → B's bell increments and B's open board updates the card within 2 seconds, no reload
- [ ] Muted watcher receives neither bell nor email for subsequent comments
- [ ] SMTP configured with MailHog in dev compose: instant email arrives; digest job produces one grouped email