Skip to content

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:

typescript
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

PolicyBehaviourUsed By
immediateSend right awayIn-app (most types), push notifications
respect_digestHonour user's digest frequencyEpisode/team/booking emails
collaboration_batchGroup into hourly batch windowsShow notes update emails
collaboration_missed_message5-minute delay, skip if user returnsChat message emails
collaboration_mention2-minute escalation delayChat/show notes mention emails

Policy Matrix

TypeIn-AppEmailPush
episode.invitedimmediaterespect_digestimmediate
chat.messageimmediate (context-aware)collaboration_missed_messageimmediate
chat.mentionimmediatecollaboration_mentionimmediate
show_notes.updatedimmediatecollaboration_batchimmediate
show_notes.mentionimmediatecollaboration_mentionimmediate
team.invitationimmediaterespect_digestimmediate
network.responseimmediaterespect_digestimmediate
booking.requestedimmediaterespect_digestimmediate

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

KeyDefaultControls
email_invited_to_episodetrueEpisode invitation notifications
email_new_messagetrueChat message notifications
email_show_notes_updatedfalseShow notes update notifications
email_team_invitationtrueTeam invitation notifications
email_new_bookingtrueBooking request notifications

Push Toggles

KeyDefaultControls
push_enabledfalseGlobal push channel toggle
push_invited_to_episodetrueEpisode invitation push
push_new_messagetrueChat message push
push_show_notes_updatedfalseShow notes update push
push_team_invitationtrueTeam invitation push
push_new_bookingtrueBooking request push

Global Settings

KeyDefaultControls
mute_allfalseKill 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

KeyDefaultControls
quiet_hours_enabledfalseMaster 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_timezonenullTimezone override (falls back to profile timezone)

Preference Evaluation

At notification planning time, resolveNotificationPreferences() builds a NotificationPreferenceSnapshot:

typescript
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:

  1. Global mute -- If mute_all is true, skip all channels
  2. Podcast mute -- If podcast is in muted_podcasts, skip
  3. Self-notification -- If actor is the recipient, skip
  4. Channel enabled -- Check type-specific toggle (e.g., email_new_booking)
  5. Active context -- If user is viewing the relevant context, suppress in-app/push
  6. 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
  7. Quiet hours -- For email/push, reschedule to quiet hours end if active
  8. 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.

typescript
// 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:

FrequencyScheduled For
dailyNext day at 9:00 AM in user's timezone
weeklyNext 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_key in 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 TypeSuppressed When User Is Viewing
chat.message / chat.mentionThe episode's chat panel
show_notes.updated / show_notes.mentionThe 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:

typescript
// 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.

Internal documentation - Not for public distribution