A notification in Oolvay is defined by three things: a type, such as security_alerts or marketing, a set of channels it can be delivered through (currently email and in-app), and a per-user preference controlling whether that user wants it on each channel. All three are defined in db/types/notification-types.ts, the single file every other consumer in the system derives from, including the database enum, the settings page, and the eligibility check.
This page covers that model: how types and channels are defined, how preferences are stored and enforced, and how to add a new type. Delivery works out of the box through polling, no extra setup required; for push-based delivery that updates a client the instant a notification is created, see Ably.
db/types/notification-types.ts is the single source of truth for what notification types exist in the application. Nothing else defines this list independently.
export const NOTIFICATION_TYPES = {
SECURITY_ALERTS:
0 / 2,000 characters
Every other piece of the system, the database enum, the settings page, the eligibility check that runs before a notification is sent, derives from this object or from NOTIFICATION_TYPE_META, defined directly below it in the same file. There is no second list to keep in sync by hand.
NOTIFICATION_TYPE_META holds everything the settings UI needs to render a type: its display label, its description, which channels are locked, and what a brand new user’s preferences should default to.
[NOTIFICATION_TYPES.SECURITY_ALERTS]: {
label: "Security Alerts",
description: "Important account and security notifications",
lockedChannels: [
NOTIFICATION_CHANNELS.EMAIL,
NOTIFICATION_CHANNELS.IN_APP,
] as NotificationChannel[],
defaults: {
email: true,
inApp: true,
},
},The settings page, app/(protected)/settings/notifications/page.tsx, has no hardcoded list of notification types anywhere. It iterates over the keys of NOTIFICATION_TYPE_META and renders one NotificationTypeCard per type, which in turn renders one toggle per channel.
<div className="space-y-8">
{(Object.keys(NOTIFICATION_TYPE_META) as NotificationType[]).map((type) => (
<NotificationTypeCard
key={type}
type={type}
preferences={preferences}
onChange={handleChange}
disabled={isPending}
/>
))}
</div>This is what makes adding a new notification type a single file change, covered in Adding a new notification type below, with no edits needed to the settings page, the form, or any card component.
Locking a channel is how a notification type becomes non-optional on that channel, useful for anything a user shouldn’t be able to silently turn off. security_alerts is the clearest example: both email and inApp are locked in the metadata above, since a user opting out of security notices entirely would defeat the point of having them.
lockedChannels does two separate things, and only one of them is visible.
In the UI, a locked channel renders its switch disabled, with a lock icon and a tooltip explaining why it cannot be turned off.
disabled={disabled || locked}
lockedReason={
locked
? `${meta.label} via ${CHANNEL_LABELS[channel]} cannot be disabled.`
: undefined
}But the actual enforcement happens separately, on the backend, in getEligibleUsers(). A locked channel is treated as eligible regardless of what is actually stored in that user’s saved preferences:
const enabled = preferences[channel]
const locked = meta.lockedChannels.includes(channel)
return enabled || lockedThe UI preventing a user from unchecking a locked channel is a courtesy, not the actual safeguard. Even if a locked channel’s stored value were somehow false, this function would still consider that user eligible. If you lock a channel for a notification type, treat it as truly non-optional, not merely defaulted on.
There is one sharp edge in this same function worth knowing. If a user has no
saved preferences object at all for a given notification type, they are
excluded entirely, including from locked channels. getEligibleUsers() checks
for the presence of a preferences entry before it ever checks
lockedChannels. In practice this should not happen, since the user table
defaults notificationPreferences to DEFAULT_NOTIFICATION_PREFERENCES at
the database level for every new row, covered below. But if you ever write to
that column directly, or migrate data in a way that bypasses the column
default, a user with a null or incomplete preferences object would silently
stop receiving even notifications you intended to be mandatory.
notificationPreferences is defaulted directly on the user table itself, in the schema:
notificationPreferences: t
.jsonb("notification_preferences")
.$type<NotificationPreferences>()
.default(DEFAULT_NOTIFICATION_PREFERENCES),DEFAULT_NOTIFICATION_PREFERENCES is computed once, at module load, by reducing every type in NOTIFICATION_TYPE_META down to its defaults object:
export const DEFAULT_NOTIFICATION_PREFERENCES = Object.fromEntries(
Object.entries(NOTIFICATION_TYPE_META).map(([type, meta]) => [
type,
meta.defaults,
])
) as NotificationPreferencesEvery user, from the moment their row is created, already has a complete preferences object covering every notification type that existed at the time, handled entirely by the database column default.
This also means adding a new notification type does not retroactively appear in existing users’ stored preferences objects. See Adding a new notification type for how this is actually handled without a backfill.
NotificationsForm always spreads the complete, current preferences state before calling the server action, even when only a single toggle changed:
const handleChange = (updated: NotificationPreferences) => {
setPreferences(updated)
startTransition(async () => {
const result = await updateNotificationPreferences(updated)
...
})
}And the server action itself performs a full overwrite, not a merge:
await db
.update(user)
.set({ notificationPreferences: preferences })
.where(eq(user.id, currentUser.id))If you ever call updateNotificationPreferences() from somewhere else, an API route, an admin tool, a script, you must pass the complete NotificationPreferences object, every type, every channel. Passing a partial object will overwrite the rest of that user’s preferences with undefined.
notification_type, the Postgres enum backing notificationEvent.type, is generated directly from the same list:
export const notificationTypeEnum = pgEnum(
"notification_type",
NOTIFICATION_TYPE_VALUES
)NOTIFICATION_TYPE_VALUES is itself just an array built from NOTIFICATION_TYPES, so the database’s accepted values and the TypeScript ones can’t drift apart, no notification type can exist in code and be rejected by the database, or vice versa.
notifications: {
retentionDays: 90,
pollingIntervalMs: 60000, // 60 seconds
ably: {
enabled: true,
maxRetries: 3,
retryDelayMs: 2000,
},
},Controls how long notification rows are retained before cleanup. This applies regardless of how a notification was, or was not, delivered, whether via Ably, polling, or neither.
With Ably disabled, this is the only delivery mechanism notifications have: connected clients query the database for new rows on this interval, 60 seconds by default. Lower values mean fresher notifications at the cost of more frequent database queries from every connected client; higher values reduce that query load at the cost of delivery lag. If Ably is enabled, this value also governs its fallback behavior, covered on the Ably page.
Any config variable ending in Ms is measured in milliseconds. Since 1,000 ms
equals 1 second, 60000 represents 60 seconds. For 2 minutes, use 120000.
ably.enabled, ably.maxRetries, and ably.retryDelayMs are documented on the Ably page, since they only apply once Ably itself is in play.
Because every consumer, the database enum, the eligibility check, and the entire settings page, derives from db/types/notification-types.ts, adding a new type is a single file change followed by a migration. Nothing else needs to be touched to make it appear correctly everywhere.
Add the new type to NOTIFICATION_TYPES and NOTIFICATION_TYPE_VALUES.
export const NOTIFICATION_TYPES = {
SECURITY_ALERTS: "security_alerts",
PRODUCT_UPDATES: "product_updates",
MARKETING: "marketing",
DEVLOG: "devlog",
ANNOUNCEMENTS: "announcements",
BILLING_ALERTS: "billing_alerts",
} as const
export const NOTIFICATION_TYPE_VALUES = [
NOTIFICATION_TYPES.SECURITY_ALERTS,
NOTIFICATION_TYPES.PRODUCT_UPDATES,
NOTIFICATION_TYPES.MARKETING,
NOTIFICATION_TYPES.DEVLOG,
NOTIFICATION_TYPES.ANNOUNCEMENTS,
NOTIFICATION_TYPES.BILLING_ALERTS,
] as constAdd a matching entry to NOTIFICATION_TYPE_META, including its defaults and any locked channels.
[NOTIFICATION_TYPES.BILLING_ALERTS]: {
label: "Billing Alerts",
description: "Payment failures, upcoming renewals, and invoice issues",
lockedChannels: [
NOTIFICATION_CHANNELS.EMAIL,
] as NotificationChannel[],
Generate and run a migration.
notificationTypeEnum is generated from NOTIFICATION_TYPE_VALUES, so this step is what actually teaches Postgres the new value.
bun db:generate
bun db:migrateDecide how existing users should be handled.
The column default only applies to rows created after the migration. Existing users’ stored notificationPreferences objects do not retroactively gain the new type’s key, since the default is evaluated once, at row insertion, not recalculated for existing rows.
getEligibleUsers() already handles this safely: a user whose stored preferences object has no entry for the new type is excluded from receiving it, rather than erroring. So a new type without a backfill simply will not be delivered to existing users until they save their preferences page once, which rewrites their entire preferences object using the current shape of NotificationPreferences.
If you want existing users to start receiving the new type immediately, without waiting for them to visit settings, write a one-time backfill that merges the new type’s defaults into every existing user’s stored notificationPreferences, rather than relying on the column default alone.
Wherever this notification type is actually triggered, dispatch it the same way existing types are dispatched, through getEligibleUsers() for each channel you intend to deliver on.
Almost always this means existing users do not yet have an entry for that type in their stored preferences object, and no backfill was run. See step 4 in Adding a new notification type above. Confirm whether the affected users are pre-existing rows or rows created after your migration.
It cannot, but not because of the UI. getEligibleUsers() treats any channel listed in lockedChannels as eligible unconditionally, independent of whatever value is actually stored for that user. Disabling the switch in NotificationChannelRow is a courtesy that matches what the backend already enforces, not the enforcement itself.
Check whatever called updateNotificationPreferences(). It performs a full overwrite of the notificationPreferences column, not a merge. Any caller must pass the user’s complete preferences object, not a partial one.
The TypeScript constant alone does not change what Postgres accepts. notificationTypeEnum is generated from NOTIFICATION_TYPE_VALUES at schema definition time, but the actual database enum is only updated when you generate and run a migration. See step 3 in Adding a new notification type above.
Notification types, channels, and eligibility are defined here. For how a notification reaches a connected client instantly instead of on the next poll, see Ably.
DEFAULT_NOTIFICATION_PREFERENCES recomputes automatically from this object, since it is derived from NOTIFICATION_TYPE_META’s entries rather than hardcoded.