Files
HiveGrid-Wireframes/hivegrid-cloud/apps/worker/src/jobs/email-sequence-trigger.ts
T

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 };
}