Initial Commit
This commit is contained in:
94
packages/communication/emails/campaigns/README.md
Normal file
94
packages/communication/emails/campaigns/README.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# @suaveplan/campaigns - Universal campaign orchestration — engine, state machine, batch planner, workflow, and pluggable store with injectable Clock/IdGenerator/TimersLike/LoggerLike, CAMPAIGNS_CODES error registry, and OTel self-telemetry
|
||||
|
||||
Universal campaign orchestration.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [batch-planner](./src/batch-planner/batch-planner.md) — Deterministic batch-planning engine for campaign sends. Splits a contact list into sized batches with throttle-aware sc…
|
||||
- [batch-planner](./src/batch-planner/batch-planner.md) — Deterministic batch-planning engine for campaign sends. Splits a contact list into sized batches with throttle-aware sc…
|
||||
- [engine](./src/engine/engine.md) — Top-level orchestrator for campaign CRUD and lifecycle. `CampaignEngine` accepts an injectable `CampaignStore` and expo…
|
||||
- [engine](./src/engine/engine.md) — Top-level orchestrator for campaign CRUD and lifecycle. `CampaignEngine` accepts an injectable `CampaignStore` and expo…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable, machine-readable error namespace for `@suaveplan/campaigns`. Every failure path in the package — fr…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable, machine-readable error namespace for `@suaveplan/campaigns`. Every failure path in the package — fr…
|
||||
- [state-machine](./src/state-machine/state-machine.md) — Strictly typed lifecycle state machine for campaigns. Owns the set of nine legal transitions between the seven `Campaig…
|
||||
- [state-machine](./src/state-machine/state-machine.md) — Strictly typed lifecycle state machine for campaigns. Owns the set of nine legal transitions between the seven `Campaig…
|
||||
- [store](./src/store/store.md) — Defines the `CampaignStore` persistence contract and ships an `InMemoryCampaignStore` reference implementation. Every s…
|
||||
- [store](./src/store/store.md) — Defines the `CampaignStore` persistence contract and ships an `InMemoryCampaignStore` reference implementation. Every s…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — OTel self-telemetry for `@suaveplan/campaigns`. Exposes a small set of counters and histograms that are emitted from ev…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — OTel self-telemetry for `@suaveplan/campaigns`. Exposes a small set of counters and histograms that are emitted from ev…
|
||||
- [workflow](./src/workflow/workflow.md) — Composable multi-step send pipeline for campaigns. `CampaignWorkflow.buildSendPipeline` returns a canonical six-step pi…
|
||||
- [workflow](./src/workflow/workflow.md) — Composable multi-step send pipeline for campaigns. `CampaignWorkflow.buildSendPipeline` returns a canonical six-step pi…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Modules
|
||||
|
||||
- [`engine/`](./src/engine/engine.md) — `CampaignEngine`: CRUD + lifecycle methods over an injectable `CampaignStore`.
|
||||
- [`state-machine/`](./src/state-machine/state-machine.md) — `CampaignStateMachine`: nine canonical transitions, typed event union.
|
||||
- [`batch-planner/`](./src/batch-planner/batch-planner.md) — `BatchPlanner`: throttle-aware splitter + duration estimator.
|
||||
- [`workflow/`](./src/workflow/workflow.md) — `CampaignWorkflow`: composable 6-step send pipeline + sequential runner.
|
||||
- [`store/`](./src/store/store.md) — `CampaignStore` interface + `InMemoryCampaignStore` reference.
|
||||
- [`error-codes/`](./src/error-codes/error-codes.md) — `CAMPAIGNS_CODES` + `CampaignsError`.
|
||||
- [`telemetry/`](./src/telemetry/telemetry.md) — OTel meter, counters, histograms.
|
||||
- [`types/`](./src/types/) — shared types.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/campaigns
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { CampaignEngine, InMemoryCampaignStore } from "@suaveplan/campaigns";
|
||||
|
||||
const engine = new CampaignEngine(new InMemoryCampaignStore());
|
||||
|
||||
const campaign = await engine.create({
|
||||
tenantId: "tenant-1",
|
||||
name: "Spring Sale Announcement",
|
||||
type: "broadcast",
|
||||
channel: "email",
|
||||
audienceConfig: {
|
||||
groupIds: ["all-subscribers"],
|
||||
excludeGroupIds: ["unsubscribed"],
|
||||
},
|
||||
templateId: "spring-sale-v1",
|
||||
});
|
||||
|
||||
await engine.schedule(campaign.id, {
|
||||
type: "scheduled",
|
||||
sendAt: new Date("2026-05-01T09:00:00Z"),
|
||||
timezone: "America/New_York",
|
||||
});
|
||||
|
||||
await engine.send(campaign.id);
|
||||
```
|
||||
|
||||
## Throttle-aware batch planning
|
||||
|
||||
```ts
|
||||
import { BatchPlanner, type ThrottleConfig } from "@suaveplan/campaigns";
|
||||
|
||||
const planner = new BatchPlanner();
|
||||
const throttle: ThrottleConfig = { messagesPerSecond: 50 };
|
||||
const batches = planner.planBatches(contactIds, { batchSize: 200 }, throttle);
|
||||
const etaMs = planner.calculateTotalDuration(contactIds.length, { batchSize: 200 }, throttle);
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
CampaignEngine ──┬── CampaignStateMachine (validation)
|
||||
├── CampaignStore (persistence)
|
||||
├── BatchPlanner (throttle-aware planning)
|
||||
└── CampaignWorkflow (6-step pipeline)
|
||||
```
|
||||
|
||||
All callers see `CampaignsError` with codes from `CAMPAIGNS_CODES`. All operations record OTel counters/histograms via the `telemetry/` module.
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
|
||||
109
packages/communication/emails/campaigns/package.json
Normal file
109
packages/communication/emails/campaigns/package.json
Normal file
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"name": "@suaveplan/campaigns",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Universal campaign orchestration — engine, state machine, batch planner, workflow, and pluggable store with injectable Clock/IdGenerator/TimersLike/LoggerLike, CAMPAIGNS_CODES error registry, and OTel self-telemetry",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./engine": {
|
||||
"import": {
|
||||
"types": "./dist/engine/index.d.ts",
|
||||
"default": "./dist/engine/index.js"
|
||||
}
|
||||
},
|
||||
"./state-machine": {
|
||||
"import": {
|
||||
"types": "./dist/state-machine/index.d.ts",
|
||||
"default": "./dist/state-machine/index.js"
|
||||
}
|
||||
},
|
||||
"./batch-planner": {
|
||||
"import": {
|
||||
"types": "./dist/batch-planner/index.d.ts",
|
||||
"default": "./dist/batch-planner/index.js"
|
||||
}
|
||||
},
|
||||
"./workflow": {
|
||||
"import": {
|
||||
"types": "./dist/workflow/index.d.ts",
|
||||
"default": "./dist/workflow/index.js"
|
||||
}
|
||||
},
|
||||
"./store": {
|
||||
"import": {
|
||||
"types": "./dist/store/index.d.ts",
|
||||
"default": "./dist/store/index.js"
|
||||
}
|
||||
},
|
||||
"./types": {
|
||||
"import": {
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"default": "./dist/types/index.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
}
|
||||
},
|
||||
"./telemetry": {
|
||||
"import": {
|
||||
"types": "./dist/telemetry/index.d.ts",
|
||||
"default": "./dist/telemetry/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/logger": "workspace:^",
|
||||
"@suaveplan/opentelemetry": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
# batch-planner
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Deterministic batch-planning engine for campaign sends. Splits a contact list into sized batches with throttle-aware scheduled timestamps, so a downstream dispatcher can read each `Batch.scheduledAt` and either dispatch immediately (when `undefined`) or schedule a delayed dispatch. The planner is the single source of truth for "how fast can we send this campaign without violating throttle limits", which keeps dispatch loops and reporting consistent.
|
||||
|
||||
The planner takes an injectable `Clock` so its output is reproducible in tests — pinning `t=0` to a fixed wall-clock value lets us assert exact timestamps without flaky `Date.now()` reads.
|
||||
|
||||
## Features
|
||||
|
||||
- `planBatches(contactIds, config?, throttle?)` returns a `Batch[]` keyed by `batchIndex`.
|
||||
- Throttle-aware delay calculation: picks the most restrictive of `messagesPerSecond`, `messagesPerMinute`, `messagesPerHour`, falling back to `delayBetweenBatches`.
|
||||
- Empty input short-circuits to `[]` while still recording the 0-size plan in telemetry.
|
||||
- `calculateTotalDuration(totalContacts, config?, throttle?)` estimates the total send-window length in milliseconds.
|
||||
- All inputs (`BatchPlannerOptions`, `BatchConfig`, `ThrottleConfig`) are Zod-validated at the boundary; bad values throw `CampaignsError(INVALID_BATCH_CONFIG|INVALID_THROTTLE_CONFIG|INVALID_OPTIONS)`.
|
||||
- Emits `campaigns_batches_planned_total`, `campaigns_batch_plan_size`, and `campaigns_send_duration_ms` instruments.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { BatchPlanner } from "@suaveplan/campaigns";
|
||||
|
||||
const planner = new BatchPlanner();
|
||||
const batches = planner.planBatches(contactIds, { batchSize: 200 });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Pin the clock for reproducible tests and use the duration estimator to drive ETA labels:
|
||||
|
||||
```ts
|
||||
import { BatchPlanner, type ThrottleConfig } from "@suaveplan/campaigns";
|
||||
|
||||
const planner = new BatchPlanner({ clock: () => 1_700_000_000_000 });
|
||||
const throttle: ThrottleConfig = { messagesPerSecond: 50 };
|
||||
const batches = planner.planBatches(contactIds, { batchSize: 250 }, throttle);
|
||||
const etaMs = planner.calculateTotalDuration(contactIds.length, { batchSize: 250 }, throttle);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `BatchPlannerOptions` — `{ clock?: Clock }`.
|
||||
- `Batch` — `{ batchIndex; contactIds; scheduledAt? }`.
|
||||
- `BatchPlanner.planBatches(contactIds, config?, throttle?): Batch[]`.
|
||||
- `BatchPlanner.calculateTotalDuration(totalContacts, config?, throttle?): number`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The first batch (`batchIndex === 0`) always has `scheduledAt === undefined`, signalling "send immediately". Each subsequent batch's `scheduledAt` is `clock() + batchIndex * effectiveDelay`. `resolveDelay` deliberately reads only the first set throttle field (`messagesPerSecond` wins over `Minute`, which wins over `Hour`) — combining them would require a more sophisticated model and is left for an OpenSpec change. The duration estimator returns `0` for zero or negative contact counts so it can be called speculatively from UI layers without guard rails.
|
||||
@@ -0,0 +1,233 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
|
||||
import { BatchPlanner } from "./batch-planner.js";
|
||||
|
||||
function makeContactIds(count: number): string[] {
|
||||
return Array.from({ length: count }, (_, i) => `contact-${i}`);
|
||||
}
|
||||
|
||||
const FIXED_NOW = 1_700_000_000_000;
|
||||
function fixedClock(): number {
|
||||
return FIXED_NOW;
|
||||
}
|
||||
|
||||
describe("BatchPlanner", () => {
|
||||
describe("construction", () => {
|
||||
it("accepts default options", { timeout: 5000 }, () => {
|
||||
const planner = new BatchPlanner();
|
||||
expect(planner).toBeInstanceOf(BatchPlanner);
|
||||
});
|
||||
|
||||
it("accepts injected clock", { timeout: 5000 }, () => {
|
||||
const planner = new BatchPlanner({ clock: fixedClock });
|
||||
const batches = planner.planBatches(makeContactIds(200), {
|
||||
batchSize: 100,
|
||||
delayBetweenBatches: 1000,
|
||||
});
|
||||
expect(batches[1].scheduledAt?.getTime()).toBe(FIXED_NOW + 1000);
|
||||
});
|
||||
|
||||
it("rejects unknown option keys", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new BatchPlanner({ bogus: 1 } as unknown as never);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CampaignsError);
|
||||
if (err instanceof CampaignsError) {
|
||||
expect(err.code).toBe(CAMPAIGNS_CODES.INVALID_OPTIONS);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("planBatches", () => {
|
||||
const planner = new BatchPlanner({ clock: fixedClock });
|
||||
|
||||
it("returns empty for no contacts", { timeout: 5000 }, () => {
|
||||
expect(planner.planBatches([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("creates single batch for small lists", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const batches = planner.planBatches(makeContactIds(50), {
|
||||
batchSize: 100,
|
||||
});
|
||||
expect(batches).toHaveLength(1);
|
||||
expect(batches[0].batchIndex).toBe(0);
|
||||
expect(batches[0].contactIds).toHaveLength(50);
|
||||
expect(batches[0].scheduledAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("splits into multiple batches", { timeout: 5000 }, () => {
|
||||
const batches = planner.planBatches(makeContactIds(250), {
|
||||
batchSize: 100,
|
||||
});
|
||||
expect(batches).toHaveLength(3);
|
||||
expect(batches[0].contactIds).toHaveLength(100);
|
||||
expect(batches[2].contactIds).toHaveLength(50);
|
||||
});
|
||||
|
||||
it("uses default batch size of 100", { timeout: 5000 }, () => {
|
||||
const batches = planner.planBatches(makeContactIds(150));
|
||||
expect(batches).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("schedules with explicit delay", { timeout: 5000 }, () => {
|
||||
const batches = planner.planBatches(makeContactIds(300), {
|
||||
batchSize: 100,
|
||||
delayBetweenBatches: 1000,
|
||||
});
|
||||
expect(batches[0].scheduledAt).toBeUndefined();
|
||||
expect(batches[1].scheduledAt?.getTime()).toBe(FIXED_NOW + 1000);
|
||||
expect(batches[2].scheduledAt?.getTime()).toBe(FIXED_NOW + 2000);
|
||||
});
|
||||
|
||||
it("respects messagesPerSecond throttle", { timeout: 5000 }, () => {
|
||||
const batches = planner.planBatches(
|
||||
makeContactIds(200),
|
||||
{ batchSize: 100 },
|
||||
{ messagesPerSecond: 50 }
|
||||
);
|
||||
expect(batches).toHaveLength(2);
|
||||
expect(batches[1].scheduledAt?.getTime()).toBe(FIXED_NOW + 2000);
|
||||
});
|
||||
|
||||
it("respects messagesPerMinute throttle", { timeout: 5000 }, () => {
|
||||
const batches = planner.planBatches(
|
||||
makeContactIds(200),
|
||||
{ batchSize: 100 },
|
||||
{ messagesPerMinute: 100 }
|
||||
);
|
||||
expect(batches[1].scheduledAt?.getTime()).toBe(FIXED_NOW + 60_000);
|
||||
});
|
||||
|
||||
it("respects messagesPerHour throttle", { timeout: 5000 }, () => {
|
||||
const batches = planner.planBatches(
|
||||
makeContactIds(200),
|
||||
{ batchSize: 100 },
|
||||
{ messagesPerHour: 1000 }
|
||||
);
|
||||
expect(batches[1].scheduledAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("falls back to baseDelay when throttle has only ramp fields", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const batches = planner.planBatches(
|
||||
makeContactIds(200),
|
||||
{ batchSize: 100, delayBetweenBatches: 500 },
|
||||
{ rampUp: true, rampUpDuration: 1000 }
|
||||
);
|
||||
expect(batches[1].scheduledAt?.getTime()).toBe(FIXED_NOW + 500);
|
||||
});
|
||||
|
||||
it("rejects invalid batch config", { timeout: 5000 }, () => {
|
||||
try {
|
||||
planner.planBatches(makeContactIds(10), { batchSize: -1 });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CampaignsError);
|
||||
if (err instanceof CampaignsError) {
|
||||
expect(err.code).toBe(CAMPAIGNS_CODES.INVALID_BATCH_CONFIG);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid throttle", { timeout: 5000 }, () => {
|
||||
try {
|
||||
planner.planBatches(
|
||||
makeContactIds(10),
|
||||
{ batchSize: 5 },
|
||||
{ messagesPerSecond: -1 }
|
||||
);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CampaignsError);
|
||||
if (err instanceof CampaignsError) {
|
||||
expect(err.code).toBe(
|
||||
CAMPAIGNS_CODES.INVALID_THROTTLE_CONFIG
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateTotalDuration", () => {
|
||||
const planner = new BatchPlanner({ clock: fixedClock });
|
||||
|
||||
it("returns 0 for no contacts", { timeout: 5000 }, () => {
|
||||
expect(planner.calculateTotalDuration(0)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for negative contact count", { timeout: 5000 }, () => {
|
||||
expect(planner.calculateTotalDuration(-5)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for single batch", { timeout: 5000 }, () => {
|
||||
expect(planner.calculateTotalDuration(50, { batchSize: 100 })).toBe(
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
it("computes from batches + delay", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
planner.calculateTotalDuration(300, {
|
||||
batchSize: 100,
|
||||
delayBetweenBatches: 1000,
|
||||
})
|
||||
).toBe(2000);
|
||||
});
|
||||
|
||||
it("computes with throttle", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
planner.calculateTotalDuration(
|
||||
200,
|
||||
{ batchSize: 100 },
|
||||
{ messagesPerSecond: 50 }
|
||||
)
|
||||
).toBe(2000);
|
||||
});
|
||||
|
||||
it("uses max of explicit + throttle-derived delay", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
planner.calculateTotalDuration(
|
||||
200,
|
||||
{ batchSize: 100, delayBetweenBatches: 5000 },
|
||||
{ messagesPerSecond: 50 }
|
||||
)
|
||||
).toBe(5000);
|
||||
});
|
||||
|
||||
it("rejects invalid batch config", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
planner.calculateTotalDuration(10, { batchSize: 0 })
|
||||
).toThrow(CampaignsError);
|
||||
});
|
||||
|
||||
it("rejects invalid throttle config", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
planner.calculateTotalDuration(10, {}, { messagesPerMinute: 0 })
|
||||
).toThrow(CampaignsError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("default clock", () => {
|
||||
it("uses Date.now when no clock is injected", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const planner = new BatchPlanner();
|
||||
const before = Date.now();
|
||||
const batches = planner.planBatches(makeContactIds(200), {
|
||||
batchSize: 100,
|
||||
delayBetweenBatches: 1000,
|
||||
});
|
||||
const after = Date.now();
|
||||
const t = batches[1].scheduledAt!.getTime();
|
||||
expect(t).toBeGreaterThanOrEqual(before + 1000);
|
||||
expect(t).toBeLessThanOrEqual(after + 1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import z from "zod";
|
||||
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
recordBatchesPlanned,
|
||||
recordBatchPlanSize,
|
||||
recordSendDuration,
|
||||
} from "../telemetry/telemetry.js";
|
||||
import {
|
||||
type BatchConfig,
|
||||
DEFAULT_BATCH_CONFIG,
|
||||
type ThrottleConfig,
|
||||
} from "../types/campaign.types.js";
|
||||
|
||||
/** A single batch in a campaign send plan. */
|
||||
export interface Batch {
|
||||
readonly batchIndex: number;
|
||||
readonly contactIds: readonly string[];
|
||||
readonly scheduledAt?: Date;
|
||||
}
|
||||
|
||||
/** Construction options for {@link BatchPlanner}. */
|
||||
export interface BatchPlannerOptions {
|
||||
/** Injectable wall-clock source. Defaults to `Date.now`. */
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
|
||||
const BatchPlannerOptionsSchema = z
|
||||
.object({
|
||||
clock: z.custom<Clock>().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const BatchConfigSchema = z
|
||||
.object({
|
||||
batchSize: z.number().int().positive().optional(),
|
||||
delayBetweenBatches: z.number().nonnegative().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const ThrottleConfigSchema = z
|
||||
.object({
|
||||
messagesPerSecond: z.number().positive().optional(),
|
||||
messagesPerMinute: z.number().positive().optional(),
|
||||
messagesPerHour: z.number().positive().optional(),
|
||||
rampUp: z.boolean().optional(),
|
||||
rampUpDuration: z.number().nonnegative().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Splits a contact list into sized batches with throttle-aware send
|
||||
* timestamps.
|
||||
*
|
||||
* The planner is purely deterministic given a fixed clock. The clock is
|
||||
* injectable so callers (and tests) can pin the `t=0` of a plan to an
|
||||
* arbitrary point.
|
||||
*/
|
||||
export class BatchPlanner {
|
||||
private readonly clock: Clock;
|
||||
|
||||
public constructor(options: BatchPlannerOptions = {}) {
|
||||
const parsed = BatchPlannerOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new CampaignsError(
|
||||
CAMPAIGNS_CODES.INVALID_OPTIONS,
|
||||
"Invalid BatchPlanner options",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
this.clock = parsed.data.clock ?? (() => Date.now());
|
||||
}
|
||||
|
||||
/** Split `contactIds` into batches respecting `config` + `throttle`. */
|
||||
public planBatches(
|
||||
contactIds: readonly string[],
|
||||
config: Partial<BatchConfig> = {},
|
||||
throttle?: ThrottleConfig
|
||||
): Batch[] {
|
||||
this.validateConfig(config);
|
||||
this.validateThrottle(throttle);
|
||||
|
||||
if (contactIds.length === 0) {
|
||||
recordBatchPlanSize(0);
|
||||
recordBatchesPlanned(0);
|
||||
return [];
|
||||
}
|
||||
|
||||
const { batchSize, delayBetweenBatches } = {
|
||||
...DEFAULT_BATCH_CONFIG,
|
||||
...config,
|
||||
};
|
||||
|
||||
const effectiveDelay = this.resolveDelay(
|
||||
delayBetweenBatches,
|
||||
batchSize,
|
||||
throttle
|
||||
);
|
||||
const batches: Batch[] = [];
|
||||
const now = this.clock();
|
||||
|
||||
for (let i = 0; i < contactIds.length; i += batchSize) {
|
||||
const batchIndex = batches.length;
|
||||
const slice = contactIds.slice(i, i + batchSize);
|
||||
const batch: Batch =
|
||||
effectiveDelay > 0 && batchIndex > 0
|
||||
? {
|
||||
batchIndex,
|
||||
contactIds: slice,
|
||||
scheduledAt: new Date(
|
||||
now + batchIndex * effectiveDelay
|
||||
),
|
||||
}
|
||||
: { batchIndex, contactIds: slice };
|
||||
batches.push(batch);
|
||||
}
|
||||
|
||||
recordBatchPlanSize(contactIds.length);
|
||||
recordBatchesPlanned(batches.length);
|
||||
if (batches.length > 1) {
|
||||
recordSendDuration((batches.length - 1) * effectiveDelay);
|
||||
}
|
||||
return batches;
|
||||
}
|
||||
|
||||
/** Estimate total send-window duration in milliseconds. */
|
||||
public calculateTotalDuration(
|
||||
totalContacts: number,
|
||||
config: Partial<BatchConfig> = {},
|
||||
throttle?: ThrottleConfig
|
||||
): number {
|
||||
this.validateConfig(config);
|
||||
this.validateThrottle(throttle);
|
||||
|
||||
if (totalContacts <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const { batchSize, delayBetweenBatches } = {
|
||||
...DEFAULT_BATCH_CONFIG,
|
||||
...config,
|
||||
};
|
||||
|
||||
const effectiveDelay = this.resolveDelay(
|
||||
delayBetweenBatches,
|
||||
batchSize,
|
||||
throttle
|
||||
);
|
||||
const totalBatches = Math.ceil(totalContacts / batchSize);
|
||||
const duration =
|
||||
totalBatches > 1 ? (totalBatches - 1) * effectiveDelay : 0;
|
||||
recordSendDuration(duration);
|
||||
return duration;
|
||||
}
|
||||
|
||||
private validateConfig(config: Partial<BatchConfig>): void {
|
||||
const parsed = BatchConfigSchema.safeParse(config);
|
||||
if (!parsed.success) {
|
||||
throw new CampaignsError(
|
||||
CAMPAIGNS_CODES.INVALID_BATCH_CONFIG,
|
||||
"Invalid batch config",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private validateThrottle(throttle: ThrottleConfig | undefined): void {
|
||||
if (!throttle) return;
|
||||
const parsed = ThrottleConfigSchema.safeParse(throttle);
|
||||
if (!parsed.success) {
|
||||
throw new CampaignsError(
|
||||
CAMPAIGNS_CODES.INVALID_THROTTLE_CONFIG,
|
||||
"Invalid throttle config",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveDelay(
|
||||
baseDelay: number,
|
||||
batchSize: number,
|
||||
throttle?: ThrottleConfig
|
||||
): number {
|
||||
if (!throttle) {
|
||||
return baseDelay;
|
||||
}
|
||||
if (throttle.messagesPerSecond) {
|
||||
const secondsPerBatch = batchSize / throttle.messagesPerSecond;
|
||||
return Math.max(baseDelay, secondsPerBatch * 1000);
|
||||
}
|
||||
if (throttle.messagesPerMinute) {
|
||||
const minutesPerBatch = batchSize / throttle.messagesPerMinute;
|
||||
return Math.max(baseDelay, minutesPerBatch * 60_000);
|
||||
}
|
||||
if (throttle.messagesPerHour) {
|
||||
const hoursPerBatch = batchSize / throttle.messagesPerHour;
|
||||
return Math.max(baseDelay, hoursPerBatch * 3_600_000);
|
||||
}
|
||||
return baseDelay;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { Batch, BatchPlannerOptions } from "./batch-planner.js";
|
||||
export { BatchPlanner } from "./batch-planner.js";
|
||||
60
packages/communication/emails/campaigns/src/engine/engine.md
Normal file
60
packages/communication/emails/campaigns/src/engine/engine.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# engine
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Top-level orchestrator for campaign CRUD and lifecycle. `CampaignEngine` accepts an injectable `CampaignStore` and exposes one method per high-level operation — `create`, `schedule`, `send`, `pause`, `resume`, `cancel`, `complete`, `fail`, `duplicate`, `updateStats`, `getById`, `list`. Lifecycle methods delegate validation to `CampaignStateMachine`; persistence delegates to the store. The engine itself contains no transition rules and no storage details, which keeps it stable across plugins (in-memory, SurrealDB, durable-object) and across UI surfaces.
|
||||
|
||||
All inputs cross a Zod boundary before they touch the store, so bad payloads surface as `CampaignsError(INVALID_INPUT|INVALID_OPTIONS)` rather than leaking through to the persistence layer.
|
||||
|
||||
## Features
|
||||
|
||||
- Injectable `Clock`, `IdGenerator`, and `LoggerLike` so the engine is deterministic under test.
|
||||
- Zod validation on construction options and `CreateCampaignInput`.
|
||||
- Delegates state validation to `CampaignStateMachine`, never duplicates rules.
|
||||
- Records OTel instruments on every meaningful event (created / sent / failed / duplicate).
|
||||
- `duplicate(id)` produces a draft copy with reset stats, blanked `sentAt`/`completedAt`, and the original name suffixed with `(copy)`.
|
||||
- `fail(id, reason?)` stores the optional reason under `metadata.failureReason` before transitioning to the terminal `failed` state.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CampaignEngine, InMemoryCampaignStore } from "@suaveplan/campaigns";
|
||||
|
||||
const engine = new CampaignEngine(new InMemoryCampaignStore());
|
||||
const campaign = await engine.create({
|
||||
tenantId: "t-1",
|
||||
name: "Spring Sale",
|
||||
type: "broadcast",
|
||||
channel: "email",
|
||||
audienceConfig: { groupIds: ["subscribers"] },
|
||||
});
|
||||
await engine.send(campaign.id);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject deterministic dependencies for tests:
|
||||
|
||||
```ts
|
||||
import { CampaignEngine, InMemoryCampaignStore } from "@suaveplan/campaigns";
|
||||
|
||||
let id = 0;
|
||||
const engine = new CampaignEngine(new InMemoryCampaignStore(), {
|
||||
clock: () => 1_700_000_000_000,
|
||||
idGenerator: () => `cmp-${++id}`,
|
||||
logger: pinoCompatibleLogger,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CampaignEngineOptions` — `{ clock?; idGenerator?; logger? }`.
|
||||
- `CampaignEngine.create(input)`, `.schedule(id, schedule)`, `.send(id)`, `.pause(id)`, `.resume(id)`, `.cancel(id)`, `.complete(id)`, `.fail(id, reason?)`, `.duplicate(id)`, `.updateStats(id, stats)`, `.getById(id)`, `.list(query)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The default id generator uses `cmp_<timestamp>_<rand>`. Inject a deterministic generator in tests. `requireCampaign` is the single helper that produces `CAMPAIGN_NOT_FOUND` — every lifecycle method funnels through it, which keeps the error path uniform. `fail` writes the reason before the status transition so that observers seeing the `failed` status are guaranteed to also see the reason in metadata. `duplicate` deliberately copies `audienceConfig` and other config by reference; downstream callers that need true isolation should clone before mutating.
|
||||
@@ -0,0 +1,282 @@
|
||||
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
|
||||
import { InMemoryCampaignStore } from "../store/store.js";
|
||||
import type { CreateCampaignInput } from "../types/campaign.types.js";
|
||||
import { CampaignEngine } from "./engine.js";
|
||||
|
||||
function makeInput(
|
||||
overrides: Partial<CreateCampaignInput> = {}
|
||||
): CreateCampaignInput {
|
||||
return {
|
||||
tenantId: "tenant-1",
|
||||
name: "Test Campaign",
|
||||
type: "broadcast",
|
||||
channel: "email",
|
||||
audienceConfig: { groupIds: ["group-1"] },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CampaignEngine", () => {
|
||||
let engine: CampaignEngine;
|
||||
let counter = 0;
|
||||
let now = 1_700_000_000_000;
|
||||
const logs: { level: string; message: string }[] = [];
|
||||
const logger = {
|
||||
debug: (m: string) => logs.push({ level: "debug", message: m }),
|
||||
info: (m: string) => logs.push({ level: "info", message: m }),
|
||||
warn: (m: string) => logs.push({ level: "warn", message: m }),
|
||||
error: (m: string) => logs.push({ level: "error", message: m }),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
counter = 0;
|
||||
now = 1_700_000_000_000;
|
||||
logs.length = 0;
|
||||
engine = new CampaignEngine(new InMemoryCampaignStore(), {
|
||||
clock: () => now,
|
||||
idGenerator: () => `cmp-${++counter}`,
|
||||
logger,
|
||||
});
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
it("rejects invalid options", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new CampaignEngine(new InMemoryCampaignStore(), {
|
||||
bogus: 1,
|
||||
} as unknown as never);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CampaignsError);
|
||||
if (err instanceof CampaignsError) {
|
||||
expect(err.code).toBe(CAMPAIGNS_CODES.INVALID_OPTIONS);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("works with default options (no clock/id/logger)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const e = new CampaignEngine(new InMemoryCampaignStore());
|
||||
const c = await e.create(makeInput());
|
||||
expect(c.id).toMatch(/^cmp_/);
|
||||
expect(c.status).toBe("draft");
|
||||
});
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("creates in draft status with empty stats", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = await engine.create(makeInput());
|
||||
expect(c.id).toBe("cmp-1");
|
||||
expect(c.status).toBe("draft");
|
||||
expect(c.stats.sent).toBe(0);
|
||||
expect(c.metadata).toEqual({});
|
||||
expect(
|
||||
logs.find((l) => l.message === "campaign.created")
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("preserves metadata", { timeout: 5000 }, async () => {
|
||||
const c = await engine.create(
|
||||
makeInput({ metadata: { source: "api" } })
|
||||
);
|
||||
expect(c.metadata).toEqual({ source: "api" });
|
||||
});
|
||||
|
||||
it("preserves all optional fields when supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = await engine.create(
|
||||
makeInput({
|
||||
templateId: "tmpl-1",
|
||||
templateData: { name: "Paul" },
|
||||
schedule: { type: "immediate" },
|
||||
abTestConfig: {
|
||||
variants: [
|
||||
{
|
||||
id: "a",
|
||||
name: "A",
|
||||
templateId: "t-a",
|
||||
weight: 50,
|
||||
},
|
||||
],
|
||||
splitType: "even",
|
||||
},
|
||||
throttle: { messagesPerSecond: 10 },
|
||||
})
|
||||
);
|
||||
expect(c.templateId).toBe("tmpl-1");
|
||||
expect(c.templateData).toEqual({ name: "Paul" });
|
||||
expect(c.schedule).toEqual({ type: "immediate" });
|
||||
expect(c.abTestConfig?.splitType).toBe("even");
|
||||
expect(c.throttle?.messagesPerSecond).toBe(10);
|
||||
});
|
||||
|
||||
it("rejects invalid input (empty tenantId)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
try {
|
||||
await engine.create({ ...makeInput(), tenantId: "" });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CampaignsError);
|
||||
if (err instanceof CampaignsError) {
|
||||
expect(err.code).toBe(CAMPAIGNS_CODES.INVALID_INPUT);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("schedule", () => {
|
||||
it("schedules a draft campaign", { timeout: 5000 }, async () => {
|
||||
const c = await engine.create(makeInput());
|
||||
const s = await engine.schedule(c.id, {
|
||||
type: "scheduled",
|
||||
sendAt: new Date("2026-04-01"),
|
||||
});
|
||||
expect(s.status).toBe("scheduled");
|
||||
expect(s.schedule?.type).toBe("scheduled");
|
||||
});
|
||||
|
||||
it("throws when scheduling a sending campaign", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = await engine.create(makeInput());
|
||||
await engine.send(c.id);
|
||||
await expect(
|
||||
engine.schedule(c.id, { type: "immediate" })
|
||||
).rejects.toThrow(/Invalid transition/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("send", () => {
|
||||
it("draft -> sending", { timeout: 5000 }, async () => {
|
||||
const c = await engine.create(makeInput());
|
||||
const sent = await engine.send(c.id);
|
||||
expect(sent.status).toBe("sending");
|
||||
});
|
||||
|
||||
it("throws for non-existent campaign", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
try {
|
||||
await engine.send("nonexistent");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CampaignsError);
|
||||
if (err instanceof CampaignsError) {
|
||||
expect(err.code).toBe(CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("pause/resume", () => {
|
||||
it("pause + resume", { timeout: 5000 }, async () => {
|
||||
const c = await engine.create(makeInput());
|
||||
await engine.send(c.id);
|
||||
const paused = await engine.pause(c.id);
|
||||
expect(paused.status).toBe("paused");
|
||||
const resumed = await engine.resume(c.id);
|
||||
expect(resumed.status).toBe("sending");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancel", () => {
|
||||
it("cancel a scheduled campaign", { timeout: 5000 }, async () => {
|
||||
const c = await engine.create(makeInput());
|
||||
await engine.schedule(c.id, { type: "immediate" });
|
||||
expect((await engine.cancel(c.id)).status).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("cancel a paused campaign", { timeout: 5000 }, async () => {
|
||||
const c = await engine.create(makeInput());
|
||||
await engine.send(c.id);
|
||||
await engine.pause(c.id);
|
||||
expect((await engine.cancel(c.id)).status).toBe("cancelled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("complete", () => {
|
||||
it("sending -> completed", { timeout: 5000 }, async () => {
|
||||
const c = await engine.create(makeInput());
|
||||
await engine.send(c.id);
|
||||
expect((await engine.complete(c.id)).status).toBe("completed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fail", () => {
|
||||
it("sending -> failed with reason", { timeout: 5000 }, async () => {
|
||||
const c = await engine.create(makeInput());
|
||||
await engine.send(c.id);
|
||||
await engine.fail(c.id, "Provider error");
|
||||
const fetched = await engine.getById(c.id);
|
||||
expect(fetched?.status).toBe("failed");
|
||||
expect(fetched?.metadata).toEqual({
|
||||
failureReason: "Provider error",
|
||||
});
|
||||
expect(
|
||||
logs.find((l) => l.message === "campaign.failed")
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("fail without reason", { timeout: 5000 }, async () => {
|
||||
const c = await engine.create(makeInput());
|
||||
await engine.send(c.id);
|
||||
const failed = await engine.fail(c.id);
|
||||
expect(failed.status).toBe("failed");
|
||||
expect(failed.metadata).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicate", () => {
|
||||
it("draft copy with reset stats and (copy) suffix", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = await engine.create(makeInput({ name: "Original" }));
|
||||
await engine.send(c.id);
|
||||
const copy = await engine.duplicate(c.id);
|
||||
expect(copy.id).not.toBe(c.id);
|
||||
expect(copy.name).toBe("Original (copy)");
|
||||
expect(copy.status).toBe("draft");
|
||||
expect(copy.stats.sent).toBe(0);
|
||||
expect(copy.sentAt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateStats", () => {
|
||||
it("merges stats", { timeout: 5000 }, async () => {
|
||||
const c = await engine.create(makeInput());
|
||||
const updated = await engine.updateStats(c.id, {
|
||||
sent: 50,
|
||||
delivered: 48,
|
||||
});
|
||||
expect(updated.stats.sent).toBe(50);
|
||||
expect(updated.stats.delivered).toBe(48);
|
||||
expect(updated.stats.opened).toBe(0);
|
||||
});
|
||||
|
||||
it("throws for missing", { timeout: 5000 }, async () => {
|
||||
await expect(
|
||||
engine.updateStats("nope", { sent: 1 })
|
||||
).rejects.toBeInstanceOf(CampaignsError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("list + getById", () => {
|
||||
it("filters by channel", { timeout: 5000 }, async () => {
|
||||
await engine.create(makeInput({ name: "A", channel: "email" }));
|
||||
await engine.create(makeInput({ name: "B", channel: "sms" }));
|
||||
const emails = await engine.list({ channel: "email" });
|
||||
expect(emails).toHaveLength(1);
|
||||
expect(emails[0].name).toBe("A");
|
||||
});
|
||||
|
||||
it("getById null for missing", { timeout: 5000 }, async () => {
|
||||
expect(await engine.getById("nope")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
275
packages/communication/emails/campaigns/src/engine/engine.ts
Normal file
275
packages/communication/emails/campaigns/src/engine/engine.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import type { Clock, IdGenerator, LoggerLike } from "@suaveplan/types";
|
||||
import z from "zod";
|
||||
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
|
||||
import { CampaignStateMachine } from "../state-machine/state-machine.js";
|
||||
import type { CampaignStore } from "../store/store.js";
|
||||
import {
|
||||
recordCampaignCreated,
|
||||
recordFailure,
|
||||
recordSend,
|
||||
} from "../telemetry/telemetry.js";
|
||||
import type {
|
||||
Campaign,
|
||||
CampaignQuery,
|
||||
CampaignSchedule,
|
||||
CampaignStats,
|
||||
CreateCampaignInput,
|
||||
} from "../types/campaign.types.js";
|
||||
|
||||
/** Construction options for {@link CampaignEngine}. */
|
||||
export interface CampaignEngineOptions {
|
||||
/** Injectable wall-clock source. Defaults to `Date.now`. */
|
||||
readonly clock?: Clock;
|
||||
/** Injectable id generator. Defaults to a `cmp_<timestamp>_<rand>` factory. */
|
||||
readonly idGenerator?: IdGenerator;
|
||||
/** Optional logger for state transitions + failure traces. */
|
||||
readonly logger?: LoggerLike;
|
||||
}
|
||||
|
||||
const EngineOptionsSchema = z
|
||||
.object({
|
||||
clock: z.custom<Clock>().optional(),
|
||||
idGenerator: z.custom<IdGenerator>().optional(),
|
||||
logger: z.custom<LoggerLike>().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const CreateInputSchema = z
|
||||
.object({
|
||||
tenantId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
type: z.enum(["broadcast", "triggered", "ab_test", "drip"]),
|
||||
channel: z.string().min(1),
|
||||
audienceConfig: z.object({
|
||||
groupIds: z.array(z.string()).readonly(),
|
||||
excludeGroupIds: z.array(z.string()).readonly().optional(),
|
||||
excludeContactIds: z.array(z.string()).readonly().optional(),
|
||||
segmentIds: z.array(z.string()).readonly().optional(),
|
||||
suppressionListIds: z.array(z.string()).readonly().optional(),
|
||||
}),
|
||||
templateId: z.string().optional(),
|
||||
templateData: z.record(z.string(), z.unknown()).optional(),
|
||||
schedule: z
|
||||
.object({
|
||||
type: z.enum(["immediate", "scheduled", "recurring"]),
|
||||
sendAt: z.date().optional(),
|
||||
cronExpression: z.string().optional(),
|
||||
timezone: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
abTestConfig: z.unknown().optional(),
|
||||
throttle: z.unknown().optional(),
|
||||
metadata: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
function emptyStats(): CampaignStats {
|
||||
return {
|
||||
totalRecipients: 0,
|
||||
sent: 0,
|
||||
delivered: 0,
|
||||
opened: 0,
|
||||
clicked: 0,
|
||||
bounced: 0,
|
||||
unsubscribed: 0,
|
||||
complained: 0,
|
||||
failed: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultIdGenerator(): string {
|
||||
return `cmp_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level CRUD + lifecycle orchestrator for campaigns. Delegates state
|
||||
* validation to {@link CampaignStateMachine} and persistence to the
|
||||
* injected {@link CampaignStore}.
|
||||
*/
|
||||
export class CampaignEngine {
|
||||
private readonly store: CampaignStore;
|
||||
private readonly stateMachine: CampaignStateMachine;
|
||||
private readonly clock: Clock;
|
||||
private readonly idGenerator: IdGenerator;
|
||||
private readonly logger?: LoggerLike;
|
||||
|
||||
public constructor(
|
||||
store: CampaignStore,
|
||||
options: CampaignEngineOptions = {}
|
||||
) {
|
||||
const parsed = EngineOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new CampaignsError(
|
||||
CAMPAIGNS_CODES.INVALID_OPTIONS,
|
||||
"Invalid CampaignEngine options",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
this.store = store;
|
||||
this.stateMachine = new CampaignStateMachine();
|
||||
this.clock = parsed.data.clock ?? (() => Date.now());
|
||||
this.idGenerator = parsed.data.idGenerator ?? defaultIdGenerator;
|
||||
if (parsed.data.logger !== undefined) {
|
||||
this.logger = parsed.data.logger;
|
||||
}
|
||||
}
|
||||
|
||||
public async create(input: CreateCampaignInput): Promise<Campaign> {
|
||||
const parsed = CreateInputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw new CampaignsError(
|
||||
CAMPAIGNS_CODES.INVALID_INPUT,
|
||||
"Invalid CreateCampaignInput",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const now = new Date(this.clock());
|
||||
const campaign: Campaign = {
|
||||
id: this.idGenerator(),
|
||||
tenantId: input.tenantId,
|
||||
name: input.name,
|
||||
status: "draft",
|
||||
type: input.type,
|
||||
channel: input.channel,
|
||||
audienceConfig: input.audienceConfig,
|
||||
metadata: input.metadata ?? {},
|
||||
stats: emptyStats(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
if (input.templateId !== undefined)
|
||||
campaign.templateId = input.templateId;
|
||||
if (input.templateData !== undefined)
|
||||
campaign.templateData = input.templateData;
|
||||
if (input.schedule !== undefined) campaign.schedule = input.schedule;
|
||||
if (input.abTestConfig !== undefined)
|
||||
campaign.abTestConfig = input.abTestConfig;
|
||||
if (input.throttle !== undefined) campaign.throttle = input.throttle;
|
||||
const created = await this.store.create(campaign);
|
||||
recordCampaignCreated(created.type, created.channel);
|
||||
this.logger?.info("campaign.created", {
|
||||
id: created.id,
|
||||
type: created.type,
|
||||
channel: created.channel,
|
||||
});
|
||||
return created;
|
||||
}
|
||||
|
||||
public async schedule(
|
||||
campaignId: string,
|
||||
schedule: CampaignSchedule
|
||||
): Promise<Campaign> {
|
||||
const campaign = await this.requireCampaign(campaignId);
|
||||
this.stateMachine.transition(campaign.status, "schedule");
|
||||
await this.store.update(campaignId, { schedule });
|
||||
return this.store.updateStatus(campaignId, "scheduled");
|
||||
}
|
||||
|
||||
public async send(campaignId: string): Promise<Campaign> {
|
||||
const campaign = await this.requireCampaign(campaignId);
|
||||
const newStatus = this.stateMachine.transition(campaign.status, "send");
|
||||
const updated = await this.store.updateStatus(campaignId, newStatus);
|
||||
recordSend(updated.channel);
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async pause(campaignId: string): Promise<Campaign> {
|
||||
const campaign = await this.requireCampaign(campaignId);
|
||||
const newStatus = this.stateMachine.transition(
|
||||
campaign.status,
|
||||
"pause"
|
||||
);
|
||||
return this.store.updateStatus(campaignId, newStatus);
|
||||
}
|
||||
|
||||
public async resume(campaignId: string): Promise<Campaign> {
|
||||
const campaign = await this.requireCampaign(campaignId);
|
||||
const newStatus = this.stateMachine.transition(
|
||||
campaign.status,
|
||||
"resume"
|
||||
);
|
||||
return this.store.updateStatus(campaignId, newStatus);
|
||||
}
|
||||
|
||||
public async cancel(campaignId: string): Promise<Campaign> {
|
||||
const campaign = await this.requireCampaign(campaignId);
|
||||
const newStatus = this.stateMachine.transition(
|
||||
campaign.status,
|
||||
"cancel"
|
||||
);
|
||||
return this.store.updateStatus(campaignId, newStatus);
|
||||
}
|
||||
|
||||
public async complete(campaignId: string): Promise<Campaign> {
|
||||
const campaign = await this.requireCampaign(campaignId);
|
||||
const newStatus = this.stateMachine.transition(
|
||||
campaign.status,
|
||||
"complete"
|
||||
);
|
||||
return this.store.updateStatus(campaignId, newStatus);
|
||||
}
|
||||
|
||||
public async fail(campaignId: string, reason?: string): Promise<Campaign> {
|
||||
const campaign = await this.requireCampaign(campaignId);
|
||||
const newStatus = this.stateMachine.transition(campaign.status, "fail");
|
||||
if (reason !== undefined) {
|
||||
await this.store.update(campaignId, {
|
||||
metadata: { ...campaign.metadata, failureReason: reason },
|
||||
});
|
||||
}
|
||||
const updated = await this.store.updateStatus(campaignId, newStatus);
|
||||
recordFailure(updated.channel);
|
||||
this.logger?.warn("campaign.failed", {
|
||||
id: campaignId,
|
||||
reason,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async duplicate(campaignId: string): Promise<Campaign> {
|
||||
const campaign = await this.requireCampaign(campaignId);
|
||||
const now = new Date(this.clock());
|
||||
const copy: Campaign = {
|
||||
...campaign,
|
||||
id: this.idGenerator(),
|
||||
name: `${campaign.name} (copy)`,
|
||||
status: "draft",
|
||||
stats: emptyStats(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
delete copy.sentAt;
|
||||
delete copy.completedAt;
|
||||
const created = await this.store.create(copy);
|
||||
recordCampaignCreated(created.type, created.channel);
|
||||
return created;
|
||||
}
|
||||
|
||||
public async updateStats(
|
||||
campaignId: string,
|
||||
stats: Partial<CampaignStats>
|
||||
): Promise<Campaign> {
|
||||
await this.requireCampaign(campaignId);
|
||||
return this.store.updateStats(campaignId, stats);
|
||||
}
|
||||
|
||||
public async getById(campaignId: string): Promise<Campaign | null> {
|
||||
return this.store.getById(campaignId);
|
||||
}
|
||||
|
||||
public async list(query: CampaignQuery): Promise<Campaign[]> {
|
||||
return this.store.list(query);
|
||||
}
|
||||
|
||||
private async requireCampaign(id: string): Promise<Campaign> {
|
||||
const campaign = await this.store.getById(id);
|
||||
if (!campaign) {
|
||||
throw new CampaignsError(
|
||||
CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND,
|
||||
`Campaign not found: ${id}`,
|
||||
{ context: { id } }
|
||||
);
|
||||
}
|
||||
return campaign;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { CampaignEngineOptions } from "./engine.js";
|
||||
export { CampaignEngine } from "./engine.js";
|
||||
@@ -0,0 +1,63 @@
|
||||
# error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the stable, machine-readable error namespace for `@suaveplan/campaigns`. Every failure path in the package — from invalid options at construction time, through state-machine violations during a send, to missing records and store contract breaches — produces a `CampaignsError` whose `code` is drawn from the `CAMPAIGNS_CODES` registry.
|
||||
|
||||
Centralising the registry here keeps the error namespace decoupled from any one feature module. Downstream consumers can pattern-match on the code constants instead of importing concrete error classes, which keeps deserialised errors (over the wire, across worker boundaries) just as switchable as in-process ones.
|
||||
|
||||
## Features
|
||||
|
||||
- `CAMPAIGNS_CODES` literal map produced by `defineCodes("CAMPAIGNS", [...])`.
|
||||
- `CampaignsCode` discriminating union literal type derived from the registry.
|
||||
- `CampaignsError extends SuaveplanError<CampaignsCode>` with an explicit public constructor so `new CampaignsError(...)` is allowed at any call site.
|
||||
- Eight initial codes covering construction, input validation, state transitions, missing records, store breaches, batch + throttle config validation, and workflow failures.
|
||||
- Codes are namespaced under the `CAMPAIGNS.` prefix so they never clash with codes from sibling packages.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CAMPAIGNS_CODES, CampaignsError } from "@suaveplan/campaigns";
|
||||
|
||||
throw new CampaignsError(
|
||||
CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND,
|
||||
"Campaign not found: c-42",
|
||||
{ context: { id: "c-42" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Discriminating in consumer code without an `instanceof` chain:
|
||||
|
||||
```ts
|
||||
import { CAMPAIGNS_CODES, CampaignsError } from "@suaveplan/campaigns";
|
||||
|
||||
try {
|
||||
await engine.send(id);
|
||||
} catch (err) {
|
||||
if (!(err instanceof CampaignsError)) throw err;
|
||||
switch (err.code) {
|
||||
case CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND:
|
||||
return { kind: "not-found" as const };
|
||||
case CAMPAIGNS_CODES.INVALID_TRANSITION:
|
||||
return { kind: "conflict" as const };
|
||||
default:
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CAMPAIGNS_CODES` — readonly literal map of `KEY -> "CAMPAIGNS.KEY"` strings.
|
||||
- `CampaignsCode` — `typeof CAMPAIGNS_CODES[keyof typeof CAMPAIGNS_CODES]`.
|
||||
- `CampaignsError(code, message, options?)` — public constructor delegating to `SuaveplanError`. `options.cause` chains an underlying error; `options.context` attaches a structured payload.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`SuaveplanError`'s base constructor is protected, so without the explicit `public constructor` here, `new CampaignsError(...)` would fail with TS2674. The constructor sets `this.name = "CampaignsError"` so stack traces are self-describing. The `code` field narrows against `CampaignsCode`, which means consumers get exhaustiveness checking on `switch` statements with `noFallthroughCasesInSwitch` enabled.
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CAMPAIGNS_CODES, CampaignsError } from "./error-codes.js";
|
||||
|
||||
describe("CAMPAIGNS_CODES", () => {
|
||||
it("exposes every documented code", { timeout: 5000 }, () => {
|
||||
expect(CAMPAIGNS_CODES.INVALID_OPTIONS).toBe(
|
||||
"CAMPAIGNS.INVALID_OPTIONS"
|
||||
);
|
||||
expect(CAMPAIGNS_CODES.INVALID_INPUT).toBe("CAMPAIGNS.INVALID_INPUT");
|
||||
expect(CAMPAIGNS_CODES.INVALID_TRANSITION).toBe(
|
||||
"CAMPAIGNS.INVALID_TRANSITION"
|
||||
);
|
||||
expect(CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND).toBe(
|
||||
"CAMPAIGNS.CAMPAIGN_NOT_FOUND"
|
||||
);
|
||||
expect(CAMPAIGNS_CODES.STORE_FAILURE).toBe("CAMPAIGNS.STORE_FAILURE");
|
||||
expect(CAMPAIGNS_CODES.INVALID_BATCH_CONFIG).toBe(
|
||||
"CAMPAIGNS.INVALID_BATCH_CONFIG"
|
||||
);
|
||||
expect(CAMPAIGNS_CODES.INVALID_THROTTLE_CONFIG).toBe(
|
||||
"CAMPAIGNS.INVALID_THROTTLE_CONFIG"
|
||||
);
|
||||
expect(CAMPAIGNS_CODES.WORKFLOW_STEP_FAILED).toBe(
|
||||
"CAMPAIGNS.WORKFLOW_STEP_FAILED"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CampaignsError", () => {
|
||||
it("constructs with code + message", { timeout: 5000 }, () => {
|
||||
const err = new CampaignsError(
|
||||
CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND,
|
||||
"missing"
|
||||
);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).toBeInstanceOf(CampaignsError);
|
||||
expect(err.code).toBe(CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND);
|
||||
expect(err.message).toBe("missing");
|
||||
expect(err.name).toBe("CampaignsError");
|
||||
});
|
||||
|
||||
it("threads cause + context", { timeout: 5000 }, () => {
|
||||
const cause = new Error("upstream");
|
||||
const err = new CampaignsError(CAMPAIGNS_CODES.STORE_FAILURE, "boom", {
|
||||
cause,
|
||||
context: { id: "c-1" },
|
||||
});
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context).toEqual({ id: "c-1" });
|
||||
});
|
||||
|
||||
it("supports switch discrimination on code", { timeout: 5000 }, () => {
|
||||
const err = new CampaignsError(
|
||||
CAMPAIGNS_CODES.INVALID_TRANSITION,
|
||||
"bad"
|
||||
);
|
||||
let hit = false;
|
||||
switch (err.code) {
|
||||
case CAMPAIGNS_CODES.INVALID_TRANSITION:
|
||||
hit = true;
|
||||
break;
|
||||
default:
|
||||
hit = false;
|
||||
}
|
||||
expect(hit).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/campaigns`.
|
||||
*
|
||||
* Every failure path in the package surfaces a `CampaignsError` whose
|
||||
* `code` is drawn from this registry. Consumers SHOULD discriminate on
|
||||
* `error.code` rather than `instanceof` so the namespace survives module
|
||||
* boundaries and runtime serialisation (see `@suaveplan/error`).
|
||||
*
|
||||
* The registry covers Zod boundary failures (`INVALID_OPTIONS`,
|
||||
* `INVALID_INPUT`), illegal state-machine transitions
|
||||
* (`INVALID_TRANSITION`), missing-record lookups (`CAMPAIGN_NOT_FOUND`),
|
||||
* store contract violations (`STORE_FAILURE`), batch planner argument
|
||||
* validation (`INVALID_BATCH_CONFIG`, `INVALID_THROTTLE_CONFIG`), and
|
||||
* workflow execution failures (`WORKFLOW_STEP_FAILED`).
|
||||
*/
|
||||
export const CAMPAIGNS_CODES = defineCodes("CAMPAIGNS", [
|
||||
"INVALID_OPTIONS",
|
||||
"INVALID_INPUT",
|
||||
"INVALID_TRANSITION",
|
||||
"CAMPAIGN_NOT_FOUND",
|
||||
"STORE_FAILURE",
|
||||
"INVALID_BATCH_CONFIG",
|
||||
"INVALID_THROTTLE_CONFIG",
|
||||
"WORKFLOW_STEP_FAILED",
|
||||
] as const);
|
||||
|
||||
/** Discriminating union literal for {@link CampaignsError.code}. */
|
||||
export type CampaignsCode =
|
||||
(typeof CAMPAIGNS_CODES)[keyof typeof CAMPAIGNS_CODES];
|
||||
|
||||
/**
|
||||
* Typed error for every `@suaveplan/campaigns` failure path.
|
||||
*
|
||||
* Extends `SuaveplanError<CampaignsCode>` so the `code` field narrows
|
||||
* against the literal union and consumers get full type-checking on
|
||||
* `switch (err.code)` blocks. Carries a public constructor so that
|
||||
* `new CampaignsError(...)` works at the call site — `SuaveplanError`'s
|
||||
* own constructor is protected.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { CAMPAIGNS_CODES, CampaignsError } from "@suaveplan/campaigns";
|
||||
*
|
||||
* try {
|
||||
* await engine.send("missing");
|
||||
* } catch (err) {
|
||||
* if (err instanceof CampaignsError && err.code === CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND) {
|
||||
* console.error("no such campaign", err.context);
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class CampaignsError extends SuaveplanError<CampaignsCode> {
|
||||
public constructor(
|
||||
code: CampaignsCode,
|
||||
message: string,
|
||||
options?: { cause?: unknown; context?: Record<string, unknown> }
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.name = "CampaignsError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { CampaignsCode } from "./error-codes.js";
|
||||
export { CAMPAIGNS_CODES, CampaignsError } from "./error-codes.js";
|
||||
54
packages/communication/emails/campaigns/src/index.ts
Normal file
54
packages/communication/emails/campaigns/src/index.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export type {
|
||||
Batch,
|
||||
BatchPlannerOptions,
|
||||
} from "./batch-planner/batch-planner.js";
|
||||
export { BatchPlanner } from "./batch-planner/batch-planner.js";
|
||||
export type { CampaignEngineOptions } from "./engine/engine.js";
|
||||
export { CampaignEngine } from "./engine/engine.js";
|
||||
export type { CampaignsCode } from "./error-codes/error-codes.js";
|
||||
export { CAMPAIGNS_CODES, CampaignsError } from "./error-codes/error-codes.js";
|
||||
export type { CampaignEvent } from "./state-machine/state-machine.js";
|
||||
export { CampaignStateMachine } from "./state-machine/state-machine.js";
|
||||
export type {
|
||||
CampaignStore,
|
||||
InMemoryCampaignStoreOptions,
|
||||
} from "./store/store.js";
|
||||
export { InMemoryCampaignStore } from "./store/store.js";
|
||||
export {
|
||||
batchesPlannedCounter,
|
||||
batchPlanSizeHistogram,
|
||||
campaignFailuresCounter,
|
||||
campaignSendsCounter,
|
||||
campaignsCreatedCounter,
|
||||
campaignTransitionsCounter,
|
||||
recordBatchesPlanned,
|
||||
recordBatchPlanSize,
|
||||
recordCampaignCreated,
|
||||
recordFailure,
|
||||
recordSend,
|
||||
recordSendDuration,
|
||||
recordTransition,
|
||||
sendDurationHistogram,
|
||||
} from "./telemetry/telemetry.js";
|
||||
export type {
|
||||
ABTestConfig,
|
||||
ABTestVariant,
|
||||
AudienceConfig,
|
||||
BatchConfig,
|
||||
Campaign,
|
||||
CampaignQuery,
|
||||
CampaignSchedule,
|
||||
CampaignStats,
|
||||
CampaignStatus,
|
||||
CampaignType,
|
||||
CreateCampaignInput,
|
||||
ThrottleConfig,
|
||||
UpdateCampaignInput,
|
||||
} from "./types/campaign.types.js";
|
||||
export { DEFAULT_BATCH_CONFIG } from "./types/campaign.types.js";
|
||||
export type {
|
||||
CampaignWorkflowOptions,
|
||||
WorkflowContext,
|
||||
WorkflowStep,
|
||||
} from "./workflow/workflow.js";
|
||||
export { CampaignWorkflow } from "./workflow/workflow.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { CampaignEvent } from "./state-machine.js";
|
||||
export { CampaignStateMachine } from "./state-machine.js";
|
||||
@@ -0,0 +1,59 @@
|
||||
# state-machine
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Strictly typed lifecycle state machine for campaigns. Owns the set of nine legal transitions between the seven `CampaignStatus` values and rejects every other combination with a typed `CampaignsError`. The state machine is consumed by `CampaignEngine` for every status-changing operation (`schedule`, `send`, `pause`, `resume`, `cancel`, `complete`, `fail`) and by UI layers that need to enumerate the available next-step actions for the user.
|
||||
|
||||
By centralising the transition rules here we keep the rest of the package free of conditional `if (status === ...)` ladders; new lifecycle events are added by appending to the `TRANSITIONS` table.
|
||||
|
||||
## Features
|
||||
|
||||
- Nine canonical transitions covering draft, scheduled, sending, paused, plus the three terminal states (completed, cancelled, failed).
|
||||
- `transition(currentStatus, event)` returns the next status or throws `CampaignsError(INVALID_TRANSITION)` with the offending `{from, event}` in `context`.
|
||||
- `canTransition(currentStatus, event)` boolean predicate for UI affordances.
|
||||
- `getAvailableEvents(currentStatus)` lists every legal event for the current status — terminal states return `[]`.
|
||||
- Records a `campaigns_transitions_total` counter point per successful transition tagged with `from`, `to`, and `event` labels.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CampaignStateMachine } from "@suaveplan/campaigns";
|
||||
|
||||
const sm = new CampaignStateMachine();
|
||||
const next = sm.transition("draft", "send"); // "sending"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Drive UI buttons from the available-events list:
|
||||
|
||||
```ts
|
||||
import { CampaignStateMachine, type CampaignEvent } from "@suaveplan/campaigns";
|
||||
|
||||
const sm = new CampaignStateMachine();
|
||||
const available: CampaignEvent[] = sm.getAvailableEvents(campaign.status);
|
||||
return available.map((event) => (
|
||||
<Button
|
||||
key={event}
|
||||
disabled={!sm.canTransition(campaign.status, event)}
|
||||
onClick={() => onEvent(event)}
|
||||
>
|
||||
{event}
|
||||
</Button>
|
||||
));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CampaignEvent` — `"schedule" | "send" | "start" | "cancel" | "pause" | "complete" | "fail" | "resume"`.
|
||||
- `CampaignStateMachine.transition(currentStatus, event): CampaignStatus` — throws `CampaignsError` with code `CAMPAIGNS.INVALID_TRANSITION` if the combination is illegal.
|
||||
- `CampaignStateMachine.canTransition(currentStatus, event): boolean`.
|
||||
- `CampaignStateMachine.getAvailableEvents(currentStatus): CampaignEvent[]`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `TRANSITIONS` table is a `readonly Transition[]` — frozen at module load. Lookups are linear (n=9), which is faster in practice than a nested `Record<Status, Record<Event, Status>>` lookup for this size. Adding a new event means appending to the array; removing one is a public API change and requires an OpenSpec proposal. The telemetry record is fired only on success, so the counter never double-counts a rejected transition.
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
|
||||
import { CampaignStateMachine } from "./state-machine.js";
|
||||
|
||||
describe("CampaignStateMachine", () => {
|
||||
const sm = new CampaignStateMachine();
|
||||
|
||||
describe("transition", () => {
|
||||
it("draft -> scheduled via schedule", { timeout: 5000 }, () => {
|
||||
expect(sm.transition("draft", "schedule")).toBe("scheduled");
|
||||
});
|
||||
|
||||
it("draft -> sending via send", { timeout: 5000 }, () => {
|
||||
expect(sm.transition("draft", "send")).toBe("sending");
|
||||
});
|
||||
|
||||
it("scheduled -> sending via start", { timeout: 5000 }, () => {
|
||||
expect(sm.transition("scheduled", "start")).toBe("sending");
|
||||
});
|
||||
|
||||
it("scheduled -> cancelled via cancel", { timeout: 5000 }, () => {
|
||||
expect(sm.transition("scheduled", "cancel")).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("sending -> paused via pause", { timeout: 5000 }, () => {
|
||||
expect(sm.transition("sending", "pause")).toBe("paused");
|
||||
});
|
||||
|
||||
it("sending -> completed via complete", { timeout: 5000 }, () => {
|
||||
expect(sm.transition("sending", "complete")).toBe("completed");
|
||||
});
|
||||
|
||||
it("sending -> failed via fail", { timeout: 5000 }, () => {
|
||||
expect(sm.transition("sending", "fail")).toBe("failed");
|
||||
});
|
||||
|
||||
it("paused -> sending via resume", { timeout: 5000 }, () => {
|
||||
expect(sm.transition("paused", "resume")).toBe("sending");
|
||||
});
|
||||
|
||||
it("paused -> cancelled via cancel", { timeout: 5000 }, () => {
|
||||
expect(sm.transition("paused", "cancel")).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("throws CampaignsError on invalid transition", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
sm.transition("draft", "pause");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CampaignsError);
|
||||
if (err instanceof CampaignsError) {
|
||||
expect(err.code).toBe(CAMPAIGNS_CODES.INVALID_TRANSITION);
|
||||
expect(err.context).toEqual({
|
||||
from: "draft",
|
||||
event: "pause",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("throws from completed", { timeout: 5000 }, () => {
|
||||
expect(() => sm.transition("completed", "send")).toThrow(
|
||||
/Invalid transition/
|
||||
);
|
||||
});
|
||||
|
||||
it("throws from cancelled", { timeout: 5000 }, () => {
|
||||
expect(() => sm.transition("cancelled", "resume")).toThrow(
|
||||
/Invalid transition/
|
||||
);
|
||||
});
|
||||
|
||||
it("throws from failed", { timeout: 5000 }, () => {
|
||||
expect(() => sm.transition("failed", "send")).toThrow(
|
||||
/Invalid transition/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canTransition", () => {
|
||||
it("true for valid", { timeout: 5000 }, () => {
|
||||
expect(sm.canTransition("draft", "schedule")).toBe(true);
|
||||
expect(sm.canTransition("draft", "send")).toBe(true);
|
||||
expect(sm.canTransition("sending", "pause")).toBe(true);
|
||||
});
|
||||
|
||||
it("false for invalid", { timeout: 5000 }, () => {
|
||||
expect(sm.canTransition("draft", "pause")).toBe(false);
|
||||
expect(sm.canTransition("completed", "send")).toBe(false);
|
||||
expect(sm.canTransition("failed", "resume")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAvailableEvents", () => {
|
||||
it("draft", { timeout: 5000 }, () => {
|
||||
const events = sm.getAvailableEvents("draft");
|
||||
expect(events).toEqual(
|
||||
expect.arrayContaining(["schedule", "send"])
|
||||
);
|
||||
expect(events).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("scheduled", { timeout: 5000 }, () => {
|
||||
const events = sm.getAvailableEvents("scheduled");
|
||||
expect(events).toEqual(expect.arrayContaining(["start", "cancel"]));
|
||||
expect(events).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("sending", { timeout: 5000 }, () => {
|
||||
const events = sm.getAvailableEvents("sending");
|
||||
expect(events).toEqual(
|
||||
expect.arrayContaining(["pause", "complete", "fail"])
|
||||
);
|
||||
expect(events).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("paused", { timeout: 5000 }, () => {
|
||||
const events = sm.getAvailableEvents("paused");
|
||||
expect(events).toEqual(
|
||||
expect.arrayContaining(["resume", "cancel"])
|
||||
);
|
||||
expect(events).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("terminal states are empty", { timeout: 5000 }, () => {
|
||||
expect(sm.getAvailableEvents("completed")).toHaveLength(0);
|
||||
expect(sm.getAvailableEvents("cancelled")).toHaveLength(0);
|
||||
expect(sm.getAvailableEvents("failed")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
|
||||
import { recordTransition } from "../telemetry/telemetry.js";
|
||||
import type { CampaignStatus } from "../types/campaign.types.js";
|
||||
|
||||
/**
|
||||
* Discrete lifecycle events that can be applied to a campaign's status to
|
||||
* transition it to a new status.
|
||||
*/
|
||||
export type CampaignEvent =
|
||||
| "schedule"
|
||||
| "send"
|
||||
| "start"
|
||||
| "cancel"
|
||||
| "pause"
|
||||
| "complete"
|
||||
| "fail"
|
||||
| "resume";
|
||||
|
||||
interface Transition {
|
||||
readonly from: CampaignStatus;
|
||||
readonly event: CampaignEvent;
|
||||
readonly to: CampaignStatus;
|
||||
}
|
||||
|
||||
const TRANSITIONS: readonly Transition[] = [
|
||||
{ from: "draft", event: "schedule", to: "scheduled" },
|
||||
{ from: "draft", event: "send", to: "sending" },
|
||||
{ from: "scheduled", event: "start", to: "sending" },
|
||||
{ from: "scheduled", event: "cancel", to: "cancelled" },
|
||||
{ from: "sending", event: "pause", to: "paused" },
|
||||
{ from: "sending", event: "complete", to: "completed" },
|
||||
{ from: "sending", event: "fail", to: "failed" },
|
||||
{ from: "paused", event: "resume", to: "sending" },
|
||||
{ from: "paused", event: "cancel", to: "cancelled" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Validates and executes campaign-status transitions.
|
||||
*
|
||||
* Use {@link transition} to apply an event or throw on invalid input,
|
||||
* {@link canTransition} for predicate-only checks (UI affordances), and
|
||||
* {@link getAvailableEvents} to drive menus / action buttons.
|
||||
*/
|
||||
export class CampaignStateMachine {
|
||||
/**
|
||||
* Apply `event` to `currentStatus` and return the resulting status.
|
||||
*
|
||||
* @throws {CampaignsError} with code `CAMPAIGNS.INVALID_TRANSITION` if
|
||||
* no transition rule matches.
|
||||
*/
|
||||
public transition(
|
||||
currentStatus: CampaignStatus,
|
||||
event: CampaignEvent
|
||||
): CampaignStatus {
|
||||
const match = TRANSITIONS.find(
|
||||
(t) => t.from === currentStatus && t.event === event
|
||||
);
|
||||
if (!match) {
|
||||
throw new CampaignsError(
|
||||
CAMPAIGNS_CODES.INVALID_TRANSITION,
|
||||
`Invalid transition: cannot apply '${event}' to status '${currentStatus}'`,
|
||||
{ context: { from: currentStatus, event } }
|
||||
);
|
||||
}
|
||||
recordTransition(match.from, match.to, match.event);
|
||||
return match.to;
|
||||
}
|
||||
|
||||
/** Returns `true` if applying `event` to `currentStatus` is legal. */
|
||||
public canTransition(
|
||||
currentStatus: CampaignStatus,
|
||||
event: CampaignEvent
|
||||
): boolean {
|
||||
return TRANSITIONS.some(
|
||||
(t) => t.from === currentStatus && t.event === event
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns every event legal in `currentStatus`. */
|
||||
public getAvailableEvents(currentStatus: CampaignStatus): CampaignEvent[] {
|
||||
return TRANSITIONS.filter((t) => t.from === currentStatus).map(
|
||||
(t) => t.event
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { CampaignStore, InMemoryCampaignStoreOptions } from "./store.js";
|
||||
export { InMemoryCampaignStore } from "./store.js";
|
||||
56
packages/communication/emails/campaigns/src/store/store.md
Normal file
56
packages/communication/emails/campaigns/src/store/store.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# store
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the `CampaignStore` persistence contract and ships an `InMemoryCampaignStore` reference implementation. Every store responsibility — create, read, update, delete, list with filters, mutate stats, mutate status — is captured here. The engine and workflow modules consume only the contract, never the concrete class, so production deployments can swap in a SurrealDB-, Postgres-, or Durable-Object-backed store without touching the orchestration layer.
|
||||
|
||||
The in-memory implementation is the default for unit tests, prototype workloads, and single-process scenarios. It is byte-for-byte compatible with the SurrealDB plugin's expected surface so workflows written against it port unchanged.
|
||||
|
||||
## Features
|
||||
|
||||
- `CampaignStore` interface with seven methods: `create`, `getById`, `update`, `delete`, `list`, `updateStats`, `updateStatus`.
|
||||
- `InMemoryCampaignStore` backed by `Map<string, Campaign>` with defensive cloning on every read/write so callers cannot tamper with internal records by reference.
|
||||
- Injectable `Clock` on the in-memory implementation so `updatedAt` is reproducible in tests.
|
||||
- `list(query)` filters by `tenantId`, `status`, `type`, `channel`, plus `limit`/`offset` pagination (defaults: 50 / 0).
|
||||
- Missing-record paths throw `CampaignsError(CAMPAIGN_NOT_FOUND)` with the offending id in `context`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CampaignEngine, InMemoryCampaignStore } from "@suaveplan/campaigns";
|
||||
|
||||
const store = new InMemoryCampaignStore();
|
||||
const engine = new CampaignEngine(store);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Implement a custom store backed by a database:
|
||||
|
||||
```ts
|
||||
import type { CampaignStore } from "@suaveplan/campaigns";
|
||||
|
||||
class SurrealCampaignStore implements CampaignStore {
|
||||
async create(c) { /* INSERT */ }
|
||||
async getById(id) { /* SELECT */ }
|
||||
async update(id, input) { /* UPDATE */ }
|
||||
async delete(id) { /* DELETE */ }
|
||||
async list(q) { /* SELECT WHERE */ }
|
||||
async updateStats(id, stats) { /* UPDATE stats */ }
|
||||
async updateStatus(id, status) { /* UPDATE status */ }
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CampaignStore` — interface with seven async methods.
|
||||
- `InMemoryCampaignStoreOptions` — `{ clock?: Clock }`.
|
||||
- `InMemoryCampaignStore` — reference implementation.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The defensive clones use `{ ...campaign }` which is a shallow copy. Nested objects (`audienceConfig`, `metadata`, `stats`) are shared by reference; the engine never mutates them in place, but custom store implementations should preserve the same contract. `list()` paginates after filtering — a future change can swap in a database-side `LIMIT/OFFSET`. The default page size of 50 mirrors the upstream API plugin's expectation.
|
||||
159
packages/communication/emails/campaigns/src/store/store.test.ts
Normal file
159
packages/communication/emails/campaigns/src/store/store.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
|
||||
import type { Campaign } from "../types/campaign.types.js";
|
||||
import { InMemoryCampaignStore } from "./store.js";
|
||||
|
||||
function makeCampaign(overrides: Partial<Campaign> = {}): Campaign {
|
||||
return {
|
||||
id: "cmp-1",
|
||||
tenantId: "tenant-1",
|
||||
name: "Test",
|
||||
status: "draft",
|
||||
type: "broadcast",
|
||||
channel: "email",
|
||||
audienceConfig: { groupIds: ["g"] },
|
||||
metadata: {},
|
||||
stats: {
|
||||
totalRecipients: 0,
|
||||
sent: 0,
|
||||
delivered: 0,
|
||||
opened: 0,
|
||||
clicked: 0,
|
||||
bounced: 0,
|
||||
unsubscribed: 0,
|
||||
complained: 0,
|
||||
failed: 0,
|
||||
},
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("InMemoryCampaignStore", () => {
|
||||
it("create + getById round-trip", { timeout: 5000 }, async () => {
|
||||
const store = new InMemoryCampaignStore();
|
||||
const created = await store.create(makeCampaign());
|
||||
expect(created.id).toBe("cmp-1");
|
||||
const fetched = await store.getById("cmp-1");
|
||||
expect(fetched).toEqual(created);
|
||||
});
|
||||
|
||||
it("getById returns null for missing", { timeout: 5000 }, async () => {
|
||||
const store = new InMemoryCampaignStore();
|
||||
expect(await store.getById("nope")).toBeNull();
|
||||
});
|
||||
|
||||
it("update mutates fields + bumps updatedAt", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let now = 1000;
|
||||
const store = new InMemoryCampaignStore({ clock: () => now });
|
||||
await store.create(makeCampaign());
|
||||
now = 2000;
|
||||
const updated = await store.update("cmp-1", { name: "renamed" });
|
||||
expect(updated.name).toBe("renamed");
|
||||
expect(updated.updatedAt.getTime()).toBe(2000);
|
||||
});
|
||||
|
||||
it("update throws on missing", { timeout: 5000 }, async () => {
|
||||
const store = new InMemoryCampaignStore();
|
||||
try {
|
||||
await store.update("nope", { name: "x" });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CampaignsError);
|
||||
if (err instanceof CampaignsError) {
|
||||
expect(err.code).toBe(CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("delete returns true/false", { timeout: 5000 }, async () => {
|
||||
const store = new InMemoryCampaignStore();
|
||||
await store.create(makeCampaign());
|
||||
expect(await store.delete("cmp-1")).toBe(true);
|
||||
expect(await store.delete("cmp-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("list filters by tenantId/status/type/channel + paginates", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = new InMemoryCampaignStore();
|
||||
await store.create(
|
||||
makeCampaign({ id: "a", tenantId: "t1", channel: "email" })
|
||||
);
|
||||
await store.create(
|
||||
makeCampaign({ id: "b", tenantId: "t1", channel: "sms" })
|
||||
);
|
||||
await store.create(
|
||||
makeCampaign({
|
||||
id: "c",
|
||||
tenantId: "t2",
|
||||
status: "sending",
|
||||
type: "ab_test",
|
||||
})
|
||||
);
|
||||
|
||||
expect(await store.list({ tenantId: "t1" })).toHaveLength(2);
|
||||
expect(await store.list({ status: "sending" })).toHaveLength(1);
|
||||
expect(await store.list({ type: "ab_test" })).toHaveLength(1);
|
||||
expect(await store.list({ channel: "sms" })).toHaveLength(1);
|
||||
expect(await store.list({ limit: 1, offset: 1 })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("updateStats merges partial", { timeout: 5000 }, async () => {
|
||||
const store = new InMemoryCampaignStore();
|
||||
await store.create(makeCampaign());
|
||||
const updated = await store.updateStats("cmp-1", {
|
||||
sent: 5,
|
||||
delivered: 4,
|
||||
});
|
||||
expect(updated.stats.sent).toBe(5);
|
||||
expect(updated.stats.delivered).toBe(4);
|
||||
expect(updated.stats.opened).toBe(0);
|
||||
});
|
||||
|
||||
it("updateStats throws on missing", { timeout: 5000 }, async () => {
|
||||
const store = new InMemoryCampaignStore();
|
||||
await expect(store.updateStats("nope", { sent: 1 })).rejects.toThrow(
|
||||
CampaignsError
|
||||
);
|
||||
});
|
||||
|
||||
it("updateStatus sets status + bumps updatedAt", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = new InMemoryCampaignStore();
|
||||
await store.create(makeCampaign());
|
||||
const updated = await store.updateStatus("cmp-1", "sending");
|
||||
expect(updated.status).toBe("sending");
|
||||
});
|
||||
|
||||
it("updateStatus throws on missing", { timeout: 5000 }, async () => {
|
||||
const store = new InMemoryCampaignStore();
|
||||
await expect(
|
||||
store.updateStatus("nope", "sending")
|
||||
).rejects.toBeInstanceOf(CampaignsError);
|
||||
});
|
||||
|
||||
it("default clock works (Date.now)", { timeout: 5000 }, async () => {
|
||||
const store = new InMemoryCampaignStore();
|
||||
await store.create(makeCampaign());
|
||||
const before = Date.now();
|
||||
const updated = await store.update("cmp-1", { name: "x" });
|
||||
const after = Date.now();
|
||||
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(before);
|
||||
expect(updated.updatedAt.getTime()).toBeLessThanOrEqual(after);
|
||||
});
|
||||
|
||||
it("returned objects are defensively cloned", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = new InMemoryCampaignStore();
|
||||
const created = await store.create(makeCampaign());
|
||||
created.name = "tampered";
|
||||
const fetched = await store.getById("cmp-1");
|
||||
expect(fetched?.name).toBe("Test");
|
||||
});
|
||||
});
|
||||
133
packages/communication/emails/campaigns/src/store/store.ts
Normal file
133
packages/communication/emails/campaigns/src/store/store.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
Campaign,
|
||||
CampaignQuery,
|
||||
CampaignStats,
|
||||
CampaignStatus,
|
||||
UpdateCampaignInput,
|
||||
} from "../types/campaign.types.js";
|
||||
|
||||
/**
|
||||
* Persistence contract for campaigns. Implementations may be in-memory
|
||||
* (see {@link InMemoryCampaignStore}), SurrealDB-backed, or Cloudflare
|
||||
* Durable-Object-backed — the engine is store-agnostic.
|
||||
*/
|
||||
export interface CampaignStore {
|
||||
create(campaign: Campaign): Promise<Campaign>;
|
||||
getById(id: string): Promise<Campaign | null>;
|
||||
update(id: string, input: UpdateCampaignInput): Promise<Campaign>;
|
||||
delete(id: string): Promise<boolean>;
|
||||
list(query: CampaignQuery): Promise<Campaign[]>;
|
||||
updateStats(id: string, stats: Partial<CampaignStats>): Promise<Campaign>;
|
||||
updateStatus(id: string, status: CampaignStatus): Promise<Campaign>;
|
||||
}
|
||||
|
||||
/** Options for {@link InMemoryCampaignStore}. */
|
||||
export interface InMemoryCampaignStoreOptions {
|
||||
/** Injectable wall-clock used to stamp `updatedAt`. Defaults to `Date.now`. */
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference {@link CampaignStore} implementation backed by a `Map`. Suitable
|
||||
* for unit tests, prototype workloads, and the default in-process engine.
|
||||
*
|
||||
* Every mutation returns a defensive clone so callers cannot tamper with
|
||||
* internal records by reference.
|
||||
*/
|
||||
export class InMemoryCampaignStore implements CampaignStore {
|
||||
private readonly campaigns = new Map<string, Campaign>();
|
||||
private readonly clock: Clock;
|
||||
|
||||
public constructor(options: InMemoryCampaignStoreOptions = {}) {
|
||||
this.clock = options.clock ?? (() => Date.now());
|
||||
}
|
||||
|
||||
public async create(campaign: Campaign): Promise<Campaign> {
|
||||
this.campaigns.set(campaign.id, { ...campaign });
|
||||
return { ...campaign };
|
||||
}
|
||||
|
||||
public async getById(id: string): Promise<Campaign | null> {
|
||||
const campaign = this.campaigns.get(id);
|
||||
return campaign ? { ...campaign } : null;
|
||||
}
|
||||
|
||||
public async update(
|
||||
id: string,
|
||||
input: UpdateCampaignInput
|
||||
): Promise<Campaign> {
|
||||
const existing = this.requireExisting(id);
|
||||
const updated: Campaign = {
|
||||
...existing,
|
||||
...input,
|
||||
updatedAt: new Date(this.clock()),
|
||||
};
|
||||
this.campaigns.set(id, updated);
|
||||
return { ...updated };
|
||||
}
|
||||
|
||||
public async delete(id: string): Promise<boolean> {
|
||||
return this.campaigns.delete(id);
|
||||
}
|
||||
|
||||
public async list(query: CampaignQuery): Promise<Campaign[]> {
|
||||
let results = Array.from(this.campaigns.values());
|
||||
if (query.tenantId !== undefined) {
|
||||
results = results.filter((c) => c.tenantId === query.tenantId);
|
||||
}
|
||||
if (query.status !== undefined) {
|
||||
results = results.filter((c) => c.status === query.status);
|
||||
}
|
||||
if (query.type !== undefined) {
|
||||
results = results.filter((c) => c.type === query.type);
|
||||
}
|
||||
if (query.channel !== undefined) {
|
||||
results = results.filter((c) => c.channel === query.channel);
|
||||
}
|
||||
const offset = query.offset ?? 0;
|
||||
const limit = query.limit ?? 50;
|
||||
return results.slice(offset, offset + limit).map((c) => ({ ...c }));
|
||||
}
|
||||
|
||||
public async updateStats(
|
||||
id: string,
|
||||
stats: Partial<CampaignStats>
|
||||
): Promise<Campaign> {
|
||||
const existing = this.requireExisting(id);
|
||||
const updated: Campaign = {
|
||||
...existing,
|
||||
stats: { ...existing.stats, ...stats },
|
||||
updatedAt: new Date(this.clock()),
|
||||
};
|
||||
this.campaigns.set(id, updated);
|
||||
return { ...updated };
|
||||
}
|
||||
|
||||
public async updateStatus(
|
||||
id: string,
|
||||
status: CampaignStatus
|
||||
): Promise<Campaign> {
|
||||
const existing = this.requireExisting(id);
|
||||
const updated: Campaign = {
|
||||
...existing,
|
||||
status,
|
||||
updatedAt: new Date(this.clock()),
|
||||
};
|
||||
this.campaigns.set(id, updated);
|
||||
return { ...updated };
|
||||
}
|
||||
|
||||
private requireExisting(id: string): Campaign {
|
||||
const existing = this.campaigns.get(id);
|
||||
if (!existing) {
|
||||
throw new CampaignsError(
|
||||
CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND,
|
||||
`Campaign not found: ${id}`,
|
||||
{ context: { id } }
|
||||
);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export {
|
||||
batchesPlannedCounter,
|
||||
batchPlanSizeHistogram,
|
||||
campaignFailuresCounter,
|
||||
campaignSendsCounter,
|
||||
campaignsCreatedCounter,
|
||||
campaignTransitionsCounter,
|
||||
recordBatchesPlanned,
|
||||
recordBatchPlanSize,
|
||||
recordCampaignCreated,
|
||||
recordFailure,
|
||||
recordSend,
|
||||
recordSendDuration,
|
||||
recordTransition,
|
||||
sendDurationHistogram,
|
||||
} from "./telemetry.js";
|
||||
@@ -0,0 +1,58 @@
|
||||
# telemetry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
OTel self-telemetry for `@suaveplan/campaigns`. Exposes a small set of counters and histograms that are emitted from every meaningful operation in the package — campaign creation, lifecycle transitions, send dispatches, batch planning, and failures. The instruments resolve against the globally registered `MeterProvider` via `@opentelemetry/api`; when no provider is registered, the OTel API returns no-op meters, so importing this module is always safe in production code that has not opted into telemetry.
|
||||
|
||||
Centralising the meter and record helpers in one module keeps the instrumentation surface explicit and prevents feature modules from drifting into ad-hoc, untagged metrics.
|
||||
|
||||
## Features
|
||||
|
||||
- Five counters: `campaigns_created_total`, `campaigns_transitions_total`, `campaigns_sends_total`, `campaigns_batches_planned_total`, `campaigns_failures_total`.
|
||||
- Two histograms: `campaigns_batch_plan_size` (recipient count per plan) and `campaigns_send_duration_ms` (estimated total send-window duration in ms).
|
||||
- Record helpers (`recordCampaignCreated`, `recordTransition`, `recordSend`, `recordBatchesPlanned`, `recordFailure`, `recordBatchPlanSize`, `recordSendDuration`) so call sites stay readable.
|
||||
- Lazy meter resolution via `metrics.getMeter("@suaveplan/campaigns")` so the module has zero side effects beyond the OTel API itself.
|
||||
- Per-instrument descriptions and units (`ms`, `{contact}`) so dashboards can label axes correctly.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { recordCampaignCreated, recordSend } from "@suaveplan/campaigns";
|
||||
|
||||
recordCampaignCreated("broadcast", "email");
|
||||
recordSend("email");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Pair the histogram with the planner output for percentile dashboards:
|
||||
|
||||
```ts
|
||||
import {
|
||||
BatchPlanner,
|
||||
recordBatchPlanSize,
|
||||
recordSendDuration,
|
||||
} from "@suaveplan/campaigns";
|
||||
|
||||
const planner = new BatchPlanner();
|
||||
const contacts = await audience.resolve(campaign.audienceConfig);
|
||||
const batches = planner.planBatches(contacts, { batchSize: 250 });
|
||||
// planBatches() already records both metrics; helpers are exported so
|
||||
// callers can mirror the recording from custom dispatch loops too.
|
||||
recordBatchPlanSize(contacts.length);
|
||||
recordSendDuration(planner.calculateTotalDuration(contacts.length));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `campaignsCreatedCounter`, `campaignTransitionsCounter`, `campaignSendsCounter`, `batchesPlannedCounter`, `campaignFailuresCounter` — OTel `Counter` instances.
|
||||
- `batchPlanSizeHistogram`, `sendDurationHistogram` — OTel `Histogram` instances.
|
||||
- `recordCampaignCreated(type, channel)`, `recordTransition(from, to, event)`, `recordSend(channel)`, `recordBatchesPlanned(count)`, `recordFailure(channel)`, `recordBatchPlanSize(count)`, `recordSendDuration(ms)` — record helpers.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The meter is acquired once at module load time. Because `getMeter` is idempotent and the OTel API guarantees safe no-op behaviour when no provider is registered, the helpers can be called unconditionally from hot paths. Counters carry low-cardinality labels (`type`, `channel`, `from`, `to`, `event`) — never per-tenant or per-campaign identifiers, which would explode metric series. Add such labels via downstream OTel views if needed.
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
batchesPlannedCounter,
|
||||
batchPlanSizeHistogram,
|
||||
campaignFailuresCounter,
|
||||
campaignSendsCounter,
|
||||
campaignsCreatedCounter,
|
||||
campaignTransitionsCounter,
|
||||
recordBatchesPlanned,
|
||||
recordBatchPlanSize,
|
||||
recordCampaignCreated,
|
||||
recordFailure,
|
||||
recordSend,
|
||||
recordSendDuration,
|
||||
recordTransition,
|
||||
sendDurationHistogram,
|
||||
} from "./telemetry.js";
|
||||
|
||||
describe("campaigns telemetry", () => {
|
||||
it("exposes counters + histograms", { timeout: 5000 }, () => {
|
||||
expect(campaignsCreatedCounter).toBeDefined();
|
||||
expect(campaignTransitionsCounter).toBeDefined();
|
||||
expect(campaignSendsCounter).toBeDefined();
|
||||
expect(batchesPlannedCounter).toBeDefined();
|
||||
expect(campaignFailuresCounter).toBeDefined();
|
||||
expect(batchPlanSizeHistogram).toBeDefined();
|
||||
expect(sendDurationHistogram).toBeDefined();
|
||||
});
|
||||
|
||||
it("every record helper is callable without a registered provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => recordCampaignCreated("broadcast", "email")).not.toThrow();
|
||||
expect(() =>
|
||||
recordTransition("draft", "sending", "send")
|
||||
).not.toThrow();
|
||||
expect(() => recordSend("email")).not.toThrow();
|
||||
expect(() => recordBatchesPlanned(3)).not.toThrow();
|
||||
expect(() => recordFailure("email")).not.toThrow();
|
||||
expect(() => recordBatchPlanSize(1000)).not.toThrow();
|
||||
expect(() => recordSendDuration(42)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/campaigns";
|
||||
|
||||
/**
|
||||
* Lazy-bound meter — resolves the globally registered `MeterProvider` on
|
||||
* first access. When no provider is registered the OTel API returns a
|
||||
* no-op meter, so instruments are safe to import in production code that
|
||||
* does not opt into telemetry.
|
||||
*/
|
||||
const meter = metrics.getMeter(METER_NAME);
|
||||
|
||||
/** Counter for created campaigns. */
|
||||
export const campaignsCreatedCounter: Counter = meter.createCounter(
|
||||
"campaigns_created_total",
|
||||
{
|
||||
description:
|
||||
"Total campaigns created via CampaignEngine.create(), tagged by type/channel",
|
||||
}
|
||||
);
|
||||
|
||||
/** Counter for campaign lifecycle transitions. */
|
||||
export const campaignTransitionsCounter: Counter = meter.createCounter(
|
||||
"campaigns_transitions_total",
|
||||
{
|
||||
description:
|
||||
"Total successful campaign state-machine transitions, tagged by from/to/event",
|
||||
}
|
||||
);
|
||||
|
||||
/** Counter for campaign send dispatches. */
|
||||
export const campaignSendsCounter: Counter = meter.createCounter(
|
||||
"campaigns_sends_total",
|
||||
{
|
||||
description:
|
||||
"Total campaign sends initiated via CampaignEngine.send(), tagged by channel",
|
||||
}
|
||||
);
|
||||
|
||||
/** Counter for batches emitted by the BatchPlanner. */
|
||||
export const batchesPlannedCounter: Counter = meter.createCounter(
|
||||
"campaigns_batches_planned_total",
|
||||
{
|
||||
description: "Total batches produced by BatchPlanner.planBatches()",
|
||||
}
|
||||
);
|
||||
|
||||
/** Counter for campaign failures. */
|
||||
export const campaignFailuresCounter: Counter = meter.createCounter(
|
||||
"campaigns_failures_total",
|
||||
{
|
||||
description:
|
||||
"Total campaign failures registered via CampaignEngine.fail()",
|
||||
}
|
||||
);
|
||||
|
||||
/** Histogram of total recipients per batch plan. */
|
||||
export const batchPlanSizeHistogram: Histogram = meter.createHistogram(
|
||||
"campaigns_batch_plan_size",
|
||||
{
|
||||
description: "Total contact count fed into a single planBatches() call",
|
||||
unit: "{contact}",
|
||||
}
|
||||
);
|
||||
|
||||
/** Histogram of computed total send duration in milliseconds. */
|
||||
export const sendDurationHistogram: Histogram = meter.createHistogram(
|
||||
"campaigns_send_duration_ms",
|
||||
{
|
||||
description:
|
||||
"Estimated total send-window duration in milliseconds, recorded on planBatches()/calculateTotalDuration()",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
|
||||
/** Record one created-campaign event. */
|
||||
export function recordCampaignCreated(type: string, channel: string): void {
|
||||
campaignsCreatedCounter.add(1, { type, channel });
|
||||
}
|
||||
|
||||
/** Record one successful state transition. */
|
||||
export function recordTransition(
|
||||
from: string,
|
||||
to: string,
|
||||
event: string
|
||||
): void {
|
||||
campaignTransitionsCounter.add(1, { from, to, event });
|
||||
}
|
||||
|
||||
/** Record one send-dispatch event. */
|
||||
export function recordSend(channel: string): void {
|
||||
campaignSendsCounter.add(1, { channel });
|
||||
}
|
||||
|
||||
/** Record one batch-plan emission, attaching the batch count. */
|
||||
export function recordBatchesPlanned(batchCount: number): void {
|
||||
batchesPlannedCounter.add(batchCount);
|
||||
}
|
||||
|
||||
/** Record one fail-dispatch event. */
|
||||
export function recordFailure(channel: string): void {
|
||||
campaignFailuresCounter.add(1, { channel });
|
||||
}
|
||||
|
||||
/** Record the total-recipient size of one plan. */
|
||||
export function recordBatchPlanSize(totalContacts: number): void {
|
||||
batchPlanSizeHistogram.record(totalContacts);
|
||||
}
|
||||
|
||||
/** Record an estimated send-window duration in milliseconds. */
|
||||
export function recordSendDuration(durationMs: number): void {
|
||||
sendDurationHistogram.record(durationMs);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# campaign.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Canonical shared types for `@suaveplan/campaigns`. Every cross-module data shape lives here — `Campaign`, `CampaignStatus`, `CampaignType`, `AudienceConfig`, `CampaignSchedule`, `ThrottleConfig`, `ABTestConfig`, `ABTestVariant`, `CampaignStats`, `CreateCampaignInput`, `UpdateCampaignInput`, `CampaignQuery`, and `BatchConfig` — plus the `DEFAULT_BATCH_CONFIG` constant. Centralising these types here breaks circular dependencies between the engine, state machine, batch planner, workflow, and store modules.
|
||||
|
||||
The types are intentionally framework-agnostic and serialisation-safe. There are no class instances, no `Map`s, no `Set`s — only plain object shapes whose JSON representation round-trips cleanly through any persistence or transport layer.
|
||||
|
||||
## Features
|
||||
|
||||
- Seven `CampaignStatus` values modelling the full lifecycle (`draft`, `scheduled`, `sending`, `paused`, `completed`, `cancelled`, `failed`).
|
||||
- Four `CampaignType` values (`broadcast`, `triggered`, `ab_test`, `drip`).
|
||||
- `AudienceConfig` with inclusion (`groupIds`), exclusion (`excludeGroupIds`, `excludeContactIds`), segment-driven targeting (`segmentIds`), and explicit suppression-list references (`suppressionListIds`).
|
||||
- `CampaignSchedule` covering immediate, scheduled, and recurring sends.
|
||||
- `ThrottleConfig` with per-second / per-minute / per-hour limits and optional ramp-up duration.
|
||||
- `ABTestConfig` + `ABTestVariant` with even or weighted splits, optional test duration, and the standard `open_rate` / `click_rate` / `conversion` winner criteria.
|
||||
- `Campaign` generic over an arbitrary `TMeta` metadata shape, with reset-safe `stats: CampaignStats`.
|
||||
- Input shapes (`CreateCampaignInput`, `UpdateCampaignInput`, `CampaignQuery`) that mirror the engine's public surface.
|
||||
- `BatchConfig` + `DEFAULT_BATCH_CONFIG` (`{ batchSize: 100, delayBetweenBatches: 0 }`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { Campaign, CampaignStatus } from "@suaveplan/campaigns";
|
||||
|
||||
function isTerminal(status: CampaignStatus): boolean {
|
||||
return (
|
||||
status === "completed" ||
|
||||
status === "cancelled" ||
|
||||
status === "failed"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Parameterise the metadata shape per tenant:
|
||||
|
||||
```ts
|
||||
import type { Campaign, CreateCampaignInput } from "@suaveplan/campaigns";
|
||||
|
||||
interface AcmeMeta {
|
||||
readonly source: "api" | "ui" | "webhook";
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
const input: CreateCampaignInput<AcmeMeta> = {
|
||||
tenantId: "acme",
|
||||
name: "Quarterly newsletter",
|
||||
type: "broadcast",
|
||||
channel: "email",
|
||||
audienceConfig: { groupIds: ["all"] },
|
||||
metadata: { source: "api", correlationId: "corr-1" },
|
||||
};
|
||||
|
||||
declare const campaign: Campaign<AcmeMeta>;
|
||||
campaign.metadata.correlationId; // strongly typed
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CampaignStatus`, `CampaignType` — string-literal unions.
|
||||
- `AudienceConfig`, `CampaignSchedule`, `ThrottleConfig`, `ABTestConfig`, `ABTestVariant`, `CampaignStats` — shape interfaces.
|
||||
- `Campaign<TMeta>` — main aggregate.
|
||||
- `CreateCampaignInput<TMeta>`, `UpdateCampaignInput<TMeta>`, `CampaignQuery` — engine input shapes.
|
||||
- `BatchConfig`, `DEFAULT_BATCH_CONFIG` — batch sizing.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`AudienceConfig` uses `readonly` arrays so call sites cannot mutate config in place. `Campaign.stats` is mutable on purpose — the engine reads-modifies-writes it during `updateStats`. Adding a new `CampaignStatus` or `CampaignType` is a public API change and requires an OpenSpec proposal because the state machine, the API plugin, and persistence schemas all switch on these literal unions.
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Lifecycle status for a campaign.
|
||||
*
|
||||
* - `draft` — created but not yet scheduled or sent.
|
||||
* - `scheduled` — awaiting a future send time.
|
||||
* - `sending` — actively dispatching batches.
|
||||
* - `paused` — temporarily halted; can resume.
|
||||
* - `completed` — terminal; all batches dispatched.
|
||||
* - `cancelled` — terminal; user-cancelled before completion.
|
||||
* - `failed` — terminal; aborted due to an error.
|
||||
*/
|
||||
export type CampaignStatus =
|
||||
| "draft"
|
||||
| "scheduled"
|
||||
| "sending"
|
||||
| "paused"
|
||||
| "completed"
|
||||
| "cancelled"
|
||||
| "failed";
|
||||
|
||||
/** High-level send pattern for a campaign. */
|
||||
export type CampaignType = "broadcast" | "triggered" | "ab_test" | "drip";
|
||||
|
||||
/** Audience targeting configuration. */
|
||||
export interface AudienceConfig {
|
||||
readonly groupIds: readonly string[];
|
||||
readonly excludeGroupIds?: readonly string[];
|
||||
readonly excludeContactIds?: readonly string[];
|
||||
readonly segmentIds?: readonly string[];
|
||||
readonly suppressionListIds?: readonly string[];
|
||||
}
|
||||
|
||||
/** When and how a campaign should be dispatched. */
|
||||
export interface CampaignSchedule {
|
||||
readonly type: "immediate" | "scheduled" | "recurring";
|
||||
readonly sendAt?: Date;
|
||||
readonly cronExpression?: string;
|
||||
readonly timezone?: string;
|
||||
}
|
||||
|
||||
/** Throttle controls for batch delivery. */
|
||||
export interface ThrottleConfig {
|
||||
readonly messagesPerSecond?: number;
|
||||
readonly messagesPerMinute?: number;
|
||||
readonly messagesPerHour?: number;
|
||||
readonly rampUp?: boolean;
|
||||
readonly rampUpDuration?: number;
|
||||
}
|
||||
|
||||
/** Single variant in an A/B test configuration. */
|
||||
export interface ABTestVariant {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly templateId: string;
|
||||
readonly templateData?: Record<string, unknown>;
|
||||
readonly weight?: number;
|
||||
}
|
||||
|
||||
/** Full A/B test configuration for a campaign. */
|
||||
export interface ABTestConfig {
|
||||
readonly variants: readonly ABTestVariant[];
|
||||
readonly splitType: "even" | "weighted";
|
||||
readonly testDuration?: number;
|
||||
readonly winnerCriteria?: "open_rate" | "click_rate" | "conversion";
|
||||
}
|
||||
|
||||
/** Aggregate delivery + engagement statistics for a campaign. */
|
||||
export interface CampaignStats {
|
||||
totalRecipients: number;
|
||||
sent: number;
|
||||
delivered: number;
|
||||
opened: number;
|
||||
clicked: number;
|
||||
bounced: number;
|
||||
unsubscribed: number;
|
||||
complained: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
/** Full campaign object as stored. */
|
||||
export interface Campaign<TMeta = Record<string, unknown>> {
|
||||
readonly id: string;
|
||||
readonly tenantId: string;
|
||||
name: string;
|
||||
status: CampaignStatus;
|
||||
type: CampaignType;
|
||||
channel: string;
|
||||
audienceConfig: AudienceConfig;
|
||||
templateId?: string;
|
||||
templateData?: Record<string, unknown>;
|
||||
schedule?: CampaignSchedule;
|
||||
abTestConfig?: ABTestConfig;
|
||||
throttle?: ThrottleConfig;
|
||||
metadata: TMeta;
|
||||
stats: CampaignStats;
|
||||
readonly createdAt: Date;
|
||||
updatedAt: Date;
|
||||
sentAt?: Date;
|
||||
completedAt?: Date;
|
||||
}
|
||||
|
||||
/** Input for creating a new campaign. */
|
||||
export interface CreateCampaignInput<TMeta = Record<string, unknown>> {
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly type: CampaignType;
|
||||
readonly channel: string;
|
||||
readonly audienceConfig: AudienceConfig;
|
||||
readonly templateId?: string;
|
||||
readonly templateData?: Record<string, unknown>;
|
||||
readonly schedule?: CampaignSchedule;
|
||||
readonly abTestConfig?: ABTestConfig;
|
||||
readonly throttle?: ThrottleConfig;
|
||||
readonly metadata?: TMeta;
|
||||
}
|
||||
|
||||
/** Input for updating an existing campaign. */
|
||||
export interface UpdateCampaignInput<TMeta = Record<string, unknown>> {
|
||||
readonly name?: string;
|
||||
readonly channel?: string;
|
||||
readonly audienceConfig?: AudienceConfig;
|
||||
readonly templateId?: string;
|
||||
readonly templateData?: Record<string, unknown>;
|
||||
readonly schedule?: CampaignSchedule;
|
||||
readonly abTestConfig?: ABTestConfig;
|
||||
readonly throttle?: ThrottleConfig;
|
||||
readonly metadata?: TMeta;
|
||||
}
|
||||
|
||||
/** Filter parameters for listing campaigns. */
|
||||
export interface CampaignQuery {
|
||||
readonly tenantId?: string;
|
||||
readonly status?: CampaignStatus;
|
||||
readonly type?: CampaignType;
|
||||
readonly channel?: string;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
}
|
||||
|
||||
/** Batch sizing + delay configuration. */
|
||||
export interface BatchConfig {
|
||||
readonly batchSize: number;
|
||||
readonly delayBetweenBatches: number;
|
||||
}
|
||||
|
||||
/** Default batch configuration used when no overrides are supplied. */
|
||||
export const DEFAULT_BATCH_CONFIG: BatchConfig = {
|
||||
batchSize: 100,
|
||||
delayBetweenBatches: 0,
|
||||
};
|
||||
16
packages/communication/emails/campaigns/src/types/index.ts
Normal file
16
packages/communication/emails/campaigns/src/types/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type {
|
||||
ABTestConfig,
|
||||
ABTestVariant,
|
||||
AudienceConfig,
|
||||
BatchConfig,
|
||||
Campaign,
|
||||
CampaignQuery,
|
||||
CampaignSchedule,
|
||||
CampaignStats,
|
||||
CampaignStatus,
|
||||
CampaignType,
|
||||
CreateCampaignInput,
|
||||
ThrottleConfig,
|
||||
UpdateCampaignInput,
|
||||
} from "./campaign.types.js";
|
||||
export { DEFAULT_BATCH_CONFIG } from "./campaign.types.js";
|
||||
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
CampaignWorkflowOptions,
|
||||
WorkflowContext,
|
||||
WorkflowStep,
|
||||
} from "./workflow.js";
|
||||
export { CampaignWorkflow } from "./workflow.js";
|
||||
@@ -0,0 +1,60 @@
|
||||
# workflow
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Composable multi-step send pipeline for campaigns. `CampaignWorkflow.buildSendPipeline` returns a canonical six-step pipeline — `resolve-audience`, `apply-suppression`, `plan-batches`, `send-batches`, `update-stats`, `complete` — that downstream integrations can wrap, instrument, or replace step-by-step. `CampaignWorkflow.run` executes the supplied step list against an initial `WorkflowContext`, threading the result of each step into the next, and wraps any step-level error in a typed `CampaignsError` so callers can pattern-match on the canonical registry.
|
||||
|
||||
The workflow is intentionally minimal — it owns ordering and error handling, not business logic. Each step is a plain async function that returns the next `WorkflowContext`, which keeps swapping in real audience resolvers, suppression services, and dispatchers a one-line override.
|
||||
|
||||
## Features
|
||||
|
||||
- Canonical six-step send pipeline returned by `buildSendPipeline(campaign)`.
|
||||
- `run(steps, initial)` executes every step in order, threading context through.
|
||||
- Errors raised by a step are wrapped in `CampaignsError(WORKFLOW_STEP_FAILED)` with the failing step id in `context.stepId` and the original error preserved on `cause`.
|
||||
- Injectable `LoggerLike` for debug-level tracing of step start/end events.
|
||||
- `WorkflowStep` carries `id`, `name`, `description`, and an async `execute` — descriptions surface in UI-driven progress panels.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CampaignWorkflow } from "@suaveplan/campaigns";
|
||||
|
||||
const workflow = new CampaignWorkflow();
|
||||
const steps = workflow.buildSendPipeline(campaign);
|
||||
const result = await workflow.run(steps, { campaign });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Override individual steps with real integrations:
|
||||
|
||||
```ts
|
||||
import { CampaignWorkflow, type WorkflowStep } from "@suaveplan/campaigns";
|
||||
|
||||
const workflow = new CampaignWorkflow({ logger });
|
||||
const defaults = workflow.buildSendPipeline(campaign);
|
||||
const overrides: WorkflowStep[] = defaults.map((step) =>
|
||||
step.id === "resolve-audience"
|
||||
? { ...step, execute: async (ctx) => ({ ...ctx, contactIds: await audience.resolve(campaign) }) }
|
||||
: step.id === "send-batches"
|
||||
? { ...step, execute: async (ctx) => dispatch(ctx) }
|
||||
: step
|
||||
);
|
||||
const result = await workflow.run(overrides, { campaign });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `WorkflowContext` — `{ campaign; contactIds?; batches?; results? }`.
|
||||
- `WorkflowStep` — `{ id; name; description; execute(context) }`.
|
||||
- `CampaignWorkflowOptions` — `{ logger? }`.
|
||||
- `CampaignWorkflow.buildSendPipeline(campaign): WorkflowStep[]`.
|
||||
- `CampaignWorkflow.run(steps, initial): Promise<WorkflowContext>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`run` executes sequentially — there is no parallel-step support, because the default pipeline is strictly ordered (you can't send batches before you've planned them). For fan-out scenarios, wrap `send-batches` to dispatch its work to a worker pool. Error wrapping uses `{ cause }`, so `err.cause` preserves the original throw and is consumable by `Error.cause` walkers and structured-logger redactors.
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
|
||||
import type { Campaign } from "../types/campaign.types.js";
|
||||
import {
|
||||
CampaignWorkflow,
|
||||
type WorkflowContext,
|
||||
type WorkflowStep,
|
||||
} from "./workflow.js";
|
||||
|
||||
function makeCampaign(): Campaign {
|
||||
return {
|
||||
id: "cmp-1",
|
||||
tenantId: "tenant-1",
|
||||
name: "Test Campaign",
|
||||
status: "sending",
|
||||
type: "broadcast",
|
||||
channel: "email",
|
||||
audienceConfig: { groupIds: ["group-1"] },
|
||||
metadata: {},
|
||||
stats: {
|
||||
totalRecipients: 0,
|
||||
sent: 0,
|
||||
delivered: 0,
|
||||
opened: 0,
|
||||
clicked: 0,
|
||||
bounced: 0,
|
||||
unsubscribed: 0,
|
||||
complained: 0,
|
||||
failed: 0,
|
||||
},
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
};
|
||||
}
|
||||
|
||||
describe("CampaignWorkflow", () => {
|
||||
const workflow = new CampaignWorkflow();
|
||||
|
||||
describe("buildSendPipeline", () => {
|
||||
it("returns 6 steps in canonical order", { timeout: 5000 }, () => {
|
||||
const steps = workflow.buildSendPipeline(makeCampaign());
|
||||
expect(steps.map((s) => s.id)).toEqual([
|
||||
"resolve-audience",
|
||||
"apply-suppression",
|
||||
"plan-batches",
|
||||
"send-batches",
|
||||
"update-stats",
|
||||
"complete",
|
||||
]);
|
||||
});
|
||||
|
||||
it("every step has name + description + execute", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const step of workflow.buildSendPipeline(makeCampaign())) {
|
||||
expect(typeof step.execute).toBe("function");
|
||||
expect(step.name).toBeTruthy();
|
||||
expect(step.description).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("execute returns enriched context", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = makeCampaign();
|
||||
const steps = workflow.buildSendPipeline(c);
|
||||
const result = await steps[0].execute({ campaign: c });
|
||||
expect(result.contactIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("apply-suppression preserves existing contactIds", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = makeCampaign();
|
||||
const steps = workflow.buildSendPipeline(c);
|
||||
const result = await steps[1].execute({
|
||||
campaign: c,
|
||||
contactIds: ["a", "b"],
|
||||
});
|
||||
expect(result.contactIds).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("apply-suppression defaults to [] when no contactIds", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = makeCampaign();
|
||||
const steps = workflow.buildSendPipeline(c);
|
||||
const result = await steps[1].execute({ campaign: c });
|
||||
expect(result.contactIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("plan-batches preserves existing batches", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = makeCampaign();
|
||||
const steps = workflow.buildSendPipeline(c);
|
||||
const existing = [{ batchIndex: 0, contactIds: ["a"] }];
|
||||
const result = await steps[2].execute({
|
||||
campaign: c,
|
||||
batches: existing,
|
||||
});
|
||||
expect(result.batches).toEqual(existing);
|
||||
});
|
||||
|
||||
it("plan-batches defaults to []", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = makeCampaign();
|
||||
const steps = workflow.buildSendPipeline(c);
|
||||
const r = await steps[2].execute({ campaign: c });
|
||||
expect(r.batches).toEqual([]);
|
||||
});
|
||||
|
||||
it("send-batches/update-stats/complete pass context through", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = makeCampaign();
|
||||
const steps = workflow.buildSendPipeline(c);
|
||||
const ctx = {
|
||||
campaign: c,
|
||||
contactIds: ["a"],
|
||||
results: { ok: 1 },
|
||||
};
|
||||
for (const step of steps.slice(3)) {
|
||||
const r = await step.execute(ctx);
|
||||
expect(r.results).toEqual({ ok: 1 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("run", () => {
|
||||
it("executes every step in order", { timeout: 5000 }, async () => {
|
||||
const order: string[] = [];
|
||||
const steps: WorkflowStep[] = [
|
||||
{
|
||||
id: "a",
|
||||
name: "A",
|
||||
description: "first",
|
||||
execute: async (ctx) => {
|
||||
order.push("a");
|
||||
return { ...ctx, results: { ...ctx.results, a: 1 } };
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
name: "B",
|
||||
description: "second",
|
||||
execute: async (ctx) => {
|
||||
order.push("b");
|
||||
return { ...ctx, results: { ...ctx.results, b: 2 } };
|
||||
},
|
||||
},
|
||||
];
|
||||
const result = await workflow.run(steps, {
|
||||
campaign: makeCampaign(),
|
||||
});
|
||||
expect(order).toEqual(["a", "b"]);
|
||||
expect(result.results).toEqual({ a: 1, b: 2 });
|
||||
});
|
||||
|
||||
it("wraps step errors in WORKFLOW_STEP_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cause = new Error("boom");
|
||||
const steps: WorkflowStep[] = [
|
||||
{
|
||||
id: "broken",
|
||||
name: "Broken",
|
||||
description: "fails",
|
||||
execute: async () => {
|
||||
throw cause;
|
||||
},
|
||||
},
|
||||
];
|
||||
try {
|
||||
await workflow.run(steps, { campaign: makeCampaign() });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CampaignsError);
|
||||
if (err instanceof CampaignsError) {
|
||||
expect(err.code).toBe(CAMPAIGNS_CODES.WORKFLOW_STEP_FAILED);
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context?.stepId).toBe("broken");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("logs step start/end when logger injected", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const logs: string[] = [];
|
||||
const wf = new CampaignWorkflow({
|
||||
logger: {
|
||||
debug: (m) => logs.push(m),
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
},
|
||||
});
|
||||
const ctx: WorkflowContext = { campaign: makeCampaign() };
|
||||
await wf.run(
|
||||
[
|
||||
{
|
||||
id: "x",
|
||||
name: "X",
|
||||
description: "x",
|
||||
execute: async (c) => c,
|
||||
},
|
||||
],
|
||||
ctx
|
||||
);
|
||||
expect(logs).toEqual(["workflow.step.start", "workflow.step.end"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
127
packages/communication/emails/campaigns/src/workflow/workflow.ts
Normal file
127
packages/communication/emails/campaigns/src/workflow/workflow.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
import type { Batch } from "../batch-planner/batch-planner.js";
|
||||
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
|
||||
import type { Campaign } from "../types/campaign.types.js";
|
||||
|
||||
/** Context object threaded through every workflow step. */
|
||||
export interface WorkflowContext {
|
||||
readonly campaign: Campaign;
|
||||
contactIds?: string[];
|
||||
batches?: Batch[];
|
||||
results?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** A single named step in a campaign send workflow. */
|
||||
export interface WorkflowStep {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
execute(context: WorkflowContext): Promise<WorkflowContext>;
|
||||
}
|
||||
|
||||
/** Construction options for {@link CampaignWorkflow}. */
|
||||
export interface CampaignWorkflowOptions {
|
||||
/** Optional logger used to trace step execution. */
|
||||
readonly logger?: LoggerLike;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and runs the canonical send pipeline for a campaign.
|
||||
*
|
||||
* Step set is fixed (6 steps) but each step is an injectable async function
|
||||
* — call sites can wrap the default pipeline to layer audience resolution,
|
||||
* suppression filtering, batch planning, dispatch, stats roll-up, and
|
||||
* completion.
|
||||
*/
|
||||
export class CampaignWorkflow {
|
||||
private readonly logger?: LoggerLike;
|
||||
|
||||
public constructor(options: CampaignWorkflowOptions = {}) {
|
||||
if (options.logger !== undefined) {
|
||||
this.logger = options.logger;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a 6-step default send pipeline keyed by the campaign id. */
|
||||
public buildSendPipeline(_campaign: Campaign): WorkflowStep[] {
|
||||
return [
|
||||
{
|
||||
id: "resolve-audience",
|
||||
name: "Resolve Audience",
|
||||
description:
|
||||
"Resolve audience groups to individual contact IDs",
|
||||
execute: async (context) => ({
|
||||
...context,
|
||||
contactIds: context.contactIds ?? [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "apply-suppression",
|
||||
name: "Apply Suppression",
|
||||
description: "Filter out suppressed and excluded contacts",
|
||||
execute: async (context) => ({
|
||||
...context,
|
||||
contactIds: context.contactIds ?? [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "plan-batches",
|
||||
name: "Plan Batches",
|
||||
description:
|
||||
"Split contacts into send batches based on throttle config",
|
||||
execute: async (context) => ({
|
||||
...context,
|
||||
batches: context.batches ?? [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "send-batches",
|
||||
name: "Send Batches",
|
||||
description:
|
||||
"Process and send each batch through the configured channel",
|
||||
execute: async (context) => context,
|
||||
},
|
||||
{
|
||||
id: "update-stats",
|
||||
name: "Update Stats",
|
||||
description: "Update campaign statistics with send results",
|
||||
execute: async (context) => context,
|
||||
},
|
||||
{
|
||||
id: "complete",
|
||||
name: "Complete Campaign",
|
||||
description: "Mark the campaign as completed",
|
||||
execute: async (context) => context,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the supplied step list against `initial`. Returns the final
|
||||
* context. Wraps any step error in a {@link CampaignsError} with code
|
||||
* `WORKFLOW_STEP_FAILED` so callers can switch on the canonical
|
||||
* registry instead of `instanceof` ladders.
|
||||
*/
|
||||
public async run(
|
||||
steps: readonly WorkflowStep[],
|
||||
initial: WorkflowContext
|
||||
): Promise<WorkflowContext> {
|
||||
let ctx = initial;
|
||||
for (const step of steps) {
|
||||
try {
|
||||
this.logger?.debug("workflow.step.start", {
|
||||
id: step.id,
|
||||
});
|
||||
ctx = await step.execute(ctx);
|
||||
this.logger?.debug("workflow.step.end", { id: step.id });
|
||||
} catch (cause) {
|
||||
throw new CampaignsError(
|
||||
CAMPAIGNS_CODES.WORKFLOW_STEP_FAILED,
|
||||
`Workflow step '${step.id}' failed`,
|
||||
{ cause, context: { stepId: step.id } }
|
||||
);
|
||||
}
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
12
packages/communication/emails/campaigns/tsconfig.lib.json
Normal file
12
packages/communication/emails/campaigns/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
54
packages/communication/emails/campaigns/vite.config.ts
Normal file
54
packages/communication/emails/campaigns/vite.config.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir:
|
||||
"../../../node_modules/.vite/packages/communication-emails-campaigns",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
"engine/index": "src/engine/index.ts",
|
||||
"state-machine/index": "src/state-machine/index.ts",
|
||||
"batch-planner/index": "src/batch-planner/index.ts",
|
||||
"workflow/index": "src/workflow/index.ts",
|
||||
"store/index": "src/store/index.ts",
|
||||
"types/index": "src/types/index.ts",
|
||||
"error-codes/index": "src/error-codes/index.ts",
|
||||
"telemetry/index": "src/telemetry/index.ts",
|
||||
},
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/logger",
|
||||
"@suaveplan/opentelemetry",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
32
packages/communication/emails/campaigns/vitest.config.ts
Normal file
32
packages/communication/emails/campaigns/vitest.config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
hookTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user