Skip to content

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:

TypeCategoryTriggerDefault EmailDefault Push
episode.invitedEpisodeInvited to episode (guest)OnOn
episode.team_addedTeamAdded to an episode roster (#293)On (manual adds only; the auto-add DB trigger writes in-app rows with no email)On
chat.messageCollaborationNew chat messageOn (delayed)On
chat.mentionCollaboration@mentioned in chatOn (escalated)On
show_notes.updatedCollaborationShow notes editedOffOff
show_notes.mentionCollaboration@mentioned in notesOnOn
team.invitationTeamTeam invite receivedOn (sent directly by the invite path)On
team.invitation_acceptedTeamYour team invite was accepted (#293)OnOn
network.responseTeamNetwork invite responseOnOn
booking.requestedBookingNew booking requestOnOn

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:

ts
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, unreadCountVersion included. Bumping the version without moving the count discards an in-flight /counts or /unread-count response 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. upsertNotification measures the transition against its own copy, which makes the echo of an optimistic mutation idempotent. An INSERT needs no copy, because a row that has only just been created was not counted before.
  • An UPDATE for 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, so subscribeRealtime takes an onUnknownUpdate callback 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 renderer

Database Tables

TablePurpose
notificationsCore notification records (recipient, type, content, read/archived state)
notification_deliveriesPer-channel delivery attempts (status, scheduling, retry tracking)
notification_email_tracking_tokensOpen/click/unsubscribe tokens with expiry
notification_push_subscriptionsWeb Push endpoint registrations per user/device
notification_delivery_dead_lettersArchive of permanently failed deliveries

Environment Variables

VariableRequired ByPurpose
RESEND_API_KEYEmail channel, WorkersResend API authentication
RESEND_FROM_EMAILEmail channel, WorkersSender email address
RESEND_FROM_NAMEEmail channelSender display name
RESEND_WEBHOOK_SECRETAPI routesResend webhook signature verification
PUBLIC_APP_URLEmail tracking, WorkersBase URL for tracking links
VAPID_PUBLIC_KEYPush channelVAPID public key (P-256)
VAPID_PRIVATE_KEYPush channelVAPID private key (P-256)
VAPID_SUBJECTPush channelVAPID subject (mailto: or https: URL)

Internal documentation - Not for public distribution