Docs.

Notifications

architecture/notifications.md

Staff notifications for studio.chat: in-app (toast + inbox) and Slack, driven off the audit trail and delivered through a durable queue.

The event spine

Almost every business mutation already writes an audit_log row via createAuditEntry (src/lib/office/audit.ts). That is the single hook point: after a successful audit insert, createAuditEntry calls onAuditEntry (src/lib/notifications/dispatch.ts), fully guarded — a notification failure can never break the audit write or the transition that triggered it.

onAuditEntry classifies the row with the pure registry (src/lib/notifications/registry.ts, 100% covered): classifyAuditEvent maps (entity_type, action, to_state) → a NotificationEvent (key, category, severity, title, subjectType) or null. The registry is a deliberate subset — "the important stuff" (lead created, quote sent/accepted, reservation confirmed/returned/settled/closed/cancelled/disputed, payment, claim, inspection, verification decisions). Child entities (payments/claims/condition_reports) audit with entity_id = reservationId, so their subjectType is reservations and a "watch reservation X" subscription captures them.

Notifiable events fan out to two channels:

  1. In-app — a notifications row per subscribed staff account (the actor is excluded — no self-notify). Surfaced as a toast (poller) and on /office/inbox, with a live unread badge in the nav.
  2. Slack — for each matching notification_routes row, an enqueue onto the outbox queue. The actual HTTP call is the worker's job (decoupled).

Data model (migration 0036_notifications.sql)

  • notifications — in-app inbox. recipient_account_id, event_key, category, entity_type/entity_id (the subject, for deep-linking + entity-sub matching), title, body, severity, audit_id, read_at.
  • notification_subscriptions(account_id, scope_type, scope_key, in_app, slack). scope_type is entity (scope_key = '<type>:<id>') or category (scope_key = 'leads' | 'reservations' | …). Soft-deletable.
  • notification_outboxthe queue. status (pending → processing → delivered | failed), attempts/max_attempts, run_after (backoff), payload, last_error.
  • notification_routes — admin routing: (match_type ['category'|'event'], match_key, slack_channel, webhook_url, enabled). Seeded with the brief's two examples (leads → #sales, reservation.confirmed → #operations).

All four are service-role-only (RLS on, no policies — read/write via supabaseAdmin, like comments/audit).

The queue (transactional outbox)

src/lib/notifications/queue.ts defines a broker-shaped interface, NotificationQueue (enqueue / claimBatch / markDelivered / markFailed), with one implementation today — DbOutboxQueue, backed by notification_outbox. This needs no external broker: it's durable and at-least-once on its own (claim-by-status, exponential backoff, attempt cap).

The worker is drainOutbox (src/lib/notifications/slack.ts): claim a batch, deliver each, ack (delivered) or re-queue with backoff (failed). It runs on three triggers:

  • the cron /api/cron/notifications (every 5 min) — the reliable backstop;
  • a best-effort inline kick after enqueue (low latency in dev);
  • the /office/tools "drain outbox" button (manual, for demos).

Going real (Kafka / SQS)

The outbox is the integration seam. Two options, both keeping dispatch.ts/drainOutbox semantics:

  • Relay: keep the outbox; add a relay that ships pending rows to the broker and marks them delivered. (Classic transactional-outbox + CDC/relay.)
  • Swap: implement KafkaQueue / SqsQueue against NotificationQueue and point notificationQueue at it; the consumer side calls the same delivery code. enqueue becomes a producer.send; claimBatch/markDelivered/ markFailed become poll/ack against the broker.

Nothing in the dispatcher or the UI changes either way.

Slack delivery — making it live

deliverSlack mirrors the email module's safe-mode contract. Per message, destination resolution:

  1. the route's own webhook_url (set in the admin routing UI), else
  2. SLACK_WEBHOOK_URL (global env), else
  3. SLACK_BOT_TOKENchat.postMessage to the channel, else
  4. dry-run — logged, marked delivered (dryRun: true).

Dry-run is the default with no Slack app wired, so the whole pipeline is demoable today. To make it real:

  • Incoming webhooks (simplest): create a Slack app → enable Incoming Webhooks → add one per channel → paste each URL into its route on /office/integrations. Per-route URLs mean different events hit different channels with no shared secret.
  • Bot token (one app, many channels): add a bot token with chat:write, invite it to the channels, set SLACK_BOT_TOKEN. Routes then only need the channel name.

Recipient identity

In-app notifications target staff accounts. currentStaffAccountId (src/lib/notifications/identity.ts) resolves the real signed-in accountId, or — under dev bypass (no account) — the super-admin account, so the sole local operator still has an inbox. Seed a real local admin with the "seed demo admin" tool on /office/tools.

Subscriptions & routing UI

/office/inbox is the inbox (mark-read); /office/inbox/settings has per-category in-app/Slack toggles + a "watching" list; admins manage Slack channel routing under /office/integrations. A reusable watch toggle (WatchToggle/WatchButton) on reservation, account, and lead detail pages manages per-entity subscriptions.

Testing / demo

/office/tools (non-prod only, hard-gated) drives the whole flow: seed a demo admin + lead, send a test notification, drain the outbox, run the crons, and push a reservation through its stages (record deposit / accept quote / force stage). See tests/e2e/notifications-lifecycle.e2e.test.ts for the automated lead→closed walkthrough that asserts the fan-out at every stage.