/** * @factory/shared/realtime/notification-prefs — the per-type notification * EMAIL-channel preference schema + defaults (TASK-0094 R6). * * IN_APP is ALWAYS on; the per-type pref controls the EMAIL channel only. * The product spec (functional-requirements §10, PR-096) requires per-type * preferences. The shape: * * notificationPrefs: { [NotificationType]: "all" | "instant" | "digest" | "off" } * * Semantics (the email enqueue decision, recorded in the task file): * - "off" → no email for this type; * - "digest" → batch into the 1 h digest; * - "instant" → send now; * - "all" → treated as "instant" (immediate email) — "all" is the * spec's fourth value; for the EMAIL channel it maps to send-now (there * is no "both immediate AND digest" behavior; recorded). * * Defaults (recorded): MENTION / ASSIGNMENT = instant; COMMENT / * DATABASE_CHANGE = digest; the rest = digest. */ import { z } from "zod"; import { NOTIFICATION_TYPES, type NotificationTypeValue } from "./notification-policy.js"; /** The per-type EMAIL-channel modes. */ export const NOTIFICATION_PREF_MODES = ["all", "instant", "digest", "off"] as const; export type NotificationPrefMode = (typeof NOTIFICATION_PREF_MODES)[number]; /** A user's per-type email prefs (partial — absent types use the default). */ export type NotificationPrefs = Partial>; /** * Zod schema: a (partial) map of NotificationType → pref mode. `z.record` * with an enum KEY schema is itself the partial map: any subset of the * known types is accepted, and a key outside the enum is stripped (zod v3 * has no `.partial()` on a record). Absent types → the default on read. */ export const NotificationPrefsSchema = z .record(z.enum(NOTIFICATION_TYPES), z.enum(NOTIFICATION_PREF_MODES)) .default({}); export type NotificationPrefsParsed = z.infer; /** The per-type defaults (R6). */ export const DEFAULT_NOTIFICATION_PREFS: Record = { MENTION: "instant", ASSIGNMENT: "instant", COMMENT: "digest", DATABASE_CHANGE: "digest", TASK_ASSIGNED: "digest", PAGE_SHARED: "digest", AUTOMATION: "digest", INTEGRATION: "digest", FORM_SUBMITTED: "digest", // Phase 10 (TASK-0108): the owner's digest // Confluence expansion (T-157/T-163/T-160): review requests need the // reviewer's attention → instant; the rest fold into the digest. REVIEW_REQUESTED: "instant", APPROVAL_DECISION: "digest", WATCH_UPDATE: "digest", TASK_DUE: "digest" }; /** * Resolve the effective pref mode for a type given a user's stored prefs * (absent → the default). Pure — the API/worker enqueue logic calls this. */ export function effectivePrefMode( type: NotificationTypeValue, prefs: NotificationPrefs, ): NotificationPrefMode { return prefs[type] ?? DEFAULT_NOTIFICATION_PREFS[type]; } /** * Whether a type's email should be sent IMMEDIATELY (vs. batched into the * digest vs. suppressed). "off" → false (no email at all, the caller checks * that separately); "digest" → false; "instant" | "all" → true. */ export function prefSendsInstant(type: NotificationTypeValue, prefs: NotificationPrefs): boolean { const mode = effectivePrefMode(type, prefs); return mode === "instant" || mode === "all"; } /** Whether a type's email is suppressed entirely ("off"). */ export function prefIsOff(type: NotificationTypeValue, prefs: NotificationPrefs): boolean { return effectivePrefMode(type, prefs) === "off"; } /** * Merge a user's STORED (partial, unvalidated — it comes from the * `User.notificationPrefs` JSON column) prefs over the per-type defaults, * returning the full effective map. Lenient on purpose: a malformed stored * value (corruption, a pre-schema row) falls back to the defaults instead of * throwing — the prefs endpoint re-validates on write, this is the read path * the API `getPrefs` and the worker's email decision both use (one source of * truth, so the two never diverge). */ export function mergeNotificationPrefs(stored: unknown): NotificationPrefs { const parsed = NotificationPrefsSchema.safeParse(stored ?? {}); const out = { ...DEFAULT_NOTIFICATION_PREFS }; if (parsed.success) { for (const [type, mode] of Object.entries(parsed.data)) { out[type as NotificationTypeValue] = mode; } } return out; }