Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { evaluateAllConditions, evaluateCondition, resolvePath } from "./conditions.js";
|
||||
|
||||
describe("resolvePath", () => {
|
||||
it("walks nested objects", () => {
|
||||
expect(resolvePath({ a: { b: { c: 1 } } }, "a.b.c")).toBe(1);
|
||||
});
|
||||
it("walks array indices", () => {
|
||||
expect(resolvePath({ items: [{ name: "x" }, { name: "y" }] }, "items.1.name")).toBe("y");
|
||||
});
|
||||
it("returns undefined for missing path", () => {
|
||||
expect(resolvePath({ a: 1 }, "a.b.c")).toBeUndefined();
|
||||
});
|
||||
it("returns undefined on empty path", () => {
|
||||
expect(resolvePath({}, "")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateCondition", () => {
|
||||
const payload = { lead: { status: "qualified", tags: ["alpha", "beta"] }, count: 5 };
|
||||
it("supports eq / neq", () => {
|
||||
expect(evaluateCondition(payload, { path: "lead.status", op: "eq", value: "qualified" })).toBe(true);
|
||||
expect(evaluateCondition(payload, { path: "lead.status", op: "neq", value: "qualified" })).toBe(false);
|
||||
});
|
||||
it("supports in / not_in with arrays", () => {
|
||||
expect(evaluateCondition(payload, { path: "lead.status", op: "in", value: ["new", "qualified"] })).toBe(true);
|
||||
expect(evaluateCondition(payload, { path: "lead.status", op: "not_in", value: ["new"] })).toBe(true);
|
||||
expect(evaluateCondition(payload, { path: "lead.status", op: "not_in", value: ["qualified"] })).toBe(false);
|
||||
});
|
||||
it("supports exists / not_exists", () => {
|
||||
expect(evaluateCondition(payload, { path: "lead.status", op: "exists" })).toBe(true);
|
||||
expect(evaluateCondition(payload, { path: "lead.missing", op: "not_exists" })).toBe(true);
|
||||
expect(evaluateCondition(payload, { path: "lead.status", op: "not_exists" })).toBe(false);
|
||||
});
|
||||
it("returns false for unknown op", () => {
|
||||
expect(
|
||||
evaluateCondition(payload, { path: "lead.status", op: "unknown" as unknown as "eq", value: "x" })
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateAllConditions", () => {
|
||||
it("returns true for undefined / empty list", () => {
|
||||
expect(evaluateAllConditions({}, undefined)).toBe(true);
|
||||
expect(evaluateAllConditions({}, [])).toBe(true);
|
||||
});
|
||||
it("AND-combines multiple conditions", () => {
|
||||
const payload = { status: "qualified", plan: "pro" };
|
||||
expect(
|
||||
evaluateAllConditions(payload, [
|
||||
{ path: "status", op: "eq", value: "qualified" },
|
||||
{ path: "plan", op: "eq", value: "pro" },
|
||||
])
|
||||
).toBe(true);
|
||||
expect(
|
||||
evaluateAllConditions(payload, [
|
||||
{ path: "status", op: "eq", value: "qualified" },
|
||||
{ path: "plan", op: "eq", value: "starter" },
|
||||
])
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Condition-evaluator for the sequence step DSL.
|
||||
*
|
||||
* Pure function — no DB, no IO. The trigger handler builds the
|
||||
* `context` payload (typically a copy of the outbox event plus
|
||||
* resolved lead/tenant fields) and asks the engine whether the step
|
||||
* should run.
|
||||
*/
|
||||
|
||||
import type { SequenceStepCondition } from "./types.js";
|
||||
|
||||
/**
|
||||
* Read a dot-path out of a JSON-like payload. Returns `undefined` when
|
||||
* any intermediate segment is missing. Array-index access uses
|
||||
* numeric segments (`items.0.name`).
|
||||
*/
|
||||
export function resolvePath(payload: Readonly<Record<string, unknown>>, path: string): unknown {
|
||||
if (path.length === 0) return undefined;
|
||||
const segments = path.split(".");
|
||||
let cur: unknown = payload;
|
||||
for (const seg of segments) {
|
||||
if (cur === null || cur === undefined) return undefined;
|
||||
if (Array.isArray(cur)) {
|
||||
const idx = Number(seg);
|
||||
if (!Number.isInteger(idx)) return undefined;
|
||||
cur = cur[idx];
|
||||
continue;
|
||||
}
|
||||
if (typeof cur === "object") {
|
||||
cur = (cur as Record<string, unknown>)[seg];
|
||||
continue;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
function asArray(v: SequenceStepCondition["value"]): readonly (string | number)[] {
|
||||
if (Array.isArray(v)) return v;
|
||||
if (v === undefined || v === null) return [];
|
||||
return [v as string | number];
|
||||
}
|
||||
|
||||
export function evaluateCondition(
|
||||
payload: Readonly<Record<string, unknown>>,
|
||||
cond: SequenceStepCondition
|
||||
): boolean {
|
||||
const actual = resolvePath(payload, cond.path);
|
||||
switch (cond.op) {
|
||||
case "eq":
|
||||
return actual === cond.value;
|
||||
case "neq":
|
||||
return actual !== cond.value;
|
||||
case "in":
|
||||
return asArray(cond.value).includes(actual as string | number);
|
||||
case "not_in":
|
||||
return !asArray(cond.value).includes(actual as string | number);
|
||||
case "exists":
|
||||
return actual !== undefined && actual !== null;
|
||||
case "not_exists":
|
||||
return actual === undefined || actual === null;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AND-evaluate every condition. Empty array → always true (no
|
||||
* conditions means "always run").
|
||||
*/
|
||||
export function evaluateAllConditions(
|
||||
payload: Readonly<Record<string, unknown>>,
|
||||
conditions: readonly SequenceStepCondition[] | undefined
|
||||
): boolean {
|
||||
if (!conditions || conditions.length === 0) return true;
|
||||
for (const c of conditions) {
|
||||
if (!evaluateCondition(payload, c)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
startSequenceRun,
|
||||
advanceSequenceRun,
|
||||
type SequenceEngineStore,
|
||||
type SequenceEngineQueue,
|
||||
type SequenceRunRow,
|
||||
} from "./engine.js";
|
||||
import type { SequenceDefinition } from "./types.js";
|
||||
|
||||
function makeStore(initial: SequenceRunRow | null = null): SequenceEngineStore & {
|
||||
createdDeliveries: Array<{ runId: string; stepIndex: number; scheduledAt: Date }>;
|
||||
createdRuns: Array<{ sequenceId: string }>;
|
||||
statusUpdates: Array<{ runId: string; status: string }>;
|
||||
} {
|
||||
const createdDeliveries: Array<{ runId: string; stepIndex: number; scheduledAt: Date }> = [];
|
||||
const createdRuns: Array<{ sequenceId: string }> = [];
|
||||
const statusUpdates: Array<{ runId: string; status: string }> = [];
|
||||
return {
|
||||
createdDeliveries,
|
||||
createdRuns,
|
||||
statusUpdates,
|
||||
createRun: vi.fn(async ({ sequenceId, leadId, tenantId, triggerEventId }) => {
|
||||
createdRuns.push({ sequenceId });
|
||||
return {
|
||||
id: `run_${createdRuns.length}`,
|
||||
sequenceId,
|
||||
leadId,
|
||||
tenantId,
|
||||
triggerEventId,
|
||||
status: "running" as const,
|
||||
startedAt: new Date("2026-05-17T12:00:00Z"),
|
||||
completedAt: null,
|
||||
stepStateJson: null,
|
||||
};
|
||||
}),
|
||||
findRunByTrigger: vi.fn(async () => initial),
|
||||
updateRunStatus: vi.fn(async (runId, status) => {
|
||||
statusUpdates.push({ runId, status });
|
||||
}),
|
||||
createDelivery: vi.fn(async ({ runId, stepIndex, scheduledAt }) => {
|
||||
createdDeliveries.push({ runId, stepIndex, scheduledAt });
|
||||
return {
|
||||
id: `del_${createdDeliveries.length}`,
|
||||
runId,
|
||||
stepIndex,
|
||||
status: "pending" as const,
|
||||
scheduledAt,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeQueue(): SequenceEngineQueue & { scheduled: Array<{ runId: string; stepIndex: number; runAt: Date }> } {
|
||||
const scheduled: Array<{ runId: string; stepIndex: number; runAt: Date }> = [];
|
||||
return {
|
||||
scheduled,
|
||||
scheduleStep: vi.fn(async (args) => {
|
||||
scheduled.push(args);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const BASE_SEQUENCE: SequenceDefinition = {
|
||||
id: "seq_1",
|
||||
tenantId: null,
|
||||
name: "Welcome",
|
||||
description: null,
|
||||
triggerEvent: "lead.captured",
|
||||
isEnabled: true,
|
||||
steps: [
|
||||
{ stepIndex: 0, delayHours: 0, template: "welcome", subject: "Welcome" },
|
||||
{ stepIndex: 1, delayHours: 24, template: "generic-step", subject: "Day 1" },
|
||||
{ stepIndex: 2, delayHours: 72, template: "generic-step", subject: "Day 3" },
|
||||
],
|
||||
};
|
||||
|
||||
const TRIGGER = {
|
||||
triggerEventId: "evt_1",
|
||||
leadId: "lead_1",
|
||||
tenantId: null,
|
||||
email: "alice@example.com",
|
||||
recipientName: "Alice",
|
||||
payload: { lead: { status: "new" } },
|
||||
} as const;
|
||||
|
||||
const NOW = () => new Date("2026-05-17T12:00:00Z");
|
||||
|
||||
describe("startSequenceRun", () => {
|
||||
it("creates a run and schedules step 0 immediately when delayHours=0", async () => {
|
||||
const store = makeStore();
|
||||
const queue = makeQueue();
|
||||
const result = await startSequenceRun({ sequence: BASE_SEQUENCE, trigger: TRIGGER }, { store, queue, now: NOW });
|
||||
expect(result.scheduled).toBe(true);
|
||||
expect(result.alreadyExisted).toBe(false);
|
||||
expect(store.createdRuns).toHaveLength(1);
|
||||
expect(store.createdDeliveries).toHaveLength(1);
|
||||
expect(store.createdDeliveries[0]?.scheduledAt.toISOString()).toBe("2026-05-17T12:00:00.000Z");
|
||||
expect(queue.scheduled).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns the existing run when (sequence, trigger-event-id) already produced one", async () => {
|
||||
const existing: SequenceRunRow = {
|
||||
id: "run_existing",
|
||||
sequenceId: BASE_SEQUENCE.id,
|
||||
leadId: "lead_1",
|
||||
tenantId: null,
|
||||
triggerEventId: "evt_1",
|
||||
status: "running",
|
||||
startedAt: NOW(),
|
||||
completedAt: null,
|
||||
stepStateJson: null,
|
||||
};
|
||||
const store = makeStore(existing);
|
||||
const queue = makeQueue();
|
||||
const result = await startSequenceRun({ sequence: BASE_SEQUENCE, trigger: TRIGGER }, { store, queue, now: NOW });
|
||||
expect(result.alreadyExisted).toBe(true);
|
||||
expect(store.createdRuns).toHaveLength(0);
|
||||
expect(queue.scheduled).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips disabled sequences", async () => {
|
||||
const store = makeStore();
|
||||
const queue = makeQueue();
|
||||
const result = await startSequenceRun(
|
||||
{ sequence: { ...BASE_SEQUENCE, isEnabled: false }, trigger: TRIGGER },
|
||||
{ store, queue, now: NOW }
|
||||
);
|
||||
expect(result.scheduled).toBe(false);
|
||||
expect(store.createdRuns).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips when sequence-level conditions are false", async () => {
|
||||
const store = makeStore();
|
||||
const queue = makeQueue();
|
||||
const result = await startSequenceRun(
|
||||
{
|
||||
sequence: {
|
||||
...BASE_SEQUENCE,
|
||||
conditions: [{ path: "lead.status", op: "eq", value: "qualified" }],
|
||||
},
|
||||
trigger: TRIGGER,
|
||||
},
|
||||
{ store, queue, now: NOW }
|
||||
);
|
||||
expect(result.scheduled).toBe(false);
|
||||
expect(store.createdRuns).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("advanceSequenceRun", () => {
|
||||
it("schedules the next step with its delay-from-now", async () => {
|
||||
const store = makeStore();
|
||||
const queue = makeQueue();
|
||||
const run: SequenceRunRow = {
|
||||
id: "run_1",
|
||||
sequenceId: "seq_1",
|
||||
leadId: "lead_1",
|
||||
tenantId: null,
|
||||
triggerEventId: "evt_1",
|
||||
status: "running",
|
||||
startedAt: NOW(),
|
||||
completedAt: null,
|
||||
stepStateJson: null,
|
||||
};
|
||||
const result = await advanceSequenceRun(
|
||||
{ run, steps: BASE_SEQUENCE.steps, previousStepIndex: 0 },
|
||||
{ store, queue, now: NOW }
|
||||
);
|
||||
expect(result.completed).toBe(false);
|
||||
expect(result.scheduledAt?.toISOString()).toBe("2026-05-18T12:00:00.000Z");
|
||||
expect(queue.scheduled).toHaveLength(1);
|
||||
expect(queue.scheduled[0]?.stepIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("completes the run when no further steps exist", async () => {
|
||||
const store = makeStore();
|
||||
const queue = makeQueue();
|
||||
const run: SequenceRunRow = {
|
||||
id: "run_1",
|
||||
sequenceId: "seq_1",
|
||||
leadId: "lead_1",
|
||||
tenantId: null,
|
||||
triggerEventId: "evt_1",
|
||||
status: "running",
|
||||
startedAt: NOW(),
|
||||
completedAt: null,
|
||||
stepStateJson: null,
|
||||
};
|
||||
const result = await advanceSequenceRun(
|
||||
{ run, steps: BASE_SEQUENCE.steps, previousStepIndex: 2 },
|
||||
{ store, queue, now: NOW }
|
||||
);
|
||||
expect(result.completed).toBe(true);
|
||||
expect(store.statusUpdates).toEqual([{ runId: "run_1", status: "completed" }]);
|
||||
expect(queue.scheduled).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Sequence-execution engine.
|
||||
*
|
||||
* Two entry points:
|
||||
* - `startSequenceRun()` — called by the trigger worker. Materialises
|
||||
* an EmailSequenceRun row + a pending Step-0 delivery + schedules
|
||||
* the first step.
|
||||
* - `advanceSequenceRun()` — called by the step-runner after a step
|
||||
* completes. Materialises the next step's delivery row + schedules
|
||||
* it; flips the run to `completed` when no more steps remain.
|
||||
*
|
||||
* Storage + queue are injected so the engine stays testable.
|
||||
*/
|
||||
|
||||
import type { SequenceDefinition, SequenceStep, SequenceTriggerContext } from "./types.js";
|
||||
import { parseSequenceSteps } from "./types.js";
|
||||
import { evaluateAllConditions } from "./conditions.js";
|
||||
|
||||
export type SequenceRunStatus = "pending" | "running" | "completed" | "cancelled" | "unsubscribed" | "failed";
|
||||
export type StepDeliveryStatus = "pending" | "sent" | "skipped" | "bounced" | "failed";
|
||||
|
||||
export interface SequenceRunRow {
|
||||
readonly id: string;
|
||||
readonly sequenceId: string;
|
||||
readonly leadId: string | null;
|
||||
readonly tenantId: string | null;
|
||||
readonly triggerEventId: string | null;
|
||||
readonly status: SequenceRunStatus;
|
||||
readonly startedAt: Date;
|
||||
readonly completedAt: Date | null;
|
||||
readonly stepStateJson: Readonly<Record<string, unknown>> | null;
|
||||
}
|
||||
|
||||
export interface StepDeliveryRow {
|
||||
readonly id: string;
|
||||
readonly runId: string;
|
||||
readonly stepIndex: number;
|
||||
readonly status: StepDeliveryStatus;
|
||||
readonly scheduledAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage surface required by the engine. Implementations wrap Prisma
|
||||
* (in the worker) or in-memory fakes (in tests).
|
||||
*/
|
||||
export interface SequenceEngineStore {
|
||||
createRun(input: {
|
||||
sequenceId: string;
|
||||
leadId: string | null;
|
||||
tenantId: string | null;
|
||||
triggerEventId: string | null;
|
||||
}): Promise<SequenceRunRow>;
|
||||
/** Idempotent — returns the existing run if `(sequenceId,triggerEventId)` already exists. */
|
||||
findRunByTrigger(sequenceId: string, triggerEventId: string): Promise<SequenceRunRow | null>;
|
||||
updateRunStatus(runId: string, status: SequenceRunStatus, completedAt?: Date | null): Promise<void>;
|
||||
createDelivery(input: { runId: string; stepIndex: number; scheduledAt: Date }): Promise<StepDeliveryRow>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue surface — `scheduleStep` enqueues a delayed job that wakes up
|
||||
* at `runAt` and invokes the step-runner.
|
||||
*/
|
||||
export interface SequenceEngineQueue {
|
||||
scheduleStep(args: { runId: string; stepIndex: number; runAt: Date }): Promise<void>;
|
||||
}
|
||||
|
||||
export interface SequenceEngineLogger {
|
||||
info?: (record: Record<string, unknown>, message?: string) => void;
|
||||
warn?: (record: Record<string, unknown>, message?: string) => void;
|
||||
error: (record: Record<string, unknown>, message?: string) => void;
|
||||
}
|
||||
|
||||
export interface SequenceEngineDeps {
|
||||
readonly store: SequenceEngineStore;
|
||||
readonly queue: SequenceEngineQueue;
|
||||
readonly logger?: SequenceEngineLogger;
|
||||
readonly now?: () => Date;
|
||||
}
|
||||
|
||||
export interface StartSequenceRunArgs {
|
||||
readonly sequence: SequenceDefinition;
|
||||
readonly trigger: SequenceTriggerContext;
|
||||
}
|
||||
|
||||
export interface StartSequenceRunResult {
|
||||
readonly run: SequenceRunRow;
|
||||
readonly firstDelivery: StepDeliveryRow | null;
|
||||
readonly scheduled: boolean;
|
||||
/** True when an existing run was returned instead of inserting a duplicate. */
|
||||
readonly alreadyExisted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the steps array, applying sequence-level enable + condition
|
||||
* gating before producing the first delivery row.
|
||||
*/
|
||||
export async function startSequenceRun(
|
||||
args: StartSequenceRunArgs,
|
||||
deps: SequenceEngineDeps
|
||||
): Promise<StartSequenceRunResult> {
|
||||
const { sequence, trigger } = args;
|
||||
if (!sequence.isEnabled) {
|
||||
return emptyResult();
|
||||
}
|
||||
if (!evaluateAllConditions(trigger.payload, sequence.conditions)) {
|
||||
deps.logger?.info?.(
|
||||
{ op: "sequence.skipped.sequence_conditions", sequenceId: sequence.id },
|
||||
"sequence-level conditions not met"
|
||||
);
|
||||
return emptyResult();
|
||||
}
|
||||
const steps = parseSequenceSteps(sequence.steps as unknown);
|
||||
if (steps.length === 0) {
|
||||
return emptyResult();
|
||||
}
|
||||
|
||||
if (trigger.triggerEventId) {
|
||||
const existing = await deps.store.findRunByTrigger(sequence.id, trigger.triggerEventId);
|
||||
if (existing) {
|
||||
return { run: existing, firstDelivery: null, scheduled: false, alreadyExisted: true };
|
||||
}
|
||||
}
|
||||
|
||||
const run = await deps.store.createRun({
|
||||
sequenceId: sequence.id,
|
||||
leadId: trigger.leadId ?? null,
|
||||
tenantId: trigger.tenantId ?? null,
|
||||
triggerEventId: trigger.triggerEventId ?? null,
|
||||
});
|
||||
const firstStep = steps[0]!;
|
||||
const now = (deps.now ?? (() => new Date()))();
|
||||
const scheduledAt = new Date(now.getTime() + firstStep.delayHours * 60 * 60 * 1000);
|
||||
const delivery = await deps.store.createDelivery({
|
||||
runId: run.id,
|
||||
stepIndex: firstStep.stepIndex,
|
||||
scheduledAt,
|
||||
});
|
||||
await deps.queue.scheduleStep({ runId: run.id, stepIndex: firstStep.stepIndex, runAt: scheduledAt });
|
||||
deps.logger?.info?.(
|
||||
{ op: "sequence.run.started", sequenceId: sequence.id, runId: run.id, stepIndex: firstStep.stepIndex },
|
||||
"sequence run started"
|
||||
);
|
||||
return { run, firstDelivery: delivery, scheduled: true, alreadyExisted: false };
|
||||
}
|
||||
|
||||
function emptyResult(): StartSequenceRunResult {
|
||||
const placeholder: SequenceRunRow = {
|
||||
id: "",
|
||||
sequenceId: "",
|
||||
leadId: null,
|
||||
tenantId: null,
|
||||
triggerEventId: null,
|
||||
status: "cancelled",
|
||||
startedAt: new Date(0),
|
||||
completedAt: null,
|
||||
stepStateJson: null,
|
||||
};
|
||||
return { run: placeholder, firstDelivery: null, scheduled: false, alreadyExisted: false };
|
||||
}
|
||||
|
||||
export interface AdvanceArgs {
|
||||
readonly run: SequenceRunRow;
|
||||
readonly steps: readonly SequenceStep[];
|
||||
readonly previousStepIndex: number;
|
||||
}
|
||||
|
||||
export interface AdvanceResult {
|
||||
readonly nextDelivery: StepDeliveryRow | null;
|
||||
readonly scheduledAt: Date | null;
|
||||
readonly completed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a step finishes (sent, skipped or bounced) move to the next.
|
||||
* Marks the run completed when no further step exists.
|
||||
*/
|
||||
export async function advanceSequenceRun(args: AdvanceArgs, deps: SequenceEngineDeps): Promise<AdvanceResult> {
|
||||
const { run, steps, previousStepIndex } = args;
|
||||
const sorted = [...steps].sort((a, b) => a.stepIndex - b.stepIndex);
|
||||
const nextIdx = sorted.findIndex((s) => s.stepIndex > previousStepIndex);
|
||||
if (nextIdx === -1) {
|
||||
await deps.store.updateRunStatus(run.id, "completed", (deps.now ?? (() => new Date()))());
|
||||
deps.logger?.info?.({ op: "sequence.run.completed", runId: run.id }, "sequence run completed");
|
||||
return { nextDelivery: null, scheduledAt: null, completed: true };
|
||||
}
|
||||
const nextStep = sorted[nextIdx]!;
|
||||
const now = (deps.now ?? (() => new Date()))();
|
||||
const scheduledAt = new Date(now.getTime() + nextStep.delayHours * 60 * 60 * 1000);
|
||||
const delivery = await deps.store.createDelivery({
|
||||
runId: run.id,
|
||||
stepIndex: nextStep.stepIndex,
|
||||
scheduledAt,
|
||||
});
|
||||
await deps.queue.scheduleStep({ runId: run.id, stepIndex: nextStep.stepIndex, runAt: scheduledAt });
|
||||
return { nextDelivery: delivery, scheduledAt, completed: false };
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Marketing-Funnel Phase 3 — email-sequence engine (Phase 05 #80).
|
||||
*
|
||||
* Pure-logic package: definitions, condition DSL, step engine, step runner.
|
||||
* Storage + queue + mail-provider surfaces are injected by the worker so
|
||||
* this package has zero runtime dependencies on Prisma or BullMQ.
|
||||
*/
|
||||
|
||||
export * from "./types.js";
|
||||
export {
|
||||
resolvePath,
|
||||
evaluateCondition,
|
||||
evaluateAllConditions,
|
||||
} from "./conditions.js";
|
||||
export {
|
||||
startSequenceRun,
|
||||
advanceSequenceRun,
|
||||
type SequenceEngineStore,
|
||||
type SequenceEngineQueue,
|
||||
type SequenceEngineDeps,
|
||||
type SequenceEngineLogger,
|
||||
type SequenceRunRow,
|
||||
type SequenceRunStatus,
|
||||
type StepDeliveryRow,
|
||||
type StepDeliveryStatus,
|
||||
type StartSequenceRunArgs,
|
||||
type StartSequenceRunResult,
|
||||
type AdvanceArgs,
|
||||
type AdvanceResult,
|
||||
} from "./engine.js";
|
||||
export {
|
||||
runStep,
|
||||
type StepRunnerStore,
|
||||
type StepRunnerDeps,
|
||||
type StepRunResult,
|
||||
type RenderedMail,
|
||||
type MailSendResult,
|
||||
} from "./step-runner.js";
|
||||
export {
|
||||
buildUnsubscribeUrl,
|
||||
signUnsubscribeToken,
|
||||
UnsubscribeTokenSecretMissingError,
|
||||
verifyUnsubscribeToken,
|
||||
type SignUnsubscribeTokenInput,
|
||||
type UnsubscribeTokenPayload,
|
||||
} from "./unsubscribe-token.js";
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { runStep, type StepRunnerStore, type StepRunnerDeps } from "./step-runner.js";
|
||||
import type { SequenceStep, SequenceTriggerContext } from "./types.js";
|
||||
import type { SequenceRunRow } from "./engine.js";
|
||||
|
||||
const NOW = () => new Date("2026-05-17T12:00:00Z");
|
||||
|
||||
function buildRun(overrides: Partial<SequenceRunRow> = {}): SequenceRunRow {
|
||||
return {
|
||||
id: "run_1",
|
||||
sequenceId: "seq_1",
|
||||
leadId: "lead_1",
|
||||
tenantId: null,
|
||||
triggerEventId: "evt_1",
|
||||
status: "running",
|
||||
startedAt: NOW(),
|
||||
completedAt: null,
|
||||
stepStateJson: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildStore(opts: {
|
||||
run?: SequenceRunRow | null;
|
||||
steps?: readonly SequenceStep[];
|
||||
trigger?: SequenceTriggerContext | null;
|
||||
}): StepRunnerStore & { delivered: unknown[]; statusUpdates: Array<{ runId: string; status: string }> } {
|
||||
const delivered: unknown[] = [];
|
||||
const statusUpdates: Array<{ runId: string; status: string }> = [];
|
||||
return {
|
||||
delivered,
|
||||
statusUpdates,
|
||||
loadRun: vi.fn(async () => opts.run ?? null),
|
||||
loadSteps: vi.fn(async () => opts.steps ?? []),
|
||||
loadTriggerContext: vi.fn(async () => opts.trigger ?? null),
|
||||
markDelivery: vi.fn(async (input) => {
|
||||
delivered.push(input);
|
||||
}),
|
||||
createRun: vi.fn(),
|
||||
findRunByTrigger: vi.fn(async () => null),
|
||||
updateRunStatus: vi.fn(async (runId, status) => {
|
||||
statusUpdates.push({ runId, status });
|
||||
}),
|
||||
createDelivery: vi.fn(async ({ runId, stepIndex, scheduledAt }) => ({
|
||||
id: `del_${stepIndex}`,
|
||||
runId,
|
||||
stepIndex,
|
||||
status: "pending" as const,
|
||||
scheduledAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDeps(store: StepRunnerStore, overrides: Partial<StepRunnerDeps> = {}): StepRunnerDeps {
|
||||
return {
|
||||
store,
|
||||
queue: { scheduleStep: vi.fn() },
|
||||
now: NOW,
|
||||
renderTemplate: () => ({ html: "<p>x</p>", text: "x", subject: "Hello" }),
|
||||
sendMail: async () => ({ ok: true, messageId: "msg_1" }),
|
||||
buildUnsubscribeUrl: () => "https://app.example.com/unsubscribe/tok",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const STEPS: readonly SequenceStep[] = [
|
||||
{ stepIndex: 0, delayHours: 0, template: "welcome", subject: "S0" },
|
||||
{ stepIndex: 1, delayHours: 24, template: "generic-step", subject: "S1" },
|
||||
];
|
||||
|
||||
const TRIGGER: SequenceTriggerContext = {
|
||||
triggerEventId: "evt_1",
|
||||
leadId: "lead_1",
|
||||
tenantId: null,
|
||||
email: "alice@example.com",
|
||||
recipientName: "Alice",
|
||||
payload: { lead: { status: "new" } },
|
||||
};
|
||||
|
||||
describe("runStep", () => {
|
||||
it("cancels when the run is not found", async () => {
|
||||
const store = buildStore({ run: null });
|
||||
const deps = buildDeps(store);
|
||||
const result = await runStep({ runId: "run_1", stepIndex: 0 }, deps);
|
||||
expect(result.action).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("skips when run is unsubscribed mid-sequence", async () => {
|
||||
const store = buildStore({ run: buildRun({ status: "unsubscribed" }), steps: STEPS, trigger: TRIGGER });
|
||||
const deps = buildDeps(store);
|
||||
const result = await runStep({ runId: "run_1", stepIndex: 1 }, deps);
|
||||
expect(result.action).toBe("cancelled");
|
||||
expect(store.delivered).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips when step conditions evaluate false and advances", async () => {
|
||||
const conditionalSteps: readonly SequenceStep[] = [
|
||||
{ stepIndex: 0, delayHours: 0, template: "welcome", subject: "S0", conditions: [{ path: "lead.status", op: "eq", value: "qualified" }] },
|
||||
{ stepIndex: 1, delayHours: 24, template: "generic-step", subject: "S1" },
|
||||
];
|
||||
const store = buildStore({ run: buildRun(), steps: conditionalSteps, trigger: TRIGGER });
|
||||
const queueSchedule = vi.fn();
|
||||
const deps = buildDeps(store, { queue: { scheduleStep: queueSchedule } });
|
||||
const result = await runStep({ runId: "run_1", stepIndex: 0 }, deps);
|
||||
expect(result.action).toBe("skipped");
|
||||
expect(store.delivered).toEqual([{ runId: "run_1", stepIndex: 0, status: "skipped" }]);
|
||||
expect(queueSchedule).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("skips and advances without sending when trigger context is missing", async () => {
|
||||
const store = buildStore({ run: buildRun(), steps: STEPS, trigger: null });
|
||||
const sendMail = vi.fn();
|
||||
const queueSchedule = vi.fn();
|
||||
const deps = buildDeps(store, { sendMail, queue: { scheduleStep: queueSchedule } });
|
||||
const result = await runStep({ runId: "run_1", stepIndex: 0 }, deps);
|
||||
expect(result.action).toBe("skipped");
|
||||
expect(result.completed).toBe(false);
|
||||
expect(sendMail).not.toHaveBeenCalled();
|
||||
expect(store.delivered).toEqual([
|
||||
{
|
||||
runId: "run_1",
|
||||
stepIndex: 0,
|
||||
status: "skipped",
|
||||
errorMessage: "trigger_context_missing",
|
||||
},
|
||||
]);
|
||||
expect(queueSchedule).toHaveBeenCalledOnce();
|
||||
expect(queueSchedule.mock.calls[0]?.[0]).toMatchObject({ runId: "run_1", stepIndex: 1 });
|
||||
});
|
||||
|
||||
it("sends the step and advances to the next when conditions pass", async () => {
|
||||
const store = buildStore({ run: buildRun(), steps: STEPS, trigger: TRIGGER });
|
||||
const sendMail = vi.fn().mockResolvedValue({ ok: true, messageId: "msg_42" });
|
||||
const queueSchedule = vi.fn();
|
||||
const deps = buildDeps(store, { sendMail, queue: { scheduleStep: queueSchedule } });
|
||||
const result = await runStep({ runId: "run_1", stepIndex: 0 }, deps);
|
||||
expect(result.action).toBe("sent");
|
||||
expect(result.completed).toBe(false);
|
||||
expect(sendMail).toHaveBeenCalledOnce();
|
||||
expect(store.delivered[0]).toMatchObject({ status: "sent", providerMessageId: "msg_42" });
|
||||
expect(queueSchedule).toHaveBeenCalledOnce();
|
||||
expect(queueSchedule.mock.calls[0]?.[0].stepIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("marks run completed after the last step sends", async () => {
|
||||
const store = buildStore({ run: buildRun(), steps: STEPS, trigger: TRIGGER });
|
||||
const deps = buildDeps(store);
|
||||
const result = await runStep({ runId: "run_1", stepIndex: 1 }, deps);
|
||||
expect(result.action).toBe("sent");
|
||||
expect(result.completed).toBe(true);
|
||||
expect(store.statusUpdates).toEqual([{ runId: "run_1", status: "completed" }]);
|
||||
});
|
||||
|
||||
it("marks delivery bounced + run failed on hard bounce", async () => {
|
||||
const store = buildStore({ run: buildRun(), steps: STEPS, trigger: TRIGGER });
|
||||
const sendMail = vi.fn().mockResolvedValue({ ok: false, bounced: true, errorTag: "hard_bounce" });
|
||||
const deps = buildDeps(store, { sendMail });
|
||||
const result = await runStep({ runId: "run_1", stepIndex: 0 }, deps);
|
||||
expect(result.action).toBe("failed");
|
||||
expect(result.completed).toBe(true);
|
||||
expect(store.delivered[0]).toMatchObject({ status: "bounced", errorMessage: "hard_bounce" });
|
||||
expect(store.statusUpdates).toEqual([{ runId: "run_1", status: "failed" }]);
|
||||
});
|
||||
|
||||
it("marks delivery failed without bounce status when sendMail fails transiently", async () => {
|
||||
const store = buildStore({ run: buildRun(), steps: STEPS, trigger: TRIGGER });
|
||||
const sendMail = vi.fn().mockResolvedValue({ ok: false, errorTag: "timeout" });
|
||||
const deps = buildDeps(store, { sendMail, logger: { error: vi.fn() } });
|
||||
const result = await runStep({ runId: "run_1", stepIndex: 0 }, deps);
|
||||
expect(result.action).toBe("failed");
|
||||
expect(result.completed).toBe(false);
|
||||
expect(store.delivered[0]).toMatchObject({ status: "failed", errorMessage: "timeout" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Step-runner — invoked by the worker scheduler when a delayed
|
||||
* `email-sequence.step` job wakes up.
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Load run + delivery rows.
|
||||
* 2. Skip / cancel based on run status (unsubscribe / cancellation
|
||||
* mid-sequence).
|
||||
* 3. Evaluate per-step conditions against the cached payload.
|
||||
* 4. Render template via injected `templateRenderer` → { html, text }.
|
||||
* 5. Call injected `mailSender` to send the email with the
|
||||
* List-Unsubscribe header. Persist providerMessageId.
|
||||
* 6. Advance the run (next step or completion) via {@link advanceSequenceRun}.
|
||||
*
|
||||
* Storage + queue surfaces are injected so the runner is fully unit-testable.
|
||||
*/
|
||||
|
||||
import type { SequenceRunRow, SequenceEngineStore, SequenceEngineQueue, SequenceEngineLogger, StepDeliveryStatus } from "./engine.js";
|
||||
import { advanceSequenceRun } from "./engine.js";
|
||||
import type { SequenceStep, SequenceTriggerContext } from "./types.js";
|
||||
import { evaluateAllConditions } from "./conditions.js";
|
||||
|
||||
export interface RenderedMail {
|
||||
readonly html: string;
|
||||
readonly text: string;
|
||||
readonly subject: string;
|
||||
}
|
||||
|
||||
export interface MailSendResult {
|
||||
readonly ok: boolean;
|
||||
readonly messageId?: string;
|
||||
readonly errorTag?: string;
|
||||
readonly bounced?: boolean;
|
||||
}
|
||||
|
||||
export interface StepRunnerStore extends SequenceEngineStore {
|
||||
loadRun(runId: string): Promise<SequenceRunRow | null>;
|
||||
loadSteps(runId: string): Promise<readonly SequenceStep[]>;
|
||||
loadTriggerContext(runId: string): Promise<SequenceTriggerContext | null>;
|
||||
/** Atomically transition the delivery row to a terminal status. */
|
||||
markDelivery(args: {
|
||||
runId: string;
|
||||
stepIndex: number;
|
||||
status: StepDeliveryStatus;
|
||||
sentAt?: Date;
|
||||
bouncedAt?: Date;
|
||||
errorMessage?: string;
|
||||
providerMessageId?: string;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
export interface StepRunnerDeps {
|
||||
readonly store: StepRunnerStore;
|
||||
readonly queue: SequenceEngineQueue;
|
||||
readonly logger?: SequenceEngineLogger;
|
||||
readonly now?: () => Date;
|
||||
readonly renderTemplate: (
|
||||
step: SequenceStep,
|
||||
trigger: SequenceTriggerContext,
|
||||
runId: string
|
||||
) => Promise<RenderedMail> | RenderedMail;
|
||||
readonly sendMail: (input: {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text: string;
|
||||
unsubscribeUrl: string;
|
||||
listUnsubscribeMailto?: string;
|
||||
}) => Promise<MailSendResult>;
|
||||
readonly buildUnsubscribeUrl: (trigger: SequenceTriggerContext, runId: string) => string;
|
||||
readonly buildUnsubscribeMailto?: (trigger: SequenceTriggerContext, runId: string) => string | undefined;
|
||||
}
|
||||
|
||||
export interface StepRunResult {
|
||||
readonly action: "sent" | "skipped" | "failed" | "cancelled";
|
||||
readonly completed: boolean;
|
||||
}
|
||||
|
||||
export async function runStep(
|
||||
args: { runId: string; stepIndex: number },
|
||||
deps: StepRunnerDeps
|
||||
): Promise<StepRunResult> {
|
||||
const run = await deps.store.loadRun(args.runId);
|
||||
if (!run) {
|
||||
deps.logger?.warn?.({ op: "sequence.step.run_missing", runId: args.runId }, "sequence run not found");
|
||||
return { action: "cancelled", completed: true };
|
||||
}
|
||||
if (run.status === "cancelled" || run.status === "unsubscribed" || run.status === "completed" || run.status === "failed") {
|
||||
deps.logger?.info?.(
|
||||
{ op: "sequence.step.run_inactive", runId: args.runId, status: run.status },
|
||||
"sequence run inactive — skip"
|
||||
);
|
||||
return { action: "cancelled", completed: true };
|
||||
}
|
||||
|
||||
const steps = await deps.store.loadSteps(args.runId);
|
||||
const step = steps.find((s) => s.stepIndex === args.stepIndex);
|
||||
if (!step) {
|
||||
deps.logger?.warn?.(
|
||||
{ op: "sequence.step.unknown_step", runId: args.runId, stepIndex: args.stepIndex },
|
||||
"step index out of range"
|
||||
);
|
||||
return { action: "cancelled", completed: true };
|
||||
}
|
||||
|
||||
const trigger = await deps.store.loadTriggerContext(args.runId);
|
||||
if (!trigger) {
|
||||
deps.logger?.warn?.(
|
||||
{ op: "sequence.step.no_trigger", runId: args.runId },
|
||||
"trigger context missing — skip"
|
||||
);
|
||||
await deps.store.markDelivery({
|
||||
runId: args.runId,
|
||||
stepIndex: args.stepIndex,
|
||||
status: "skipped",
|
||||
errorMessage: "trigger_context_missing",
|
||||
});
|
||||
const adv = await advanceSequenceRun(
|
||||
{ run, steps, previousStepIndex: args.stepIndex },
|
||||
{ store: deps.store, queue: deps.queue, logger: deps.logger, now: deps.now }
|
||||
);
|
||||
return { action: "skipped", completed: adv.completed };
|
||||
}
|
||||
|
||||
if (!evaluateAllConditions(trigger.payload, step.conditions)) {
|
||||
deps.logger?.info?.(
|
||||
{ op: "sequence.step.conditions_false", runId: args.runId, stepIndex: args.stepIndex },
|
||||
"step conditions not met — skip"
|
||||
);
|
||||
await deps.store.markDelivery({
|
||||
runId: args.runId,
|
||||
stepIndex: args.stepIndex,
|
||||
status: "skipped",
|
||||
});
|
||||
const adv = await advanceSequenceRun(
|
||||
{ run, steps, previousStepIndex: args.stepIndex },
|
||||
{ store: deps.store, queue: deps.queue, logger: deps.logger, now: deps.now }
|
||||
);
|
||||
return { action: "skipped", completed: adv.completed };
|
||||
}
|
||||
|
||||
const rendered = await deps.renderTemplate(step, trigger, args.runId);
|
||||
const unsubscribeUrl = deps.buildUnsubscribeUrl(trigger, args.runId);
|
||||
const listUnsubscribeMailto = deps.buildUnsubscribeMailto?.(trigger, args.runId);
|
||||
const result = await deps.sendMail({
|
||||
to: trigger.email,
|
||||
subject: rendered.subject,
|
||||
html: rendered.html,
|
||||
text: rendered.text,
|
||||
unsubscribeUrl,
|
||||
listUnsubscribeMailto,
|
||||
});
|
||||
const sentAt = (deps.now ?? (() => new Date()))();
|
||||
if (result.ok) {
|
||||
await deps.store.markDelivery({
|
||||
runId: args.runId,
|
||||
stepIndex: args.stepIndex,
|
||||
status: "sent",
|
||||
sentAt,
|
||||
providerMessageId: result.messageId,
|
||||
});
|
||||
deps.logger?.info?.(
|
||||
{ op: "sequence.step.sent", runId: args.runId, stepIndex: args.stepIndex, providerMessageId: result.messageId },
|
||||
"sequence step sent"
|
||||
);
|
||||
const adv = await advanceSequenceRun(
|
||||
{ run, steps, previousStepIndex: args.stepIndex },
|
||||
{ store: deps.store, queue: deps.queue, logger: deps.logger, now: deps.now }
|
||||
);
|
||||
return { action: "sent", completed: adv.completed };
|
||||
}
|
||||
if (result.bounced) {
|
||||
await deps.store.markDelivery({
|
||||
runId: args.runId,
|
||||
stepIndex: args.stepIndex,
|
||||
status: "bounced",
|
||||
bouncedAt: sentAt,
|
||||
errorMessage: result.errorTag,
|
||||
});
|
||||
await deps.store.updateRunStatus(args.runId, "failed", sentAt);
|
||||
deps.logger?.warn?.(
|
||||
{ op: "sequence.step.bounced", runId: args.runId, stepIndex: args.stepIndex, errorTag: result.errorTag },
|
||||
"sequence step bounced — run failed"
|
||||
);
|
||||
return { action: "failed", completed: true };
|
||||
}
|
||||
await deps.store.markDelivery({
|
||||
runId: args.runId,
|
||||
stepIndex: args.stepIndex,
|
||||
status: "failed",
|
||||
errorMessage: result.errorTag,
|
||||
});
|
||||
deps.logger?.error(
|
||||
{ op: "sequence.step.failed", runId: args.runId, stepIndex: args.stepIndex, errorTag: result.errorTag },
|
||||
"sequence step send failed"
|
||||
);
|
||||
return { action: "failed", completed: false };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseSequenceSteps, parseConditions, SequenceValidationError } from "./types.js";
|
||||
|
||||
describe("parseSequenceSteps", () => {
|
||||
it("parses a well-formed steps array", () => {
|
||||
const steps = parseSequenceSteps([
|
||||
{ stepIndex: 0, delayHours: 0, template: "welcome", subject: "Welcome" },
|
||||
{ stepIndex: 1, delayHours: 24, template: "generic-step", subject: "Day 1" },
|
||||
]);
|
||||
expect(steps).toHaveLength(2);
|
||||
expect(steps[0]?.delayHours).toBe(0);
|
||||
});
|
||||
it("rejects non-array input", () => {
|
||||
expect(() => parseSequenceSteps({} as unknown)).toThrow(SequenceValidationError);
|
||||
});
|
||||
it("rejects negative delayHours", () => {
|
||||
expect(() => parseSequenceSteps([{ stepIndex: 0, delayHours: -1, template: "x", subject: "y" }])).toThrow(
|
||||
SequenceValidationError
|
||||
);
|
||||
});
|
||||
it("rejects missing template", () => {
|
||||
expect(() => parseSequenceSteps([{ stepIndex: 0, delayHours: 0, subject: "y" }])).toThrow(SequenceValidationError);
|
||||
});
|
||||
it("rejects missing subject", () => {
|
||||
expect(() => parseSequenceSteps([{ stepIndex: 0, delayHours: 0, template: "welcome" }])).toThrow(
|
||||
SequenceValidationError
|
||||
);
|
||||
});
|
||||
it("defaults stepIndex to array index when missing", () => {
|
||||
const steps = parseSequenceSteps([
|
||||
{ delayHours: 0, template: "welcome", subject: "a" },
|
||||
{ delayHours: 1, template: "welcome", subject: "b" },
|
||||
]);
|
||||
expect(steps[0]?.stepIndex).toBe(0);
|
||||
expect(steps[1]?.stepIndex).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseConditions", () => {
|
||||
it("returns undefined for undefined input", () => {
|
||||
expect(parseConditions(undefined, "test")).toBeUndefined();
|
||||
});
|
||||
it("rejects unknown op", () => {
|
||||
expect(() => parseConditions([{ path: "x", op: "bogus" }], "test")).toThrow(SequenceValidationError);
|
||||
});
|
||||
it("parses valid op", () => {
|
||||
const conds = parseConditions([{ path: "a", op: "eq", value: 1 }], "test");
|
||||
expect(conds?.[0]?.op).toBe("eq");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Marketing-Funnel Phase 3 email-sequence engine (Phase 05 #80).
|
||||
*
|
||||
* Types for the sequence-definition DSL stored in
|
||||
* `EmailSequence.stepsJson`. Sequences are linear V1: step 0, 1, 2, ...
|
||||
* Each step carries a delay-from-trigger so a sequence is reusable for
|
||||
* any new lead/tenant without absolute-date math.
|
||||
*/
|
||||
|
||||
export type SequenceTriggerEvent =
|
||||
| "lead.captured"
|
||||
| "lead.qualified"
|
||||
| "lead.stale_60d"
|
||||
| "lead.converted_to_trial"
|
||||
| "lead.converted_to_customer"
|
||||
| "tenant.signup"
|
||||
| "tenant.trial_expiring"
|
||||
| "tenant.cancelled"
|
||||
| "custom";
|
||||
|
||||
/**
|
||||
* Built-in template identifiers shipped by `@toolctrl/email-templates`.
|
||||
* Operators can also point at a `custom:<id>` value when their tenant
|
||||
* has a registry-stored override.
|
||||
*/
|
||||
export type SequenceStepTemplateId =
|
||||
| "welcome"
|
||||
| "trial-reminder"
|
||||
| "re-activation"
|
||||
| "generic-step";
|
||||
|
||||
export type SequenceStepConditionOperator =
|
||||
| "eq"
|
||||
| "neq"
|
||||
| "in"
|
||||
| "not_in"
|
||||
| "exists"
|
||||
| "not_exists";
|
||||
|
||||
export interface SequenceStepCondition {
|
||||
/** Dot-path into the trigger payload (e.g. `lead.status`). */
|
||||
readonly path: string;
|
||||
readonly op: SequenceStepConditionOperator;
|
||||
/** Comparator value. Ignored for `exists` / `not_exists`. */
|
||||
readonly value?: string | number | boolean | readonly (string | number)[];
|
||||
}
|
||||
|
||||
export interface SequenceStep {
|
||||
/** Zero-based ordinal — used as the `EmailSequenceStepDelivery.stepIndex`. */
|
||||
readonly stepIndex: number;
|
||||
/**
|
||||
* Hours to wait between trigger-time (step 0) or the previous step's
|
||||
* sentAt (step 1+). `0` means "send immediately".
|
||||
*/
|
||||
readonly delayHours: number;
|
||||
/** Template-id from the registry. */
|
||||
readonly template: SequenceStepTemplateId | string;
|
||||
/** Subject-line for the outbound email. Variables are substituted. */
|
||||
readonly subject: string;
|
||||
/**
|
||||
* Free-form variable bag forwarded to the template. Keys like
|
||||
* `headline`, `bodyParagraphs`, `ctaUrl` map to the named props on
|
||||
* the corresponding template component.
|
||||
*/
|
||||
readonly variables?: Readonly<Record<string, unknown>>;
|
||||
/** Per-step conditions ANDed together. */
|
||||
readonly conditions?: readonly SequenceStepCondition[];
|
||||
}
|
||||
|
||||
export interface SequenceDefinition {
|
||||
readonly id: string;
|
||||
readonly tenantId?: string | null;
|
||||
readonly name: string;
|
||||
readonly description?: string | null;
|
||||
readonly triggerEvent: SequenceTriggerEvent | string;
|
||||
readonly steps: readonly SequenceStep[];
|
||||
readonly conditions?: readonly SequenceStepCondition[];
|
||||
readonly isEnabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recipient context passed by the trigger handler to the engine. The
|
||||
* concrete fields are merged into the template-variable bag — the
|
||||
* engine itself does not interpret them.
|
||||
*/
|
||||
export interface SequenceTriggerContext {
|
||||
/** Outbox event-id from the originating trigger. */
|
||||
readonly triggerEventId?: string | null;
|
||||
/** Stable target id — Lead.id or Tenant.id depending on event type. */
|
||||
readonly leadId?: string | null;
|
||||
readonly tenantId?: string | null;
|
||||
readonly email: string;
|
||||
readonly recipientName?: string | null;
|
||||
/** Free-form payload for condition evaluation. */
|
||||
readonly payload: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export class SequenceValidationError extends Error {
|
||||
public override readonly name = "SequenceValidationError";
|
||||
public readonly code: "INVALID_STEPS" | "INVALID_STEP" | "INVALID_CONDITION";
|
||||
constructor(message: string, code: SequenceValidationError["code"]) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate + parse a `stepsJson` blob coming out of the DB. Throws
|
||||
* {@link SequenceValidationError} when the shape does not match.
|
||||
*/
|
||||
export function parseSequenceSteps(raw: unknown): SequenceStep[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
throw new SequenceValidationError("stepsJson must be an array", "INVALID_STEPS");
|
||||
}
|
||||
return raw.map((entry, idx) => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
throw new SequenceValidationError(`step ${idx} is not an object`, "INVALID_STEP");
|
||||
}
|
||||
const e = entry as Record<string, unknown>;
|
||||
const stepIndex = typeof e.stepIndex === "number" ? e.stepIndex : idx;
|
||||
if (typeof e.delayHours !== "number" || e.delayHours < 0) {
|
||||
throw new SequenceValidationError(`step ${idx} delayHours must be a non-negative number`, "INVALID_STEP");
|
||||
}
|
||||
if (typeof e.template !== "string" || e.template.length === 0) {
|
||||
throw new SequenceValidationError(`step ${idx} template must be a non-empty string`, "INVALID_STEP");
|
||||
}
|
||||
if (typeof e.subject !== "string" || e.subject.length === 0) {
|
||||
throw new SequenceValidationError(`step ${idx} subject must be a non-empty string`, "INVALID_STEP");
|
||||
}
|
||||
const variables =
|
||||
e.variables && typeof e.variables === "object"
|
||||
? (e.variables as Record<string, unknown>)
|
||||
: undefined;
|
||||
const conditions = parseConditions(e.conditions, `step ${idx}`);
|
||||
return {
|
||||
stepIndex,
|
||||
delayHours: e.delayHours,
|
||||
template: e.template,
|
||||
subject: e.subject,
|
||||
variables,
|
||||
conditions,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function parseConditions(raw: unknown, context: string): SequenceStepCondition[] | undefined {
|
||||
if (raw === undefined || raw === null) return undefined;
|
||||
if (!Array.isArray(raw)) {
|
||||
throw new SequenceValidationError(`${context} conditions must be an array`, "INVALID_CONDITION");
|
||||
}
|
||||
return raw.map((c, idx) => {
|
||||
if (!c || typeof c !== "object") {
|
||||
throw new SequenceValidationError(`${context} condition ${idx} not an object`, "INVALID_CONDITION");
|
||||
}
|
||||
const o = c as Record<string, unknown>;
|
||||
if (typeof o.path !== "string" || o.path.length === 0) {
|
||||
throw new SequenceValidationError(`${context} condition ${idx} path must be a string`, "INVALID_CONDITION");
|
||||
}
|
||||
const op = o.op as SequenceStepConditionOperator;
|
||||
if (!["eq", "neq", "in", "not_in", "exists", "not_exists"].includes(op)) {
|
||||
throw new SequenceValidationError(`${context} condition ${idx} op unknown: ${String(op)}`, "INVALID_CONDITION");
|
||||
}
|
||||
return { path: o.path, op, value: o.value as SequenceStepCondition["value"] };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildUnsubscribeUrl,
|
||||
signUnsubscribeToken,
|
||||
UnsubscribeTokenSecretMissingError,
|
||||
verifyUnsubscribeToken,
|
||||
} from "./unsubscribe-token.js";
|
||||
|
||||
const SECRET = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
describe("signUnsubscribeToken", () => {
|
||||
it("throws when no secret is configured", () => {
|
||||
expect(() => signUnsubscribeToken({ runId: "run_1" })).toThrow(UnsubscribeTokenSecretMissingError);
|
||||
});
|
||||
|
||||
it("throws when secret is too short", () => {
|
||||
expect(() => signUnsubscribeToken({ runId: "run_1", secret: "short" })).toThrow(UnsubscribeTokenSecretMissingError);
|
||||
});
|
||||
|
||||
it("requires runId", () => {
|
||||
expect(() => signUnsubscribeToken({ runId: "" as unknown as string, secret: SECRET })).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("returns a u1 payload signature string", () => {
|
||||
const token = signUnsubscribeToken({ runId: "run_1", secret: SECRET });
|
||||
expect(token.split(".")).toHaveLength(3);
|
||||
expect(token.startsWith("u1.")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses MARKETING_TOKEN_SECRET when an env record is injected", () => {
|
||||
const env = { MARKETING_TOKEN_SECRET: SECRET };
|
||||
const token = signUnsubscribeToken({ runId: "run_1", env });
|
||||
expect(verifyUnsubscribeToken(token, { env })).toMatchObject({ runId: "run_1" });
|
||||
});
|
||||
|
||||
it("falls back to LEAD_CONVERSION_SECRET when an env record is injected", () => {
|
||||
const env = { LEAD_CONVERSION_SECRET: SECRET };
|
||||
const token = signUnsubscribeToken({ runId: "run_1", env });
|
||||
expect(verifyUnsubscribeToken(token, { env })).toMatchObject({ runId: "run_1" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyUnsubscribeToken", () => {
|
||||
it("returns null for null or empty input", () => {
|
||||
expect(verifyUnsubscribeToken(null, { secret: SECRET })).toBeNull();
|
||||
expect(verifyUnsubscribeToken("", { secret: SECRET })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when secret is missing", () => {
|
||||
const token = signUnsubscribeToken({ runId: "run_1", secret: SECRET });
|
||||
expect(verifyUnsubscribeToken(token)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on tampered signature", () => {
|
||||
const token = signUnsubscribeToken({ runId: "run_1", secret: SECRET });
|
||||
const tampered = `${token.slice(0, -2)}XX`;
|
||||
expect(verifyUnsubscribeToken(tampered, { secret: SECRET })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on tampered payload", () => {
|
||||
const token = signUnsubscribeToken({ runId: "run_1", secret: SECRET });
|
||||
const parts = token.split(".");
|
||||
const tamperedPayload = Buffer.from(JSON.stringify({ runId: "run_2", iat: 0 }), "utf8").toString("base64url");
|
||||
const tampered = `${parts[0]}.${tamperedPayload}.${parts[2]}`;
|
||||
expect(verifyUnsubscribeToken(tampered, { secret: SECRET })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on wrong version prefix", () => {
|
||||
const token = signUnsubscribeToken({ runId: "run_1", secret: SECRET });
|
||||
const tampered = token.replace("u1.", "u2.");
|
||||
expect(verifyUnsubscribeToken(tampered, { secret: SECRET })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the payload on a valid token", () => {
|
||||
const token = signUnsubscribeToken({ runId: "run_1", leadId: "lead_1", tenantId: "tenant_1", secret: SECRET });
|
||||
expect(verifyUnsubscribeToken(token, { secret: SECRET })).toMatchObject({
|
||||
runId: "run_1",
|
||||
leadId: "lead_1",
|
||||
tenantId: "tenant_1",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when verifying with a different secret", () => {
|
||||
const token = signUnsubscribeToken({ runId: "run_1", secret: SECRET });
|
||||
expect(verifyUnsubscribeToken(token, { secret: "f".repeat(64) })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildUnsubscribeUrl", () => {
|
||||
it("builds an absolute url with the signed token", () => {
|
||||
const url = buildUnsubscribeUrl({
|
||||
baseUrl: "https://app.example.com",
|
||||
runId: "run_1",
|
||||
secret: SECRET,
|
||||
});
|
||||
expect(url.startsWith("https://app.example.com/unsubscribe/u1.")).toBe(true);
|
||||
});
|
||||
|
||||
it("strips trailing slashes from the base url", () => {
|
||||
const url = buildUnsubscribeUrl({
|
||||
baseUrl: "https://app.example.com/",
|
||||
runId: "run_1",
|
||||
secret: SECRET,
|
||||
});
|
||||
expect(url.startsWith("https://app.example.com/unsubscribe/u1.")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Shared unsubscribe-token helper for marketing sequence mails.
|
||||
*
|
||||
* Sequence emails carry `/unsubscribe/<token>` links. The token is the
|
||||
* authentication boundary for one-click unsubscribe, so web routes and worker
|
||||
* mail producers must sign and verify the same payload shape.
|
||||
*/
|
||||
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
type EnvRecord = Record<string, string | undefined>;
|
||||
|
||||
export interface UnsubscribeTokenPayload {
|
||||
/** EmailSequenceRun.id this token unsubscribes from. */
|
||||
runId: string;
|
||||
/** Lead.id (may be null for tenant-scoped sequences). */
|
||||
leadId: string | null;
|
||||
/** Tenant.id (may be null for lead-scoped sequences). */
|
||||
tenantId: string | null;
|
||||
/** Issued-at unix seconds, used for debugging only. */
|
||||
iat: number;
|
||||
}
|
||||
|
||||
export interface SignUnsubscribeTokenInput {
|
||||
runId: string;
|
||||
leadId?: string | null;
|
||||
tenantId?: string | null;
|
||||
now?: () => Date;
|
||||
secret?: string;
|
||||
env?: EnvRecord;
|
||||
}
|
||||
|
||||
export interface VerifyUnsubscribeTokenOptions {
|
||||
secret?: string;
|
||||
env?: EnvRecord;
|
||||
}
|
||||
|
||||
export class UnsubscribeTokenSecretMissingError extends Error {
|
||||
override readonly name = "UnsubscribeTokenSecretMissingError";
|
||||
|
||||
constructor() {
|
||||
super("MARKETING_TOKEN_SECRET env var is not set; cannot sign or verify unsubscribe tokens.");
|
||||
}
|
||||
}
|
||||
|
||||
const TOKEN_VERSION = "u1";
|
||||
const MIN_SECRET_BYTES = 32;
|
||||
|
||||
function getSecret(override?: string, env?: EnvRecord): Buffer {
|
||||
const raw = override ?? env?.MARKETING_TOKEN_SECRET ?? env?.LEAD_CONVERSION_SECRET;
|
||||
if (!raw || raw.length === 0) throw new UnsubscribeTokenSecretMissingError();
|
||||
if (raw.length < MIN_SECRET_BYTES) throw new UnsubscribeTokenSecretMissingError();
|
||||
return Buffer.from(raw, "utf8");
|
||||
}
|
||||
|
||||
function b64uEncode(input: Buffer | string): string {
|
||||
const buf = Buffer.isBuffer(input) ? input : Buffer.from(input, "utf8");
|
||||
return buf.toString("base64url");
|
||||
}
|
||||
|
||||
function b64uDecode(input: string): Buffer | null {
|
||||
if (!/^[A-Za-z0-9_-]+$/u.test(input)) return null;
|
||||
try {
|
||||
return Buffer.from(input, "base64url");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function nowSec(now?: () => Date): number {
|
||||
return Math.floor((now ? now() : new Date()).getTime() / 1000);
|
||||
}
|
||||
|
||||
export function signUnsubscribeToken(input: SignUnsubscribeTokenInput): string {
|
||||
if (!input.runId || typeof input.runId !== "string") {
|
||||
throw new TypeError("signUnsubscribeToken: runId required");
|
||||
}
|
||||
const secret = getSecret(input.secret, input.env);
|
||||
const payload: UnsubscribeTokenPayload = {
|
||||
runId: input.runId,
|
||||
leadId: input.leadId ?? null,
|
||||
tenantId: input.tenantId ?? null,
|
||||
iat: nowSec(input.now),
|
||||
};
|
||||
const payloadB64 = b64uEncode(JSON.stringify(payload));
|
||||
const sig = createHmac("sha256", secret).update(`${TOKEN_VERSION}.${payloadB64}`).digest();
|
||||
return `${TOKEN_VERSION}.${payloadB64}.${b64uEncode(sig)}`;
|
||||
}
|
||||
|
||||
export function verifyUnsubscribeToken(
|
||||
token: string | null | undefined,
|
||||
options: VerifyUnsubscribeTokenOptions = {}
|
||||
): UnsubscribeTokenPayload | null {
|
||||
if (!token || typeof token !== "string") return null;
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
const [version, payloadB64, sigB64] = parts;
|
||||
if (version !== TOKEN_VERSION) return null;
|
||||
let secret: Buffer;
|
||||
try {
|
||||
secret = getSecret(options.secret, options.env);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const expected = createHmac("sha256", secret).update(`${version}.${payloadB64}`).digest();
|
||||
const actual = b64uDecode(sigB64);
|
||||
if (!actual || actual.length !== expected.length) return null;
|
||||
if (!timingSafeEqual(actual, expected)) return null;
|
||||
const payloadBuf = b64uDecode(payloadB64);
|
||||
if (!payloadBuf) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(payloadBuf.toString("utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
const rec = parsed as Record<string, unknown>;
|
||||
if (typeof rec.runId !== "string" || rec.runId.length === 0) return null;
|
||||
return {
|
||||
runId: rec.runId,
|
||||
leadId: typeof rec.leadId === "string" ? rec.leadId : null,
|
||||
tenantId: typeof rec.tenantId === "string" ? rec.tenantId : null,
|
||||
iat: typeof rec.iat === "number" ? rec.iat : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUnsubscribeUrl(args: {
|
||||
baseUrl: string;
|
||||
runId: string;
|
||||
leadId?: string | null;
|
||||
tenantId?: string | null;
|
||||
secret?: string;
|
||||
now?: () => Date;
|
||||
}): string {
|
||||
const token = signUnsubscribeToken(args);
|
||||
const base = args.baseUrl.replace(/\/+$/, "");
|
||||
return `${base}/unsubscribe/${token}`;
|
||||
}
|
||||
Reference in new issue
Block a user