import { ok, err, type CommandContext, type ReadonlyDB } from "@tailor-platform/erp-kit/core"; import { parseVariableSchema } from "../command/createNotificationTemplate"; import type { DB, Transaction } from "../generated/kysely-tailordb"; import type { DispatchAdapters } from "../lib/dispatchAdapters"; import { DEFAULT_LOCALE, loadChannelByChannelId, loadTemplateWithFallback, } from "../lib/dispatchQueries"; import { CategoryNotFoundError } from "../lib/errors.generated"; import { isCriticalReason, type NotificationReason, type NotificationRecipientHint, reasonRank, } from "../lib/events"; import { interpolate, validatePayloadVars } from "../lib/templateRendering"; import { planDestinations, type PlannedDestination } from "./planDestinations"; function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object"; } export interface PlanNotificationInput { eventType: string; sourceType: string; sourceId: string; // The emitter's reason-tagged naturally-interested set (role-holders // only the emitter can compute: ASSIGNED / AUTHOR / MENTION). recipients?: NotificationRecipientHint[]; // Actor that triggered the event; suppressed from the audience unless // `notifySelf` is set. actorUserId?: string | null; notifySelf?: boolean; payloadVars: Record | string; idempotencyKey?: string; locale?: string; } interface ResolvedRecipient { userId: string; reason: NotificationReason; } // A planned PERSONAL row. QUEUED rows are handed to the delivery worker; FAILED // rows reached a terminal state at plan time (no template / invalid vars / // missing address) and are never delivered. interface PlannedNotification { id: string; recipientUserId: string; channelId: string; deliveryStatus: "QUEUED" | "FAILED"; reason?: string; } // Why a (recipient[, channel]) pair was silently suppressed before any // Notification row could be anchored. Surfaced on the plan result so // callers/operators can distinguish "muted" from "lost". export type PlanSkipReason = "RECIPIENT_UNRESOLVED" | "CHANNEL_DISABLED" | "PREFERENCE_MUTED"; export interface SkippedRecipient { recipientUserId: string; /** Channel key (e.g. IN_APP / EMAIL); absent for recipient-level skips. */ channelId?: string; reason: PlanSkipReason; } interface PlanResult { notifications: PlannedNotification[]; deduped: PlannedNotification[]; destinations: PlannedDestination[]; skipped: SkippedRecipient[]; } interface NotificationRow { id: string; recipientUserId: string; channelId: string; eventType: string; sourceType: string; sourceId: string; reason: NotificationReason; locale: string; payloadVars: string | null; subject: string; body: string; htmlBody: string | null; idempotencyKey: string; deliveryStatus: "QUEUED" | "SENT" | "DELIVERED" | "FAILED" | "BOUNCED"; engagementStatuses: string[]; seenAt: Date | null; readAt: Date | null; archivedAt: Date | null; adapterMessageId: string | null; createdAt: Date; updatedAt: Date; } function dayBucket(now: Date): string { return now.toISOString().slice(0, 10); } function deriveIdempotencyKey( eventType: string, sourceId: string, recipientUserId: string, now: Date, ): string { return `${eventType}:${sourceId}:${recipientUserId}:${dayBucket(now)}`; } function serializePayloadVars(payloadVars: Record | string): { json: string; obj: Record; } { if (typeof payloadVars === "string") { try { const parsed: unknown = JSON.parse(payloadVars); const obj: Record = isRecord(parsed) ? parsed : {}; return { json: payloadVars, obj }; } catch { return { json: payloadVars, obj: {} }; } } return { json: JSON.stringify(payloadVars), obj: payloadVars }; } // Validate against the same schema grammar the query path uses; a malformed // variableSchema is itself a validation failure (matching renderNotificationTemplate). function validatePayloadVarsJson( variableSchemaJson: string, vars: Record, ): string | null { const schema = parseVariableSchema(variableSchemaJson); if (!schema) return "invalid variableSchema"; return validatePayloadVars(schema, vars); } async function loadEventBinding(db: ReadonlyDB, eventType: string) { return db .selectFrom("EventCategoryBinding") .selectAll() .where("eventType", "=", eventType) .executeTakeFirst(); } async function loadPreference( db: ReadonlyDB, userId: string, categoryId: string, channelDbId: string, ) { return db .selectFrom("NotificationPreference") .selectAll() .where("userId", "=", userId) .where("categoryId", "=", categoryId) .where("channelId", "=", channelDbId) .executeTakeFirst(); } async function loadExistingNotification( db: ReadonlyDB, recipientUserId: string, channelDbId: string, idempotencyKey: string, ): Promise { return (await db .selectFrom("Notification") .selectAll() .where("recipientUserId", "=", recipientUserId) .where("channelId", "=", channelDbId) .where("idempotencyKey", "=", idempotencyKey) .executeTakeFirst()) as NotificationRow | undefined; } type AuditEventType = "QUEUED" | "FAILED"; async function writeAudit( db: Transaction, notificationId: string, eventType: AuditEventType, occurredAt: Date, occurredBy: string, errorClass: string | null = null, errorDetail: string | null = null, ) { await db .insertInto("NotificationDeliveryAudit") .values({ id: crypto.randomUUID(), notificationId, eventType, occurredAt, occurredBy, errorClass, errorDetail, createdAt: occurredAt, updatedAt: occurredAt, }) .execute(); } // Internal SUBSCRIBED-watcher resolution: read NotificationSubscription rows // following the event's (sourceType, sourceId). async function loadWatcherUserIds( db: ReadonlyDB, sourceType: string, sourceId: string, ): Promise { const rows = await db .selectFrom("NotificationSubscription") .select(["userId"]) .where("sourceType", "=", sourceType) .where("sourceId", "=", sourceId) .execute(); return rows.map((r) => r.userId); } // Union the emitter's reason-tagged set and the internally-resolved SUBSCRIBED // watchers; dedup by userId keeping the highest-precedence reason; suppress the // actor unless notifySelf is set. function resolveAudience( input: PlanNotificationInput, watcherUserIds: string[], ): ResolvedRecipient[] { const byUser = new Map(); const consider = (userId: string, reason: NotificationReason) => { if (!userId) return; const current = byUser.get(userId); if (current === undefined || reasonRank(reason) > reasonRank(current)) { byUser.set(userId, reason); } }; for (const hint of input.recipients ?? []) consider(hint.userId, hint.reason); for (const userId of watcherUserIds) consider(userId, "SUBSCRIBED"); const actorUserId = input.actorUserId ?? null; const out: ResolvedRecipient[] = []; for (const [userId, reason] of byUser) { if (!input.notifySelf && actorUserId !== null && userId === actorUserId) continue; out.push({ userId, reason }); } return out; } async function persistNotification( db: Transaction, row: NotificationRow, ): Promise { return ( ((await db.insertInto("Notification").values(row).returningAll().executeTakeFirstOrThrow()) as | NotificationRow | undefined) ?? row ); } async function markFailed( db: Transaction, notificationId: string, now: Date, occurredBy: string, errorClass: string, errorDetail: string, ) { await db .updateTable("Notification") .set({ deliveryStatus: "FAILED", updatedAt: now }) .where("id", "=", notificationId) .execute(); await writeAudit(db, notificationId, "FAILED", now, occurredBy, errorClass, errorDetail); } /** * Resolves an event's audience, applies preference/transactional/critical-bypass gates, renders per recipient, and persists Notification (QUEUED) and DestinationDeliveryLog (PENDING) outbox rows. Performs no external delivery — the delivery worker drains the outbox rows this produces. * * Internal plan engine: not a registered command. The source-event ingress * is `logNotificationEvent`; this engine is invoked only by the CDC dispatch * and re-drain executors via `internal/planEvent`. */ export async function planNotificationInternal( db: Transaction, input: PlanNotificationInput, ctx: CommandContext, adapters: DispatchAdapters, ) { const now = adapters.now ? adapters.now() : new Date(); const result: PlanResult = { notifications: [], deduped: [], destinations: [], skipped: [], }; const binding = await loadEventBinding(db, input.eventType); if (!binding) { return err(new CategoryNotFoundError(input.eventType)); } // The binding's `transactional` flag and the category's `optOutAllowed=false` // both mark must-deliver events; either one suppresses the optional-path // preference gate. const category = await db .selectFrom("NotificationCategory") .select(["id", "optOutAllowed"]) .where("id", "=", binding.categoryId) .executeTakeFirst(); const transactional = Boolean(binding.transactional) || category?.optOutAllowed === false; const { json: payloadVarsJson, obj: payloadVarsObj } = serializePayloadVars(input.payloadVars); const watcherUserIds = await loadWatcherUserIds(db, input.sourceType, input.sourceId); const audience = resolveAudience(input, watcherUserIds); // Stage 1 (PERSONAL fan-out) iterates the resolved audience; an empty audience // is a natural no-op. The DESTINATION stage runs afterward regardless of the // recipient set, so we do NOT early-return on an empty audience here. for (const { userId: recipientUserId, reason } of audience) { const profile = await adapters.resolveRecipient(db, recipientUserId); if (!profile) { result.skipped.push({ recipientUserId, reason: "RECIPIENT_UNRESOLVED" }); continue; } const locale = input.locale ?? profile.locale ?? DEFAULT_LOCALE; for (const channelKey of binding.defaultChannels ?? []) { const channel = await loadChannelByChannelId(db, channelKey); if (!channel || !channel.enabled) { result.skipped.push({ recipientUserId, channelId: channelKey, reason: "CHANNEL_DISABLED" }); continue; } // DESTINATION channels are not part of the per-recipient fan-out; they are // handled once per binding in the Stage 2 loop below. if (channel.kind === "DESTINATION") continue; const channelDbId = channel.id; const idempotencyKey = input.idempotencyKey ?? deriveIdempotencyKey(input.eventType, input.sourceId, recipientUserId, now); const existing = await loadExistingNotification( db, recipientUserId, channelDbId, idempotencyKey, ); if (existing) { result.deduped.push({ id: existing.id, recipientUserId, channelId: channelDbId, deliveryStatus: existing.deliveryStatus === "FAILED" ? "FAILED" : "QUEUED", }); continue; } if (!transactional) { const allowed = (await loadPreference(db, recipientUserId, binding.categoryId, channelDbId)) ?.allowed; // Critical bypass: an ASSIGNED / MENTION recipient is delivered past a // preference mute. if (allowed === false && !isCriticalReason(reason)) { result.skipped.push({ recipientUserId, channelId: channelKey, reason: "PREFERENCE_MUTED", }); continue; } } const template = await loadTemplateWithFallback(db, input.eventType, channelDbId, locale); const validationError = template ? validatePayloadVarsJson(template.variableSchema, payloadVarsObj) : null; const recipientAddress = profile.addresses[channelKey] ?? null; const subject = template ? interpolate(template.subject, payloadVarsObj, false) : ""; const body = template ? interpolate(template.body, payloadVarsObj, false) : ""; const htmlBody = template?.htmlBody ? interpolate(template.htmlBody, payloadVarsObj, true) : null; const notificationId = crypto.randomUUID(); const baseRow: NotificationRow = { id: notificationId, recipientUserId, channelId: channelDbId, eventType: input.eventType, sourceType: input.sourceType, sourceId: input.sourceId, reason, locale, payloadVars: payloadVarsJson, subject, body, htmlBody, idempotencyKey, deliveryStatus: "QUEUED", engagementStatuses: [], seenAt: null, readAt: null, archivedAt: null, adapterMessageId: null, createdAt: now, updatedAt: now, }; const persisted = await persistNotification(db, baseRow); await writeAudit(db, persisted.id, "QUEUED", now, ctx.actorId); // Terminal plan-time failures: the row can never be delivered, so it is // FAILED here and excluded from the deliverable set. if (!template) { await markFailed( db, persisted.id, now, ctx.actorId, "TemplateNotFound", `${input.eventType}:${channelKey}:${locale}`, ); result.notifications.push({ id: persisted.id, recipientUserId, channelId: channelDbId, deliveryStatus: "FAILED", reason: "TEMPLATE_NOT_FOUND", }); continue; } if (validationError !== null) { await markFailed( db, persisted.id, now, ctx.actorId, "TemplateVarValidationFailed", validationError, ); result.notifications.push({ id: persisted.id, recipientUserId, channelId: channelDbId, deliveryStatus: "FAILED", reason: "TEMPLATE_VAR_VALIDATION_FAILED", }); continue; } if (recipientAddress === null) { await markFailed(db, persisted.id, now, ctx.actorId, "MissingRecipientAddress", channelKey); result.notifications.push({ id: persisted.id, recipientUserId, channelId: channelDbId, deliveryStatus: "FAILED", reason: "MISSING_RECIPIENT_ADDRESS", }); continue; } result.notifications.push({ id: persisted.id, recipientUserId, channelId: channelDbId, deliveryStatus: "QUEUED", }); } } // Stage 2: DESTINATION resolution loop — plan one PENDING log per active // binding (see internal/planDestinations). if (adapters.destination) { result.destinations = await planDestinations( db, { eventType: input.eventType, sourceType: input.sourceType, sourceId: input.sourceId, // Mirror the PERSONAL path: dedup destination logs on the event's // logical identity. planEvent always supplies the payloadHash-bearing // key; the fallback keeps direct callers deterministic. idempotencyKey: input.idempotencyKey ?? `${input.eventType}:${input.sourceType}:${input.sourceId}`, locale: input.locale, payloadVars: payloadVarsObj, }, now, ); } return ok(result); }