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

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 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): 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): 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