Skip to content
Chapter 71Lesson 1

Project overview

Three events in your SaaS are worth telling a user about: someone invites them to an organization, an admin changes their role, or their billing goes past due. Each must reach them twice, as an email and as a row in the in-app notification feed. The naive build scatters a sendEmail(...) and a db.insert(notifications) at every call site that fires one, and within a month no one can answer “what can this app notify about, and on which channels?” without grepping the codebase.

This project builds the alternative: a single dispatch(event) seam every call site hands off to. You verify it from one demo loop: fire invite-sent, and the inbox panel and email counter each tick up by one; hit Rapid-fire 5x, and the inbox grows by exactly one while the dedup badge reads 4 deduped. By the end you will have written the dispatcher, the event registry, the two channel functions, the preference and dedup logic, three database tables, and the wiring at three real call sites. The notification inspector and the email templates are handed to you.

The notification inspector: the page that drives and verifies every behavior you build.
  • The dispatcher seam — one named entry point every call site and channel routes through.
  • The registry as source of truth — a new event is one entry; a new channel is one function with the same signature.
  • Preference resolution — read once per dispatch, default-on, with a critical-channel override that keeps billing email flowing after a user opts out.
  • Time-windowed dedup — keyed per recipient, so a burst of one event collapses to a single notification.
  • Fire-after-commit — at three real call sites, so work that rolls back never notifies anyone.
  • Channel independence — each channel runs behind its own try/catch, so one failure can’t take down the others.

The whole project is one seam with a backing store. The shape below is what you’ll fill in; signatures and SQL come in the lessons that own them.

  • Call sites (sendInvitation, changeMemberRole, the Stripe billing webhook) build a NotificationEvent and await dispatch(...) after their transaction commits.
  • dispatch(event) reads the registry entry and all recipients’ preferences in one pass, then per recipient resolves channels, claims the dedup window, and fans out.
  • Channel functions (sendEmailChannel, writeInboxChannel) share one signature — ({ recipient, event, payload, rendered }) — each behind its own try/catch.
  • The registry maps each event type to its preference category, channels, dedup window, templates, and optional critical channel.
  • Three tables back the seam: notifications (the inbox feed), user_notification_preferences (per-category toggles), and notification_dedup (the time window).
  • /inspector drives every behavior from one page; /inbox is a plain server-rendered read of notifications.
dispatch is the one entry point every event flows through.

The starter forks the billing project from From Stripe webhook to plan entitlement, so the org auth, invitation actions, Stripe webhook, audit log, and plan-entitlements row already work — you layer the dispatcher on top instead of rewriting any of it. The notifications module is a scaffold: the types, the error class, and the barrel are written for you; every other file ships as a no-op or throwing stub.

Highlighted files carry a TODO — your work for the next three lessons. Everything else is provided: read it as needed, but you won’t author it.

  • Directorysrc/
    • Directorylib/
      • Directorynotifications/
        • types.ts provided — shared types (NotificationEvent, DispatchResult, ChannelFn, …)
        • errors.ts provided — NotificationError (REGISTRY_MISS | RECIPIENT_NOT_FOUND)
        • index.ts provided — barrel: re-exports dispatch and the public types
        • registry.ts the notifiableEvents map (source of truth)
        • dispatcher.ts dispatch(event): the seam
        • dedup.ts isDuplicate / recordDedup / computeDedupKey
        • prefs.ts readPrefsForCategory + resolveChannels
        • get-user-email.ts resolve a recipient’s email from the user table
        • Directorychannels/
          • email.ts sendEmailChannel
          • inbox.ts writeInboxChannel
      • Directoryinvitations/
        • send.ts sendInvitation: dispatch after commit
        • manage.ts changeMemberRole: dispatch after commit
      • Directorywebhooks/
        • stripe.ts push a billing-past-due event in the past-due branch
      • email.ts provided — sendEmail wrapper; EMAIL_MOCK mode bumps the counter
    • Directorydb/
      • schema.ts three notification tables, commented out under // TODO(L2)
      • schema/auth.ts provided — Better Auth tables (user, organization, member, …)
    • Directoryemails/
      • InviteSentEmail.tsx provided — React Email template
      • RoleChangedEmail.tsx provided
      • BillingPastDueEmail.tsx provided
    • Directoryapp/
      • Directoryapi/webhooks/stripe/
        • route.ts drain pending dispatches after db.transaction commits
      • Directory(protected)/
        • inbox/page.tsx provided — server-rendered notifications list for the session user
        • Directoryinspector/ provided in full — page, actions, reads, and panels
  • docker-compose.yml provided — Postgres 18
  • drizzle.config.ts provided
  • .env.example provided
  • package.json provided — db:migrate, db:seed, dev, test:lesson, …

Lesson 2 — Registry, dispatcher, and dedup

Define the three events, write dispatch() with stubbed channels, and prove the 60-second dedup window from the inspector.

Lesson 3 — Channels and preferences live

Replace the stubs with the inbox writer, the email channel, and a batched preferences read with default-on and the critical-channel override.

Lesson 4 — Wire the three call sites

Call dispatch() after commit in sendInvitation, changeMemberRole, and the Stripe past-due webhook branch.

The starter runs on a local Postgres 18 container with email mocked, so you can verify your work without a live Resend account.

  1. Get the starter codebase from the project repository, under Chapter 071/start/. The fastest way is degit, which copies the directory without its git history:

    Terminal window
    npx degit terencicp/react-saas-course-projects/Chapter\ 071/start notification-dispatcher
    cd notification-dispatcher
  2. Install dependencies:

    Terminal window
    pnpm install
  3. Start Postgres 18:

    Terminal window
    docker compose up -d
  4. Copy the environment template and fill in the two secrets:

    Terminal window
    cp .env.example .env
    openssl rand -base64 32 # paste into BETTER_AUTH_SECRET
    openssl rand -base64 32 # paste into INVITATION_SIGNING_SECRET

    Leave EMAIL_MOCK=1 as it ships.

  5. Migrate and seed the two organizations and four users:

    Terminal window
    pnpm db:migrate && pnpm db:seed
  6. Start the dev server:

    Terminal window
    pnpm dev

Each environment variable and where its value comes from:

VariablePurposeHow to obtain
DATABASE_URL (+ DATABASE_URL_UNPOOLED)Postgres connectionAlready set to the Docker container in .env.example
BETTER_AUTH_SECRETSigns session cookies and tokensopenssl rand -base64 32
INVITATION_SIGNING_SECRETSigns the invitation accept URLopenssl rand -base64 32
RESEND_API_KEYResend API keyMocked under EMAIL_MOCK=1; any non-empty value works
STRIPE_WEBHOOK_SECRETVerifies Stripe webhook signaturesThe whsec_… value stripe listen prints; only for the live billing-past-due path
APP_URL / NEXT_PUBLIC_APP_URLApp originhttp://localhost:3000 (already set)
EMAIL_MOCKShort-circuits Resend and bumps the inspector’s email-sent counterLeave at 1

With pnpm dev up, open http://localhost:3000: the billing project’s dashboard works as before. Open /inspector and the page loads, but every fire button errors with dispatch not implemented, notification reads return empty, and /inbox renders an empty feed. That is the expected starting state; the dispatcher is the first thing you write.