Skip to content
Chapter 71Lesson 3

Implement channels and preferences

Last lesson the dispatcher fanned out over stub channels that only ticked a counter, and resolveChannels returned every channel regardless of the user’s toggles. This lesson replaces both stubs, so every inspector button takes its real effect: an inbox row that lands in the database, an email that moves the mock’s send counter, and a preference that decides which of the two runs.

By the end, firing invite-sent as seeded bob — who has team → email off — writes one inbox row, leaves the email counter flat, and returns suppressedByPrefs: 1. As alice, who has no preferences row, it sends through both channels. Switch off one channel and only that channel goes quiet. Firing billing-past-due with billing → email off still increments the email counter, because the registry marks email a channel a past-due notice cannot skip.

Both halves of the dispatcher go live together, because neither is verifiable alone: proving a send and a suppression end to end needs both ends real. The first half is the two channel functions that write: writeInboxChannel inserts a row into the notifications table, and sendEmailChannel resolves the recipient’s address and hands a rendered template to the email wrapper. The second is preference resolution: a batched read of every recipient’s per-category toggles, and a resolveChannels that decides which channels survive for each one.

Four constraints carry the lesson:

  • Read preferences once per dispatch, batched across all recipients in a single WHERE userId IN (...) AND category = ? query — never one read per recipient inside the loop. That is the N+1 discipline from Spotting N+1, and the dispatcher is where it bites.
  • A user with no preferences row defaults to on. The ?? true rule keeps the app from going silent the moment a row is missing.
  • The critical-channel override lives inside resolveChannels, so every reason a channel runs or not sits in one function.
  • Render the inbox content once, before the recipient loop, and freeze it onto each row. The inbox channel writes the title and body it was handed, so the feed never drifts when an actor later renames itself.

These are transactional notifications, so the email carries no unsubscribe header: opt-out is the per-category toggle, and a critical channel ignores even that. Firing the dispatcher from real product code is out of scope; sendInvitation, changeMemberRole, and the Stripe webhook stay stubbed until the next lesson. The inbox page, the preferences panel, its setPref action, and every fire button are already built, so you are filling in the engine, not the dashboard.

As bob (seeded team → email off), Fire invite-sent writes one new inbox row, leaves the email counter unchanged, and returns suppressedByPrefs: 1.
tested
As alice (no preferences row), Fire invite-sent increments both the inbox panel and the email counter — default-on holds.
tested
Toggling alice’s team → inbox off and refiring increments the email counter only.
tested
With billing → email off, Fire billing-past-due still increments the email counter — the critical-channel override holds.
tested
A new inbox row’s title and body match the registry’s inbox template for the event, written once at dispatch.
tested
Make email fail then fire — the inbox row is still written and the email error is swallowed.
tested
The security → email toggle renders disabled on the preferences panel and has no server-side effect.
untested

Implement prefs.ts, get-user-email.ts, channels/inbox.ts, and channels/email.ts, then finish the dispatcher’s // TODO(L3) block — the batched preferences read, the resolveChannels call per recipient, and the render-once step. Build against the brief and the Lesson 3 tests; the preferences panel and setPref action are already wired, so you can drive every scenario from the UI.

Reference solution and walkthrough

Start with prefs.ts. Its two exports split along an async boundary: readPrefsForCategory touches the database, while resolveChannels is a pure synchronous function the dispatcher calls per recipient against the rows the read fetched.

The batched read is the N+1 guard made concrete: one query pulls every recipient’s row for the category into a Map keyed by userId. The early return on empty userIds avoids an IN () against zero ids, a query you should never send. A recipient with no row never gets a key, so map.get(userId) returns undefined — and that undefined carries the default-on behavior downstream.

import 'server-only';
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
import { userNotificationPreferences } from '@/db/schema';
import type { ChannelName, NotifiableEvent } from './types';
export type NotificationPrefRow =
typeof userNotificationPreferences.$inferSelect;
// One batched `WHERE userId IN (...) AND category = ?` query, then a per-recipient Map
// lookup. Users with no row map to undefined so default-on holds at resolveChannels.
export const readPrefsForCategory = async (
userIds: string[],
category: string,
): Promise<Map<string, NotificationPrefRow | undefined>> => {
const map = new Map<string, NotificationPrefRow | undefined>();
if (userIds.length === 0) {
return map;
}
const rows = await db
.select()
.from(userNotificationPreferences)
.where(
and(
inArray(userNotificationPreferences.userId, userIds),
eq(userNotificationPreferences.category, category),
),
);
for (const row of rows) {
map.set(row.userId, row);
}
return map;
};

NotificationPrefRow is typeof userNotificationPreferences.$inferSelect — the row type Drizzle infers from the table, so it tracks the schema and you never hand-write the column list.

Now resolveChannels. It is the smallest function in the module and carries every preference decision the project makes in one filter. Read both clauses through the annotations.

export const resolveChannels = (
event: NotifiableEvent,
prefs: NotificationPrefRow | undefined,
): ChannelName[] =>
event.channels.filter(
(channel) =>
(prefs?.[channel] ?? true) || channel === event.criticalChannel,
);

Start from the channels the registry declares for this event and keep only the ones that pass the test. The output is a subset — every channel the user did not silence, in the registry’s order.

export const resolveChannels = (
event: NotifiableEvent,
prefs: NotificationPrefRow | undefined,
): ChannelName[] =>
event.channels.filter(
(channel) =>
(prefs?.[channel] ?? true) || channel === event.criticalChannel,
);

The default-on clause. Read the per-channel boolean off the user’s row. If there is no row, prefs?. is undefined; if the row exists but the column is somehow absent, the same. Either way ?? true reads it as on. A missing preference can never mean silence.

export const resolveChannels = (
event: NotifiableEvent,
prefs: NotificationPrefRow | undefined,
): ChannelName[] =>
event.channels.filter(
(channel) =>
(prefs?.[channel] ?? true) || channel === event.criticalChannel,
);

The override. Even when the user explicitly toggled this channel off, if it is the event’s critical channel the || forces it back on. This is why org.billing.past_due declares criticalChannel: 'email' — a past-due notice reaches the owner regardless of a billing-email opt-out.

1 / 1

get-user-email.ts is a single lookup against Better Auth’s user table — note the import from @/db/schema/auth, since user belongs to the auth schema, not the notifications one. It returns string | null, and the null is a real signal: the email channel turns it into a RECIPIENT_NOT_FOUND for a recipient id that no longer resolves to a user.

import 'server-only';
import { eq } from 'drizzle-orm';
import { db } from '@/db';
import { user } from '@/db/schema/auth';
// Resolve a recipient's email from Better Auth's `user` table. Returns null when the
// user has no row (the email channel turns null into RECIPIENT_NOT_FOUND, which the
// dispatcher swallows per-channel).
export const getUserEmail = async (userId: string): Promise<string | null> => {
const row = await db.query.user.findFirst({
where: eq(user.id, userId),
columns: { email: true },
});
return row?.email ?? null;
};

The columns: { email: true } projection selects the one field you need rather than hydrating the whole user row.

channels/inbox.ts is one insert. It reads rendered.inbox.title and rendered.inbox.body — the strings the dispatcher rendered once, before the loop — and writes them verbatim, with no join or formatting.

import 'server-only';
import { db } from '@/db';
import { notifications } from '@/db/schema';
import type { ChannelFn } from '../types';
// The in-app inbox channel: insert one notifications row from the content rendered once at
// dispatch (rendered.inbox.title/body, frozen onto the row), so the inbox UI is a pure read
// with no joins, immune to later actor-name drift. This is the ONLY writer of the
// notifications table; any direct write outside lib/notifications/ is a regression.
export const writeInboxChannel: ChannelFn = async ({
recipient,
event,
payload,
rendered,
}) => {
await db.insert(notifications).values({
userId: recipient.userId,
orgId: rendered.orgId,
eventType: event.type,
subjectId: event.subjectId,
title: rendered.inbox.title,
body: rendered.inbox.body,
payload,
});
};

The alternative — storing only ids and joining the user and org tables on every read — lets the text rewrite itself when an actor renames itself or a plan label changes, so “Alice invited you” becomes “Alexandra invited you” weeks later. Freezing the text at dispatch keeps the row an accurate record of what was true when it fired.

channels/email.ts resolves the address, renders the template, and sends, in that order. The sendEmail wrapper already owns the from, replyTo, and suppression-list check, so this channel passes none of them and supplies only what is specific to this send.

import 'server-only';
import { createElement } from 'react';
import { sendEmail } from '@/lib/email';
import { logger } from '@/lib/logger';
import { NotificationError } from '../errors';
import { getUserEmail } from '../get-user-email';
import { notifiableEvents } from '../registry';
import type { ChannelFn, NotifiableEvent } from '../types';
// The email channel: resolve the recipient's address, render the registry template with
// the props frozen at dispatch, and send through the wrapper with a deterministic
// idempotency key. No from/replyTo (the wrapper owns them from env) and no unsubscribe
// headers (transactional notifications carry none; opt-out is the per-category toggle).
// A null address is a RECIPIENT_NOT_FOUND the dispatcher's per-channel try/catch swallows;
// a sendEmail error is logged and thrown so the channel independence still holds.
export const sendEmailChannel: ChannelFn = async ({
recipient,
event,
rendered,
}) => {
const to = await getUserEmail(recipient.userId);
if (!to) {
throw new NotificationError('RECIPIENT_NOT_FOUND', recipient.userId);
}
// Read the template through the NotifiableEvent field type — passing the raw `as const`
// union straight to createElement does not typecheck (the per-entry prop types don't
// unify, TS2769); the permissive `(props: any) => ReactElement` field accepts every one.
const eventDef: NotifiableEvent = notifiableEvents[event.type];
const react = createElement(eventDef.templates.email, rendered.emailProps);
const sent = await sendEmail({
to,
subject: rendered.inbox.title,
react,
idempotencyKey: `${event.type}:${event.subjectId}:${recipient.userId}`,
});
if (!sent.ok) {
logger.error(
{
seam: 'notifications.channel',
channel: 'email',
code: sent.error.code,
},
'email send failed',
);
throw new Error(sent.error.userMessage);
}
};

Three choices to call out:

  • createElement instead of JSX. This is a .ts file resolving the template from the registry at runtime, and createElement(eventDef.templates.email, rendered.emailProps) is the plain-function form of what JSX compiles to. The cast const eventDef: NotifiableEvent = notifiableEvents[event.type] is load-bearing: the registry is as const, so each entry’s email template has its own precise prop type, and handing that raw union to createElement fails to typecheck because the prop types don’t unify into one callable signature. Reading the entry through the NotifiableEvent field type — whose email field is the permissive (props: any) => ReactElement — makes it callable. You settled that permissive field, and the contravariance reason for it, in Registry, dispatcher, and dedup.
  • The idempotency key is deterministic. `${event.type}:${event.subjectId}:${recipient.userId}` is the same string every time the same event fires for the same recipient, so the provider collapses a retried send rather than delivering it twice.
  • RECIPIENT_NOT_FOUND is thrown, not handled here. The throw is meant to escape: it surfaces in the dispatcher’s per-channel try/catch, gets logged, and is swallowed there so the inbox channel still runs. A non-ok sendEmail Result is logged and re-thrown for the same reason. The channel fails loudly upward; isolating that failure is the dispatcher’s job.

The React Email templates ship complete in src/emails/; authoring that JSX belongs to JSX for the email DOM.

The dispatcher already has its L2 skeleton — registry lookup, the per-recipient loop, the dedup check, the fan-out, recordDedup. The // TODO(L3) resolves into three non-adjacent edits, so step through the finished file. One order stays fixed: preferences resolve, then dedup, then channels.

import 'server-only';
import { logger } from '@/lib/logger';
import { sendEmailChannel } from './channels/email';
import { writeInboxChannel } from './channels/inbox';
import { isDuplicate, recordDedup } from './dedup';
import { NotificationError } from './errors';
import { readPrefsForCategory, resolveChannels } from './prefs';
import { notifiableEvents } from './registry';
import type {
ChannelFn,
ChannelName,
DispatchResult,
NotificationEvent,
RenderedContent,
} from './types';
// The uniform channel table: the dispatcher loops `await channelFns[channel](args)` with no
// branch on channel name. Adding a channel later is one entry of the same signature.
const channelFns = {
email: sendEmailChannel,
inbox: writeInboxChannel,
} satisfies Record<ChannelName, ChannelFn>;
// The one seam: every call site builds a NotificationEvent and `await dispatch(...)`, never
// importing a channel or writing the notifications table directly. Body order: registry
// lookup (a miss is a programmer error — thrown before the loop, never swallowed); one
// batched prefs read; then a per-recipient loop that resolves channels (default-on +
// critical override), counts suppressions, skips a fully-suppressed recipient, runs the
// dedup check, fans out behind a per-channel try/catch (so one failing channel never kills
// the other), and records the dedup row last. The return is a flat count summary,
// deliberately NOT a Result<T> and NOT per-channel.
export const dispatch = async (
event: NotificationEvent,
): Promise<DispatchResult> => {
const eventDef = notifiableEvents[event.type];
if (!eventDef) {
throw new NotificationError('REGISTRY_MISS', event.type);
}
const result: DispatchResult = { sent: 0, deduped: 0, suppressedByPrefs: 0 };
// One batched read across all recipients (never per-recipient).
const prefsByUser = await readPrefsForCategory(
event.recipientUserIds,
eventDef.preferenceCategory,
);
// Rendered once per dispatch and frozen onto every recipient's inbox row / passed to the
// email template — render-at-dispatch keeps the inbox UI a pure read, immune to drift.
const rendered: RenderedContent = {
emailProps: event.payload,
inbox: eventDef.templates.inbox(event.payload),
orgId: null,
};
for (const userId of event.recipientUserIds) {
const channels = resolveChannels(eventDef, prefsByUser.get(userId));
result.suppressedByPrefs += eventDef.channels.length - channels.length;
if (channels.length === 0) {
continue;
}
const duplicate = await isDuplicate({
event,
userId,
payload: event.payload,
});
if (duplicate) {
result.deduped++;
continue;
}
for (const channel of channels) {
try {
await channelFns[channel]({
recipient: { userId },
event,
payload: event.payload,
rendered,
});
result.sent++;
} catch (e) {
logger.error(
{ seam: 'notifications.channel', channel, err: e },
'channel failed',
);
}
}
await recordDedup({ event, userId, payload: event.payload });
}
logger.info(
{ seam: 'notifications.dispatch', ...result },
'dispatch settled',
);
return result;
};

Edit one: the channel table. Each channel name maps to its function, and satisfies Record<ChannelName, ChannelFn> makes the compiler check that every ChannelName has an entry and every entry has the uniform signature. The fan-out below becomes channelFns[channel](...) with no if/switch on the channel name — adding a third channel later is one new line here.

import 'server-only';
import { logger } from '@/lib/logger';
import { sendEmailChannel } from './channels/email';
import { writeInboxChannel } from './channels/inbox';
import { isDuplicate, recordDedup } from './dedup';
import { NotificationError } from './errors';
import { readPrefsForCategory, resolveChannels } from './prefs';
import { notifiableEvents } from './registry';
import type {
ChannelFn,
ChannelName,
DispatchResult,
NotificationEvent,
RenderedContent,
} from './types';
// The uniform channel table: the dispatcher loops `await channelFns[channel](args)` with no
// branch on channel name. Adding a channel later is one entry of the same signature.
const channelFns = {
email: sendEmailChannel,
inbox: writeInboxChannel,
} satisfies Record<ChannelName, ChannelFn>;
// The one seam: every call site builds a NotificationEvent and `await dispatch(...)`, never
// importing a channel or writing the notifications table directly. Body order: registry
// lookup (a miss is a programmer error — thrown before the loop, never swallowed); one
// batched prefs read; then a per-recipient loop that resolves channels (default-on +
// critical override), counts suppressions, skips a fully-suppressed recipient, runs the
// dedup check, fans out behind a per-channel try/catch (so one failing channel never kills
// the other), and records the dedup row last. The return is a flat count summary,
// deliberately NOT a Result<T> and NOT per-channel.
export const dispatch = async (
event: NotificationEvent,
): Promise<DispatchResult> => {
const eventDef = notifiableEvents[event.type];
if (!eventDef) {
throw new NotificationError('REGISTRY_MISS', event.type);
}
const result: DispatchResult = { sent: 0, deduped: 0, suppressedByPrefs: 0 };
// One batched read across all recipients (never per-recipient).
const prefsByUser = await readPrefsForCategory(
event.recipientUserIds,
eventDef.preferenceCategory,
);
// Rendered once per dispatch and frozen onto every recipient's inbox row / passed to the
// email template — render-at-dispatch keeps the inbox UI a pure read, immune to drift.
const rendered: RenderedContent = {
emailProps: event.payload,
inbox: eventDef.templates.inbox(event.payload),
orgId: null,
};
for (const userId of event.recipientUserIds) {
const channels = resolveChannels(eventDef, prefsByUser.get(userId));
result.suppressedByPrefs += eventDef.channels.length - channels.length;
if (channels.length === 0) {
continue;
}
const duplicate = await isDuplicate({
event,
userId,
payload: event.payload,
});
if (duplicate) {
result.deduped++;
continue;
}
for (const channel of channels) {
try {
await channelFns[channel]({
recipient: { userId },
event,
payload: event.payload,
rendered,
});
result.sent++;
} catch (e) {
logger.error(
{ seam: 'notifications.channel', channel, err: e },
'channel failed',
);
}
}
await recordDedup({ event, userId, payload: event.payload });
}
logger.info(
{ seam: 'notifications.dispatch', ...result },
'dispatch settled',
);
return result;
};

Edit two, part one: the batched preferences read, hoisted above the loop. One readPrefsForCategory call covers every recipient for this event’s category. Putting it inside the loop would be the N+1 you are explicitly avoiding.

import 'server-only';
import { logger } from '@/lib/logger';
import { sendEmailChannel } from './channels/email';
import { writeInboxChannel } from './channels/inbox';
import { isDuplicate, recordDedup } from './dedup';
import { NotificationError } from './errors';
import { readPrefsForCategory, resolveChannels } from './prefs';
import { notifiableEvents } from './registry';
import type {
ChannelFn,
ChannelName,
DispatchResult,
NotificationEvent,
RenderedContent,
} from './types';
// The uniform channel table: the dispatcher loops `await channelFns[channel](args)` with no
// branch on channel name. Adding a channel later is one entry of the same signature.
const channelFns = {
email: sendEmailChannel,
inbox: writeInboxChannel,
} satisfies Record<ChannelName, ChannelFn>;
// The one seam: every call site builds a NotificationEvent and `await dispatch(...)`, never
// importing a channel or writing the notifications table directly. Body order: registry
// lookup (a miss is a programmer error — thrown before the loop, never swallowed); one
// batched prefs read; then a per-recipient loop that resolves channels (default-on +
// critical override), counts suppressions, skips a fully-suppressed recipient, runs the
// dedup check, fans out behind a per-channel try/catch (so one failing channel never kills
// the other), and records the dedup row last. The return is a flat count summary,
// deliberately NOT a Result<T> and NOT per-channel.
export const dispatch = async (
event: NotificationEvent,
): Promise<DispatchResult> => {
const eventDef = notifiableEvents[event.type];
if (!eventDef) {
throw new NotificationError('REGISTRY_MISS', event.type);
}
const result: DispatchResult = { sent: 0, deduped: 0, suppressedByPrefs: 0 };
// One batched read across all recipients (never per-recipient).
const prefsByUser = await readPrefsForCategory(
event.recipientUserIds,
eventDef.preferenceCategory,
);
// Rendered once per dispatch and frozen onto every recipient's inbox row / passed to the
// email template — render-at-dispatch keeps the inbox UI a pure read, immune to drift.
const rendered: RenderedContent = {
emailProps: event.payload,
inbox: eventDef.templates.inbox(event.payload),
orgId: null,
};
for (const userId of event.recipientUserIds) {
const channels = resolveChannels(eventDef, prefsByUser.get(userId));
result.suppressedByPrefs += eventDef.channels.length - channels.length;
if (channels.length === 0) {
continue;
}
const duplicate = await isDuplicate({
event,
userId,
payload: event.payload,
});
if (duplicate) {
result.deduped++;
continue;
}
for (const channel of channels) {
try {
await channelFns[channel]({
recipient: { userId },
event,
payload: event.payload,
rendered,
});
result.sent++;
} catch (e) {
logger.error(
{ seam: 'notifications.channel', channel, err: e },
'channel failed',
);
}
}
await recordDedup({ event, userId, payload: event.payload });
}
logger.info(
{ seam: 'notifications.dispatch', ...result },
'dispatch settled',
);
return result;
};

Edit two, part two: render the content once, before any recipient is processed. eventDef.templates.inbox(event.payload) runs a single time and the resulting title/body are reused for every recipient — and frozen onto each inbox row.

import 'server-only';
import { logger } from '@/lib/logger';
import { sendEmailChannel } from './channels/email';
import { writeInboxChannel } from './channels/inbox';
import { isDuplicate, recordDedup } from './dedup';
import { NotificationError } from './errors';
import { readPrefsForCategory, resolveChannels } from './prefs';
import { notifiableEvents } from './registry';
import type {
ChannelFn,
ChannelName,
DispatchResult,
NotificationEvent,
RenderedContent,
} from './types';
// The uniform channel table: the dispatcher loops `await channelFns[channel](args)` with no
// branch on channel name. Adding a channel later is one entry of the same signature.
const channelFns = {
email: sendEmailChannel,
inbox: writeInboxChannel,
} satisfies Record<ChannelName, ChannelFn>;
// The one seam: every call site builds a NotificationEvent and `await dispatch(...)`, never
// importing a channel or writing the notifications table directly. Body order: registry
// lookup (a miss is a programmer error — thrown before the loop, never swallowed); one
// batched prefs read; then a per-recipient loop that resolves channels (default-on +
// critical override), counts suppressions, skips a fully-suppressed recipient, runs the
// dedup check, fans out behind a per-channel try/catch (so one failing channel never kills
// the other), and records the dedup row last. The return is a flat count summary,
// deliberately NOT a Result<T> and NOT per-channel.
export const dispatch = async (
event: NotificationEvent,
): Promise<DispatchResult> => {
const eventDef = notifiableEvents[event.type];
if (!eventDef) {
throw new NotificationError('REGISTRY_MISS', event.type);
}
const result: DispatchResult = { sent: 0, deduped: 0, suppressedByPrefs: 0 };
// One batched read across all recipients (never per-recipient).
const prefsByUser = await readPrefsForCategory(
event.recipientUserIds,
eventDef.preferenceCategory,
);
// Rendered once per dispatch and frozen onto every recipient's inbox row / passed to the
// email template — render-at-dispatch keeps the inbox UI a pure read, immune to drift.
const rendered: RenderedContent = {
emailProps: event.payload,
inbox: eventDef.templates.inbox(event.payload),
orgId: null,
};
for (const userId of event.recipientUserIds) {
const channels = resolveChannels(eventDef, prefsByUser.get(userId));
result.suppressedByPrefs += eventDef.channels.length - channels.length;
if (channels.length === 0) {
continue;
}
const duplicate = await isDuplicate({
event,
userId,
payload: event.payload,
});
if (duplicate) {
result.deduped++;
continue;
}
for (const channel of channels) {
try {
await channelFns[channel]({
recipient: { userId },
event,
payload: event.payload,
rendered,
});
result.sent++;
} catch (e) {
logger.error(
{ seam: 'notifications.channel', channel, err: e },
'channel failed',
);
}
}
await recordDedup({ event, userId, payload: event.payload });
}
logger.info(
{ seam: 'notifications.dispatch', ...result },
'dispatch settled',
);
return result;
};

Edit three opens the loop: resolve this recipient’s channels from the row the batched read fetched, then add the count of dropped channels to suppressedByPrefs. A recipient whose channels all resolve away is skipped with continue — no dedup row, no fan-out, nothing.

import 'server-only';
import { logger } from '@/lib/logger';
import { sendEmailChannel } from './channels/email';
import { writeInboxChannel } from './channels/inbox';
import { isDuplicate, recordDedup } from './dedup';
import { NotificationError } from './errors';
import { readPrefsForCategory, resolveChannels } from './prefs';
import { notifiableEvents } from './registry';
import type {
ChannelFn,
ChannelName,
DispatchResult,
NotificationEvent,
RenderedContent,
} from './types';
// The uniform channel table: the dispatcher loops `await channelFns[channel](args)` with no
// branch on channel name. Adding a channel later is one entry of the same signature.
const channelFns = {
email: sendEmailChannel,
inbox: writeInboxChannel,
} satisfies Record<ChannelName, ChannelFn>;
// The one seam: every call site builds a NotificationEvent and `await dispatch(...)`, never
// importing a channel or writing the notifications table directly. Body order: registry
// lookup (a miss is a programmer error — thrown before the loop, never swallowed); one
// batched prefs read; then a per-recipient loop that resolves channels (default-on +
// critical override), counts suppressions, skips a fully-suppressed recipient, runs the
// dedup check, fans out behind a per-channel try/catch (so one failing channel never kills
// the other), and records the dedup row last. The return is a flat count summary,
// deliberately NOT a Result<T> and NOT per-channel.
export const dispatch = async (
event: NotificationEvent,
): Promise<DispatchResult> => {
const eventDef = notifiableEvents[event.type];
if (!eventDef) {
throw new NotificationError('REGISTRY_MISS', event.type);
}
const result: DispatchResult = { sent: 0, deduped: 0, suppressedByPrefs: 0 };
// One batched read across all recipients (never per-recipient).
const prefsByUser = await readPrefsForCategory(
event.recipientUserIds,
eventDef.preferenceCategory,
);
// Rendered once per dispatch and frozen onto every recipient's inbox row / passed to the
// email template — render-at-dispatch keeps the inbox UI a pure read, immune to drift.
const rendered: RenderedContent = {
emailProps: event.payload,
inbox: eventDef.templates.inbox(event.payload),
orgId: null,
};
for (const userId of event.recipientUserIds) {
const channels = resolveChannels(eventDef, prefsByUser.get(userId));
result.suppressedByPrefs += eventDef.channels.length - channels.length;
if (channels.length === 0) {
continue;
}
const duplicate = await isDuplicate({
event,
userId,
payload: event.payload,
});
if (duplicate) {
result.deduped++;
continue;
}
for (const channel of channels) {
try {
await channelFns[channel]({
recipient: { userId },
event,
payload: event.payload,
rendered,
});
result.sent++;
} catch (e) {
logger.error(
{ seam: 'notifications.channel', channel, err: e },
'channel failed',
);
}
}
await recordDedup({ event, userId, payload: event.payload });
}
logger.info(
{ seam: 'notifications.dispatch', ...result },
'dispatch settled',
);
return result;
};

The dedup check sits after preferences resolve and before any channel runs — the order the registry’s window assumes. A duplicate increments deduped and continues past the fan-out.

import 'server-only';
import { logger } from '@/lib/logger';
import { sendEmailChannel } from './channels/email';
import { writeInboxChannel } from './channels/inbox';
import { isDuplicate, recordDedup } from './dedup';
import { NotificationError } from './errors';
import { readPrefsForCategory, resolveChannels } from './prefs';
import { notifiableEvents } from './registry';
import type {
ChannelFn,
ChannelName,
DispatchResult,
NotificationEvent,
RenderedContent,
} from './types';
// The uniform channel table: the dispatcher loops `await channelFns[channel](args)` with no
// branch on channel name. Adding a channel later is one entry of the same signature.
const channelFns = {
email: sendEmailChannel,
inbox: writeInboxChannel,
} satisfies Record<ChannelName, ChannelFn>;
// The one seam: every call site builds a NotificationEvent and `await dispatch(...)`, never
// importing a channel or writing the notifications table directly. Body order: registry
// lookup (a miss is a programmer error — thrown before the loop, never swallowed); one
// batched prefs read; then a per-recipient loop that resolves channels (default-on +
// critical override), counts suppressions, skips a fully-suppressed recipient, runs the
// dedup check, fans out behind a per-channel try/catch (so one failing channel never kills
// the other), and records the dedup row last. The return is a flat count summary,
// deliberately NOT a Result<T> and NOT per-channel.
export const dispatch = async (
event: NotificationEvent,
): Promise<DispatchResult> => {
const eventDef = notifiableEvents[event.type];
if (!eventDef) {
throw new NotificationError('REGISTRY_MISS', event.type);
}
const result: DispatchResult = { sent: 0, deduped: 0, suppressedByPrefs: 0 };
// One batched read across all recipients (never per-recipient).
const prefsByUser = await readPrefsForCategory(
event.recipientUserIds,
eventDef.preferenceCategory,
);
// Rendered once per dispatch and frozen onto every recipient's inbox row / passed to the
// email template — render-at-dispatch keeps the inbox UI a pure read, immune to drift.
const rendered: RenderedContent = {
emailProps: event.payload,
inbox: eventDef.templates.inbox(event.payload),
orgId: null,
};
for (const userId of event.recipientUserIds) {
const channels = resolveChannels(eventDef, prefsByUser.get(userId));
result.suppressedByPrefs += eventDef.channels.length - channels.length;
if (channels.length === 0) {
continue;
}
const duplicate = await isDuplicate({
event,
userId,
payload: event.payload,
});
if (duplicate) {
result.deduped++;
continue;
}
for (const channel of channels) {
try {
await channelFns[channel]({
recipient: { userId },
event,
payload: event.payload,
rendered,
});
result.sent++;
} catch (e) {
logger.error(
{ seam: 'notifications.channel', channel, err: e },
'channel failed',
);
}
}
await recordDedup({ event, userId, payload: event.payload });
}
logger.info(
{ seam: 'notifications.dispatch', ...result },
'dispatch settled',
);
return result;
};

The fan-out, now over the resolved channels. Each call is wrapped in its own try/catch, so one channel throwing is logged and the loop moves to the next channel — channel independence. recordDedup runs last, after the recipient’s channels have all been attempted.

1 / 1

Two things the try/catch placement decides. The catch is per channel, so an email failure never touches the inbox write for the same recipient. And REGISTRY_MISS is thrown before the loop, outside any try/catch — an unknown event type is a programmer error that must surface loudly, the opposite of an expected channel failure you log and move past.

Run the lesson’s gate:

Terminal window
pnpm test:lesson 3

The suite drives your dispatch() against the same local Postgres and email mock the app uses, reading the notifications table and the email-sent counter to confirm what each channel did. It needs the Lesson 2 migration applied and pnpm db:seed run — it leans on bob’s team → email off row and alice’s missing row. A green run looks like this:

Terminal window
tests/lessons/Lesson 3.test.ts (6)
a recipient with a channel toggled off has just that channel suppressed
a recipient with no preferences row receives every channel
toggling one channel off suppresses only that channel
a critical channel ignores a per-category opt-out
the inbox row freezes the registry-rendered title and body
a failing email channel does not stop the inbox channel
Test Files 1 passed (1)
Tests 6 passed (6)

The suite never opens the inspector, so walk the UI once by hand to confirm the panels render the same outcomes and to check the one requirement no test reaches:

As bob, Fire invite-sent adds a row to the inbox panel, leaves the email counter unchanged, and the dispatch result shows suppressedByPrefs: 1.
untested
As alice, Fire invite-sent advances both the inbox panel and the email counter; then toggle team → inbox off and refire — only the email counter moves.
untested
After a reset, set billing → email off and Fire billing-past-due — the email counter still advances.
untested
A new inbox row’s title and body read as the registry template renders them (e.g. Invitation to Acme).
untested
Make email fail, then fire — the inbox row still appears and the dispatch log shows the email error swallowed, not thrown.
untested
The security → email toggle is rendered disabled and toggling it changes nothing server-side — the critical-channel affordance, illustrative since no security event ships.
untested

Every inspector button now fires its real effect: inbox rows land, the EMAIL_MOCK counter moves, and preferences decide which channels run. What is still missing is the dispatcher firing from real product code instead of the inspector’s buttons — wiring it into sendInvitation, changeMemberRole, and the Stripe webhook is the next lesson.