Notification System
The notification system is a centralised, policy-driven delivery platform that handles in-app, email, and push notifications across show.fm. It replaces ad-hoc email sends with a unified pipeline that respects user preferences, supports scheduling, deduplication, and tracking.
Source: src/lib/notifications/
Architecture Overview
Notification Types
The registry (src/lib/notifications/policies.ts) is the authoritative list; the most-used types and their delivery policies:
| Type | Category | Trigger | Default Email | Default Push |
|---|---|---|---|---|
episode.invited | Episode | Invited to episode (guest) | On | On |
episode.team_added | Team | Added to an episode roster (#293) | On (manual adds only; the auto-add DB trigger writes in-app rows with no email) | On |
chat.message | Collaboration | New chat message | On (delayed) | On |
chat.mention | Collaboration | @mentioned in chat | On (escalated) | On |
show_notes.updated | Collaboration | Show notes edited | Off | Off |
show_notes.mention | Collaboration | @mentioned in notes | On | On |
team.invitation | Team | Team invite received | On (sent directly by the invite path) | On |
team.invitation_accepted | Team | Your team invite was accepted (#293) | On | On |
network.response | Team | Network invite response | On | On |
booking.requested | Booking | New booking request | On | On |
Delivery Channels
In-App
Notifications are written to the notifications table and delivered in real-time via Supabase Realtime subscriptions. The bell and inbox collapse some of them into bundles; see In-App Bundling below.
Two surfaces render them, both driven by the same module-level store (src/lib/stores/notifications.svelte.ts): NotificationBell in the (app) layout header, and /notifications. The bell owns the Realtime subscription and seeds the store, which is why the sidebar's unread badge can read from the store directly but falls back to the layout's own initialUnreadCount until the bell has seeded it (the store is browser-only, so it is zero during SSR).
Rail and list-header totals come from GET /api/notifications/counts, not from the loaded page. The page paginates 25 rows at a time, so counting the loaded array understates every figure past the first page and disagrees with the bell badge. A counts failure is caught and swallowed: the rail loses its badges and the inbox still renders.
Unread is the exception: nothing renders counts.unread. The bell, the sidebar and the rail all read notificationStore.unreadCount, which /counts and /unread-count reconcile. Two sources for one figure could disagree in the window between a mutation and the next count refresh, and did: reading a single notification on a 58-row inbox left the rail on the server's 43 while the bell showed 19, the unread rows in the loaded page. fetchCounts also declines to reconcile the badge when a newer mutation has bumped unreadCountVersion, so counts.unread can legitimately hold a pre-mutation figure; it is an input to the badge, never a thing to render.
Read and archive are reversible from the UI (/:id/unread, /:id/unarchive, /unarchive-all). Restoring never clears read_at, so it cannot inflate the badge. There is no group-scoped unread endpoint, so the client offers Mark unread on single notifications only.
Email
Emails are rendered by type-specific renderers (see Email Tracking), sent via Resend API, and include open/click/unsubscribe tracking tokens. Collaboration emails support batching and hourly caps to prevent flooding.
Push
Web Push notifications use the VAPID protocol with P-256 ECDSA keys. Subscriptions are managed per-device and automatically revoked on 404/410 responses. See Push Notifications for details.
Key Concepts
Policy-Driven Decisions
Every notification type has a registered policy that defines how each channel behaves:
immediate-- Send right away (most in-app notifications)respect_digest-- Honour user's digest frequency (daily/weekly)collaboration_batch-- Group into batch windows (1-hour default)collaboration_missed_message-- 5-minute delay before email (suppressed if user returns)collaboration_mention-- 2-minute escalation delay for @mentions
In-App Bundling
Bundling is a client-side grouping over individual notifications rows, computed by groupNotifications in src/lib/notifications/ui-utils.ts. It exists to compress a repetitive activity stream that leads to one place.
Each registry entry declares its posture in a required field on NotificationTypeDefinition:
inAppBundling: 'collapse' | 'none';collapse is exactly the four same-destination collaboration streams: chat.message, chat.mention, show_notes.updated, show_notes.mention. Everything else is 'none' and renders as its own reachable row. Rows written outside this registry (the automation executor's automation.webhook_skipped, RPC-inserted rows) never bundle.
The required field is the enforcement mechanism, not any list or count here. A new notification type cannot be registered without stating its posture, because pnpm check fails on a NotificationTypeRegistry entry that omits it. Do not add a test pinning the number of registry entries: sibling efforts add types and must be able to land in any order.
The bundle key is groupKey|link, never group_key alone. Nothing upstream guarantees a group key maps to a single destination, and a bundle whose members lead to different places has no correct click target. Subdividing by link makes that state unrepresentable. batch_key is deliberately not consulted: it buckets email digests by hour and has no bearing on what belongs together in the bell.
Interaction is whole-bundle. Clicking, "Mark read" and "Archive" on a bundle call POST /api/notifications/read-group and POST /api/notifications/archive-group, both scoped by user_id + group_key + link, so they also reach members the client has not paginated to. link is required in the request body (explicitly null for a link-less bundle) so a caller cannot fall back to coarse matching by omitting it. A coarse group_key-only archive would silently remove a sibling bundle whose destination the user never visited.
The unread badge keeps counting items, not bundles, so the SSR count stays a cheap row count and "7 unread chat messages" stays honest. With whole-bundle read-state the count now actually reaches zero on interaction.
Never recompute the badge from the loaded array — after any mutation, not just a bundle one. The store holds a page of rows while serverUnreadCount can be far larger, which is why fetchNotifications guards it with Math.max(serverUnreadCount, countUnread(...)). Every mutation instead states what it changed and applyUnreadDelta moves the server figure by that much; a recount is the fallback for the one case where it is the best information available, which is before any server figure has landed. Recomputing instead drops a 43-unread badge to however many rows happen to be in memory.
Two consequences worth keeping in mind:
- A zero delta is a no-op,
unreadCountVersionincluded. Bumping the version without moving the count discards an in-flight/countsor/unread-countresponse for nothing. That is how the realtime echo of the client's own read used to strand a stale badge next to a freshly counted rail. - A realtime echo carries a delta only when the store already holds the row.
upsertNotificationmeasures the transition against its own copy, which makes the echo of an optimistic mutation idempotent. AnINSERTneeds no copy, because a row that has only just been created was not counted before. - An
UPDATEfor a row below the loaded page asks the server rather than guessing. Its previous state is unknown, so there is no delta to apply; leaving the badge alone drifts too, just more quietly, because nothing recounts on a timer and the bell only refreshes on init or on open. The store cannot fetch on its own, sosubscribeRealtimetakes anonUnknownUpdatecallback and the bell supplies one reading its own access token at call time. Calls are coalesced over 500 ms: a "Mark all read" on another device echoes once per row, and the rows below this client's page must cost one count query between them rather than one each.
A group or category mutation subtracts only the members it can see and then reconciles against /unread-count, because those endpoints deliberately reach rows the client has not paginated to, so the local delta is a floor rather than the whole story.
Background: docs/planning/plans/2026-07-31-notification-bundling-and-push.md (issue #294).
Deduplication
Every notification generates a stable dedupe key (FNV hash of recipient + actor + context + content). If a matching notification exists within the 24-hour dedup window, the new one is skipped.
Preference Evaluation
Before sending, the system evaluates a NotificationPreferenceSnapshot that captures:
- Global mute state and per-podcast mutes
- Channel-specific toggles (email/push per notification type)
- Quiet hours with timezone-aware scheduling
- Digest frequency (immediate, daily, weekly)
- Collaboration email mode (immediate, mentions_only, batched)
- Active context suppression (skip if user is currently viewing the content)
Scheduling & Retry
Notifications that aren't immediate (digests, batched, quiet hours) are written as scheduled deliveries with a scheduled_for timestamp. The notification-scheduler worker polls every minute and enqueues due deliveries to the notification-executor worker, which handles execution with retry logic.
File Structure
src/lib/notifications/
├── index.ts # Public API exports
├── types.ts # Core type definitions
├── notify.ts # Planning, deduplication, store interface
├── policies.ts # Type registry, channel policies, key generators
├── preferences.ts # Preference evaluation, quiet hours, digest scheduling
├── delivery.ts # Scheduler/executor orchestration, retry logic
├── integrations.ts # Domain event publishers (high-level API)
├── email-tracking.ts # Tracking token generation and URL builders
├── ui-utils.ts # Frontend display helpers (icons, tiles, relative time, day grouping)
├── channels/
│ ├── shared.ts # HTML escaping, text truncation, URL resolution
│ ├── in-app.ts # In-app channel executor
│ ├── email.ts # Email channel executor (Resend + tracking)
│ └── push.ts # Push channel executor (Web Push)
├── push/
│ ├── payloads.ts # Push notification payload builder
│ ├── subscriptions.ts # Subscription schema and helpers
│ ├── vapid.ts # VAPID encryption and JWT generation
│ └── client.ts # Browser-side push subscription API
└── email-renderers/
├── registry.ts # Renderer lookup and fallback
├── generic.ts # Default renderer (title + body + CTA)
├── booking-host.ts # booking.requested renderer
├── chat-missed-message.ts # chat.message renderer
├── episode-invited.ts # episode.invited renderer
└── network-response.ts # network.response rendererDatabase Tables
| Table | Purpose |
|---|---|
notifications | Core notification records (recipient, type, content, read/archived state) |
notification_deliveries | Per-channel delivery attempts (status, scheduling, retry tracking) |
notification_email_tracking_tokens | Open/click/unsubscribe tokens with expiry |
notification_push_subscriptions | Web Push endpoint registrations per user/device |
notification_delivery_dead_letters | Archive of permanently failed deliveries |
Environment Variables
| Variable | Required By | Purpose |
|---|---|---|
RESEND_API_KEY | Email channel, Workers | Resend API authentication |
RESEND_FROM_EMAIL | Email channel, Workers | Sender email address |
RESEND_FROM_NAME | Email channel | Sender display name |
RESEND_WEBHOOK_SECRET | API routes | Resend webhook signature verification |
PUBLIC_APP_URL | Email tracking, Workers | Base URL for tracking links |
VAPID_PUBLIC_KEY | Push channel | VAPID public key (P-256) |
VAPID_PRIVATE_KEY | Push channel | VAPID private key (P-256) |
VAPID_SUBJECT | Push channel | VAPID subject (mailto: or https: URL) |
Related Documentation
- Delivery Pipeline -- Scheduling, execution, and retry flow
- Preferences & Policies -- Preference evaluation and channel policies
- Push Notifications -- Web Push / VAPID implementation
- Email Tracking -- Open, click, and unsubscribe tracking
- Notification API -- REST API endpoints
- Notification Scheduler Worker -- Cron-based delivery scheduler
- Notification Executor Worker -- Queue-based delivery executor
- Resend Email Service -- Underlying email provider