Preferences & Policies
The notification system uses a two-layer configuration model: policies define how each notification type behaves per channel, and preferences let users customise delivery to their needs.
Source: src/lib/notifications/policies.ts, src/lib/notifications/preferences.ts, src/lib/types/notifications.ts
Notification Type Registry
Every notification type is registered in NOTIFICATION_TYPE_REGISTRY with:
interface NotificationTypeDefinition {
type: NotificationType;
category: 'episode' | 'collaboration' | 'team' | 'booking';
defaultPriority: 'immediate' | 'normal' | 'low';
emailPreferenceKey: NotificationEmailPreferenceKey;
pushPreferenceKey: string;
dedupeWindowHours: number; // Always 24
emailRenderer: string;
emailFallbackPolicy: 'generic' | 'skip';
channelPolicies: {
in_app: ChannelPolicy;
email: ChannelPolicy;
push: ChannelPolicy;
};
}Channel Policy Types
| Policy | Behaviour | Used By |
|---|---|---|
immediate | Send right away | In-app (most types), push notifications |
respect_digest | Honour user's digest frequency | Episode/team/booking emails |
collaboration_batch | Group into hourly batch windows | Show notes update emails |
collaboration_missed_message | 5-minute delay, skip if user returns | Chat message emails |
collaboration_mention | 2-minute escalation delay | Chat/show notes mention emails |
Policy Matrix
| Type | In-App | Push | |
|---|---|---|---|
episode.invited | immediate | respect_digest | immediate |
chat.message | immediate (context-aware) | collaboration_missed_message | immediate |
chat.mention | immediate | collaboration_mention | immediate |
show_notes.updated | immediate | collaboration_batch | immediate |
show_notes.mention | immediate | collaboration_mention | immediate |
team.invitation | immediate | respect_digest | immediate |
network.response | immediate | respect_digest | immediate |
booking.requested | immediate | respect_digest | immediate |
User Preferences
Preferences are stored in user_profiles.notification_preferences as a JSONB column. The full interface is defined in src/lib/types/notifications.ts.
Preference Keys
Email Toggles
| Key | Default | Controls |
|---|---|---|
email_invited_to_episode | true | Episode invitation notifications |
email_new_message | true | Chat message notifications |
email_show_notes_updated | false | Show notes update notifications |
email_team_invitation | true | Team invitation notifications |
email_new_booking | true | Booking request notifications |
Push Toggles
| Key | Default | Controls |
|---|---|---|
push_enabled | false | Global push channel toggle |
push_invited_to_episode | true | Episode invitation push |
push_new_message | true | Chat message push |
push_show_notes_updated | false | Show notes update push |
push_team_invitation | true | Team invitation push |
push_new_booking | true | Booking request push |
Global Settings
| Key | Default | Controls |
|---|---|---|
mute_all | false | Kill switch for all notifications |
muted_podcasts | [] | Per-podcast mute list |
digest_frequency | 'immediate' | immediate / daily / weekly |
collaboration_email_mode | 'immediate' | immediate / mentions_only / batched |
Quiet Hours
| Key | Default | Controls |
|---|---|---|
quiet_hours_enabled | false | Master toggle |
quiet_hours_start | '22:00' | Start time (24-hour HH:mm) |
quiet_hours_end | '08:00' | End time (24-hour HH:mm) |
quiet_hours_timezone | null | Timezone override (falls back to profile timezone) |
Preference Evaluation
At notification planning time, resolveNotificationPreferences() builds a NotificationPreferenceSnapshot:
interface NotificationPreferenceSnapshot {
muteAll: boolean;
podcastMuted: boolean;
quietHours: { active: boolean; endsAt?: Date };
digestFrequency: 'immediate' | 'daily' | 'weekly';
collaborationEmailMode: 'immediate' | 'mentions_only' | 'batched';
channelEnabled: { in_app: boolean; email: boolean; push: boolean };
hasPushSubscription: boolean;
activeContextMatch: boolean;
}Evaluation Order
The decideChannel() function evaluates in this order:
- Global mute -- If
mute_allis true, skip all channels - Podcast mute -- If podcast is in
muted_podcasts, skip - Self-notification -- If actor is the recipient, skip
- Channel enabled -- Check type-specific toggle (e.g.,
email_new_booking) - Active context -- If user is viewing the relevant context, suppress in-app/push
- Policy-specific logic -- Apply the channel's registered policy:
- Digest scheduling for
respect_digest - Batch window calculation for
collaboration_batch - Delay timers for
collaboration_missed_message/collaboration_mention
- Digest scheduling for
- Quiet hours -- For email/push, reschedule to quiet hours end if active
- Push subscription -- For push, verify at least one active subscription exists
Quiet Hours
Quiet hours evaluation is timezone-aware and supports windows crossing midnight. The resolved timezone is quiet_hours_timezone when set, otherwise user_profiles.timezone. Offset math is done with Intl.DateTimeFormat.formatToParts — see Timezone Management for the shared utility.
// Example: 22:00-08:00 in America/New_York on 2026-04-08
evaluateQuietHours(preferences, userTimezone);
// → { active: true, endsAt: new Date('2026-04-08T12:00:00.000Z') }
// (08:00 America/New_York during EDT = 12:00Z. `endsAt` is a UTC instant.)When quiet hours are active:
- Email/Push: Rescheduled to
endsAt(delivery waits until quiet hours end) - In-App: Not affected (always delivered)
Quiet hours are re-evaluated at execution time by the notification-executor, in case the user changed their preferences between planning and delivery.
Digest Scheduling
When a notification type uses respect_digest and the user's digest_frequency is not immediate:
| Frequency | Scheduled For |
|---|---|
daily | Next day at 9:00 AM in user's timezone |
weekly | Next Monday at 9:00 AM in user's timezone |
The user's timezone is read from user_profiles.timezone (defaults to 'UTC'). The 9:00 AM wall-clock target is converted to a UTC instant via the shared parseDateTimeLocal() helper and stored in scheduled_for. See Timezone Management for the utility and the fallback chain.
Batch Windows
For collaboration_batch and collaboration modes set to batched:
- Notifications are grouped into 1-hour windows using
getBatchWindowBucket() - The bucket key format is:
batch:{type}:{contextId}:{hourBucket} - All notifications in the same bucket share a
batch_keyin the database - The digest email aggregates all batched notifications at window end
Active Context Suppression
The system can suppress notifications when the user is actively viewing the related content:
| Context Type | Suppressed When User Is Viewing |
|---|---|
chat.message / chat.mention | The episode's chat panel |
show_notes.updated / show_notes.mention | The episode's show notes editor |
Active user/guest IDs are passed via the /api/notifications/events/chat endpoint's activeUserIds and activeGuestIds fields.
Key Generation
Dedupe Key
Stable FNV hash of recipient_id + actor_id + notification context + title + body + payload. Used to prevent duplicate notifications within the 24-hour window.
Batch Key
Groups related notifications: batch:{type}:{contextId}:{hourBucket}. Used by the digest system to aggregate notifications.
Group Key
Groups notifications for UI display: group:{type}:{contextId}. Used by the frontend to collapse related notifications.
Preference Merging
Preferences support partial updates and forward-compatible merging:
// Merge stored fragments without forcing defaults
mergeStoredNotificationPreferences(storedPrefs, patchFromUI);
// Merge with defaults (all keys guaranteed to exist)
mergeNotificationPreferences(storedPrefs);Unknown keys are preserved so older UIs don't wipe future preference fields.
Related Documentation
- Notification System Overview -- Architecture and notification types
- Delivery Pipeline -- How preferences affect delivery flow
- Push Notifications -- Push channel details
- Timezone Management -- Shared utility for quiet-hours and digest-time resolution