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:
- In-app — a
notificationsrow 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. - Slack — for each matching
notification_routesrow, 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_typeisentity(scope_key = '<type>:<id>') orcategory(scope_key = 'leads' | 'reservations' | …). Soft-deletable.notification_outbox— the 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
pendingrows to the broker and marks them delivered. (Classic transactional-outbox + CDC/relay.) - Swap: implement
KafkaQueue/SqsQueueagainstNotificationQueueand pointnotificationQueueat it; the consumer side calls the same delivery code.enqueuebecomes aproducer.send;claimBatch/markDelivered/markFailedbecome 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:
- the route's own
webhook_url(set in the admin routing UI), else SLACK_WEBHOOK_URL(global env), elseSLACK_BOT_TOKEN→chat.postMessageto the channel, else- 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, setSLACK_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.