// Event-ingress helpers shared by logNotificationEvent and the dispatch // executors. Kept free of any DB / command import so it stays a pure, // bundle-clean leaf (the Tailor function runtime ships no `node:*` builtins, // so hashing uses Web Crypto — see the comment on hashPayload). /** * Why a recipient was included. Carried through the event payload by the * emitter as a hint and consumed by the dispatcher: the reason is * unioned with internally-resolved SUBSCRIBED watchers, deduped by precedence, * persisted on the Notification row, and drives the critical bypass * (`ASSIGNED` / `MENTION`). */ export const NOTIFICATION_REASONS = ["SUBSCRIBED", "ASSIGNED", "AUTHOR", "MENTION"] as const; /** One of the {@link NOTIFICATION_REASONS} inclusion reasons. */ export type NotificationReason = (typeof NOTIFICATION_REASONS)[number]; /** Emitter-supplied pairing of a recipient with the reason they were included. */ export interface NotificationRecipientHint { userId: string; reason: NotificationReason; } /** * The two reasons that mark a recipient as personally accountable for the * source — directly assigned or @-mentioned. These trigger the dispatcher's * critical bypass: an `allowed = false` NotificationPreference mute does not * suppress delivery. `SUBSCRIBED` / `AUTHOR` do not bypass. */ export function isCriticalReason(reason: NotificationReason): boolean { return reason === "ASSIGNED" || reason === "MENTION"; } /** * Precedence used to dedup the resolved audience: when one user is reached for * more than one reason (e.g. a SUBSCRIBED watcher who is also ASSIGNED), the * highest-rank reason is kept so critical-bypass behavior is preserved. */ export function reasonRank(reason: NotificationReason): number { switch (reason) { case "MENTION": return 4; case "ASSIGNED": return 3; case "AUTHOR": return 2; case "SUBSCRIBED": return 1; } } /** * The cross-module event payload contract. This shape is the stable seam * between every emitter (via a host-app `emitNotificationEvent` helper) and the * notification module; it is stored as canonical key-sorted JSON on * `NotificationEvent.payload` and * mapped to the dispatcher input by the dispatch executor. */ export interface NotificationEventPayload { /** Naturally-interested recipients the emitter already resolved. */ recipients?: NotificationRecipientHint[]; /** When true, the actor is not suppressed from the recipient set. */ notifySelf?: boolean; /** Locale override for template rendering. */ locale?: string; /** Template variables (includes title/body/deepLinkUrl used by seeded templates). */ title?: string; body?: string; deepLinkUrl?: string; // Emitters may carry additional template variables. [key: string]: unknown; } export function parseNotificationEventPayload(payload: string): NotificationEventPayload | null { try { const parsed: unknown = JSON.parse(payload); if (typeof parsed !== "object" || parsed === null) return null; // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- runtime JSON boundary return parsed as NotificationEventPayload; } catch { return null; } } // Recursively sort object keys so the serialized form (and therefore the // payloadHash) is independent of key insertion order and whitespace. function canonicalize(value: unknown): unknown { if (Array.isArray(value)) return value.map(canonicalize); if (value !== null && typeof value === "object") { const record = value as Record; const out: Record = {}; for (const key of Object.keys(record).sort()) { out[key] = canonicalize(record[key]); } return out; } return value; } export function normalizePayload(payload: string | NotificationEventPayload): { raw: string; parsed: NotificationEventPayload | null; } { const parsed = typeof payload === "string" ? parseNotificationEventPayload(payload) : structuredClone(payload); if (!parsed) { return { raw: typeof payload === "string" ? payload : JSON.stringify(payload), parsed: null }; } return { raw: JSON.stringify(canonicalize(parsed)), parsed }; } /** * SHA-256 hex of the normalized payload, used as the idempotency discriminator. * Uses Web Crypto (not `node:crypto`) so the file stays loadable inside the * Tailor function runtime, which bundles no `node:*` builtins. */ export async function hashPayload(payload: string): Promise { const encoded = new TextEncoder().encode(payload); const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoded)); let hex = ""; for (let i = 0; i < digest.length; i++) { hex += digest[i].toString(16).padStart(2, "0"); } return hex; }