1/** 2 * @factory/shared/realtime/notification-prefs — the per-type notification 3 * EMAIL-channel preference schema + defaults (TASK-0094 R6). 4 * 5 * IN_APP is ALWAYS on; the per-type pref controls the EMAIL channel only. 6 * The product spec (functional-requirements §10, PR-096) requires per-type 7 * preferences. The shape: 8 * 9 * notificationPrefs: { [NotificationType]: "all" | "instant" | "digest" | "off" } 10 * 11 * Semantics (the email enqueue decision, recorded in the task file): 12 * - "off" → no email for this type; 13 * - "digest" → batch into the 1 h digest; 14 * - "instant" → send now; 15 * - "all" → treated as "instant" (immediate email) — "all" is the 16 * spec's fourth value; for the EMAIL channel it maps to send-now (there 17 * is no "both immediate AND digest" behavior; recorded). 18 * 19 * Defaults (recorded): MENTION / ASSIGNMENT = instant; COMMENT / 20 * DATABASE_CHANGE = digest; the rest = digest. 21 */ 22 23import { z } from "zod"; 24 25import { NOTIFICATION_TYPES, type NotificationTypeValue } from "./notification-policy.js"; 26 27/** The per-type EMAIL-channel modes. */ 28export const NOTIFICATION_PREF_MODES = ["all", "instant", "digest", "off"] as const; 29export type NotificationPrefMode = (typeof NOTIFICATION_PREF_MODES)[number]; 30 31/** A user's per-type email prefs (partial — absent types use the default). */ 32export type NotificationPrefs = Partial<Record<NotificationTypeValue, NotificationPrefMode>>; 33 34/** 35 * Zod schema: a (partial) map of NotificationType → pref mode. `z.record` 36 * with an enum KEY schema is itself the partial map: any subset of the 37 * known types is accepted, and a key outside the enum is stripped (zod v3 38 * has no `.partial()` on a record). Absent types → the default on read. 39 */ 40export const NotificationPrefsSchema = z 41 .record(z.enum(NOTIFICATION_TYPES), z.enum(NOTIFICATION_PREF_MODES)) 42 .default({}); 43export type NotificationPrefsParsed = z.infer<typeof NotificationPrefsSchema>; 44 45/** The per-type defaults (R6). */ 46export const DEFAULT_NOTIFICATION_PREFS: Record<NotificationTypeValue, NotificationPrefMode> = { 47 MENTION: "instant", 48 ASSIGNMENT: "instant", 49 COMMENT: "digest", 50 DATABASE_CHANGE: "digest", 51 TASK_ASSIGNED: "digest", 52 PAGE_SHARED: "digest", 53 AUTOMATION: "digest", 54 INTEGRATION: "digest", 55 FORM_SUBMITTED: "digest", // Phase 10 (TASK-0108): the owner's digest 56 // Confluence expansion (T-157/T-163/T-160): review requests need the 57 // reviewer's attention → instant; the rest fold into the digest. 58 REVIEW_REQUESTED: "instant", 59 APPROVAL_DECISION: "digest", 60 WATCH_UPDATE: "digest", 61 TASK_DUE: "digest" 62}; 63 64/** 65 * Resolve the effective pref mode for a type given a user's stored prefs 66 * (absent → the default). Pure — the API/worker enqueue logic calls this. 67 */ 68export function effectivePrefMode( 69 type: NotificationTypeValue, 70 prefs: NotificationPrefs, 71): NotificationPrefMode { 72 return prefs[type] ?? DEFAULT_NOTIFICATION_PREFS[type]; 73} 74 75/** 76 * Whether a type's email should be sent IMMEDIATELY (vs. batched into the 77 * digest vs. suppressed). "off" → false (no email at all, the caller checks 78 * that separately); "digest" → false; "instant" | "all" → true. 79 */ 80export function prefSendsInstant(type: NotificationTypeValue, prefs: NotificationPrefs): boolean { 81 const mode = effectivePrefMode(type, prefs); 82 return mode === "instant" || mode === "all"; 83} 84 85/** Whether a type's email is suppressed entirely ("off"). */ 86export function prefIsOff(type: NotificationTypeValue, prefs: NotificationPrefs): boolean { 87 return effectivePrefMode(type, prefs) === "off"; 88} 89 90/** 91 * Merge a user's STORED (partial, unvalidated — it comes from the 92 * `User.notificationPrefs` JSON column) prefs over the per-type defaults, 93 * returning the full effective map. Lenient on purpose: a malformed stored 94 * value (corruption, a pre-schema row) falls back to the defaults instead of 95 * throwing — the prefs endpoint re-validates on write, this is the read path 96 * the API `getPrefs` and the worker's email decision both use (one source of 97 * truth, so the two never diverge). 98 */ 99export function mergeNotificationPrefs(stored: unknown): NotificationPrefs { 100 const parsed = NotificationPrefsSchema.safeParse(stored ?? {}); 101 const out = { ...DEFAULT_NOTIFICATION_PREFS }; 102 if (parsed.success) { 103 for (const [type, mode] of Object.entries(parsed.data)) { 104 out[type as NotificationTypeValue] = mode; 105 } 106 } 107 return out; 108} 109