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.
Three things happen in your SaaS that a user genuinely wants to hear about: someone invites them to an organization, an admin changes their role, and their billing goes past due. Each of those has to reach the person two ways — an email in their inbox and a row in the in-app notification feed. The naive version scatters a sendEmail(...) and a db.insert(notifications) at every call site that triggers one of these events, and within a month nobody can answer “what can this app notify about, and through which channels?” without grepping the whole codebase.
This project builds the alternative: a single dispatch(event) seam every call site hands off to. You will reproduce one demo loop end to end. Fire invite-sent and watch the inbox panel tick up by one and the email counter tick up by one. Hit Rapid-fire 5x and watch the inbox grow by exactly one while the dedup badge reads 4 deduped — a burst collapsed to a single notification. Then toggle the email channel off and refire: the inbox keeps growing, the email counter does not. By the end you will have written the dispatcher, the registry of notifiable events, 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.
try/catch, so one failing channel never takes the other down with it.The whole project is one seam with a backing store. Read this as the shape you are about to fill in — no signatures, no SQL; those land in the lessons that own them.
sendInvitation, changeMemberRole, the Stripe billing webhook) build a NotificationEvent and await dispatch(...) after their transaction commits.dispatch(event) looks up the registry entry, reads preferences once for all recipients, then for each recipient resolves which channels to use, claims the dedup window, and fans out.sendEmailChannel, writeInboxChannel) share one signature — ({ recipient, event, payload, rendered }) — and each runs behind its own try/catch.notifications (the inbox feed), user_notification_preferences (per-category channel toggles), and notification_dedup (the time window)./inspector drives every behavior from one page; /inbox is the plain server-rendered read of the notifications table.dispatch(event) registry source of truth sendEmailChannelwriteInboxChannel notifications inbox feed user_notification_preferences per-category toggles notification_dedup the time window Notice what this shape buys you. Adding a fourth event is one entry in the registry. Adding a third channel — push, say — is one new function with the exact same signature, registered in one place. And because dispatch is the only door, a single grep tells you everything the app can notify about.
The starter is a fork of the billing project from From Stripe webhook to plan entitlement — the org auth, the invitation actions, the Stripe webhook, the audit log, and the plan-entitlements row all already work. This project layers the dispatcher on top rather than rewriting any of it. The notifications module already exists on disk as a scaffold: the types, the error class, and the barrel are written for you, and every other file ships as a no-op or throwing stub you fill in.
The highlighted files are the ones carrying a TODO — your work for the next three lessons lives there. Everything uncommented is provided and you can read it as needed but you will not author it.
NotificationEvent, DispatchResult, ChannelFn, …)NotificationError (REGISTRY_MISS | RECIPIENT_NOT_FOUND)dispatch and the public typesnotifiableEvents map (source of truth)dispatch(event): the seamisDuplicate / recordDedup / computeDedupKeyreadPrefsForCategory + resolveChannelssendEmailChannelwriteInboxChannelsendInvitation: dispatch after commitchangeMemberRole: dispatch after commitbilling-past-due event in the past-due branchsendEmail wrapper; EMAIL_MOCK mode bumps the counter// TODO(L2)user, organization, member, …)db.transaction commitsdb:migrate, db:seed, dev, test:lesson, …The three notification tables live as a commented-out block under a // TODO(L2) marker in db/schema.ts; the billing-project tables around them are untouched. The three React Email templates in src/emails/ are complete components — you never write email JSX.
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
Add dispatch() after commit in sendInvitation, changeMemberRole, and the Stripe past-due webhook branch.
The starter runs on a local Postgres 18 container. Email is mocked by default, so you do not need a live Resend account to verify your work.
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:
npx degit terencicp/react-saas-course-projects/Chapter\ 071/start notification-dispatchercd notification-dispatcherInstall dependencies:
pnpm installStart Postgres 18:
docker compose up -dCopy the environment template and fill in the two secrets:
cp .env.example .envopenssl rand -base64 32 # paste into BETTER_AUTH_SECRETopenssl rand -base64 32 # paste into INVITATION_SIGNING_SECRETLeave EMAIL_MOCK=1 as it ships.
Run the migrations and seed the two organizations and four users:
pnpm db:migrate && pnpm db:seedStart the dev server:
pnpm devThe environment variables you need, and where each value comes from:
| Variable | Purpose | How to obtain |
| --- | --- | --- |
| DATABASE_URL (+ DATABASE_URL_UNPOOLED) | Postgres connection | Already set to the Docker container in .env.example |
| BETTER_AUTH_SECRET | Signs session cookies and tokens | openssl rand -base64 32 |
| INVITATION_SIGNING_SECRET | Signs the invitation accept URL | openssl rand -base64 32 |
| RESEND_API_KEY | Resend API key | Mocked under EMAIL_MOCK=1 — any non-empty value works locally |
| STRIPE_WEBHOOK_SECRET | Verifies Stripe webhook signatures | The whsec_… value stripe listen prints — needed only for the live billing-past-due path |
| APP_URL / NEXT_PUBLIC_APP_URL | App origin | http://localhost:3000 (already set) |
| EMAIL_MOCK | Short-circuits Resend and bumps the inspector’s email-sent counter | Leave at 1 |
When pnpm dev is up, open http://localhost:3000. You should see the billing project’s dashboard working as before. Navigate to /inspector: the page loads, but every fire button errors with dispatch not implemented — the dispatcher is the first thing you write. Its notification reads return empty, and /inbox renders an empty feed. That is the expected starting state. In the next lesson you make the first fire button do something real.