Skip to content

Notifications and delivery

Jaloliddin Ismailov edited this page Oct 4, 2026 · 3 revisions

The Hub sends email and web push through a single outbox. Nothing sends straight from the request that caused it — a notification is written to a queue row first, then a separate drain step delivers it. This is what keeps a slow SMTP server from stalling a decision transition, and what makes delivery failures retryable rather than lost.

The outbox model

flowchart LR
    Evt["Something happens<br/>(decision transitions,<br/>review resolves, …)"]
    Evt --> Out["notifications outbox row<br/>channel, category,<br/>payload, status"]
    Cron["POST /api/cron/<br/>notifications-drain"]
    Cron --> Out
    Out --> Email["SMTP<br/>(EMAIL_FROM)"]
    Out --> Push["Web push<br/>(VAPID)"]
Loading

Draining is a cron endpoint — Vercel's scheduler (or any cron) calls POST /api/cron/notifications-drain on an interval, and the handler processes a bounded batch. Deployments do not print reset links or secrets; the drain endpoint is the only routine write that touches the mail path.

Categories and opt-out

Notifications carry a category, and categories are what make preference toggles honest. Members can silence the categories that are optional — digests, group activity, review chatter — while a small set is mandatory and cannot be opted out: security notices, safeguarding, legal. The mandatory set is defined in src/lib/notifications.ts alongside the channel list, and endpoints honour it at send time.

// Simplified shape — a notification row
{
  channel: 'email' | 'push',
  category: 'decision.transition' | 'review.resolved' | …,
  recipientAccountId: number,
  payload: { … },
  status: 'queued' | 'sent' | 'failed',
}

Unsubscribe links carry a signed token (EMAIL_UNSUBSCRIBE_SECRET) so a member can opt out of a category from the email itself without needing to be logged in — but an unsubscribe link can never reach the mandatory categories, because the token only encodes what the sender is allowed to silence.

Web push

Push delivery uses the standard VAPID scheme. A deployment needs a keypair once:

node scripts/setup/generate-vapid-keys.mjs
# → VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT for the env

The public key is served to browsers at /api/push/vapid-key; subscribe/unsubscribe/status are member endpoints, and /api/push/send is the staff path for manual notices. /api/notifications/provider-events receives delivery receipts.

Where things can go wrong

  • Nothing arrives — check the outbox (/api/member/admin/notifications/outbox): rows stuck in queued mean the drain isn't being called; rows in failed carry the delivery error.
  • Email works, push doesn't — the VAPID keys are unset or the browser subscription expired; /api/push/status tells you which.
  • One member gets nothing — their category toggles, then their verified email, then the outbox — in that order.

Clone this wiki locally