/** * Marketing-Funnel Phase 3 (Phase 05 #80) — sequence trigger handler. * * Subscribed to BullMQ queue `outbox.marketing` (and a fan-out for * `tenant.*` lifecycle events). For every received outbox-event the * handler: * 1. Normalises the event-type (strip `marketing.` prefix so * `marketing.lead.captured` matches sequences with trigger * `lead.captured`). * 2. Looks up all enabled EmailSequences with matching `triggerEvent`. * 3. Builds a `SequenceTriggerContext` from the outbox payload * (lead-id, tenant-id, recipient email + name, payload bag for * condition evaluation). * 4. Invokes {@link startSequenceRun} once per sequence. Idempotency * is provided by `(sequenceId, triggerEventId)` unique constraint * on EmailSequenceRun — duplicate deliveries are no-ops. * * Storage + queue + sequence-loader surfaces are injected so the * handler is fully unit-testable. Production wiring lives in * `email-sequence-trigger-bootstrap.ts`. */ import { startSequenceRun, type SequenceDefinition, type SequenceEngineQueue, type SequenceEngineStore, type SequenceTriggerContext, } from "@toolctrl/email-sequences"; export const EMAIL_SEQUENCE_TRIGGER_QUEUE = "outbox.marketing" as const; /** Set of outbox event-types this handler will react to. */ export const SEQUENCE_TRIGGER_EVENT_TYPES: ReadonlyArray = [ "marketing.lead.captured", "marketing.lead.qualified", "marketing.lead.convertedtotrial", "marketing.lead.convertedtocustomer", "marketing.lead.stale_60d", "lead.captured", "lead.qualified", "lead.converted_to_trial", "lead.converted_to_customer", "lead.stale_60d", "tenant.signup", "tenant.trial_expiring", "tenant.cancelled", ]; export interface SequenceTriggerJobData { /** Outbox row id — used for idempotency in startSequenceRun. */ readonly outboxEventId?: string; readonly tenantId?: string | null; readonly eventType: string; readonly payload: Readonly>; } export class SequenceTriggerJobError extends Error { override readonly name = "SequenceTriggerJobError"; readonly code: "INVALID_PAYLOAD" | "UNKNOWN_EVENT_TYPE"; constructor(message: string, code: SequenceTriggerJobError["code"]) { super(message); this.code = code; } } export interface SequenceTriggerLogger { info?: (record: Record, message?: string) => void; warn?: (record: Record, message?: string) => void; error: (record: Record, message?: string) => void; } /** * Read-side surface needed to find sequences matching an event. The * production implementation queries Prisma; tests provide an in-memory * fake. */ export interface SequenceTriggerLoader { listEnabledForEvent(normalisedEventType: string): Promise; } export interface SequenceTriggerDeps { readonly loader: SequenceTriggerLoader; readonly store: SequenceEngineStore & { rememberTrigger?: (trigger: SequenceTriggerContext | null) => void; }; readonly queue: SequenceEngineQueue; readonly logger?: SequenceTriggerLogger; readonly now?: () => Date; } export interface SequenceTriggerJobResult { readonly normalisedEventType: string; readonly matched: number; readonly started: number; readonly skipped: number; } /** * Strip the `marketing.` prefix so an outbox event-type like * `marketing.lead.captured` matches a sequence whose `triggerEvent` is * the bare `lead.captured`. We also unify the dashed * `marketing.lead.convertedtotrial` (no underscore — that is what the * dispatcher emits today) to the documented `lead.converted_to_trial`. */ export function normaliseTriggerEventType(eventType: string): string { const trimmed = eventType.startsWith("marketing.") ? eventType.slice("marketing.".length) : eventType; switch (trimmed) { case "lead.convertedtotrial": return "lead.converted_to_trial"; case "lead.convertedtocustomer": return "lead.converted_to_customer"; default: return trimmed; } } export function parseSequenceTriggerJob(data: unknown): SequenceTriggerJobData { if (!data || typeof data !== "object") { throw new SequenceTriggerJobError("trigger job missing data envelope", "INVALID_PAYLOAD"); } const e = data as Record; const eventType = typeof e.eventType === "string" ? e.eventType : ""; if (eventType.length === 0) { throw new SequenceTriggerJobError("trigger job missing eventType", "INVALID_PAYLOAD"); } if (!SEQUENCE_TRIGGER_EVENT_TYPES.includes(eventType)) { throw new SequenceTriggerJobError(`unsupported event-type: ${eventType}`, "UNKNOWN_EVENT_TYPE"); } const payload = e.payload; if (!payload || typeof payload !== "object") { throw new SequenceTriggerJobError("trigger job missing payload", "INVALID_PAYLOAD"); } return { outboxEventId: typeof e.outboxEventId === "string" ? e.outboxEventId : undefined, tenantId: typeof e.tenantId === "string" ? e.tenantId : null, eventType, payload: payload as Readonly>, }; } function extractEmail(payload: Readonly>): string | null { const direct = payload["email"]; if (typeof direct === "string" && direct.includes("@")) return direct; const lead = payload["lead"]; if (lead && typeof lead === "object") { const leadEmail = (lead as Record)["email"]; if (typeof leadEmail === "string" && leadEmail.includes("@")) return leadEmail; } const tenant = payload["tenant"]; if (tenant && typeof tenant === "object") { const tenantEmail = (tenant as Record)["email"]; if (typeof tenantEmail === "string" && tenantEmail.includes("@")) return tenantEmail; } return null; } function extractLeadId(payload: Readonly>): string | null { const direct = payload["leadId"]; if (typeof direct === "string" && direct) return direct; const lead = payload["lead"]; if (lead && typeof lead === "object") { const id = (lead as Record)["id"]; if (typeof id === "string" && id) return id; } return null; } function extractTenantId(payload: Readonly>, fallback: string | null): string | null { const direct = payload["tenantId"]; if (typeof direct === "string" && direct) return direct; const tenant = payload["tenant"]; if (tenant && typeof tenant === "object") { const id = (tenant as Record)["id"]; if (typeof id === "string" && id) return id; } return fallback; } function extractRecipientName(payload: Readonly>): string | null { const direct = payload["recipientName"] ?? payload["firstName"]; if (typeof direct === "string" && direct) return direct; const lead = payload["lead"]; if (lead && typeof lead === "object") { const firstName = (lead as Record)["firstName"]; if (typeof firstName === "string" && firstName) return firstName; } return null; } /** * Main handler entry point. Looks up matching sequences and starts a * run for each. Per-sequence failures are isolated so one bad * definition cannot block the others. */ export async function runSequenceTriggerJob( data: unknown, deps: SequenceTriggerDeps ): Promise { if (process.env.MARKETING_SEQUENCES_ENABLED !== "true") { deps.logger?.info?.({ op: "sequence.trigger.skipped" }, "email sequences disabled by env"); return { normalisedEventType: "(disabled)", matched: 0, started: 0, skipped: 0 }; } const job = parseSequenceTriggerJob(data); const normalised = normaliseTriggerEventType(job.eventType); const email = extractEmail(job.payload); if (!email) { deps.logger?.warn?.( { op: "sequence.trigger.no_email", eventType: job.eventType, outboxEventId: job.outboxEventId }, "trigger payload missing recipient email — cannot enrol any sequence" ); return { normalisedEventType: normalised, matched: 0, started: 0, skipped: 0 }; } const sequences = await deps.loader.listEnabledForEvent(normalised); if (sequences.length === 0) { deps.logger?.info?.({ op: "sequence.trigger.no_match", eventType: normalised }, "no enabled sequences match event"); return { normalisedEventType: normalised, matched: 0, started: 0, skipped: 0 }; } const trigger: SequenceTriggerContext = { triggerEventId: job.outboxEventId ?? null, leadId: extractLeadId(job.payload), tenantId: extractTenantId(job.payload, job.tenantId ?? null), email, recipientName: extractRecipientName(job.payload), payload: job.payload, }; let started = 0; let skipped = 0; for (const sequence of sequences) { try { deps.store.rememberTrigger?.(trigger); const result = await startSequenceRun( { sequence, trigger }, { store: deps.store, queue: deps.queue, logger: deps.logger, now: deps.now } ); if (result.scheduled) started += 1; else skipped += 1; deps.logger?.info?.( { op: result.scheduled ? "sequence.trigger.started" : "sequence.trigger.skipped", sequenceId: sequence.id, runId: result.run.id || null, alreadyExisted: result.alreadyExisted, }, result.scheduled ? "sequence run started" : "sequence run not scheduled" ); } catch (error) { skipped += 1; deps.logger?.error( { op: "sequence.trigger.error", sequenceId: sequence.id, error: error instanceof Error ? error.message : String(error), }, "sequence run failed to start" ); } } return { normalisedEventType: normalised, matched: sequences.length, started, skipped }; }