Notification preferences, default-on
How the dispatcher resolves per-user, per-channel notification preferences, with a default-on rule that treats a missing row as opted in.
The dispatcher loop you built last lesson runs over a channels array and fans each event out to every channel in it.
That array arrives already filtered, and the loop never asks how.
One recipient gets ['email', 'inbox'], another muted email and gets ['inbox'], a third turned everything off and gets [].
Something decides that before the loop runs, and that something is what this lesson builds: the notification opt-out itself, the real one, not an email header.
The job is small and the stakes are not. A user wants to control which kinds of events reach them and on which channels: keep the in-app inbox but stop the email, or mute team chatter while still hearing about billing. So the dispatcher has to turn an event’s default channels into a per-recipient list before it sends anything. Three questions decide the design. Where do preferences live? What shape do they take? And the one everyone gets wrong: what happens for a user who never opened their settings, or an event type that didn’t exist when they signed up? The answers are one Postgres table, one resolver of about fifteen lines, and a single default rule that has to be the right one.
The preferences table: one row per category, per-channel booleans
Section titled “The preferences table: one row per category, per-channel booleans”The resolver’s shape follows from the table’s, so the table comes first. Two decisions set that shape, and each is clearest once you’ve watched the obvious alternative fail.
Categories, not events. The instinct is a row per (userId, eventType), one preference per kind of notification.
But the registry grows, and a real app accumulates dozens of event types.
A row per event type turns the settings screen into a wall of toggles nobody manages, and every new event ships a migration problem: existing users have no row for an event type that didn’t exist when they last opened settings, so each release backfills rows or special-cases the gap.
The fix is to aggregate.
A category bundles related events the user toggles as a unit: team, billing, security, product, four to six of them, matched to how a user actually thinks (“I want billing stuff, I don’t care about team noise”).
Each registry entry already declares its category in the preferenceCategory field from the registry The notification dispatcher introduced:
'org.member.role_changed': { channels: ['email', 'inbox'], templates: { email: roleChangedEmail, inbox: roleChangedInbox }, preferenceCategory: 'team', // …subject, dedup, description},preferenceCategory is the join key between the registry and the preferences table: the string the resolver uses to look up the user’s row.
The payoff is what makes this the right call.
A new event in an existing category ships with zero migration: add org.comment.mentioned to team and it inherits whatever the user already chose, no backfill, no gap.
The category is the unit of user choice precisely so new events can join one without asking the user anything.
Per-channel booleans, not one switch. The next instinct is a single enabled flag per row: on means notify, off means don’t.
But the most common real-world request you will get is, almost verbatim, “I see it in the app, just stop emailing me.”
One enabled flag can’t say that; it collapses two independent decisions, inbox and email, into one.
So the row carries one boolean per channel: email, inbox, and a push reserved for later.
Muting email leaves the inbox untouched, because they’re separate columns.
Here is the table, read column by column.
export const userNotificationPreferences = pgTable( 'user_notification_preferences', { id: uuid().primaryKey().$defaultFn(() => uuidv7()), userId: text() .notNull() .references(() => user.id, { onDelete: 'cascade' }), category: text().notNull(), email: boolean().notNull().default(true), inbox: boolean().notNull().default(true), push: boolean().notNull().default(true), updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow(), }, (t) => [ unique('user_notification_preferences_user_id_category_unique').on( t.userId, t.category, ), ],);One row per (user, category) pair. The named composite unique makes “this user’s billing preferences” a single addressable row you can upsert into. userId is text because it references Better Auth’s user.id, which is text, and a foreign key always matches the type it points at.
export const userNotificationPreferences = pgTable( 'user_notification_preferences', { id: uuid().primaryKey().$defaultFn(() => uuidv7()), userId: text() .notNull() .references(() => user.id, { onDelete: 'cascade' }), category: text().notNull(), email: boolean().notNull().default(true), inbox: boolean().notNull().default(true), push: boolean().notNull().default(true), updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow(), }, (t) => [ unique('user_notification_preferences_user_id_category_unique').on( t.userId, t.category, ), ],);Three independent toggles, one per channel. Muting email leaves inbox untouched, the “keep the inbox, stop the email” case expressed in the schema. push has no consumer yet, since last lesson shipped only email and inbox; it’s a reserved column for the channel the ChannelName union will grow into.
export const userNotificationPreferences = pgTable( 'user_notification_preferences', { id: uuid().primaryKey().$defaultFn(() => uuidv7()), userId: text() .notNull() .references(() => user.id, { onDelete: 'cascade' }), category: text().notNull(), email: boolean().notNull().default(true), inbox: boolean().notNull().default(true), push: boolean().notNull().default(true), updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow(), }, (t) => [ unique('user_notification_preferences_user_id_category_unique').on( t.userId, t.category, ), ],);Default-on starts at the column level: a row created without a channel gets that channel allowed. And cascade makes preferences owned children, so deleting the user deletes their preference rows.
Notice there is no orgId.
Preferences are the user’s, not the organization’s: the same person carries them across every org they belong to, so the tenant-scoping discipline you apply to org data doesn’t apply here.
This table is user-scoped by design.
One framing to carry forward: the only thing that ever writes a row here is a deliberate opt-out , a user opening settings and turning something off. A user who never touches settings has no row at all. So the key question about this table isn’t “what’s in a row,” it’s “what do we do when there is no row.” That’s next.
Default-on: no preference row means every channel allowed
Section titled “Default-on: no preference row means every channel allowed”A brand-new user just signed up.
They have never opened notification settings, so userNotificationPreferences has no row for them in any category.
An event targets them, say a team invitation.
The resolver looks for their team preferences and finds nothing.
What do they get?
Experienced people get this backwards on instinct, so look at both answers.
The wrong default: off. Sending nothing until the user explicitly opts in feels like the privacy-respecting, GDPR-friendly posture.
Now watch it fail.
Six months after launch you ship a new event, org.comment.mentioned, into the team category.
No existing user has a preference row for it, so default-off means every one of them silently gets nothing: no error, no log, just users who never hear they were mentioned.
An absent row is the normal state for the overwhelming majority of users, because most people never open settings, so default-off turns every release into a system that quietly mutes itself.
The right default: on. A missing row means opted-in on every channel. The only thing that removes a channel is a deliberate opt-out the user typed in. New event types inherit whatever the user already chose for their category, and a brand-new category defaults on. The resting state is “the user hears about things,” until the user says otherwise.
The honest trade. Default-on isn’t free, and you’ll have to defend it in a review. It risks one user per category who is mildly annoyed and clicks “stop emailing me,” a recoverable annoyance fixed in five seconds. Default-off risks a whole class of users who never learn an event type exists, silence they can’t fix because they don’t know there’s anything to fix. A recoverable annoyance beats invisible silence. The justification: these are transactional notifications the user expects, like invitations, billing alerts, and security signals, not marketing blasts. The real privacy controls are the per-category opt-out and the email suppression at the end of this lesson.
Hold the rule as a picture, because it makes the code obvious later. A preference row can only subtract channels from what the event declares by default. It can’t add one the event never declared, and a missing row subtracts nothing.
No row looks exactly like the full default. When you read the resolver next, the line that produces this will be three tokens long and read as obvious, because you’ve already seen the picture.
Resolving channels inside the dispatcher
Section titled “Resolving channels inside the dispatcher”Here is the function the lesson has been building toward.
Before reading it, hold the rule this chapter established: the preference check happens in exactly one place, the dispatcher.
Not at the call site that fired the event, and not inside last lesson’s channel functions.
Resolution happens once per dispatch, between the registry lookup and the fan-out loop.
A preference read anywhere outside lib/notifications/ is a leak in the seam, and that is the regression to grep for.
resolveChannels takes the event and the user’s preference row, which is undefined when the user has no row, and returns the resolved channel list.
It encodes all four rules in about fifteen lines.
type NotificationPrefRow = typeof userNotificationPreferences.$inferSelect;
export const resolveChannels = ( event: NotifiableEvent, prefs: NotificationPrefRow | undefined,): ChannelName[] => { const allowed = event.channels.filter( (channel) => prefs?.[channel] ?? true, );
const critical = event.criticalChannel; if (critical && !allowed.includes(critical)) { allowed.push(critical); }
return allowed;};Start from the registry’s default set. event.channels is the starting set, and the resolver can only narrow it, never invent a channel the event didn’t declare.
type NotificationPrefRow = typeof userNotificationPreferences.$inferSelect;
export const resolveChannels = ( event: NotifiableEvent, prefs: NotificationPrefRow | undefined,): ChannelName[] => { const allowed = event.channels.filter( (channel) => prefs?.[channel] ?? true, );
const critical = event.criticalChannel; if (critical && !allowed.includes(critical)) { allowed.push(critical); }
return allowed;};The whole default-on rule is three moves on one line. Optional-chain the row (prefs?.) so a missing row reads as undefined, index the channel column ([channel]), then ?? true so undefined, from a missing row or a missing column, means allowed. Absence keeps the channel; only an explicit false drops it.
type NotificationPrefRow = typeof userNotificationPreferences.$inferSelect;
export const resolveChannels = ( event: NotifiableEvent, prefs: NotificationPrefRow | undefined,): ChannelName[] => { const allowed = event.channels.filter( (channel) => prefs?.[channel] ?? true, );
const critical = event.criticalChannel; if (critical && !allowed.includes(critical)) { allowed.push(critical); }
return allowed;};Some channels can’t be fully muted. If the event marks a critical channel and the filter dropped it, force it back on. This is the one thing that can put a channel back after the filter removed it.
The critical-channel override is a product-safety decision.
Security events, a password change, a login from a new device, a payment the user must act on now, have to reach the person even if they muted everything else, or you write the support ticket yourself: “I disabled all email and never got my password-reset code.”
The override lives in the registry.
An entry that carries criticalChannel: 'email' is declaring that this channel is not the user’s to fully silence:
'auth.password.changed': { channels: ['email', 'inbox'], preferenceCategory: 'security', criticalChannel: 'email', // …templates, subject, dedup, description},The field is optional, criticalChannel?: ChannelName on the entry type, so most events omit it; only the security and act-now-billing events carry it.
The override is encoded once, as a property of the event, not scattered as a special case across the resolver.
That resolves one recipient, but a dispatch usually has several: billing past-due goes to every owner, a mention hits a handful of people. The trap is reading preferences inside the per-recipient loop, the N+1 query you’ve learned to refuse. So the dispatcher reads every recipient’s preferences in a single query, keyed by the event’s category, then resolves each recipient against an in-memory lookup.
const rows = await db .select() .from(userNotificationPreferences) .where( and( inArray(userNotificationPreferences.userId, recipientUserIds), eq(userNotificationPreferences.category, event.preferenceCategory), ), );
const prefsByUser = new Map(rows.map((row) => [row.userId, row]));
// per recipient, in memory — no further queries:// resolveChannels(event, prefsByUser.get(userId))One query for five recipients, then a Map lookup each.
A recipient with no row simply isn’t in the Map, so prefsByUser.get(userId) returns undefined, which is exactly the value resolveChannels reads as default-on.
The missing-row case is already the resolver’s happy path, so the batched read costs nothing extra.
The query is indexed and bounded to one category across a known set of users.
The resolved array feeds last lesson’s for (const channel of channels) loop.
When preferences shrink that array, the dropped channels increment suppressedByPrefs, the report counter The notification dispatcher declared and never filled.
for (const userId of recipientUserIds) { const channels = resolveChannels(event, prefsByUser.get(userId));
const suppressed = event.channels.length - channels.length; result.suppressedByPrefs += suppressed;
for (const channel of channels) { // last lesson's branchless fan-out, inside its own try/catch await runChannel(channel, { recipient: { userId }, event, payload, rendered }); }}suppressed is the default length minus the resolved length: how many of the event’s channels this recipient’s preferences removed.
Summed across recipients, it’s the suppressedByPrefs the DispatchResult reports.
runChannel stands in for last lesson’s channelFns[channel](...) wrapped in its per-channel try/catch.
Where preferences end: the inbox-only case, the settings UI, and email suppression
Section titled “Where preferences end: the inbox-only case, the settings UI, and email suppression”The most-requested notification setting in any web app, “keep the in-app notifications, just stop emailing me,” needs no new code.
It’s a row with email: false, inbox: true, which resolveChannels already resolves to ['inbox'].
That’s the per-channel-boolean decision paying off: the most common request is data, not a code change.
The settings page that writes those rows, a /settings/notifications Server Component that upserts a row when the user flips a toggle, belongs to the project chapter.
That upsert is the moment a row first exists, which is why missing-row default-on is the steady state: most users never flip a toggle, so most users never have a row.
A user can stop receiving email through two pathways, in two different layers, and they don’t overlap:
- Preferences (this lesson): the user toggles
emailoff for a category. This short-circuits before the email channel runs, becauseresolveChannelsdropsemailfrom the array. - Email suppression (from your Resend and deliverability work): a hard bounce, spam complaint, or unsubscribe writes the address into the suppression list. This short-circuits inside the email channel’s wrapper, which refuses a send to a suppressed address.
They are complementary, not redundant. Preferences are the user’s product choice (“I don’t want these”); suppression is the deliverability and compliance backstop (“this address must not be mailed, regardless of preference”). One consequence: these transactional emails carry no in-email unsubscribe link. The opt-out is the per-category preference, not an email header. Signed, single-click unsubscribe links belong to marketing bulk email, a different sender entirely.
Which layer owns each outcome? Drag each item into the bucket it belongs to, then press Check.
email for the team categoryinbox rowQuiet hours, digest mode, and per-org admin overrides are real next steps but out of scope here; build them when the inbox actually gets loud, not before.
Where this goes next
Section titled “Where this goes next”Preferences decided whether a recipient hears about an event. They don’t handle the same event firing five times in two seconds, from an importer that re-runs, a webhook delivered twice, or a triple-clicked “save” button, which without a guard is five identical notifications in one inbox. The next lesson adds a 60-second dedup window that collapses a burst into a single notification.
External resources
Section titled “External resources”A wider tour of the dispatcher's world: preference storage, categories, per-channel routing, and opt-out handling — the architecture this lesson designs one slice of.
Why these transactional notifications need no in-email unsubscribe link: the legal line between commercial email and transactional or relationship messages.
The unique() and composite-constraint syntax behind the one-row-per-(user, category) rule the resolver relies on.
The UX framing for when a system should notify a user at all — the question every registry entry's category has to answer.