import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { Transaction } from "../generated/kysely-tailordb"; import { ChannelNotFoundError, DuplicateTemplateKeyError, EventTypeNotFoundError, MissingRequiredFieldError, UndeclaredVariableReferenceError, } from "../lib/errors.generated"; export interface VariableSchemaEntry { name: string; type: string; required?: boolean; } export interface VariableSchemaShape { variables: VariableSchemaEntry[]; } export interface CreateNotificationTemplateInput { eventType: string; channelId: string; locale: string; subject: string; body: string; htmlBody?: string | null; /** JSON-encoded VariableSchemaShape string */ variableSchema: string; } const PLACEHOLDER_PATTERN = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g; export function extractPlaceholders(...sources: Array): string[] { const found = new Set(); for (const source of sources) { if (!source) continue; for (const match of source.matchAll(PLACEHOLDER_PATTERN)) { found.add(match[1]); } } return [...found]; } function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object"; } export function parseVariableSchema(raw: string): VariableSchemaShape | null { try { const parsed: unknown = JSON.parse(raw); if (!isRecord(parsed)) return null; const rawVariables: unknown = parsed.variables; if (!Array.isArray(rawVariables)) return null; const variables: VariableSchemaEntry[] = []; for (const rawEntry of rawVariables) { const entry: unknown = rawEntry; if (!isRecord(entry)) return null; if (typeof entry.name !== "string" || typeof entry.type !== "string") return null; variables.push({ name: entry.name, type: entry.type, required: typeof entry.required === "boolean" ? entry.required : false, }); } return { variables }; } catch { return null; } } /** * Inserts a NotificationTemplate for an (eventType, channelId, locale) triple after validating the catalog event type, channel, variable schema, and placeholder references. */ export async function run( db: Transaction, input: CreateNotificationTemplateInput, _ctx: CommandContext, ) { const { eventType, channelId, locale, subject, body, htmlBody, variableSchema } = input; if (!eventType) return err(new MissingRequiredFieldError("eventType")); if (!channelId) return err(new MissingRequiredFieldError("channelId")); if (!locale) return err(new MissingRequiredFieldError("locale")); const trimmedSubject = subject?.trim() ?? ""; if (trimmedSubject === "") return err(new MissingRequiredFieldError("subject")); const trimmedBody = body?.trim() ?? ""; if (trimmedBody === "") return err(new MissingRequiredFieldError("body")); const trimmedHtmlBody = htmlBody?.trim(); if (htmlBody !== undefined && htmlBody !== null && trimmedHtmlBody === "") { return err(new MissingRequiredFieldError("htmlBody")); } if (!variableSchema) return err(new MissingRequiredFieldError("variableSchema")); const schema = parseVariableSchema(variableSchema); if (!schema) return err(new MissingRequiredFieldError("variableSchema")); const eventBinding = await db .selectFrom("EventCategoryBinding") .select("id") .where("eventType", "=", eventType) .executeTakeFirst(); if (!eventBinding) return err(new EventTypeNotFoundError(eventType)); const channel = await db .selectFrom("NotificationChannel") .select("id") .where("id", "=", channelId) .executeTakeFirst(); if (!channel) return err(new ChannelNotFoundError(channelId)); const existing = await db .selectFrom("NotificationTemplate") .select("id") .where("eventType", "=", eventType) .where("channelId", "=", channelId) .where("locale", "=", locale) .executeTakeFirst(); if (existing) { return err(new DuplicateTemplateKeyError(`${eventType}:${channelId}:${locale}`)); } const declared = new Set(schema.variables.map((v) => v.name)); const referenced = extractPlaceholders(subject, body, htmlBody ?? null); for (const ref of referenced) { if (!declared.has(ref)) { return err(new UndeclaredVariableReferenceError(ref)); } } const now = new Date(); const template = await db .insertInto("NotificationTemplate") .values({ id: crypto.randomUUID(), eventType, channelId, locale, subject, body, htmlBody: htmlBody ?? null, variableSchema, createdAt: now, updatedAt: now, }) .returningAll() .executeTakeFirstOrThrow(); return ok({ template }); }