3.7 KiB
3.7 KiB
09 — Notifications & Real-time (SSE)
Three delivery surfaces from one fan-out pass in the event dispatcher (03-domain-events.md): the in-app inbox/bell, email, and SSE live updates.
Fan-out rules (recipient computation per event)
issue.assigned→ notification typeassignedto the new assignee- mention parsed in issue/epic/project/wiki description or comment → type
mentionedto each mentioned user (must be a project member; non-members are ignored) comment.created→ typecommentedto all unmuted watchers of the issueproject.member_added→ typeadded_to_projectto the added userissue.status_changed→ typestatus_changedto 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
notificationsrows carry a denormalizedpayload(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
- Per-user
email_mode:off | instant | daily_digest(settings page; default instant) - Instant:
send_emailjob per notification; plain, single-purpose emails (subject[SoloPM] {actor} {verb} {resource}, body = snippet + deep link viaBASE_URL); text/plain + minimal HTML - Daily digest: nightly
daily_digestjob 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, heartbeatpingframe every 25s - One firehose stream per user (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, JSONdata:):notification—{id, type, payload, created_at}→ bell increments, toast showndata—{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 screenping— keepalive, no body
- Hub: in-memory
map[userID][]chan frame; register on connect, deregister on disconnect; membership resolved at connect time and re-resolved onproject.member_added/removedframes for that user; non-blocking sends (drop frame to a slow client — they self-heal on reconnect) - Client contract (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