Repository navigation
Notifications and delivery
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.
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)"]
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.
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.
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 envThe 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.
-
Nothing arrives — check the outbox (
/api/member/admin/notifications/outbox): rows stuck inqueuedmean the drain isn't being called; rows infailedcarry the delivery error. -
Email works, push doesn't — the VAPID keys are unset or the browser subscription expired;
/api/push/statustells you which. - One member gets nothing — their category toggles, then their verified email, then the outbox — in that order.