264 lines
9.6 KiB
TypeScript
264 lines
9.6 KiB
TypeScript
/**
|
|
* 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<string> = [
|
|
"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<Record<string, unknown>>;
|
|
}
|
|
|
|
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<string, unknown>, message?: string) => void;
|
|
warn?: (record: Record<string, unknown>, message?: string) => void;
|
|
error: (record: Record<string, unknown>, 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<readonly SequenceDefinition[]>;
|
|
}
|
|
|
|
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<string, unknown>;
|
|
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<Record<string, unknown>>,
|
|
};
|
|
}
|
|
|
|
function extractEmail(payload: Readonly<Record<string, unknown>>): 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<string, unknown>)["email"];
|
|
if (typeof leadEmail === "string" && leadEmail.includes("@")) return leadEmail;
|
|
}
|
|
const tenant = payload["tenant"];
|
|
if (tenant && typeof tenant === "object") {
|
|
const tenantEmail = (tenant as Record<string, unknown>)["email"];
|
|
if (typeof tenantEmail === "string" && tenantEmail.includes("@")) return tenantEmail;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function extractLeadId(payload: Readonly<Record<string, unknown>>): 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<string, unknown>)["id"];
|
|
if (typeof id === "string" && id) return id;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function extractTenantId(payload: Readonly<Record<string, unknown>>, 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<string, unknown>)["id"];
|
|
if (typeof id === "string" && id) return id;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function extractRecipientName(payload: Readonly<Record<string, unknown>>): 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<string, unknown>)["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<SequenceTriggerJobResult> {
|
|
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 };
|
|
}
|