import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { Transaction } from "../generated/kysely-tailordb"; import { InvalidEventTypeError, MissingRequiredFieldError, NotificationNotFoundError, } from "../lib/errors.generated"; // recordDeliveryEvent is invoked by channel adapters processing provider // webhooks. The contract surface accepts the three webhook-driven event types // that downstream providers report. The dispatcher does not consume webhooks // — this is the "reserved port" for provider-webhook wiring. export type RecordableEventType = "DELIVERED" | "BOUNCED" | "OPENED"; const ALLOWED_EVENT_TYPES: readonly RecordableEventType[] = ["DELIVERED", "BOUNCED", "OPENED"]; function isRecordableEventType(value: string): value is RecordableEventType { return (ALLOWED_EVENT_TYPES as readonly string[]).includes(value); } export interface RecordDeliveryEventInput { notificationId: string; eventType: string; occurredAt: string; errorClass?: string; errorDetail?: string; } function sanitizeErrorDetail(value: string): string { return value .replace(/(api[_-]?key|token|password|secret)=([^&\s]+)/gi, "$1=[REDACTED]") .slice(0, 2_000); } /** * Records a provider delivery event (DELIVERED/BOUNCED/OPENED) for a Notification: appends an audit row and advances deliveryStatus within its lifecycle. Adapter-permission authorized; append-only. */ export async function run(db: Transaction, input: RecordDeliveryEventInput, ctx: CommandContext) { void ctx; const { notificationId, eventType, occurredAt, errorClass, errorDetail } = input; if (!isRecordableEventType(eventType)) { return err(new InvalidEventTypeError(eventType)); } if (!occurredAt) { return err(new MissingRequiredFieldError("occurredAt")); } if (eventType === "BOUNCED" && (!errorClass || !errorDetail)) { return err(new MissingRequiredFieldError("errorClass,errorDetail")); } const notification = await db .selectFrom("Notification") .selectAll() .where("id", "=", notificationId) .executeTakeFirst(); if (!notification) { return err(new NotificationNotFoundError(notificationId)); } const now = new Date(); const occurredAtDate = new Date(occurredAt); const audit = await db .insertInto("NotificationDeliveryAudit") .values({ id: crypto.randomUUID(), notificationId, eventType, occurredAt: occurredAtDate, occurredBy: "system", errorClass: errorClass ?? null, errorDetail: errorDetail ? sanitizeErrorDetail(errorDetail) : null, createdAt: now, updatedAt: now, }) .returningAll() .executeTakeFirstOrThrow(); // Notification.deliveryStatus transitions (per the ProviderBounce / // ProviderDelivered transitions in db/notification.lifecycle.generated.ts): // DELIVERED advances SENT -> DELIVERED // BOUNCED advances SENT | DELIVERED -> BOUNCED // OPENED is observability-only (no status change) // Out-of-lifecycle statuses (QUEUED, FAILED, BOUNCED) keep their status; // the audit row above is written regardless. if (eventType === "DELIVERED" && notification.deliveryStatus === "SENT") { await db .updateTable("Notification") .set({ deliveryStatus: "DELIVERED", updatedAt: now }) .where("id", "=", notificationId) .execute(); } else if ( eventType === "BOUNCED" && (notification.deliveryStatus === "SENT" || notification.deliveryStatus === "DELIVERED") ) { await db .updateTable("Notification") .set({ deliveryStatus: "BOUNCED", updatedAt: now }) .where("id", "=", notificationId) .execute(); } return ok({ audit }); }