Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
# @suaveplan/api-plugin-campaigns - Campaigns + suppression + delivery-webhooks HTTP plugin for @suaveplan/api. Mounts /campaigns, /suppression, and /webhooks on a Hono OpenAPI registry, backed by SurrealDB implementations of @suaveplan/campaigns CampaignStore and a local CampaignSuppressionStore, plus the @suaveplan/delivery-tracking SurrealDeliveryEventStore. Chunked migration (A: foundation, B: repositories, C: routes).
Campaigns + suppression + delivery-webhooks HTTP plugin for `@suaveplan/api`.
Mounts the `/campaigns`, `/suppression`, and `/webhooks` route trees on a
Hono `OpenAPIHono` registry, backed by SurrealDB implementations of the
universal `@suaveplan/campaigns` `CampaignStore`, a local
`CampaignSuppressionStore`, and the already-shipped
`@suaveplan/delivery-tracking` `SurrealDeliveryEventStore`.
<!-- begin:auto-docs -->
## Documentation tree
- [context](./src/context/context.md) — The `context` module owns the Hono `ContextVariableMap` declaration-merge augmentation applied by `@suaveplan/api-plugi…
- [context](./src/context/context.md) — The `context` module owns the Hono `ContextVariableMap` declaration-merge augmentation applied by `@suaveplan/api-plugi…
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module owns the stable `CAMPAIGNS_API.*` namespace used by every failure path inside `@suaveplan/api-pl…
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module owns the stable `CAMPAIGNS_API.*` namespace used by every failure path inside `@suaveplan/api-pl…
- [otel](./src/otel/instruments.md) — The `otel/instruments` module owns the OpenTelemetry instrument bundle emitted by `@suaveplan/api-plugin-campaigns`. It…
- [instruments](./src/otel/instruments.md) — The `otel/instruments` module owns the OpenTelemetry instrument bundle emitted by `@suaveplan/api-plugin-campaigns`. It…
- [plugin](./src/plugin/plugin.md) — The `plugin` module exports `createCampaignsPlugin` — the `PluginDescriptor` factory consumed by `@suaveplan/api` hosts…
- [plugin](./src/plugin/plugin.md) — The `plugin` module exports `createCampaignsPlugin` — the `PluginDescriptor` factory consumed by `@suaveplan/api` hosts…
- [campaign](./src/repositories/campaign/campaign.md) — This module owns the SurrealDB-backed implementation of the campaign persistence contract for `@suaveplan/api-plugin-ca…
- [campaign](./src/repositories/campaign/campaign.md) — This module owns the SurrealDB-backed implementation of the campaign persistence contract for `@suaveplan/api-plugin-ca…
- [suppression](./src/repositories/suppression/suppression.md) — This module owns the SurrealDB-backed implementation of the tenant + channel-scoped `CampaignSuppressionStore` interfac…
- [suppression-internals](./src/repositories/suppression/suppression-internals.md) — This module owns the validation and mapping primitives shared by the two `CampaignSuppressionStore` implementations — `…
- [suppression-memory](./src/repositories/suppression/suppression-memory.md) — In-memory reference implementation of the tenant + channel-scoped `CampaignSuppressionStore` interface. Intended for un…
- [suppression](./src/repositories/suppression/suppression.md) — This module owns the SurrealDB-backed implementation of the tenant + channel-scoped `CampaignSuppressionStore` interfac…
- [action-routes](./src/routes/campaigns/action-routes.md) — This module owns the campaign action routes — state-machine transitions (`/send`, `/pause`, `/cancel`), the `/duplicate…
- [build-routes](./src/routes/campaigns/build-routes.md) — `buildCampaignRoutes` is the single aggregator that materialises every `/campaigns/*` `PluginRouteEntry` in a canonical…
- [crud-routes](./src/routes/campaigns/crud-routes.md) — This module owns the five canonical CRUD entries for the `/campaigns` route tree: list, create, get, update, and delete…
- [schemas](./src/routes/campaigns/schemas.md) — This module is the OpenAPI-facing contract for the `/campaigns` route tree. Every incoming request body, query string,…
- [require-tenant](./src/routes/shared/require-tenant.md) — `requireTenant` extracts the request principal from the Hono context slot `c.get("user")` (populated by `@suaveplan/api…
- [strip-undefined](./src/routes/shared/strip-undefined.md) — `stripUndefined` is a route-layer adapter that bridges Zod's `optional()` output (`T | undefined`) with the `exactOptio…
- [build-routes](./src/routes/suppression/build-routes.md) — `buildSuppressionRoutes` aggregates the four `/suppression/*` plugin route entries in canonical order (list, check, cre…
- [routes](./src/routes/suppression/routes.md) — The suppression-route module owns the four endpoints that manage the `(tenantId, channel, channelValue)`-keyed suppress…
- [schemas](./src/routes/suppression/schemas.md) — The suppression-route schemas describe the OpenAPI contract for the `/suppression` route tree. The package's local `Cam…
- [build-routes](./src/routes/webhooks/build-routes.md) — `buildWebhookRoutes` aggregates the two `/webhooks/*` route entries in canonical order — delivery ingest first, then on…
- [routes](./src/routes/webhooks/routes.md) — The webhook-route module owns the two webhook endpoints: a delivery event ingestion endpoint that normalises and persis…
- [schemas](./src/routes/webhooks/schemas.md) — The webhook-route schemas describe the OpenAPI contract for the `/webhooks` route tree, plus a `decodeUnsubscribeToken`…
- [schemas](./src/schemas/options.md) — The `schemas/options` module owns the Zod validator that `createCampaignsPlugin` runs against its caller-supplied optio…
- [options](./src/schemas/options.md) — The `schemas/options` module owns the Zod validator that `createCampaignsPlugin` runs against its caller-supplied optio…
- [services](./src/services/registry-augmentation.md) — The `services/registry-augmentation` module owns the `ServiceRegistry` declaration-merge augmentation applied by `@suav…
- [registry-augmentation](./src/services/registry-augmentation.md) — The `services/registry-augmentation` module owns the `ServiceRegistry` declaration-merge augmentation applied by `@suav…
- [types](./src/types/types.md) — The `types` module exports the public type bundle for `@suaveplan/api-plugin-campaigns` — the option-input shape consum…
- [types](./src/types/types.md) — The `types` module exports the public type bundle for `@suaveplan/api-plugin-campaigns` — the option-input shape consum…
<!-- end:auto-docs -->
## Chunked migration status
This package is being being migrated in three sequential chunks so
each ships green on its own:
- **Chunk A — foundation (this version)** — package shell, error codes,
OTel instruments, Zod options schema, shared types, plugin scaffold with a
no-op `onInit` placeholder.
- **Chunk B — repositories** (follow-up) — `SurrealCampaignRepository`,
`CampaignSuppressionStore` + Surreal implementation, delivery-event store
wiring, schema DDL bundle, repository registration on the host
`ServiceRegistry`.
- **Chunk C — routes** (follow-up) — `/campaigns`, `/suppression`, and
`/webhooks` route trees registered via `definePluginRoutes`, integration
tests against a SurrealDB testcontainer.
## Quality gates
`bunx turbo lint typecheck test --filter=@suaveplan/api-plugin-campaigns`
plus `bunx turbo test:coverage --filter=@suaveplan/api-plugin-campaigns`
at 100/100/100/100 are required for every chunk to land.

View File

@@ -0,0 +1,83 @@
{
"name": "@suaveplan/api-plugin-campaigns",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Campaigns + suppression + delivery-webhooks HTTP plugin for @suaveplan/api. Mounts /campaigns, /suppression, and /webhooks on a Hono OpenAPI registry, backed by SurrealDB implementations of @suaveplan/campaigns CampaignStore and a local CampaignSuppressionStore, plus the @suaveplan/delivery-tracking SurrealDeliveryEventStore. Chunked migration (A: foundation, B: repositories, C: routes).",
"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" }
},
"./plugin/plugin": {
"import": { "types": "./dist/plugin/plugin.d.ts", "default": "./dist/plugin/plugin.js" }
},
"./error-codes/error-codes": {
"import": { "types": "./dist/error-codes/error-codes.d.ts", "default": "./dist/error-codes/error-codes.js" }
},
"./otel/instruments": {
"import": { "types": "./dist/otel/instruments.d.ts", "default": "./dist/otel/instruments.js" }
},
"./schemas/options": {
"import": { "types": "./dist/schemas/options.d.ts", "default": "./dist/schemas/options.js" }
},
"./types/types": {
"import": { "types": "./dist/types/types.d.ts", "default": "./dist/types/types.js" }
},
"./repositories/campaign/campaign": {
"import": { "types": "./dist/repositories/campaign/campaign.d.ts", "default": "./dist/repositories/campaign/campaign.js" }
},
"./repositories/suppression/suppression": {
"import": { "types": "./dist/repositories/suppression/suppression.d.ts", "default": "./dist/repositories/suppression/suppression.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": {
"@hono/zod-openapi": "catalog:server",
"@opentelemetry/api": "catalog:observability",
"@suaveplan/api-openapi": "workspace:^",
"@suaveplan/api-plugin": "workspace:^",
"@suaveplan/api-migrations": "workspace:^",
"@suaveplan/campaigns": "workspace:^",
"@suaveplan/delivery-tracking": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/surrealdb": "workspace:^",
"@suaveplan/types": "workspace:^",
"hono": "catalog:",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "catalog:observability",
"@suaveplan/events": "workspace:^",
"@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"]
}

View File

@@ -0,0 +1,72 @@
# `context`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `context` module owns the Hono `ContextVariableMap` declaration-merge
augmentation applied by `@suaveplan/api-plugin-campaigns`. Importing the
module for its side effects (which the package root barrel does
automatically) adds the typed slots the plugin's route handlers read with
`c.get(...)`, eliminating the `as any` casts that pile up in untyped Hono
middleware chains.
In Chunk A the only slot the plugin commits to is `eventBus`, because
that is the only context entry the no-op `onInit` placeholder would set if
it had a body. Chunks B and C will extend the declaration with the
repository handles (`campaignRepository`, `campaignSuppressionStore`,
`deliveryEventStore`) once those classes exist; consumers do not need to
re-import this module to pick the extensions up.
## Features
- Pure declaration-merge — zero runtime cost, zero bundle size.
- Re-exports an empty value (`export {}`) so the module participates in
ES-module side-effect ordering without polluting the consumer's
namespace.
- Slot type drawn from `@suaveplan/types` so the augmentation does not
drag in the api-plugin-campaigns implementation surface.
## Usage: Basic
```ts
import { Hono } from "hono";
import "@suaveplan/api-plugin-campaigns"; // also pulls this module via the root barrel
const app = new Hono();
app.get("/", (c) => {
const bus = c.get("eventBus");
bus.emit("campaign.read", { id: "x" });
return c.text("ok");
});
```
## Usage: Advanced
```ts
import "@suaveplan/api-plugin-campaigns/plugin/plugin"; // ensures the augmentation
import type { ContextVariableMap } from "hono";
type EventBus = ContextVariableMap["eventBus"];
```
## API Reference
- Adds `eventBus: EventBusLike` to Hono `ContextVariableMap`.
- No runtime exports.
## Implementation Notes
Module augmentations must live in a file with at least one `export`
statement to be picked up as a module rather than a script. The empty
`export {}` at the bottom is the standard idiom; removing it would
silently demote the file to a script and the `declare module "hono"`
block would stop applying.
Chunks B and C augment the same `ContextVariableMap` interface from
their own context modules, relying on TypeScript declaration merging to
union the slots together. That means agents working on later chunks
never edit this file — they add new files with their own
`declare module "hono"` blocks.

View File

@@ -0,0 +1,29 @@
/**
* Hono `ContextVariableMap` module augmentation for
* `@suaveplan/api-plugin-campaigns`. Importing this module for side
* effects (which the package root barrel does automatically) reserves
* the slots the plugin's `onInit` middleware populates on every Hono
* request so route handlers can read them via `c.get(...)` with full
* typing.
*
* Chunk C adds the three repository slots (`campaignRepository`,
* `campaignSuppressionStore`, `deliveryEventStore`) alongside the
* `eventBus` baseline declared in Chunk A. Slots are typed as the
* structural store interfaces — `CampaignStore`,
* `CampaignSuppressionStore`, `DeliveryEventStore` — so route handlers
* stay agnostic of the concrete (in-memory vs. SurrealDB) backend.
*/
import type { CampaignStore } from "@suaveplan/campaigns";
import type { DeliveryEventStore } from "@suaveplan/delivery-tracking";
import type { EventBusLike } from "@suaveplan/types";
import type { CampaignSuppressionStore } from "../repositories/suppression/suppression.types.js";
declare module "hono" {
interface ContextVariableMap {
eventBus: EventBusLike;
campaignRepository: CampaignStore;
campaignSuppressionStore: CampaignSuppressionStore;
deliveryEventStore: DeliveryEventStore;
}
}

View File

@@ -0,0 +1 @@
import "./context.js";

View File

@@ -0,0 +1,102 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The error-codes module owns the stable `CAMPAIGNS_API.*` namespace used by
every failure path inside `@suaveplan/api-plugin-campaigns`. Consumers
branch on the string `code` value (rather than `instanceof` checks against
private subclasses) so the namespace doubles as both an internal vocabulary
and a public-API contract surface.
This is the foundation module for the chunked migration. Chunk A registers
only the codes that the foundation surface can actually throw; Chunks B
(repositories) and C (routes) extend the same `defineCodes` call rather than
introducing parallel namespaces. The intentional minimalism keeps coverage
at 100% across the package boundary at every chunk milestone.
## Features
- `CAMPAIGNS_API_CODES` — frozen `defineCodes` registry mapping reason names
to `"CAMPAIGNS_API.<REASON>"` string values.
- `CampaignsApiError` — concrete subclass of `SuaveplanError` with an
explicit public constructor delegating to `super`, plus a typed
`campaignsApiCode` field that narrows to `CampaignsApiErrorCode`.
- `CampaignsApiErrorCode` — discriminated string-literal union of every
registered code.
## Usage: Basic
```ts
import {
CAMPAIGNS_API_CODES,
CampaignsApiError,
} from "@suaveplan/api-plugin-campaigns";
try {
createCampaignsPlugin({ routePrefix: 42 as never });
} catch (err) {
if (
err instanceof CampaignsApiError &&
err.code === CAMPAIGNS_API_CODES.INVALID_OPTIONS
) {
// The ZodError is attached as `cause`.
console.error(err.cause);
}
}
```
## Usage: Advanced
```ts
import {
CAMPAIGNS_API_CODES,
CampaignsApiError,
} from "@suaveplan/api-plugin-campaigns";
function rethrowAsCampaigns(cause: unknown): never {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.INTERNAL_ERROR,
"unhandled internal failure inside campaigns plugin",
{
cause: cause instanceof Error ? cause : new Error(String(cause)),
context: { statusCode: 500 },
}
);
}
```
## API Reference
- `CAMPAIGNS_API_CODES` — frozen record with one key per registered reason.
Chunk-A keys: `INVALID_OPTIONS`, `INTERNAL_ERROR`. Chunk-B additions:
`CAMPAIGN_NOT_FOUND`, `SUPPRESSION_NOT_FOUND`, `INVALID_INPUT`,
`DB_UNAVAILABLE`, `DUPLICATE_SUPPRESSION`, `TENANT_REQUIRED`. Chunk-C
additions: `INVALID_STATE_TRANSITION`, `INVALID_UNSUBSCRIBE_TOKEN`,
`ROUTE_PREFIX_COLLISION`, `WEBHOOK_REJECTED`, `SERVICE_NOT_AVAILABLE`.
Every value is the literal string `"CAMPAIGNS_API.<KEY>"`.
- `class CampaignsApiError extends SuaveplanError<CampaignsApiErrorCode>`
- `new CampaignsApiError(code, message, options?)`
- `error.code` — the namespaced code string.
- `error.campaignsApiCode` — same as `code`, typed narrowly.
- `error.cause` / `error.context` — inherited from `SuaveplanError`.
- `type CampaignsApiErrorCode` — string-literal union over every value of
`CAMPAIGNS_API_CODES` (currently eight codes spanning Chunks A and B).
## Implementation Notes
The constructor is `public` rather than `protected` because consumers
(test suites and integration scaffolds) construct the error directly
without subclassing. `defineCodes` returns a frozen `Object.create(null)`,
so `Object.isFrozen(CAMPAIGNS_API_CODES) === true` is an invariant the
test suite enforces — accidentally swapping it for a plain object literal
would silently weaken the API contract and tests would catch it.
The Chunk-A registration deliberately excludes codes that Chunks B and C
will need. Adding unthrown codes here now would either depress branch
coverage or force placeholder tests that exercise dead code paths;
incremental registration aligned with each chunk's actual throw sites
keeps the verification gate honest.

View File

@@ -0,0 +1,119 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CAMPAIGNS_API_CODES, CampaignsApiError } from "./error-codes.js";
describe("CAMPAIGNS_API_CODES", () => {
it("every registered code starts with the CAMPAIGNS_API namespace", {
timeout: 5000,
}, () => {
for (const value of Object.values(CAMPAIGNS_API_CODES)) {
expect(value.startsWith("CAMPAIGNS_API.")).toBe(true);
}
});
it("registers the Chunk-A+B+C code set", { timeout: 5000 }, () => {
expect(Object.keys(CAMPAIGNS_API_CODES).sort()).toEqual([
"CAMPAIGN_NOT_FOUND",
"DB_UNAVAILABLE",
"DUPLICATE_SUPPRESSION",
"INTERNAL_ERROR",
"INVALID_INPUT",
"INVALID_OPTIONS",
"INVALID_STATE_TRANSITION",
"INVALID_UNSUBSCRIBE_TOKEN",
"ROUTE_PREFIX_COLLISION",
"SERVICE_NOT_AVAILABLE",
"SUPPRESSION_NOT_FOUND",
"TENANT_REQUIRED",
"WEBHOOK_REJECTED",
]);
});
it("Chunk-C codes carry the CAMPAIGNS_API. prefix", {
timeout: 5000,
}, () => {
expect(CAMPAIGNS_API_CODES.INVALID_STATE_TRANSITION).toBe(
"CAMPAIGNS_API.INVALID_STATE_TRANSITION"
);
expect(CAMPAIGNS_API_CODES.INVALID_UNSUBSCRIBE_TOKEN).toBe(
"CAMPAIGNS_API.INVALID_UNSUBSCRIBE_TOKEN"
);
expect(CAMPAIGNS_API_CODES.ROUTE_PREFIX_COLLISION).toBe(
"CAMPAIGNS_API.ROUTE_PREFIX_COLLISION"
);
expect(CAMPAIGNS_API_CODES.WEBHOOK_REJECTED).toBe(
"CAMPAIGNS_API.WEBHOOK_REJECTED"
);
expect(CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE).toBe(
"CAMPAIGNS_API.SERVICE_NOT_AVAILABLE"
);
});
it("Chunk-B codes carry the CAMPAIGNS_API. prefix", {
timeout: 5000,
}, () => {
expect(CAMPAIGNS_API_CODES.CAMPAIGN_NOT_FOUND).toBe(
"CAMPAIGNS_API.CAMPAIGN_NOT_FOUND"
);
expect(CAMPAIGNS_API_CODES.SUPPRESSION_NOT_FOUND).toBe(
"CAMPAIGNS_API.SUPPRESSION_NOT_FOUND"
);
expect(CAMPAIGNS_API_CODES.INVALID_INPUT).toBe(
"CAMPAIGNS_API.INVALID_INPUT"
);
expect(CAMPAIGNS_API_CODES.DB_UNAVAILABLE).toBe(
"CAMPAIGNS_API.DB_UNAVAILABLE"
);
expect(CAMPAIGNS_API_CODES.DUPLICATE_SUPPRESSION).toBe(
"CAMPAIGNS_API.DUPLICATE_SUPPRESSION"
);
expect(CAMPAIGNS_API_CODES.TENANT_REQUIRED).toBe(
"CAMPAIGNS_API.TENANT_REQUIRED"
);
});
it("registry is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(CAMPAIGNS_API_CODES)).toBe(true);
});
});
describe("CampaignsApiError", () => {
it("preserves cause", { timeout: 5000 }, () => {
const cause = new Error("boom");
const err = new CampaignsApiError(
CAMPAIGNS_API_CODES.INVALID_OPTIONS,
"bad options",
{ cause }
);
expect(err.cause).toBe(cause);
expect(err.code).toBe(CAMPAIGNS_API_CODES.INVALID_OPTIONS);
expect(err.campaignsApiCode).toBe(CAMPAIGNS_API_CODES.INVALID_OPTIONS);
});
it("carries context", { timeout: 5000 }, () => {
const err = new CampaignsApiError(
CAMPAIGNS_API_CODES.INTERNAL_ERROR,
"internal",
{ context: { statusCode: 500, request: "x" } }
);
expect(err.context).toEqual({ statusCode: 500, request: "x" });
});
it("is instanceof CampaignsApiError + Error", { timeout: 5000 }, () => {
const err = new CampaignsApiError(
CAMPAIGNS_API_CODES.INTERNAL_ERROR,
"x"
);
expect(err).toBeInstanceOf(CampaignsApiError);
expect(err).toBeInstanceOf(Error);
});
it("accepts no options argument (default undefined)", {
timeout: 5000,
}, () => {
const err = new CampaignsApiError(
CAMPAIGNS_API_CODES.INVALID_OPTIONS,
"x"
);
expect(err.cause).toBeUndefined();
});
});

View File

@@ -0,0 +1,91 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-plugin-campaigns`.
*
* Chunked migration: Chunk A registers only the codes thrown by the
* foundation surface (`createCampaignsPlugin` option validation and the
* no-op `onInit` placeholder's catch-all). Chunks B and C MUST extend
* this `defineCodes` call rather than introducing a parallel namespace —
* see `tasks.md §B.4` for the follow-on additions
* (`CAMPAIGN_NOT_FOUND`, `SUPPRESSION_NOT_FOUND`,
* `INVALID_STATE_TRANSITION`, `INVALID_UNSUBSCRIBE_TOKEN`,
* `ROUTE_PREFIX_COLLISION`, `DUPLICATE_SUPPRESSION`, `TENANT_REQUIRED`,
* `WEBHOOK_REJECTED`, `DB_UNAVAILABLE`).
*
* Each `CampaignsApiError` carries one of the `CAMPAIGNS_API.*` codes;
* consumers branch on the string value
* (`error.code === CAMPAIGNS_API_CODES.INVALID_OPTIONS`).
*
* - `INVALID_OPTIONS` — `createCampaignsPlugin(options)` received an
* `options` object that failed `CampaignsPluginOptionsSchema`
* validation. `cause` holds the `ZodError`.
* - `INTERNAL_ERROR` — unrecoverable internal failure raised by the
* plugin scaffold; used for catch-all paths that should never trigger
* in healthy operation.
* - `CAMPAIGN_NOT_FOUND` / `SUPPRESSION_NOT_FOUND` — lookups against
* the campaign or suppression repositories returned no row, or the
* row belonged to a different tenant.
* - `INVALID_INPUT` — Zod validation at a repository or route boundary
* rejected user input.
* - `DB_UNAVAILABLE` — the underlying `SurrealAdapter` threw — wrapped
* so callers can branch on a stable code.
* - `DUPLICATE_SUPPRESSION` — `(tenantId, channel, channelValue)` was
* already present when `add` ran.
* - `TENANT_REQUIRED` — a repository read was called with an empty
* tenant string.
* - `INVALID_STATE_TRANSITION` (Chunk C) — a campaign action route
* (`/send`, `/pause`, `/cancel`, …) was invoked against a campaign
* whose current status forbids that event.
* - `INVALID_UNSUBSCRIBE_TOKEN` (Chunk C) — `GET /webhooks/unsubscribe/:token`
* received a token that could not be decoded or lacked a `contactId`.
* - `ROUTE_PREFIX_COLLISION` (Chunk C) — the configured `routePrefix`
* collides with routes already registered on the host's
* `OpenAPIRegistry`.
* - `WEBHOOK_REJECTED` (Chunk C) — webhook ingestion rejected the
* request (malformed body, signature failure, etc.).
* - `SERVICE_NOT_AVAILABLE` (Chunk C) — `onInit` could not resolve a
* required ServiceRegistry slot, or a Hono handler found its repo
* context slot empty.
*/
export const CAMPAIGNS_API_CODES = defineCodes("CAMPAIGNS_API", [
"INVALID_OPTIONS",
"INTERNAL_ERROR",
"CAMPAIGN_NOT_FOUND",
"SUPPRESSION_NOT_FOUND",
"INVALID_INPUT",
"DB_UNAVAILABLE",
"DUPLICATE_SUPPRESSION",
"TENANT_REQUIRED",
"INVALID_STATE_TRANSITION",
"INVALID_UNSUBSCRIBE_TOKEN",
"ROUTE_PREFIX_COLLISION",
"WEBHOOK_REJECTED",
"SERVICE_NOT_AVAILABLE",
] as const);
/** Discriminated string-literal union of every `CAMPAIGNS_API.*` code. */
export type CampaignsApiErrorCode =
(typeof CAMPAIGNS_API_CODES)[keyof typeof CAMPAIGNS_API_CODES];
/**
* Concrete error class for the api-plugin-campaigns package. Extends
* `SuaveplanError` so `instanceof CampaignsApiError` narrows the `code`
* field to `CampaignsApiErrorCode`. The constructor is intentionally
* explicit (delegating straight to `super`) so consumers can subclass
* or stub the error in tests without TypeScript inferring a private
* constructor.
*/
export class CampaignsApiError extends SuaveplanError<CampaignsApiErrorCode> {
public readonly campaignsApiCode: CampaignsApiErrorCode;
public constructor(
code: CampaignsApiErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.campaignsApiCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export {
CAMPAIGNS_API_CODES,
CampaignsApiError,
type CampaignsApiErrorCode,
} from "./error-codes.js";

View File

@@ -0,0 +1,131 @@
/**
* `@suaveplan/api-plugin-campaigns` — Campaigns + suppression +
* delivery-webhooks HTTP plugin for `@suaveplan/api`. Chunk C wires
* the full route surface: `/campaigns`, `/suppression`, `/webhooks`
* mounted via `definePluginRoutes` against the host's
* `OpenAPIRegistry`.
*/
// Side-effect imports — register the error-codes namespace and the
// ServiceRegistry / ContextVariableMap slots for every consumer.
import "./error-codes/error-codes.js";
import "./context/context.js";
import "./services/registry-augmentation.js";
// Error codes
export {
CAMPAIGNS_API_CODES,
CampaignsApiError,
type CampaignsApiErrorCode,
} from "./error-codes/error-codes.js";
// OpenTelemetry instruments
export {
type CampaignsApiInstruments,
getCampaignsApiInstruments,
resetCampaignsApiInstruments,
} from "./otel/instruments.js";
// Plugin factory + lifecycle
export { createCampaignsPlugin, runOnInit } from "./plugin/plugin.js";
// Repositories
export {
InMemoryCampaignStore,
SurrealCampaignRepository,
} from "./repositories/campaign/campaign.js";
export type {
CampaignRow,
SurrealCampaignRepositoryOptions,
} from "./repositories/campaign/campaign.types.js";
export {
CreateInputSchema as SuppressionCreateInputSchema,
QuerySchema as SuppressionQuerySchema,
SurrealCampaignSuppressionRepository,
} from "./repositories/suppression/suppression.js";
export type {
CampaignSuppressionEntry,
CampaignSuppressionQuery,
CampaignSuppressionReason,
CampaignSuppressionRow,
CampaignSuppressionStore,
CreateCampaignSuppressionInput,
InMemoryCampaignSuppressionStoreOptions,
SurrealCampaignSuppressionRepositoryOptions,
} from "./repositories/suppression/suppression.types.js";
export { InMemoryCampaignSuppressionStore } from "./repositories/suppression/suppression-memory.js";
// Routes
export {
buildCampaignEventsRoute,
buildCampaignRoutes,
buildCampaignStatsRoute,
buildCancelCampaignRoute,
buildCheckSuppressionRoute,
buildCreateCampaignRoute,
buildCreateSuppressionRoute,
buildDeleteCampaignRoute,
buildDeleteSuppressionRoute,
buildDeliveryWebhookRoute,
buildDuplicateCampaignRoute,
buildGetCampaignRoute,
buildListCampaignsRoute,
buildListSuppressionRoute,
buildPauseCampaignRoute,
buildSendCampaignRoute,
buildSuppressionRoutes,
buildTestSendRoute,
buildUnsubscribeRoute,
buildUpdateCampaignRoute,
buildWebhookRoutes,
CampaignIdParamSchema,
CampaignListResponseSchema,
type CampaignQueryInput,
CampaignQuerySchema,
CampaignResponseSchema,
CampaignStatsSchema,
CampaignStatusSchema,
CampaignTypeSchema,
type CreateCampaignBody,
CreateCampaignSchema,
type CreateSuppressionBody,
CreateSuppressionSchema,
campaignToResponse,
DeliveryEventTypeSchema,
DeliveryStatsResponseSchema,
type DeliveryWebhookBody,
DeliveryWebhookResponseSchema,
DeliveryWebhookSchema,
decodeUnsubscribeToken,
EMPTY_STATS,
EventQuerySchema,
requireTenant,
SuppressionCheckQuerySchema,
SuppressionCheckResponseSchema,
SuppressionIdParamSchema,
SuppressionListResponseSchema,
type SuppressionQueryInput,
SuppressionQuerySchema as SuppressionRouteQuerySchema,
SuppressionReasonSchema,
SuppressionResponseSchema,
stripUndefined,
type TenantPrincipal,
TestSendSchema,
UnsubscribeResponseSchema,
UnsubscribeTokenParamSchema,
type UnsubscribeTokenPayload,
type UpdateCampaignBody,
UpdateCampaignSchema,
} from "./routes/index.js";
// Schemas
export {
type CampaignsPluginOptionsInput,
type CampaignsPluginOptionsParsed,
CampaignsPluginOptionsSchema,
} from "./schemas/options.js";
// Public types
export type {
CampaignsPluginConfig,
CampaignsPluginOptions,
} from "./types/types.js";

View File

@@ -0,0 +1,5 @@
export {
type CampaignsApiInstruments,
getCampaignsApiInstruments,
resetCampaignsApiInstruments,
} from "./instruments.js";

View File

@@ -0,0 +1,90 @@
# `otel/instruments`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `otel/instruments` module owns the OpenTelemetry instrument bundle
emitted by `@suaveplan/api-plugin-campaigns`. It binds a meter under the
`"@suaveplan/api-plugin-campaigns"` instrumentation-scope name against the
process-global `MeterProvider` and exposes a lazily-cached factory so the
plugin's route handlers, repositories, and lifecycle hooks share a single
counter / histogram pair without re-resolving the meter on every call.
The bundle is intentionally small in Chunk A: a request-count counter and a
request-duration histogram covering every route mounted by the plugin.
Chunks B and C extend it (in their own modules) with per-domain instruments
for campaign writes, suppression checks, and webhook ingestion outcomes.
## Features
- Lazy, cached meter binding — first call resolves the global meter; later
calls return the cached bundle so handlers can record metrics in their
hot path without provider lookup overhead.
- `resetCampaignsApiInstruments()` hook for tests that install a fresh
`MeterProvider` after the first cache write.
- Counter + histogram primitives only; aggregation strategy and views are
owned by the host's `MeterProvider`, not this package.
## Usage: Basic
```ts
import { getCampaignsApiInstruments } from "@suaveplan/api-plugin-campaigns";
const { requestsTotal, requestDurationMs } = getCampaignsApiInstruments();
const start = performance.now();
try {
await handler();
requestsTotal.add(1, { route: "/campaigns", outcome: "ok" });
} catch (err) {
requestsTotal.add(1, { route: "/campaigns", outcome: "error" });
throw err;
} finally {
requestDurationMs.record(performance.now() - start, {
route: "/campaigns",
});
}
```
## Usage: Advanced
```ts
import { metrics } from "@opentelemetry/api";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import {
getCampaignsApiInstruments,
resetCampaignsApiInstruments,
} from "@suaveplan/api-plugin-campaigns";
// Test set-up: rebind to a freshly-installed provider.
const provider = new MeterProvider({ readers: [] });
metrics.setGlobalMeterProvider(provider);
resetCampaignsApiInstruments();
// The next resolve binds to `provider`, not the implicit global from
// before the rebind.
const bundle = getCampaignsApiInstruments();
bundle.requestsTotal.add(1, { route: "/x", outcome: "ok" });
```
## API Reference
- `interface CampaignsApiInstruments { readonly requestsTotal: Counter; readonly requestDurationMs: Histogram }`
- `getCampaignsApiInstruments(): CampaignsApiInstruments` — cached factory.
- `resetCampaignsApiInstruments(): void` — drops the cache.
## Implementation Notes
The cache is a module-level mutable binding. That is intentional and
matches the upstream pattern in `@suaveplan/api-plugin-contacts`: an
inert no-op metric is cheap to recreate, but creating one per request is
not, so caching plus an explicit reset hook for tests is the simplest
honest design.
The bundle is `readonly` so handlers cannot accidentally mutate it; the
cache itself is mutable but only the reset hook reaches it. Reset is
test-only — production code never invalidates the cache because the
global provider is installed once at process start.

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
getCampaignsApiInstruments,
resetCampaignsApiInstruments,
} from "./instruments.js";
describe("getCampaignsApiInstruments", () => {
it("returns a bundle with both required instruments", {
timeout: 5000,
}, () => {
resetCampaignsApiInstruments();
const bundle = getCampaignsApiInstruments();
expect(bundle.requestsTotal).toBeDefined();
expect(bundle.requestDurationMs).toBeDefined();
});
it("caches the bundle across calls", { timeout: 5000 }, () => {
resetCampaignsApiInstruments();
const a = getCampaignsApiInstruments();
const b = getCampaignsApiInstruments();
expect(a).toBe(b);
});
it("rebinds after resetCampaignsApiInstruments()", {
timeout: 5000,
}, () => {
resetCampaignsApiInstruments();
const a = getCampaignsApiInstruments();
resetCampaignsApiInstruments();
const b = getCampaignsApiInstruments();
expect(a).not.toBe(b);
});
it("instruments accept record/add calls without throwing", {
timeout: 5000,
}, () => {
resetCampaignsApiInstruments();
const bundle = getCampaignsApiInstruments();
expect(() =>
bundle.requestsTotal.add(1, { route: "/x", outcome: "ok" })
).not.toThrow();
expect(() =>
bundle.requestDurationMs.record(42, { route: "/x" })
).not.toThrow();
});
});

View File

@@ -0,0 +1,57 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
/**
* Bundle of OpenTelemetry instruments emitted by the campaigns API
* plugin. Chunk A defines the two cross-cutting baseline instruments
* shared by every route group; Chunks B and C extend this interface
* with per-domain counters (campaign writes, suppression checks,
* webhook ingestion, etc.) by augmenting the bundle in their own
* follow-up modules.
*
* - `requestsTotal{route,outcome}` — every HTTP request handled by the
* plugin's route trees, partitioned by route and outcome.
* - `requestDurationMs{route}` — wall-time histogram of every request
* handled by the plugin, in milliseconds.
*/
export interface CampaignsApiInstruments {
readonly requestsTotal: Counter;
readonly requestDurationMs: Histogram;
}
let cached: CampaignsApiInstruments | undefined;
/**
* Lazily build (and cache) the campaigns-plugin instrument bundle
* against the current global `MeterProvider`. Call
* {@link resetCampaignsApiInstruments} in test setup to force a re-bind
* against a freshly installed provider.
*/
export function getCampaignsApiInstruments(): CampaignsApiInstruments {
if (cached !== undefined) return cached;
const meter = metrics.getMeter("@suaveplan/api-plugin-campaigns");
cached = {
requestsTotal: meter.createCounter("campaigns_api.requests_total", {
description:
"Total HTTP requests handled by the campaigns plugin, partitioned by route + outcome",
}),
requestDurationMs: meter.createHistogram(
"campaigns_api.request_duration_ms",
{
description:
"Wall-time of campaigns-plugin requests in milliseconds, partitioned by route",
unit: "ms",
}
),
};
return cached;
}
/**
* Drop the cached instrument bundle so the next
* {@link getCampaignsApiInstruments} call rebinds against the global
* `MeterProvider` in effect at that point. Tests call this after
* installing a fresh `MeterProvider`.
*/
export function resetCampaignsApiInstruments(): void {
cached = undefined;
}

View File

@@ -0,0 +1 @@
export { createCampaignsPlugin, runOnInit } from "./plugin.js";

View File

@@ -0,0 +1,101 @@
# `plugin`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `plugin` module exports `createCampaignsPlugin` — the
`PluginDescriptor` factory consumed by `@suaveplan/api` hosts. It owns
the option-validation, dependency-declaration, and lifecycle-hook
seam for the campaigns plugin. As of Chunk C the `onInit` hook is
fully wired: it resolves the required services from the host
`ServiceRegistry`, constructs the SurrealDB-backed `CampaignStore`,
`CampaignSuppressionStore`, and `DeliveryEventStore` implementations,
registers them on both the registry and the Hono `ContextVariableMap`
via a single mount-pattern middleware, and mounts every route group
through `definePluginRoutes`.
The descriptor declares `dependencies: ["database", "auth", "contacts"]`
so the host plugin registry topo-sorts the campaigns plugin after the
three prerequisites — without `database` the `db` service slot is
empty (and `onInit` throws `SERVICE_NOT_AVAILABLE`); without `auth`
the per-request `requireTenant` guard rejects every call.
## Features
- Zod-validated options via `CampaignsPluginOptionsSchema`; invalid
input surfaces as `CampaignsApiError(INVALID_OPTIONS)` with the
`ZodError` attached as `cause`.
- Service resolution: requires `app` and `db` slots; throws
`CampaignsApiError(SERVICE_NOT_AVAILABLE)` if either is empty.
- Route-prefix collision detection: throws
`CampaignsApiError(ROUTE_PREFIX_COLLISION)` if the host
`OpenAPIRegistry` already carries routes under the configured prefix.
- Hono context injection: a single `app.use(mountPattern, ...)`
middleware populates `campaignRepository`,
`campaignSuppressionStore`, `deliveryEventStore`, and `eventBus` on
every request inside the prefix.
- Route mounting: 18 routes across `/campaigns`, `/suppression`, and
`/webhooks` are registered through `definePluginRoutes` (when
`openAPIRegistry` is available) or directly via `app[method]`
(registry-less fallback for test scaffolds).
## Usage: Basic
```ts
import { createCampaignsPlugin } from "@suaveplan/api-plugin-campaigns";
import { createPluginRegistry } from "@suaveplan/api-plugin";
const registry = createPluginRegistry({ /* host config */ });
registry.register(createCampaignsPlugin());
```
## Usage: Advanced
```ts
import { createCampaignsPlugin } from "@suaveplan/api-plugin-campaigns";
const descriptor = createCampaignsPlugin({
routePrefix: "/api/v1",
maxCampaignsPerTenant: 50_000,
maxRecipientsPerCampaign: 5_000_000,
defaultThrottlePerSecond: 250,
applySchemaOnInit: false,
logger: {
debug: (msg) => process.stderr.write(`[debug] ${msg}\n`),
info: (msg) => process.stderr.write(`[info ] ${msg}\n`),
warn: (msg) => process.stderr.write(`[warn ] ${msg}\n`),
error: (msg) => process.stderr.write(`[error] ${msg}\n`),
},
});
```
## API Reference
- `createCampaignsPlugin(options?: CampaignsPluginOptions): PluginDescriptor<CampaignsPluginConfig>`
- `runOnInit(ctx: PluginContext<CampaignsPluginConfig>, config: CampaignsPluginConfig): Promise<void>`
- Exported so tests can drive the hook directly without going
through the registry.
- Throws `CampaignsApiError(CAMPAIGNS_API.INVALID_OPTIONS)` on a Zod
parse failure.
- Throws `CampaignsApiError(CAMPAIGNS_API.SERVICE_NOT_AVAILABLE)` if
`app` or `db` services are not registered.
- Throws `CampaignsApiError(CAMPAIGNS_API.ROUTE_PREFIX_COLLISION)` if
the host registry already has routes under the configured prefix.
## Implementation Notes
The mount-pattern middleware uses `*` for the root prefix (`"/"`) and
`<prefix>/*` for any non-root prefix. This is the same pattern used by
`@suaveplan/api-plugin-contacts` so plugins compose cleanly when both
are loaded with the same prefix — Hono's middleware chain dispatches
the matching pattern, not a globally-shared closure.
Logger resolution follows the pattern in `@suaveplan/api-plugin-contacts`:
caller-supplied `config.logger` takes precedence over the host-supplied
`ctx.logger`. The warn-level message in the registry-less fallback is
the only non-debug log this plugin emits during init; it documents the
fact that the plugin mounted but its routes are not in the OpenAPI
spec, which is almost always operationally significant.

View File

@@ -0,0 +1,297 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import { EventBus } from "@suaveplan/events";
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CAMPAIGNS_API_CODES,
CampaignsApiError,
} from "../error-codes/error-codes.js";
import { createCampaignsPlugin, runOnInit } from "./plugin.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
function makeFakeDb(): SurrealAdapter {
return {
async execute() {},
async query() {
return [];
},
} as unknown as SurrealAdapter;
}
function makeCtx(
overrides: {
app?: OpenAPIHono | undefined;
db?: SurrealAdapter | undefined;
registry?: ReturnType<typeof createOpenAPIRegistry> | undefined;
logger?: typeof noopLogger;
} = {}
) {
const app = overrides.app ?? new OpenAPIHono();
const services = new Map<string, unknown>();
if (app !== undefined) services.set("app", app);
if (overrides.db !== undefined) services.set("db", overrides.db);
if (overrides.registry !== undefined) {
services.set("openAPIRegistry", overrides.registry);
}
const ctx = createPluginContext({
logger: overrides.logger ?? noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: {} as never,
services,
});
return { ctx, app, services };
}
describe("createCampaignsPlugin", () => {
it("returns a typed descriptor with default config", {
timeout: 5000,
}, () => {
const desc = createCampaignsPlugin();
expect(desc.name).toBe("campaigns");
expect(desc.version).toBe("0.1.0");
expect(desc.dependencies).toEqual(["database", "auth", "contacts"]);
const defaults = desc.defaults as NonNullable<typeof desc.defaults>;
expect(defaults.routePrefix).toBe("/");
expect(defaults.maxCampaignsPerTenant).toBe(10_000);
expect(defaults.maxRecipientsPerCampaign).toBe(1_000_000);
expect(defaults.defaultThrottlePerSecond).toBe(100);
expect(defaults.applySchemaOnInit).toBe(true);
});
it("throws CAMPAIGNS_API.INVALID_OPTIONS on invalid options", {
timeout: 5000,
}, () => {
expect(() =>
createCampaignsPlugin({ maxCampaignsPerTenant: -1 } as never)
).toThrowError(CampaignsApiError);
});
it("INVALID_OPTIONS error carries the ZodError as cause", {
timeout: 5000,
}, () => {
try {
createCampaignsPlugin({ routePrefix: "" } as never);
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(CampaignsApiError);
const ce = err as CampaignsApiError;
expect(ce.code).toBe(CAMPAIGNS_API_CODES.INVALID_OPTIONS);
expect(ce.cause).toBeDefined();
}
});
it("honours a custom routePrefix", { timeout: 5000 }, () => {
const desc = createCampaignsPlugin({ routePrefix: "/api/v1" });
const defaults = desc.defaults as NonNullable<typeof desc.defaults>;
expect(defaults.routePrefix).toBe("/api/v1");
});
it("descriptor advertises name, dependencies, version, tags", {
timeout: 5000,
}, () => {
const desc = createCampaignsPlugin();
expect(desc.tags).toEqual([
"campaigns",
"suppression",
"webhooks",
"delivery",
]);
});
it("stores a logger on the resolved config when supplied", {
timeout: 5000,
}, () => {
const desc = createCampaignsPlugin({ logger: { ...noopLogger } });
const defaults = desc.defaults as NonNullable<typeof desc.defaults>;
const out = defaults.logger as NonNullable<typeof defaults.logger>;
expect(typeof out.debug).toBe("function");
expect(typeof out.info).toBe("function");
expect(typeof out.warn).toBe("function");
expect(typeof out.error).toBe("function");
});
it("omits the logger slot when none supplied", { timeout: 5000 }, () => {
const desc = createCampaignsPlugin();
const defaults = desc.defaults as NonNullable<typeof desc.defaults>;
expect("logger" in defaults).toBe(false);
});
});
describe("plugin onInit (Chunk C wiring)", () => {
it("throws SERVICE_NOT_AVAILABLE when app slot missing", {
timeout: 5000,
}, async () => {
const services = new Map<string, unknown>();
services.set("db", makeFakeDb());
const ctx = createPluginContext({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: {} as never,
services,
});
const desc = createCampaignsPlugin();
await expect(
runOnInit(ctx as never, desc.defaults as never)
).rejects.toMatchObject({
code: CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE,
});
});
it("throws SERVICE_NOT_AVAILABLE when db slot missing", {
timeout: 5000,
}, async () => {
const { ctx } = makeCtx({});
const desc = createCampaignsPlugin();
await expect(
runOnInit(ctx as never, desc.defaults as never)
).rejects.toMatchObject({
code: CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE,
});
});
it("registers campaign + suppression + delivery-event services", {
timeout: 5000,
}, async () => {
const { ctx } = makeCtx({ db: makeFakeDb() });
const desc = createCampaignsPlugin();
await runOnInit(ctx as never, desc.defaults as never);
expect(ctx.getService("campaignRepository")).toBeDefined();
expect(ctx.getService("campaignSuppressionStore")).toBeDefined();
expect(ctx.getService("deliveryEventStore")).toBeDefined();
});
it("warns when openAPIRegistry is absent and still mounts handlers", {
timeout: 5000,
}, async () => {
const messages: string[] = [];
const logger = {
...noopLogger,
warn: (msg: string) => messages.push(msg),
};
const { ctx } = makeCtx({ db: makeFakeDb(), logger });
const desc = createCampaignsPlugin();
await runOnInit(ctx as never, desc.defaults as never);
expect(messages.some((m) => m.includes("openAPIRegistry"))).toBe(true);
});
it("registers routes through the OpenAPI registry when provided", {
timeout: 5000,
}, async () => {
const registry = createOpenAPIRegistry({
info: { title: "t", version: "0.0.0" },
});
const { ctx } = makeCtx({ db: makeFakeDb(), registry });
const desc = createCampaignsPlugin();
await runOnInit(ctx as never, desc.defaults as never);
const paths = registry.getRoutes().map((r) => r.path);
expect(paths).toContain("/campaigns");
expect(paths).toContain("/suppression");
expect(paths).toContain("/webhooks/delivery");
});
it("raises ROUTE_PREFIX_COLLISION on re-run with same registry", {
timeout: 5000,
}, async () => {
const registry = createOpenAPIRegistry({
info: { title: "t", version: "0.0.0" },
});
const { ctx } = makeCtx({ db: makeFakeDb(), registry });
const desc = createCampaignsPlugin();
await runOnInit(ctx as never, desc.defaults as never);
await expect(
runOnInit(ctx as never, desc.defaults as never)
).rejects.toMatchObject({
code: CAMPAIGNS_API_CODES.ROUTE_PREFIX_COLLISION,
});
});
it("honours a custom routePrefix when no registry is available", {
timeout: 5000,
}, async () => {
const { ctx, app } = makeCtx({ db: makeFakeDb() });
const desc = createCampaignsPlugin({ routePrefix: "/api" });
await runOnInit(ctx as never, desc.defaults as never);
const res = await app.request("/api/campaigns");
expect([200, 401, 500]).toContain(res.status);
});
it("prefixes registry-mounted routes when routePrefix is non-root", {
timeout: 5000,
}, async () => {
const registry = createOpenAPIRegistry({
info: { title: "t", version: "0.0.0" },
});
const { ctx } = makeCtx({ db: makeFakeDb(), registry });
const desc = createCampaignsPlugin({ routePrefix: "/api" });
await runOnInit(ctx as never, desc.defaults as never);
const paths = registry.getRoutes().map((r) => r.path);
expect(paths).toContain("/api/campaigns");
expect(paths).toContain("/api/suppression");
});
it("descriptor onInit hook reaches runOnInit", {
timeout: 5000,
}, async () => {
const { ctx } = makeCtx({ db: makeFakeDb() });
const desc = createCampaignsPlugin();
const hook = desc.onInit;
expect(hook).toBeDefined();
await expect(
(hook as NonNullable<typeof hook>)(ctx as never)
).resolves.toBeUndefined();
});
it("exercises the default clock + idGenerator via store.add", {
timeout: 5000,
}, async () => {
const calls: Array<{ sql: string; params: readonly unknown[] }> = [];
const db = {
async execute() {},
async query(sql: string, params: readonly unknown[]) {
calls.push({ sql, params });
return [];
},
} as unknown as SurrealAdapter;
const { ctx } = makeCtx({ db });
const desc = createCampaignsPlugin();
await runOnInit(ctx as never, desc.defaults as never);
const store = ctx.getService("campaignSuppressionStore");
if (store === undefined) throw new Error("store missing");
await store.add({
tenantId: "t",
channel: "email",
channelValue: "a@b.com",
reason: "manual",
});
// idGenerator (params[0]) and clock-stamped createdAt (params[7])
// should both be set by the defaults.
const addCall = calls.find((c) => c.sql.startsWith("INSERT"));
expect(addCall).toBeDefined();
expect(typeof addCall?.params[0]).toBe("string");
expect(addCall?.params[7]).toBeInstanceOf(Date);
});
it("config.logger overrides ctx.logger for warn path", {
timeout: 5000,
}, async () => {
const messages: string[] = [];
const logger = {
...noopLogger,
warn: (msg: string) => messages.push(msg),
};
const { ctx } = makeCtx({ db: makeFakeDb() });
const desc = createCampaignsPlugin({ logger });
await runOnInit(ctx as never, desc.defaults as never);
expect(messages.some((m) => m.includes("openAPIRegistry"))).toBe(true);
});
});

View File

@@ -0,0 +1,235 @@
/**
* `createCampaignsPlugin` — `PluginDescriptor` factory for
* `@suaveplan/api`. Chunk C wires the full `onInit`: option
* validation, descriptor wiring, ServiceRegistry resolution,
* repository + delivery-event-store construction, Hono context
* injection, and route mounting via `definePluginRoutes`.
*/
import type { OpenAPIHono } from "@hono/zod-openapi";
import {
definePluginRoutes,
type OpenAPIRegistry,
} from "@suaveplan/api-openapi";
import {
definePlugin,
type PluginContext,
type PluginDescriptor,
} from "@suaveplan/api-plugin";
import type { CampaignStore } from "@suaveplan/campaigns";
import {
type DeliveryEventStore,
SurrealDeliveryEventStore,
} from "@suaveplan/delivery-tracking";
import {
CAMPAIGNS_API_CODES,
CampaignsApiError,
} from "../error-codes/error-codes.js";
import { SurrealCampaignRepository } from "../repositories/campaign/campaign.js";
import { SurrealCampaignSuppressionRepository } from "../repositories/suppression/suppression.js";
import type { CampaignSuppressionStore } from "../repositories/suppression/suppression.types.js";
import { buildCampaignRoutes } from "../routes/campaigns/build-routes.js";
import { buildSuppressionRoutes } from "../routes/suppression/build-routes.js";
import { buildWebhookRoutes } from "../routes/webhooks/build-routes.js";
import { CampaignsPluginOptionsSchema } from "../schemas/options.js";
import type {
CampaignsPluginConfig,
CampaignsPluginOptions,
} from "../types/types.js";
import "../context/context.js";
import "../services/registry-augmentation.js";
/**
* Factory — returns the typed `PluginDescriptor`. Zod-validates
* `options` at construction time; invalid options surface as
* `CampaignsApiError(INVALID_OPTIONS)` with the `ZodError` carried as
* `cause`. The descriptor declares
* `dependencies: ["database", "auth", "contacts"]` so the host
* topo-sorts after the three prerequisite plugins.
*/
export function createCampaignsPlugin(
options: CampaignsPluginOptions = {}
): PluginDescriptor<CampaignsPluginConfig> {
const parsed = CampaignsPluginOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.INVALID_OPTIONS,
"createCampaignsPlugin received invalid options",
{ cause: parsed.error, context: { statusCode: 500 } }
);
}
const resolved: CampaignsPluginConfig = {
routePrefix: parsed.data.routePrefix,
maxCampaignsPerTenant: parsed.data.maxCampaignsPerTenant,
maxRecipientsPerCampaign: parsed.data.maxRecipientsPerCampaign,
defaultThrottlePerSecond: parsed.data.defaultThrottlePerSecond,
applySchemaOnInit: parsed.data.applySchemaOnInit,
...(parsed.data.logger !== undefined
? { logger: parsed.data.logger }
: {}),
};
return definePlugin<CampaignsPluginConfig>({
name: "campaigns",
version: "0.1.0",
description:
"Campaigns + suppression + delivery-webhooks HTTP plugin for @suaveplan/api. Mounts /campaigns, /suppression, and /webhooks on a Hono OpenAPI registry, backed by SurrealDB-backed CampaignStore + CampaignSuppressionStore + DeliveryEventStore.",
dependencies: ["database", "auth", "contacts"],
defaults: resolved,
tags: ["campaigns", "suppression", "webhooks", "delivery"],
onInit: async (ctx) => runOnInit(ctx, resolved),
});
}
/** Default wall-clock used when no `clock` service is registered. */
const defaultClock = (): number => Date.now();
/** Default id-generator used to stamp suppression-entry primary keys. */
const defaultIdGenerator = (): string => crypto.randomUUID();
interface ResolvedServices {
readonly app: OpenAPIHono;
readonly openAPIRegistry: OpenAPIRegistry | undefined;
readonly campaignRepository: CampaignStore;
readonly suppressionStore: CampaignSuppressionStore;
readonly deliveryEventStore: DeliveryEventStore;
}
function resolveServices(
ctx: PluginContext<CampaignsPluginConfig>
): ResolvedServices {
const app = ctx.getService("app");
if (app === undefined) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE,
"campaigns plugin requires the `app` service in ServiceRegistry",
{ context: { missing: ["app"], statusCode: 500 } }
);
}
const db = ctx.getService("db");
const openAPIRegistry = ctx.getService("openAPIRegistry");
if (db === undefined) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE,
"campaigns plugin requires the `db` service — load @suaveplan/api-plugin-database first",
{ context: { missing: ["db"], statusCode: 500 } }
);
}
const clock = ctx.getService("clock") ?? defaultClock;
const idGenerator = defaultIdGenerator;
const campaignRepository = new SurrealCampaignRepository({
db,
clock,
});
const suppressionStore = new SurrealCampaignSuppressionRepository({
db,
clock,
idGenerator,
});
const deliveryEventStore = new SurrealDeliveryEventStore(db);
return {
app,
openAPIRegistry,
campaignRepository,
suppressionStore,
deliveryEventStore,
};
}
function detectCollision(registry: OpenAPIRegistry, routePrefix: string): void {
const collisionPrefix =
routePrefix === "/" ? "/campaigns" : `${routePrefix}/campaigns`;
const existing = registry
.getRoutes()
.filter((r) => r.path.startsWith(collisionPrefix));
if (existing.length > 0) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.ROUTE_PREFIX_COLLISION,
`Route prefix '${routePrefix}' collides with previously registered routes`,
{
context: {
prefix: routePrefix,
existing: existing.map((r) => r.path),
statusCode: 500,
},
}
);
}
}
/**
* Chunk-C `onInit`. Resolves required services from the host
* `ServiceRegistry`, constructs the campaign + suppression +
* delivery-event-store implementations, registers them on both the
* registry and the Hono `ContextVariableMap` via a single
* mount-pattern middleware, and finally mounts every route group.
*/
export async function runOnInit(
ctx: PluginContext<CampaignsPluginConfig>,
config: CampaignsPluginConfig
): Promise<void> {
const services = resolveServices(ctx);
const {
app,
openAPIRegistry,
campaignRepository,
suppressionStore,
deliveryEventStore,
} = services;
const logger = config.logger ?? ctx.logger;
ctx.registerService("campaignRepository", campaignRepository);
ctx.registerService("campaignSuppressionStore", suppressionStore);
ctx.registerService("deliveryEventStore", deliveryEventStore);
const eventBus = ctx.eventBus;
const prefix = config.routePrefix;
const mountPattern = prefix === "/" ? "*" : `${prefix}/*`;
app.use(mountPattern, async (c, next) => {
c.set("campaignRepository", campaignRepository);
c.set("campaignSuppressionStore", suppressionStore);
c.set("deliveryEventStore", deliveryEventStore);
c.set("eventBus", eventBus);
await next();
});
if (openAPIRegistry !== undefined) {
detectCollision(openAPIRegistry, prefix);
}
const entries = [
...buildCampaignRoutes(),
...buildSuppressionRoutes(),
...buildWebhookRoutes(),
];
if (openAPIRegistry !== undefined) {
const prefixedEntries = entries.map((entry) => ({
...entry,
route: {
...entry.route,
path: `${prefix === "/" ? "" : prefix}${entry.route.path}`,
},
}));
definePluginRoutes(prefixedEntries, {
app,
registry: openAPIRegistry,
pluginName: "campaigns",
});
return;
}
logger.warn(
"campaigns plugin: openAPIRegistry not registered — routes will not appear in /openapi.json"
);
for (const entry of entries) {
const method = entry.route.method as
| "get"
| "post"
| "put"
| "patch"
| "delete";
const path = `${prefix === "/" ? "" : prefix}${entry.route.path}`;
app[method](
path,
async (c) => (await entry.handler(c as never)) as Response
);
}
}

View File

@@ -0,0 +1,104 @@
# `repositories/campaign`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the SurrealDB-backed implementation of the campaign
persistence contract for `@suaveplan/api-plugin-campaigns`. It implements
the `CampaignStore` interface exported by `@suaveplan/campaigns` so the
in-process `CampaignEngine` and the HTTP route layer (Chunk C) can both
read and write campaign records through a single, swappable port.
The repository follows the suaveplan SurrealDB idiom established by
`@suaveplan/delivery-tracking`: every database call is a single
`SurrealAdapter.query<T>(sql, params)` invocation using positional
`$p0`/`$p1`/... placeholders, the SQL is auditable at a glance, and every
adapter exception is wrapped in a `CampaignsApiError` with a stable
`CAMPAIGNS_API.*` code so callers can branch on string equality rather
than inspecting driver internals.
## Features
- Positional SurQL parameter binding (`$p0`, `$p1`, ...) — no named-var
drift, no driver-specific quirks bleeding into our error messages.
- Snake-case column ↔ camelCase property mapping handled in a single
`rowToCampaign` helper so the column vocabulary lives in exactly one
place.
- Zod guards (`IdSchema`, `CampaignQuerySchema`) at the repository boundary
— invalid input throws `INVALID_INPUT` before any SurQL is issued.
- Idiomatic `update()` short-circuit: an empty `UpdateCampaignInput` skips
the write and re-reads via `getById`, mirroring the upstream behaviour
but with proper `CAMPAIGN_NOT_FOUND` semantics.
- `InMemoryCampaignStore` re-export so consumers always have a single
import surface regardless of backend choice.
## Usage: Basic
```ts
import { SurrealCampaignRepository } from "@suaveplan/api-plugin-campaigns/repositories/campaign/campaign";
import type { SurrealAdapter } from "@suaveplan/surrealdb";
declare const db: SurrealAdapter;
const repo = new SurrealCampaignRepository({ db, clock: () => Date.now() });
const campaign = await repo.getById("c-123");
```
## Usage: Advanced
```ts
import { SurrealCampaignRepository } from "@suaveplan/api-plugin-campaigns/repositories/campaign/campaign";
import { CAMPAIGNS_API_CODES, CampaignsApiError } from "@suaveplan/api-plugin-campaigns";
const repo = new SurrealCampaignRepository({
db,
clock: () => Date.now(),
table: "campaign_v2", // override for blue/green deploys
});
try {
await repo.update("c-123", { name: "Renamed" });
} catch (err) {
if (
err instanceof CampaignsApiError &&
err.code === CAMPAIGNS_API_CODES.CAMPAIGN_NOT_FOUND
) {
// ... return a 404 from the HTTP layer
}
}
```
## API Reference
- `interface SurrealCampaignRepositoryOptions` — constructor bundle:
`db`, `clock`, optional `table`, optional `logger`.
- `class SurrealCampaignRepository implements CampaignStore` — exposes
`create`, `getById`, `update`, `delete`, `list`, `updateStats`,
`updateStatus`. Every method either returns a domain `Campaign` (or
`Campaign[]`, `boolean`, `null`) or throws `CampaignsApiError`.
- `InMemoryCampaignStore` — re-export from `@suaveplan/campaigns` for
tests and prototype workloads.
- `interface CampaignRow` — SurrealDB row shape consumed by
`rowToCampaign`; exported from `./campaign.types.ts` for tests.
## Implementation Notes
The `update` short-circuit (no fields → `getById`) mirrors the upstream
contract but raises `CAMPAIGN_NOT_FOUND` rather than a generic `Error`
when the record is missing. SurrealDB rejects empty `SET` clauses, so the
short-circuit is necessary to keep the public API ergonomic.
Optional fields land on the wire as explicit `null` rather than
`undefined` because SurrealDB treats absent keys as "leave alone" on
`MERGE`, which would surprise callers using the `Partial<CampaignStats>`
input. Returning rows are normalised back to `undefined` in
`rowToCampaign` so the in-memory and SurrealDB stores are
indistinguishable from above.
`Zod` validation is intentionally narrow — only `id` and the listing
`CampaignQuery` are validated. Full `Campaign` shape validation belongs in
the route layer (Chunk C); the repository trusts callers to honour the
upstream type contract while still defending against the two boundary
shapes that come from untrusted HTTP input.

View File

@@ -0,0 +1,433 @@
import type { Campaign } from "@suaveplan/campaigns";
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CAMPAIGNS_API_CODES,
CampaignsApiError,
} from "../../error-codes/error-codes.js";
import {
InMemoryCampaignStore,
SurrealCampaignRepository,
} from "./campaign.js";
type QueryFn = (sql: string, params?: readonly unknown[]) => Promise<unknown>;
function adapter(queryFn: QueryFn): SurrealAdapter {
return { query: queryFn } as unknown as SurrealAdapter;
}
function campaign(overrides: Partial<Campaign> = {}): Campaign {
return {
id: "c1",
tenantId: "t1",
name: "Test",
status: "draft",
type: "broadcast",
channel: "email",
audienceConfig: { groupIds: ["g1"] },
metadata: {},
stats: {
totalRecipients: 0,
sent: 0,
delivered: 0,
opened: 0,
clicked: 0,
bounced: 0,
unsubscribed: 0,
complained: 0,
failed: 0,
},
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
...overrides,
};
}
function row(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: "c1",
tenant_id: "t1",
name: "Test",
status: "draft",
type: "broadcast",
channel: "email",
audience_config: { groupIds: ["g1"] },
template_id: null,
template_data: null,
schedule: null,
throttle: null,
ab_test_config: null,
metadata: { k: "v" },
stats: { sent: 0 },
created_at: "2026-01-01T00:00:00.000Z",
updated_at: "2026-01-01T00:00:00.000Z",
sent_at: null,
completed_at: null,
...overrides,
};
}
function repo(queryFn: QueryFn, table?: string): SurrealCampaignRepository {
return new SurrealCampaignRepository({
db: adapter(queryFn),
clock: () => 0,
table,
logger: { debug() {}, info() {}, warn() {}, error() {} } as never,
});
}
describe("SurrealCampaignRepository.create", () => {
it("emits a positional INSERT with the campaign payload", {
timeout: 5000,
}, async () => {
const calls: { sql: string; params: readonly unknown[] }[] = [];
const r = repo(async (sql, params) => {
calls.push({ sql, params: params ?? [] });
return [];
});
const c = campaign({ templateId: "tpl", templateData: { x: 1 } });
const result = await r.create(c);
expect(result).toBe(c);
expect(calls[0]!.sql).toContain("INSERT INTO campaign");
expect(calls[0]!.params[0]).toBe("c1");
expect(calls[0]!.params[7]).toBe("tpl");
expect(calls[0]!.params[8]).toEqual({ x: 1 });
});
it("wraps adapter errors in DB_UNAVAILABLE", {
timeout: 5000,
}, async () => {
const r = repo(async () => {
throw new Error("driver-down");
});
let captured: unknown;
try {
await r.create(campaign());
} catch (e) {
captured = e;
}
expect(captured).toBeInstanceOf(CampaignsApiError);
expect((captured as CampaignsApiError).code).toBe(
CAMPAIGNS_API_CODES.DB_UNAVAILABLE
);
});
});
describe("SurrealCampaignRepository.getById", () => {
it("returns null for an empty result", { timeout: 5000 }, async () => {
const r = repo(async () => []);
expect(await r.getById("missing")).toBeNull();
});
it("maps the row, parses Date strings and includes the WHERE clause", {
timeout: 5000,
}, async () => {
const calls: string[] = [];
const r = repo(async (sql) => {
calls.push(sql);
return [row({ sent_at: "2026-02-01T00:00:00.000Z" })];
});
const c = await r.getById("c1");
expect(c?.id).toBe("c1");
expect(c?.createdAt).toBeInstanceOf(Date);
expect(c?.sentAt).toBeInstanceOf(Date);
expect(calls[0]).toContain("WHERE id = $p0");
expect(calls[0]).toContain("FROM campaign");
});
it("preserves a Date passed through (not a string)", {
timeout: 5000,
}, async () => {
const ts = new Date("2026-06-01T12:00:00.000Z");
const r = repo(async () => [row({ created_at: ts, updated_at: ts })]);
const c = await r.getById("c1");
expect(c?.createdAt).toEqual(ts);
expect(c?.updatedAt).toEqual(ts);
});
it("uses {} for missing metadata and undefined for null optionals", {
timeout: 5000,
}, async () => {
const r = repo(async () => [
row({
metadata: null,
schedule: { type: "immediate" },
throttle: { messagesPerSecond: 1 },
ab_test_config: { variants: [], splitType: "even" },
template_id: "tpl",
template_data: { x: 1 },
completed_at: "2026-03-01",
}),
]);
const c = await r.getById("c1");
expect(c?.metadata).toEqual({});
expect(c?.templateId).toBe("tpl");
expect(c?.schedule).toEqual({ type: "immediate" });
expect(c?.throttle).toEqual({ messagesPerSecond: 1 });
expect(c?.abTestConfig).toEqual({ variants: [], splitType: "even" });
expect(c?.completedAt).toBeInstanceOf(Date);
});
it("falls back to epoch when timestamps are absent on the row", {
timeout: 5000,
}, async () => {
const r = repo(async () => [
row({ created_at: null as never, updated_at: null as never }),
]);
const c = await r.getById("c1");
expect(c?.createdAt.getTime()).toBe(0);
expect(c?.updatedAt.getTime()).toBe(0);
});
it("rejects an empty id with INVALID_INPUT", {
timeout: 5000,
}, async () => {
const r = repo(async () => []);
let captured: unknown;
try {
await r.getById("");
} catch (e) {
captured = e;
}
expect((captured as CampaignsApiError).code).toBe(
CAMPAIGNS_API_CODES.INVALID_INPUT
);
});
it("wraps query errors in DB_UNAVAILABLE", { timeout: 5000 }, async () => {
const r = repo(async () => {
throw new Error("network");
});
let captured: unknown;
try {
await r.getById("c1");
} catch (e) {
captured = e;
}
expect((captured as CampaignsApiError).code).toBe(
CAMPAIGNS_API_CODES.DB_UNAVAILABLE
);
});
});
describe("SurrealCampaignRepository.update", () => {
it("issues an UPDATE with each provided field bound positionally", {
timeout: 5000,
}, async () => {
const calls: { sql: string; params: readonly unknown[] }[] = [];
const r = repo(async (sql, params) => {
calls.push({ sql, params: params ?? [] });
return [row({ name: "Renamed" })];
});
const result = await r.update("c1", {
name: "Renamed",
channel: "sms",
audienceConfig: { groupIds: ["g2"] },
templateId: "tpl-2",
templateData: { y: 2 },
schedule: { type: "immediate" },
throttle: { messagesPerSecond: 1 },
abTestConfig: { variants: [], splitType: "even" },
metadata: { z: 3 },
});
expect(result.name).toBe("Renamed");
expect(calls[0]!.sql).toContain("name = $p1");
expect(calls[0]!.sql).toContain("channel = $p2");
expect(calls[0]!.sql).toContain("metadata = $p9");
expect(calls[0]!.sql).toContain("WHERE id = $p0");
});
it("short-circuits to getById when no fields supplied (found)", {
timeout: 5000,
}, async () => {
let getCalls = 0;
const r = repo(async (sql) => {
if (sql.startsWith("SELECT")) {
getCalls++;
return [row()];
}
throw new Error("should not have run UPDATE");
});
const result = await r.update("c1", {});
expect(result.id).toBe("c1");
expect(getCalls).toBe(1);
});
it("short-circuits to getById when no fields supplied (missing)", {
timeout: 5000,
}, async () => {
const r = repo(async () => []);
let captured: unknown;
try {
await r.update("c1", {});
} catch (e) {
captured = e;
}
expect((captured as CampaignsApiError).code).toBe(
CAMPAIGNS_API_CODES.CAMPAIGN_NOT_FOUND
);
});
it("throws CAMPAIGN_NOT_FOUND when UPDATE returns no rows", {
timeout: 5000,
}, async () => {
const r = repo(async () => []);
let captured: unknown;
try {
await r.update("c1", { name: "x" });
} catch (e) {
captured = e;
}
expect((captured as CampaignsApiError).code).toBe(
CAMPAIGNS_API_CODES.CAMPAIGN_NOT_FOUND
);
});
});
describe("SurrealCampaignRepository.delete + list + updateStats + updateStatus", () => {
it("delete returns true when DELETE returns a row", {
timeout: 5000,
}, async () => {
const r = repo(async () => [row()]);
expect(await r.delete("c1")).toBe(true);
});
it("delete returns false when DELETE returns nothing", {
timeout: 5000,
}, async () => {
const r = repo(async () => []);
expect(await r.delete("c1")).toBe(false);
});
it("list threads filters + paging into SQL and binds them positionally", {
timeout: 5000,
}, async () => {
const calls: { sql: string; params: readonly unknown[] }[] = [];
const r = repo(async (sql, params) => {
calls.push({ sql, params: params ?? [] });
return [row(), row({ id: "c2" })];
});
const result = await r.list({
tenantId: "t1",
status: "draft",
type: "broadcast",
channel: "email",
limit: 25,
offset: 5,
});
expect(result).toHaveLength(2);
expect(calls[0]!.sql).toContain("tenant_id = $p0");
expect(calls[0]!.sql).toContain("status = $p1");
expect(calls[0]!.sql).toContain("type = $p2");
expect(calls[0]!.sql).toContain("channel = $p3");
expect(calls[0]!.sql).toContain("LIMIT 25");
expect(calls[0]!.sql).toContain("START 5");
});
it("list applies defaults when limit/offset omitted and no filters", {
timeout: 5000,
}, async () => {
const calls: string[] = [];
const r = repo(async (sql) => {
calls.push(sql);
return [];
});
await r.list({});
expect(calls[0]).toContain("LIMIT 50");
expect(calls[0]).not.toContain("WHERE");
expect(calls[0]).not.toContain("START");
});
it("list rejects invalid query (negative limit)", {
timeout: 5000,
}, async () => {
const r = repo(async () => []);
let captured: unknown;
try {
await r.list({ limit: -1 });
} catch (e) {
captured = e;
}
expect((captured as CampaignsApiError).code).toBe(
CAMPAIGNS_API_CODES.INVALID_INPUT
);
});
it("updateStats MERGEs and returns the mapped row", {
timeout: 5000,
}, async () => {
const calls: { sql: string; params: readonly unknown[] }[] = [];
const r = repo(async (sql, params) => {
calls.push({ sql, params: params ?? [] });
return [row({ stats: { sent: 5 } })];
});
const result = await r.updateStats("c1", { sent: 5 });
expect(result.stats).toEqual({ sent: 5 });
expect(calls[0]!.sql).toContain("MERGE { stats: $p1 }");
expect(calls[0]!.params[1]).toEqual({ sent: 5 });
});
it("updateStats throws CAMPAIGN_NOT_FOUND on empty result", {
timeout: 5000,
}, async () => {
const r = repo(async () => []);
let captured: unknown;
try {
await r.updateStats("c1", { sent: 1 });
} catch (e) {
captured = e;
}
expect((captured as CampaignsApiError).code).toBe(
CAMPAIGNS_API_CODES.CAMPAIGN_NOT_FOUND
);
});
it("updateStatus sets the new status positionally", {
timeout: 5000,
}, async () => {
const calls: { sql: string; params: readonly unknown[] }[] = [];
const r = repo(async (sql, params) => {
calls.push({ sql, params: params ?? [] });
return [row({ status: "sending" })];
});
const c = await r.updateStatus("c1", "sending");
expect(c.status).toBe("sending");
expect(calls[0]!.sql).toContain("SET status = $p1");
expect(calls[0]!.params[1]).toBe("sending");
});
it("updateStatus throws CAMPAIGN_NOT_FOUND on empty result", {
timeout: 5000,
}, async () => {
const r = repo(async () => []);
let captured: unknown;
try {
await r.updateStatus("c1", "sending");
} catch (e) {
captured = e;
}
expect((captured as CampaignsApiError).code).toBe(
CAMPAIGNS_API_CODES.CAMPAIGN_NOT_FOUND
);
});
it("respects a custom table name", { timeout: 5000 }, async () => {
const calls: string[] = [];
const r = repo(async (sql) => {
calls.push(sql);
return [];
}, "custom_campaigns");
await r.list({});
expect(calls[0]).toContain("FROM custom_campaigns");
});
});
describe("InMemoryCampaignStore re-export", () => {
it("is exported from the same module entry point", {
timeout: 5000,
}, () => {
expect(typeof InMemoryCampaignStore).toBe("function");
const store = new InMemoryCampaignStore();
expect(store).toBeInstanceOf(InMemoryCampaignStore);
});
});

View File

@@ -0,0 +1,272 @@
/**
* SurrealDB-backed implementation of the `CampaignStore` contract from
* `@suaveplan/campaigns`. Uses positional `$p0`/`$p1`/... placeholders
* against `SurrealAdapter.query` per the genesis idiom established by
* `@suaveplan/delivery-tracking`. The in-memory implementation is exposed
* as a re-export of `@suaveplan/campaigns` `InMemoryCampaignStore`.
*/
import type {
Campaign,
CampaignQuery,
CampaignStats,
CampaignStatus,
CampaignStore,
UpdateCampaignInput,
} from "@suaveplan/campaigns";
import { InMemoryCampaignStore } from "@suaveplan/campaigns";
import z from "zod";
import {
CAMPAIGNS_API_CODES,
CampaignsApiError,
} from "../../error-codes/error-codes.js";
import type {
CampaignRow,
SurrealCampaignRepositoryOptions,
} from "./campaign.types.js";
/** Zod guard at the repository boundary for the `id` argument. */
const IdSchema = z.string().min(1, "campaign id must be a non-empty string");
/** Zod guard at the repository boundary for the listing `CampaignQuery`. */
const CampaignQuerySchema = z
.object({
tenantId: z.string().min(1).optional(),
status: z.string().min(1).optional(),
type: z.string().min(1).optional(),
channel: z.string().min(1).optional(),
limit: z.number().int().min(1).max(1000).optional(),
offset: z.number().int().min(0).optional(),
})
.strict();
function toDate(value: Date | string | null | undefined): Date | undefined {
if (value === null || value === undefined) return undefined;
return value instanceof Date ? value : new Date(value);
}
function rowToCampaign(row: CampaignRow): Campaign {
const templateId = row.template_id ?? undefined;
const templateData = row.template_data ?? undefined;
const schedule = (row.schedule ?? undefined) as Campaign["schedule"];
const throttle = (row.throttle ?? undefined) as Campaign["throttle"];
const abTestConfig = (row.ab_test_config ??
undefined) as Campaign["abTestConfig"];
const sentAt = toDate(row.sent_at);
const completedAt = toDate(row.completed_at);
return {
id: row.id,
tenantId: row.tenant_id,
name: row.name,
status: row.status as CampaignStatus,
type: row.type as Campaign["type"],
channel: row.channel,
audienceConfig: row.audience_config as Campaign["audienceConfig"],
...(templateId !== undefined ? { templateId } : {}),
...(templateData !== undefined ? { templateData } : {}),
...(schedule !== undefined ? { schedule } : {}),
...(throttle !== undefined ? { throttle } : {}),
...(abTestConfig !== undefined ? { abTestConfig } : {}),
metadata: row.metadata ?? {},
stats: row.stats as CampaignStats,
createdAt: toDate(row.created_at) ?? new Date(0),
updatedAt: toDate(row.updated_at) ?? new Date(0),
...(sentAt !== undefined ? { sentAt } : {}),
...(completedAt !== undefined ? { completedAt } : {}),
};
}
/**
* SurrealDB-backed `CampaignStore`. All Surreal calls go through the
* adapter's `query` method with a positional parameter list so the SQL is
* auditable and identical across drivers. Errors are wrapped in
* `CampaignsApiError` with the appropriate `CAMPAIGNS_API.*` code so route
* handlers can branch on stable strings.
*/
export class SurrealCampaignRepository implements CampaignStore {
private readonly db: SurrealCampaignRepositoryOptions["db"];
private readonly table: string;
public constructor(options: SurrealCampaignRepositoryOptions) {
this.db = options.db;
this.table = options.table ?? "campaign";
}
public async create(campaign: Campaign): Promise<Campaign> {
const params: unknown[] = [
campaign.id,
campaign.tenantId,
campaign.name,
campaign.status,
campaign.type,
campaign.channel,
campaign.audienceConfig,
campaign.templateId ?? null,
campaign.templateData ?? null,
campaign.schedule ?? null,
campaign.throttle ?? null,
campaign.abTestConfig ?? null,
campaign.metadata,
campaign.stats,
campaign.createdAt,
campaign.updatedAt,
campaign.sentAt ?? null,
campaign.completedAt ?? null,
];
const sql = `INSERT INTO ${this.table} {
id: $p0, tenant_id: $p1, name: $p2, status: $p3, type: $p4,
channel: $p5, audience_config: $p6, template_id: $p7,
template_data: $p8, schedule: $p9, throttle: $p10,
ab_test_config: $p11, metadata: $p12, stats: $p13,
created_at: $p14, updated_at: $p15, sent_at: $p16,
completed_at: $p17
}`;
await this.run<CampaignRow>(sql, params, "create");
return campaign;
}
public async getById(id: string): Promise<Campaign | null> {
const parsed = IdSchema.safeParse(id);
if (!parsed.success) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.INVALID_INPUT,
"campaign id is required",
{ cause: parsed.error, context: { op: "getById" } }
);
}
const rows = await this.run<CampaignRow>(
`SELECT * FROM ${this.table} WHERE id = $p0 LIMIT 1`,
[id],
"getById"
);
return rows[0] !== undefined ? rowToCampaign(rows[0]) : null;
}
public async update(
id: string,
input: UpdateCampaignInput
): Promise<Campaign> {
const sets: string[] = [];
const params: unknown[] = [id];
const push = (col: string, value: unknown): void => {
params.push(value);
sets.push(`${col} = $p${params.length - 1}`);
};
if (input.name !== undefined) push("name", input.name);
if (input.channel !== undefined) push("channel", input.channel);
if (input.audienceConfig !== undefined) {
push("audience_config", input.audienceConfig);
}
if (input.templateId !== undefined) {
push("template_id", input.templateId);
}
if (input.templateData !== undefined) {
push("template_data", input.templateData);
}
if (input.schedule !== undefined) push("schedule", input.schedule);
if (input.throttle !== undefined) push("throttle", input.throttle);
if (input.abTestConfig !== undefined) {
push("ab_test_config", input.abTestConfig);
}
if (input.metadata !== undefined) push("metadata", input.metadata);
if (sets.length === 0) {
const existing = await this.getById(id);
if (existing === null) throw this.notFound(id, "update");
return existing;
}
const sql = `UPDATE ${this.table} SET ${sets.join(", ")} WHERE id = $p0 RETURN AFTER`;
const rows = await this.run<CampaignRow>(sql, params, "update");
if (rows[0] === undefined) throw this.notFound(id, "update");
return rowToCampaign(rows[0]);
}
public async delete(id: string): Promise<boolean> {
const rows = await this.run<CampaignRow>(
`DELETE ${this.table} WHERE id = $p0 RETURN BEFORE`,
[id],
"delete"
);
return rows.length > 0;
}
public async list(query: CampaignQuery): Promise<Campaign[]> {
const parsed = CampaignQuerySchema.safeParse(query);
if (!parsed.success) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.INVALID_INPUT,
"invalid campaign query",
{ cause: parsed.error, context: { op: "list" } }
);
}
const conditions: string[] = [];
const params: unknown[] = [];
const push = (col: string, value: unknown): void => {
params.push(value);
conditions.push(`${col} = $p${params.length - 1}`);
};
if (query.tenantId !== undefined) push("tenant_id", query.tenantId);
if (query.status !== undefined) push("status", query.status);
if (query.type !== undefined) push("type", query.type);
if (query.channel !== undefined) push("channel", query.channel);
const where =
conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
const limit = query.limit ?? 50;
const offset = query.offset ?? 0;
let sql = `SELECT * FROM ${this.table}${where} LIMIT ${limit}`;
if (offset > 0) sql += ` START ${offset}`;
const rows = await this.run<CampaignRow>(sql, params, "list");
return rows.map(rowToCampaign);
}
public async updateStats(
id: string,
stats: Partial<CampaignStats>
): Promise<Campaign> {
const rows = await this.run<CampaignRow>(
`UPDATE ${this.table} MERGE { stats: $p1 } WHERE id = $p0 RETURN AFTER`,
[id, stats],
"updateStats"
);
if (rows[0] === undefined) throw this.notFound(id, "updateStats");
return rowToCampaign(rows[0]);
}
public async updateStatus(
id: string,
status: CampaignStatus
): Promise<Campaign> {
const rows = await this.run<CampaignRow>(
`UPDATE ${this.table} SET status = $p1 WHERE id = $p0 RETURN AFTER`,
[id, status],
"updateStatus"
);
if (rows[0] === undefined) throw this.notFound(id, "updateStatus");
return rowToCampaign(rows[0]);
}
private async run<T>(
sql: string,
params: unknown[],
op: string
): Promise<T[]> {
try {
return await this.db.query<T>(sql, params);
} catch (cause) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.DB_UNAVAILABLE,
`SurrealCampaignRepository.${op} failed`,
{ cause, context: { op } }
);
}
}
private notFound(id: string, op: string): CampaignsApiError {
return new CampaignsApiError(
CAMPAIGNS_API_CODES.CAMPAIGN_NOT_FOUND,
`campaign not found: ${id}`,
{ context: { op, id } }
);
}
}
export { InMemoryCampaignStore };

View File

@@ -0,0 +1,49 @@
/**
* Type definitions for the campaign repository module. Concentrated here
* so the implementation file stays under the 300-line hard cap and so
* external callers can `import type` without pulling in the SurrealDB
* adapter symbol.
*/
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import type { Clock, LoggerLike } from "@suaveplan/types";
/**
* Constructor bundle for {@link SurrealCampaignRepository}. `db` is the
* connected `SurrealAdapter`; `clock` is the injectable wall-clock used to
* stamp `updatedAt` on Surreal-side `MERGE` paths; `table` overrides the
* default `"campaign"` SurrealDB table name for test isolation.
*/
export interface SurrealCampaignRepositoryOptions {
readonly db: SurrealAdapter;
readonly clock: Clock;
readonly table?: string;
readonly logger?: LoggerLike;
}
/**
* Row shape returned by SurrealDB for the `campaign` table. The columns
* mirror the `@suaveplan/campaigns` `Campaign` interface but use
* SurrealDB-friendly null sentinels instead of `undefined` for optional
* fields, and Date strings on read.
*/
export interface CampaignRow {
id: string;
tenant_id: string;
name: string;
status: string;
type: string;
channel: string;
audience_config: unknown;
template_id?: string | null;
template_data?: Record<string, unknown> | null;
schedule?: unknown;
throttle?: unknown;
ab_test_config?: unknown;
metadata?: Record<string, unknown> | null;
stats: unknown;
created_at: Date | string;
updated_at: Date | string;
sent_at?: Date | string | null;
completed_at?: Date | string | null;
}

View File

@@ -0,0 +1,8 @@
export {
InMemoryCampaignStore,
SurrealCampaignRepository,
} from "./campaign.js";
export type {
CampaignRow,
SurrealCampaignRepositoryOptions,
} from "./campaign.types.js";

View File

@@ -0,0 +1,24 @@
export {
InMemoryCampaignStore,
SurrealCampaignRepository,
} from "./campaign/campaign.js";
export type {
CampaignRow,
SurrealCampaignRepositoryOptions,
} from "./campaign/campaign.types.js";
export {
CreateInputSchema as SuppressionCreateInputSchema,
QuerySchema as SuppressionQuerySchema,
SurrealCampaignSuppressionRepository,
} from "./suppression/suppression.js";
export type {
CampaignSuppressionEntry,
CampaignSuppressionQuery,
CampaignSuppressionReason,
CampaignSuppressionRow,
CampaignSuppressionStore,
CreateCampaignSuppressionInput,
InMemoryCampaignSuppressionStoreOptions,
SurrealCampaignSuppressionRepositoryOptions,
} from "./suppression/suppression.types.js";
export { InMemoryCampaignSuppressionStore } from "./suppression/suppression-memory.js";

View File

@@ -0,0 +1,16 @@
export {
CreateInputSchema,
QuerySchema,
SurrealCampaignSuppressionRepository,
} from "./suppression.js";
export type {
CampaignSuppressionEntry,
CampaignSuppressionQuery,
CampaignSuppressionReason,
CampaignSuppressionRow,
CampaignSuppressionStore,
CreateCampaignSuppressionInput,
InMemoryCampaignSuppressionStoreOptions,
SurrealCampaignSuppressionRepositoryOptions,
} from "./suppression.types.js";
export { InMemoryCampaignSuppressionStore } from "./suppression-memory.js";

View File

@@ -0,0 +1,88 @@
# `repositories/suppression/suppression-internals`
## Purpose
This module owns the validation and mapping primitives shared by the two
`CampaignSuppressionStore` implementations — `ReasonSchema`,
`CreateInputSchema`, `QuerySchema`, the `requireTenant` guard, the
`rowToEntry` row mapper, and the `validateCreate` wrapper. Keeping these
helpers in a dedicated sibling module is what allows both the SurrealDB
implementation (`./suppression.ts`) and the in-memory reference
(`./suppression-memory.ts`) to stay under the 300-line hard cap without
duplicating their entry-validation logic.
## Features
- `ReasonSchema` — Zod enum guard over the five canonical
`CampaignSuppressionReason` values (`unsubscribe`, `bounce`,
`complaint`, `manual`, `compliance`).
- `CreateInputSchema` / `QuerySchema` — strict Zod object guards for the
two public input shapes.
- `requireTenant(tenantId)` — boundary check that throws
`CAMPAIGNS_API.TENANT_REQUIRED` for an empty string.
- `validateCreate(input)` — parses with `CreateInputSchema` and throws
`CAMPAIGNS_API.INVALID_INPUT` on failure with the underlying `ZodError`
attached as `cause`.
- `rowToEntry(row)` — maps `CampaignSuppressionRow` ↔ `CampaignSuppressionEntry`
with `Date` normalisation and `null` → `undefined` collapsing.
## Usage: Basic
```ts
import { validateCreate, requireTenant } from "./suppression-internals.js";
requireTenant("tenant-1");
const data = validateCreate({
tenantId: "tenant-1",
channel: "email",
channelValue: "user@example.com",
reason: "unsubscribe",
});
```
## Usage: Advanced
```ts
import { QuerySchema, rowToEntry } from "./suppression-internals.js";
const parsed = QuerySchema.safeParse({
tenantId: "t1",
channel: "sms",
limit: 25,
});
if (!parsed.success) {
// handle ZodError without going through the repository
}
const entry = rowToEntry({
id: "s1",
tenant_id: "t1",
channel: "sms",
channel_value: "+15551234567",
reason: "complaint",
metadata: null,
created_at: "2026-05-24T00:00:00.000Z",
});
```
## API Reference
- `const ReasonSchema: z.ZodEnum`
- `const CreateInputSchema: z.ZodObject`
- `const QuerySchema: z.ZodObject`
- `function requireTenant(tenantId: string): void` — throws on empty.
- `function rowToEntry(row: CampaignSuppressionRow): CampaignSuppressionEntry`
- `function validateCreate(input: CreateCampaignSuppressionInput): CreateCampaignSuppressionInput`
## Implementation Notes
`CreateInputSchema` and `QuerySchema` use `.strict()` so unknown keys
fail validation rather than silently flowing through to SurrealDB —
this keeps the repository contract honest against evolving HTTP payloads
and stops typos from becoming silent feature requests.
The schemas live here (not in `../../schemas/`) because they are
implementation details of the suppression repository, not part of the
plugin's public option surface. The plugin's `options` schema in
`../../schemas/options.ts` is the user-facing configuration contract; the
repository schemas are private guards at a different boundary.

View File

@@ -0,0 +1,106 @@
/**
* Internal helpers shared by `SurrealCampaignSuppressionRepository` and
* `InMemoryCampaignSuppressionStore`: Zod schemas guarding the
* repository boundary, the tenant-presence check, the row → entry
* mapper, and the `validateCreate` helper. Kept in a sibling module
* rather than inlined so each implementation file stays under the
* 300-line hard cap.
*/
import z from "zod";
import {
CAMPAIGNS_API_CODES,
CampaignsApiError,
} from "../../error-codes/error-codes.js";
import type {
CampaignSuppressionEntry,
CampaignSuppressionReason,
CampaignSuppressionRow,
CreateCampaignSuppressionInput,
} from "./suppression.types.js";
/** Zod enum guard for `CampaignSuppressionReason`. */
export const ReasonSchema = z.enum([
"unsubscribe",
"bounce",
"complaint",
"manual",
"compliance",
]);
/** Zod guard for `CreateCampaignSuppressionInput`. */
export const CreateInputSchema = z
.object({
tenantId: z.string().min(1, "tenantId is required"),
channel: z.string().min(1, "channel is required"),
channelValue: z.string().min(1, "channelValue is required"),
reason: ReasonSchema,
source: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
})
.strict();
/** Zod guard for `CampaignSuppressionQuery`. */
export const QuerySchema = z
.object({
tenantId: z.string().min(1, "tenantId is required"),
channel: z.string().min(1).optional(),
reason: ReasonSchema.optional(),
limit: z.number().int().min(1).max(1000).optional(),
offset: z.number().int().min(0).optional(),
})
.strict();
/** Throws `TENANT_REQUIRED` when `tenantId` is the empty string. */
export function requireTenant(tenantId: string): void {
if (tenantId.length === 0) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.TENANT_REQUIRED,
"tenantId is required"
);
}
}
/** Maps a Surreal row to a domain `CampaignSuppressionEntry`. */
export function rowToEntry(
row: CampaignSuppressionRow
): CampaignSuppressionEntry {
const source = row.source ?? undefined;
const metadata = row.metadata ?? undefined;
return {
id: row.id,
tenantId: row.tenant_id,
channel: row.channel,
channelValue: row.channel_value,
reason: row.reason as CampaignSuppressionReason,
...(source !== undefined ? { source } : {}),
...(metadata !== undefined ? { metadata } : {}),
createdAt:
row.created_at instanceof Date
? row.created_at
: new Date(row.created_at),
};
}
/** Validates a create-input or throws `INVALID_INPUT`. */
export function validateCreate(
input: CreateCampaignSuppressionInput
): CreateCampaignSuppressionInput {
const parsed = CreateInputSchema.safeParse(input);
if (!parsed.success) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.INVALID_INPUT,
"invalid suppression input",
{ cause: parsed.error, context: { op: "add" } }
);
}
const data = parsed.data;
return {
tenantId: data.tenantId,
channel: data.channel,
channelValue: data.channelValue,
reason: data.reason,
...(data.source !== undefined ? { source: data.source } : {}),
...(data.metadata !== undefined ? { metadata: data.metadata } : {}),
};
}

View File

@@ -0,0 +1,92 @@
# `repositories/suppression/suppression-memory`
## Purpose
In-memory reference implementation of the tenant + channel-scoped
`CampaignSuppressionStore` interface. Intended for unit tests, prototype
workloads, and the default in-process backend before a SurrealDB
connection is wired up. Mirrors the public surface of the SurrealDB
implementation byte-for-byte so swapping between the two is a pure
constructor change.
Records are stored in a `Map` keyed by entry id; every read returns a
shallow clone so callers cannot mutate internal records by reference.
The implementation shares its Zod boundary guards with the SurrealDB
repository via `./suppression-internals.ts`, ensuring both
implementations reject invalid inputs identically.
## Features
- `Map`-backed storage with O(1) lookups by id and linear scans for
`(tenantId, channel, channelValue)` checks.
- Defensive cloning on read — `{ ...entry }` everywhere a record leaves
the store.
- Duplicate detection against the `(tenantId, channel, channelValue)`
triple — throws `CAMPAIGNS_API.DUPLICATE_SUPPRESSION` rather than
silently overwriting.
- Injectable `Clock` for `createdAt` stamping and `IdGenerator` for entry
ids so tests can produce deterministic fixtures.
## Usage: Basic
```ts
import { InMemoryCampaignSuppressionStore } from "@suaveplan/api-plugin-campaigns/repositories/suppression/suppression";
const store = new InMemoryCampaignSuppressionStore({
clock: () => Date.now(),
idGenerator: () => crypto.randomUUID(),
});
await store.add({
tenantId: "t1",
channel: "email",
channelValue: "user@example.com",
reason: "unsubscribe",
});
```
## Usage: Advanced
```ts
import { InMemoryCampaignSuppressionStore } from "@suaveplan/api-plugin-campaigns/repositories/suppression/suppression";
let now = 1_700_000_000_000;
let n = 0;
const store = new InMemoryCampaignSuppressionStore({
clock: () => now,
idGenerator: () => `s-${n++}`,
});
await store.add({
tenantId: "t1",
channel: "sms",
channelValue: "+15551234567",
reason: "complaint",
});
const entries = await store.list({ tenantId: "t1", channel: "sms" });
```
## API Reference
- `class InMemoryCampaignSuppressionStore implements CampaignSuppressionStore`
- `new InMemoryCampaignSuppressionStore({ clock, idGenerator })`
- `add`, `remove`, `check`, `list`, `count`, `removeByChannelValue` —
all match the interface signature exactly.
## Implementation Notes
The constructor is deliberately required-arguments (no defaults) so
unit tests never accidentally rely on the wall-clock or
`crypto.randomUUID`. This mirrors the rest of Suaveplan's
convention for injectable seams: opt-in only, never magical.
The duplicate-check is performed by re-using `check()` rather than a
separate code path so the in-memory and SurrealDB implementations share
identical duplicate-detection semantics — a regression in one is a
regression in the other, and the test suite picks it up immediately.
This module intentionally re-uses the `QuerySchema` from
`./suppression-internals.ts` rather than re-parsing inline; the schema
is treated as the single source of truth for input validation across
both backends, and any drift between in-memory and SurrealDB behaviour
is detectable by reading just one file.

View File

@@ -0,0 +1,306 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CAMPAIGNS_API_CODES,
type CampaignsApiError,
} from "../../error-codes/error-codes.js";
import { InMemoryCampaignSuppressionStore } from "./suppression-memory.js";
function store(): InMemoryCampaignSuppressionStore {
let n = 0;
return new InMemoryCampaignSuppressionStore({
clock: () => 1_700_000_000_000,
idGenerator: () => `mem-${n++}`,
});
}
describe("InMemoryCampaignSuppressionStore.add + check + remove", () => {
it("adds and checks an entry round-trip", { timeout: 5000 }, async () => {
const s = store();
const entry = await s.add({
tenantId: "t1",
channel: "email",
channelValue: "u@e.com",
reason: "unsubscribe",
});
expect(entry.id).toBe("mem-0");
const found = await s.check("t1", "email", "u@e.com");
expect(found?.id).toBe("mem-0");
});
it("returns a defensive clone from check + add", {
timeout: 5000,
}, async () => {
const s = store();
const added = await s.add({
tenantId: "t1",
channel: "email",
channelValue: "u@e.com",
reason: "unsubscribe",
});
const a = await s.check("t1", "email", "u@e.com");
expect(a).not.toBe(added);
});
it("returns null on miss", { timeout: 5000 }, async () => {
const s = store();
expect(await s.check("t1", "email", "nope@e.com")).toBeNull();
});
it("rejects duplicate (tenant, channel, value) with DUPLICATE_SUPPRESSION", {
timeout: 5000,
}, async () => {
const s = store();
await s.add({
tenantId: "t1",
channel: "email",
channelValue: "u@e.com",
reason: "unsubscribe",
});
let captured: unknown;
try {
await s.add({
tenantId: "t1",
channel: "email",
channelValue: "u@e.com",
reason: "bounce",
});
} catch (e) {
captured = e;
}
expect((captured as CampaignsApiError).code).toBe(
CAMPAIGNS_API_CODES.DUPLICATE_SUPPRESSION
);
});
it("remove returns true once then false", { timeout: 5000 }, async () => {
const s = store();
const entry = await s.add({
tenantId: "t1",
channel: "email",
channelValue: "u@e.com",
reason: "unsubscribe",
});
expect(await s.remove(entry.id)).toBe(true);
expect(await s.remove(entry.id)).toBe(false);
});
it("remove rejects an empty id with INVALID_INPUT", {
timeout: 5000,
}, async () => {
const s = store();
let captured: unknown;
try {
await s.remove("");
} catch (e) {
captured = e;
}
expect((captured as CampaignsApiError).code).toBe(
CAMPAIGNS_API_CODES.INVALID_INPUT
);
});
it("add rejects invalid input via the shared schema", {
timeout: 5000,
}, async () => {
const s = store();
let captured: unknown;
try {
await s.add({
tenantId: "",
channel: "email",
channelValue: "u@e.com",
reason: "unsubscribe",
});
} catch (e) {
captured = e;
}
expect((captured as CampaignsApiError).code).toBe(
CAMPAIGNS_API_CODES.INVALID_INPUT
);
});
it("check rejects an empty tenantId with TENANT_REQUIRED", {
timeout: 5000,
}, async () => {
const s = store();
let captured: unknown;
try {
await s.check("", "email", "u@e.com");
} catch (e) {
captured = e;
}
expect((captured as CampaignsApiError).code).toBe(
CAMPAIGNS_API_CODES.TENANT_REQUIRED
);
});
});
describe("InMemoryCampaignSuppressionStore.list + count + removeByChannelValue", () => {
it("lists entries scoped to a tenant", { timeout: 5000 }, async () => {
const s = store();
await s.add({
tenantId: "t1",
channel: "email",
channelValue: "a@e.com",
reason: "unsubscribe",
});
await s.add({
tenantId: "t1",
channel: "sms",
channelValue: "+1",
reason: "complaint",
});
await s.add({
tenantId: "t2",
channel: "email",
channelValue: "b@e.com",
reason: "manual",
});
const all = await s.list({ tenantId: "t1" });
expect(all).toHaveLength(2);
const onlyEmail = await s.list({ tenantId: "t1", channel: "email" });
expect(onlyEmail).toHaveLength(1);
expect(onlyEmail[0]!.channel).toBe("email");
const onlyComplaint = await s.list({
tenantId: "t1",
reason: "complaint",
});
expect(onlyComplaint).toHaveLength(1);
});
it("paginates via limit + offset", { timeout: 5000 }, async () => {
const s = store();
for (let i = 0; i < 5; i++) {
await s.add({
tenantId: "t1",
channel: "email",
channelValue: `u${i}@e.com`,
reason: "unsubscribe",
});
}
const page = await s.list({ tenantId: "t1", limit: 2, offset: 2 });
expect(page).toHaveLength(2);
});
it("rejects invalid list query (negative offset)", {
timeout: 5000,
}, async () => {
const s = store();
let captured: unknown;
try {
await s.list({ tenantId: "t1", offset: -1 });
} catch (e) {
captured = e;
}
expect((captured as CampaignsApiError).code).toBe(
CAMPAIGNS_API_CODES.INVALID_INPUT
);
});
it("count scopes by tenant", { timeout: 5000 }, async () => {
const s = store();
await s.add({
tenantId: "t1",
channel: "email",
channelValue: "a@e.com",
reason: "unsubscribe",
});
await s.add({
tenantId: "t2",
channel: "email",
channelValue: "b@e.com",
reason: "unsubscribe",
});
expect(await s.count("t1")).toBe(1);
expect(await s.count("t2")).toBe(1);
expect(await s.count("t3")).toBe(0);
});
it("count rejects an empty tenantId", { timeout: 5000 }, async () => {
const s = store();
let captured: unknown;
try {
await s.count("");
} catch (e) {
captured = e;
}
expect((captured as CampaignsApiError).code).toBe(
CAMPAIGNS_API_CODES.TENANT_REQUIRED
);
});
it("removeByChannelValue deletes and returns true; false on miss", {
timeout: 5000,
}, async () => {
const s = store();
await s.add({
tenantId: "t1",
channel: "email",
channelValue: "u@e.com",
reason: "unsubscribe",
});
expect(await s.removeByChannelValue("t1", "email", "u@e.com")).toBe(
true
);
expect(await s.removeByChannelValue("t1", "email", "u@e.com")).toBe(
false
);
});
it("removeByChannelValue iterates past non-matching entries", {
timeout: 5000,
}, async () => {
const s = store();
await s.add({
tenantId: "t1",
channel: "email",
channelValue: "a@e.com",
reason: "unsubscribe",
});
await s.add({
tenantId: "t1",
channel: "email",
channelValue: "b@e.com",
reason: "unsubscribe",
});
// first entry doesn't match `b@e.com`; deletion targets the second
expect(await s.removeByChannelValue("t1", "email", "b@e.com")).toBe(
true
);
expect(await s.list({ tenantId: "t1" })).toHaveLength(1);
});
it("add persists source and metadata when present", {
timeout: 5000,
}, async () => {
const s = store();
const entry = await s.add({
tenantId: "t1",
channel: "email",
channelValue: "u@e.com",
reason: "unsubscribe",
source: "ops",
metadata: { ip: "1.2.3.4" },
});
expect(entry.source).toBe("ops");
expect(entry.metadata).toEqual({ ip: "1.2.3.4" });
const found = await s.check("t1", "email", "u@e.com");
expect(found?.source).toBe("ops");
expect(found?.metadata).toEqual({ ip: "1.2.3.4" });
});
it("removeByChannelValue rejects an empty tenantId", {
timeout: 5000,
}, async () => {
const s = store();
let captured: unknown;
try {
await s.removeByChannelValue("", "email", "u@e.com");
} catch (e) {
captured = e;
}
expect((captured as CampaignsApiError).code).toBe(
CAMPAIGNS_API_CODES.TENANT_REQUIRED
);
});
});

View File

@@ -0,0 +1,150 @@
/**
* In-memory reference implementation of the local
* `CampaignSuppressionStore` interface. Intended for unit tests and
* prototype workloads — every mutation returns a fresh object so callers
* cannot tamper with internal records by reference.
*/
import {
CAMPAIGNS_API_CODES,
CampaignsApiError,
} from "../../error-codes/error-codes.js";
import type {
CampaignSuppressionEntry,
CampaignSuppressionQuery,
CampaignSuppressionStore,
CreateCampaignSuppressionInput,
InMemoryCampaignSuppressionStoreOptions,
} from "./suppression.types.js";
import {
QuerySchema,
requireTenant,
validateCreate,
} from "./suppression-internals.js";
/** Reference `CampaignSuppressionStore` backed by a `Map`. */
export class InMemoryCampaignSuppressionStore
implements CampaignSuppressionStore
{
private readonly entries = new Map<string, CampaignSuppressionEntry>();
private readonly clock: InMemoryCampaignSuppressionStoreOptions["clock"];
private readonly idGenerator: InMemoryCampaignSuppressionStoreOptions["idGenerator"];
public constructor(options: InMemoryCampaignSuppressionStoreOptions) {
this.clock = options.clock;
this.idGenerator = options.idGenerator;
}
public async add(
input: CreateCampaignSuppressionInput
): Promise<CampaignSuppressionEntry> {
const data = validateCreate(input);
const existing = await this.check(
data.tenantId,
data.channel,
data.channelValue
);
if (existing !== null) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.DUPLICATE_SUPPRESSION,
`suppression already exists for ${data.channel}:${data.channelValue}`,
{ context: { op: "add", existingId: existing.id } }
);
}
const entry: CampaignSuppressionEntry = {
id: this.idGenerator(),
tenantId: data.tenantId,
channel: data.channel,
channelValue: data.channelValue,
reason: data.reason,
...(data.source !== undefined ? { source: data.source } : {}),
...(data.metadata !== undefined ? { metadata: data.metadata } : {}),
createdAt: new Date(this.clock()),
};
this.entries.set(entry.id, entry);
return { ...entry };
}
public async remove(id: string): Promise<boolean> {
if (id.length === 0) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.INVALID_INPUT,
"suppression id is required"
);
}
return this.entries.delete(id);
}
public async check(
tenantId: string,
channel: string,
channelValue: string
): Promise<CampaignSuppressionEntry | null> {
requireTenant(tenantId);
for (const entry of this.entries.values()) {
if (
entry.tenantId === tenantId &&
entry.channel === channel &&
entry.channelValue === channelValue
) {
return { ...entry };
}
}
return null;
}
public async list(
query: CampaignSuppressionQuery
): Promise<readonly CampaignSuppressionEntry[]> {
const parsed = QuerySchema.safeParse(query);
if (!parsed.success) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.INVALID_INPUT,
"invalid suppression query",
{ cause: parsed.error, context: { op: "list" } }
);
}
let results = Array.from(this.entries.values()).filter(
(e) => e.tenantId === parsed.data.tenantId
);
if (parsed.data.channel !== undefined) {
const ch = parsed.data.channel;
results = results.filter((e) => e.channel === ch);
}
if (parsed.data.reason !== undefined) {
const rsn = parsed.data.reason;
results = results.filter((e) => e.reason === rsn);
}
const limit = parsed.data.limit ?? 50;
const offset = parsed.data.offset ?? 0;
return results.slice(offset, offset + limit).map((e) => ({ ...e }));
}
public async count(tenantId: string): Promise<number> {
requireTenant(tenantId);
let n = 0;
for (const e of this.entries.values()) {
if (e.tenantId === tenantId) n++;
}
return n;
}
public async removeByChannelValue(
tenantId: string,
channel: string,
channelValue: string
): Promise<boolean> {
requireTenant(tenantId);
for (const [id, entry] of this.entries.entries()) {
if (
entry.tenantId === tenantId &&
entry.channel === channel &&
entry.channelValue === channelValue
) {
this.entries.delete(id);
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,120 @@
# `repositories/suppression`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the SurrealDB-backed implementation of the tenant +
channel-scoped `CampaignSuppressionStore` interface for
`@suaveplan/api-plugin-campaigns`. It tracks contacts who should be
excluded from a tenant's campaign sends — for example because they
unsubscribed, hard-bounced, complained, or were manually opted out.
The local interface is intentional and not a wrapper over
`@suaveplan/suppression`: the suaveplan suppression package is email-only
and keyed on `(tenantId, channelValue)`, while a campaigns plugin must
support multi-channel delivery (email, SMS, push, ...). The
`(tenantId, channel, channelValue)` triple is therefore the natural
primary key, with `channel = "email"` representing the legacy case.
## Features
- Positional SurQL parameter binding (`$p0`, `$p1`, ...) per the suaveplan
Surreal idiom.
- Snake-case ↔ camelCase column mapping isolated in `rowToEntry` from
`./suppression-internals.ts`.
- Application-layer duplicate detection — `add` issues a `check` before
the INSERT and throws `CAMPAIGNS_API.DUPLICATE_SUPPRESSION` if the
triple already exists.
- Zod-guarded input boundaries (`CreateInputSchema`, `QuerySchema`) so
invalid HTTP payloads fail with `CAMPAIGNS_API.INVALID_INPUT` before
any SurQL is issued.
- Errors wrapped with stable `CAMPAIGNS_API.*` codes (`DB_UNAVAILABLE`,
`INVALID_INPUT`, `TENANT_REQUIRED`, `DUPLICATE_SUPPRESSION`).
## Usage: Basic
```ts
import { SurrealCampaignSuppressionRepository } from "@suaveplan/api-plugin-campaigns/repositories/suppression/suppression";
import type { SurrealAdapter } from "@suaveplan/surrealdb";
declare const db: SurrealAdapter;
const store = new SurrealCampaignSuppressionRepository({
db,
clock: () => Date.now(),
idGenerator: () => crypto.randomUUID(),
});
await store.add({
tenantId: "t1",
channel: "email",
channelValue: "user@example.com",
reason: "unsubscribe",
});
const blocked = await store.check("t1", "email", "user@example.com");
```
## Usage: Advanced
```ts
import { SurrealCampaignSuppressionRepository } from "@suaveplan/api-plugin-campaigns/repositories/suppression/suppression";
import { CAMPAIGNS_API_CODES, CampaignsApiError } from "@suaveplan/api-plugin-campaigns";
const store = new SurrealCampaignSuppressionRepository({
db,
clock: () => Date.now(),
idGenerator: () => crypto.randomUUID(),
table: "campaign_suppression_v2",
});
try {
await store.add({
tenantId: "t1",
channel: "sms",
channelValue: "+15551234567",
reason: "complaint",
});
} catch (err) {
if (
err instanceof CampaignsApiError &&
err.code === CAMPAIGNS_API_CODES.DUPLICATE_SUPPRESSION
) {
// ... already suppressed, treat as success
}
}
```
## API Reference
- `interface CampaignSuppressionStore` — local persistence contract:
`add`, `remove`, `check`, `list`, `count`, `removeByChannelValue`.
- `class SurrealCampaignSuppressionRepository implements CampaignSuppressionStore`
- `new SurrealCampaignSuppressionRepository({ db, clock, idGenerator, table?, logger? })`
- `interface CampaignSuppressionEntry` — domain row shape.
- `type CampaignSuppressionReason` — one of `"unsubscribe"`, `"bounce"`,
`"complaint"`, `"manual"`, `"compliance"`.
- `const CreateInputSchema`, `const QuerySchema` — re-exported Zod
schemas for callers who want to validate at a different boundary.
## Implementation Notes
The local interface is intentional and is NOT a wrapper over
`@suaveplan/suppression` — that package is email-only and would force a
lossy mapping. The repository uses `channel` as a first-class column so a
single tenant can suppress different addresses on different channels
without collision.
The Surreal `add` issues an application-layer `check` before the
INSERT. A Surreal-side `UNIQUE INDEX` on `(tenant_id, channel,
channel_value)` is recommended as defence in depth, but the application
check is the primary gate — it gives us a clean
`CAMPAIGNS_API.DUPLICATE_SUPPRESSION` error rather than a driver-specific
constraint violation that would have to be string-matched.
Errors are wrapped via the `run<T>` helper, which uniformly turns
adapter exceptions into `CampaignsApiError(DB_UNAVAILABLE)` with the
operation name attached as `context.op`. This keeps the failure modes
auditable from logs without leaking driver internals.

View File

@@ -0,0 +1,380 @@
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CAMPAIGNS_API_CODES,
type CampaignsApiError,
} from "../../error-codes/error-codes.js";
import {
CreateInputSchema,
QuerySchema,
SurrealCampaignSuppressionRepository,
} from "./suppression.js";
type QueryFn = (sql: string, params?: readonly unknown[]) => Promise<unknown>;
function adapter(queryFn: QueryFn): SurrealAdapter {
return { query: queryFn } as unknown as SurrealAdapter;
}
function row(over: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: "s1",
tenant_id: "t1",
channel: "email",
channel_value: "u@e.com",
reason: "unsubscribe",
source: null,
metadata: null,
created_at: "2026-01-01T00:00:00.000Z",
...over,
};
}
function repo(
queryFn: QueryFn,
over: { clock?: () => number; id?: () => string; table?: string } = {}
): SurrealCampaignSuppressionRepository {
let n = 0;
return new SurrealCampaignSuppressionRepository({
db: adapter(queryFn),
clock: over.clock ?? (() => 1_700_000_000_000),
idGenerator: over.id ?? (() => `gen-${n++}`),
table: over.table,
});
}
describe("SurrealCampaignSuppressionRepository.add", () => {
it("validates input and inserts when not already suppressed", {
timeout: 5000,
}, async () => {
const calls: { sql: string; params: readonly unknown[] }[] = [];
const r = repo(async (sql, params) => {
calls.push({ sql, params: params ?? [] });
// first call is the SELECT in check(); return empty
if (sql.startsWith("SELECT")) return [];
return [];
});
const entry = await r.add({
tenantId: "t1",
channel: "email",
channelValue: "u@e.com",
reason: "unsubscribe",
source: "user-action",
metadata: { foo: "bar" },
});
expect(entry.id).toBe("gen-0");
expect(entry.tenantId).toBe("t1");
expect(entry.createdAt).toBeInstanceOf(Date);
expect(calls[1]!.sql).toContain("INSERT INTO campaign_suppression");
expect(calls[1]!.params[0]).toBe("gen-0");
expect(calls[1]!.params[5]).toBe("user-action");
expect(calls[1]!.params[6]).toEqual({ foo: "bar" });
});
it("nulls absent source and metadata in the positional params", {
timeout: 5000,
}, async () => {
const calls: { params: readonly unknown[] }[] = [];
const r = repo(async (sql, params) => {
if (sql.startsWith("INSERT")) {
calls.push({ params: params ?? [] });
}
return [];
});
await r.add({
tenantId: "t1",
channel: "email",
channelValue: "u@e.com",
reason: "unsubscribe",
});
expect(calls[0]!.params[5]).toBeNull();
expect(calls[0]!.params[6]).toBeNull();
});
it("throws DUPLICATE_SUPPRESSION when check() returns an existing entry", {
timeout: 5000,
}, async () => {
const r = repo(async (sql) => {
if (sql.startsWith("SELECT")) return [row({ id: "existing" })];
return [];
});
let captured: unknown;
try {
await r.add({
tenantId: "t1",
channel: "email",
channelValue: "u@e.com",
reason: "unsubscribe",
});
} catch (e) {
captured = e;
}
expect((captured as CampaignsApiError).code).toBe(
CAMPAIGNS_API_CODES.DUPLICATE_SUPPRESSION
);
});
it("throws INVALID_INPUT for missing tenantId", {
timeout: 5000,
}, async () => {
const r = repo(async () => []);
let captured: unknown;
try {
await r.add({
tenantId: "",
channel: "email",
channelValue: "u@e.com",
reason: "unsubscribe",
});
} catch (e) {
captured = e;
}
expect((captured as CampaignsApiError).code).toBe(
CAMPAIGNS_API_CODES.INVALID_INPUT
);
});
it("wraps adapter errors during INSERT in DB_UNAVAILABLE", {
timeout: 5000,
}, async () => {
const r = repo(async (sql) => {
if (sql.startsWith("SELECT")) return [];
throw new Error("driver-down");
});
let captured: unknown;
try {
await r.add({
tenantId: "t1",
channel: "email",
channelValue: "u@e.com",
reason: "unsubscribe",
});
} catch (e) {
captured = e;
}
expect((captured as CampaignsApiError).code).toBe(
CAMPAIGNS_API_CODES.DB_UNAVAILABLE
);
});
});
describe("SurrealCampaignSuppressionRepository.remove + check", () => {
it("remove returns true when DELETE returns a row", {
timeout: 5000,
}, async () => {
const r = repo(async () => [row()]);
expect(await r.remove("s1")).toBe(true);
});
it("remove returns false when DELETE returns nothing", {
timeout: 5000,
}, async () => {
const r = repo(async () => []);
expect(await r.remove("s1")).toBe(false);
});
it("remove rejects an empty id with INVALID_INPUT", {
timeout: 5000,
}, async () => {
const r = repo(async () => []);
let captured: unknown;
try {
await r.remove("");
} catch (e) {
captured = e;
}
expect((captured as CampaignsApiError).code).toBe(
CAMPAIGNS_API_CODES.INVALID_INPUT
);
});
it("check returns null when no row matches", {
timeout: 5000,
}, async () => {
const r = repo(async () => []);
expect(await r.check("t1", "email", "u@e.com")).toBeNull();
});
it("check maps a row and binds the triple positionally", {
timeout: 5000,
}, async () => {
const calls: { params: readonly unknown[] }[] = [];
const r = repo(async (_sql, params) => {
calls.push({ params: params ?? [] });
return [row({ source: "x", metadata: { y: 1 } })];
});
const entry = await r.check("t1", "email", "u@e.com");
expect(entry?.id).toBe("s1");
expect(entry?.source).toBe("x");
expect(entry?.metadata).toEqual({ y: 1 });
expect(entry?.createdAt).toBeInstanceOf(Date);
expect(calls[0]!.params).toEqual(["t1", "email", "u@e.com"]);
});
it("check preserves a Date timestamp from the row", {
timeout: 5000,
}, async () => {
const ts = new Date("2026-04-01T00:00:00.000Z");
const r = repo(async () => [row({ created_at: ts })]);
const entry = await r.check("t1", "email", "u@e.com");
expect(entry?.createdAt).toEqual(ts);
});
it("check rejects an empty tenantId with TENANT_REQUIRED", {
timeout: 5000,
}, async () => {
const r = repo(async () => []);
let captured: unknown;
try {
await r.check("", "email", "u@e.com");
} catch (e) {
captured = e;
}
expect((captured as CampaignsApiError).code).toBe(
CAMPAIGNS_API_CODES.TENANT_REQUIRED
);
});
});
describe("SurrealCampaignSuppressionRepository.list + count + removeByChannelValue", () => {
it("list threads filters + paging into SQL positionally", {
timeout: 5000,
}, async () => {
const calls: { sql: string; params: readonly unknown[] }[] = [];
const r = repo(async (sql, params) => {
calls.push({ sql, params: params ?? [] });
return [row(), row({ id: "s2" })];
});
const result = await r.list({
tenantId: "t1",
channel: "email",
reason: "bounce",
limit: 10,
offset: 5,
});
expect(result).toHaveLength(2);
expect(calls[0]!.sql).toContain("tenant_id = $p0");
expect(calls[0]!.sql).toContain("channel = $p1");
expect(calls[0]!.sql).toContain("reason = $p2");
expect(calls[0]!.sql).toContain("LIMIT 10");
expect(calls[0]!.sql).toContain("START 5");
});
it("list applies defaults and omits START when offset is 0", {
timeout: 5000,
}, async () => {
const calls: string[] = [];
const r = repo(async (sql) => {
calls.push(sql);
return [];
});
await r.list({ tenantId: "t1" });
expect(calls[0]).toContain("LIMIT 50");
expect(calls[0]).not.toContain("START");
});
it("list rejects invalid query (missing tenantId)", {
timeout: 5000,
}, async () => {
const r = repo(async () => []);
let captured: unknown;
try {
await r.list({ tenantId: "" });
} catch (e) {
captured = e;
}
expect((captured as CampaignsApiError).code).toBe(
CAMPAIGNS_API_CODES.INVALID_INPUT
);
});
it("count returns the GROUP ALL count or zero", {
timeout: 5000,
}, async () => {
const a = repo(async () => [{ count: 3 }]);
expect(await a.count("t1")).toBe(3);
const b = repo(async () => []);
expect(await b.count("t1")).toBe(0);
});
it("count rejects an empty tenantId", { timeout: 5000 }, async () => {
const r = repo(async () => []);
let captured: unknown;
try {
await r.count("");
} catch (e) {
captured = e;
}
expect((captured as CampaignsApiError).code).toBe(
CAMPAIGNS_API_CODES.TENANT_REQUIRED
);
});
it("removeByChannelValue returns true when DELETE returns a row", {
timeout: 5000,
}, async () => {
const r = repo(async () => [row()]);
expect(await r.removeByChannelValue("t1", "email", "u@e.com")).toBe(
true
);
});
it("removeByChannelValue returns false when DELETE returns nothing", {
timeout: 5000,
}, async () => {
const r = repo(async () => []);
expect(await r.removeByChannelValue("t1", "email", "u@e.com")).toBe(
false
);
});
it("removeByChannelValue rejects an empty tenantId", {
timeout: 5000,
}, async () => {
const r = repo(async () => []);
let captured: unknown;
try {
await r.removeByChannelValue("", "email", "u@e.com");
} catch (e) {
captured = e;
}
expect((captured as CampaignsApiError).code).toBe(
CAMPAIGNS_API_CODES.TENANT_REQUIRED
);
});
it("respects a custom table name on every operation", {
timeout: 5000,
}, async () => {
const calls: string[] = [];
const r = repo(
async (sql) => {
calls.push(sql);
return [];
},
{ table: "custom_supp" }
);
await r.list({ tenantId: "t1" });
expect(calls[0]).toContain("FROM custom_supp");
});
});
describe("Re-exported schemas", () => {
it("CreateInputSchema rejects unknown keys", { timeout: 5000 }, () => {
const r = CreateInputSchema.safeParse({
tenantId: "t1",
channel: "email",
channelValue: "u@e.com",
reason: "unsubscribe",
unknown: "boom",
});
expect(r.success).toBe(false);
});
it("QuerySchema accepts a minimal valid input", {
timeout: 5000,
}, () => {
const r = QuerySchema.safeParse({ tenantId: "t1" });
expect(r.success).toBe(true);
});
});

View File

@@ -0,0 +1,213 @@
/**
* SurrealDB-backed implementation of the tenant + channel-scoped
* `CampaignSuppressionStore` interface for
* `@suaveplan/api-plugin-campaigns`. The local interface (and its
* in-memory reference implementation in `./suppression-memory.ts`) is
* intentionally channel-scoped: the genesis `@suaveplan/suppression`
* package is email-only and does not fit the multi-channel campaigns
* domain.
*/
import {
CAMPAIGNS_API_CODES,
CampaignsApiError,
} from "../../error-codes/error-codes.js";
import type {
CampaignSuppressionEntry,
CampaignSuppressionQuery,
CampaignSuppressionRow,
CampaignSuppressionStore,
CreateCampaignSuppressionInput,
SurrealCampaignSuppressionRepositoryOptions,
} from "./suppression.types.js";
import {
CreateInputSchema,
QuerySchema,
requireTenant,
rowToEntry,
validateCreate,
} from "./suppression-internals.js";
/**
* SurrealDB-backed `CampaignSuppressionStore`. Uses positional `$p0/$p1`
* parameters and wraps adapter errors in `CampaignsApiError` with the
* `DB_UNAVAILABLE` code. Duplicate `(tenant, channel, value)` adds are
* rejected at the application layer with `DUPLICATE_SUPPRESSION` so the
* Surreal-side unique index is defence-in-depth rather than the primary
* gate.
*/
export class SurrealCampaignSuppressionRepository
implements CampaignSuppressionStore
{
private readonly db: SurrealCampaignSuppressionRepositoryOptions["db"];
private readonly clock: SurrealCampaignSuppressionRepositoryOptions["clock"];
private readonly idGenerator: SurrealCampaignSuppressionRepositoryOptions["idGenerator"];
private readonly table: string;
public constructor(options: SurrealCampaignSuppressionRepositoryOptions) {
this.db = options.db;
this.clock = options.clock;
this.idGenerator = options.idGenerator;
this.table = options.table ?? "campaign_suppression";
}
public async add(
input: CreateCampaignSuppressionInput
): Promise<CampaignSuppressionEntry> {
const data = validateCreate(input);
const existing = await this.check(
data.tenantId,
data.channel,
data.channelValue
);
if (existing !== null) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.DUPLICATE_SUPPRESSION,
`suppression already exists for ${data.channel}:${data.channelValue}`,
{ context: { op: "add", existingId: existing.id } }
);
}
const entry: CampaignSuppressionEntry = {
id: this.idGenerator(),
tenantId: data.tenantId,
channel: data.channel,
channelValue: data.channelValue,
reason: data.reason,
...(data.source !== undefined ? { source: data.source } : {}),
...(data.metadata !== undefined ? { metadata: data.metadata } : {}),
createdAt: new Date(this.clock()),
};
const sql = `INSERT INTO ${this.table} {
id: $p0, tenant_id: $p1, channel: $p2, channel_value: $p3,
reason: $p4, source: $p5, metadata: $p6, created_at: $p7
}`;
await this.run<CampaignSuppressionRow>(
sql,
[
entry.id,
entry.tenantId,
entry.channel,
entry.channelValue,
entry.reason,
entry.source ?? null,
entry.metadata ?? null,
entry.createdAt,
],
"add"
);
return entry;
}
public async remove(id: string): Promise<boolean> {
if (id.length === 0) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.INVALID_INPUT,
"suppression id is required"
);
}
const rows = await this.run<CampaignSuppressionRow>(
`DELETE ${this.table} WHERE id = $p0 RETURN BEFORE`,
[id],
"remove"
);
return rows.length > 0;
}
public async check(
tenantId: string,
channel: string,
channelValue: string
): Promise<CampaignSuppressionEntry | null> {
requireTenant(tenantId);
const rows = await this.run<CampaignSuppressionRow>(
`SELECT * FROM ${this.table}
WHERE tenant_id = $p0 AND channel = $p1 AND channel_value = $p2
LIMIT 1`,
[tenantId, channel, channelValue],
"check"
);
return rows[0] !== undefined ? rowToEntry(rows[0]) : null;
}
public async list(
query: CampaignSuppressionQuery
): Promise<readonly CampaignSuppressionEntry[]> {
const parsed = QuerySchema.safeParse(query);
if (!parsed.success) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.INVALID_INPUT,
"invalid suppression query",
{ cause: parsed.error, context: { op: "list" } }
);
}
const conditions: string[] = ["tenant_id = $p0"];
const params: unknown[] = [parsed.data.tenantId];
const push = (col: string, value: unknown): void => {
params.push(value);
conditions.push(`${col} = $p${params.length - 1}`);
};
if (parsed.data.channel !== undefined) {
push("channel", parsed.data.channel);
}
if (parsed.data.reason !== undefined) {
push("reason", parsed.data.reason);
}
const limit = parsed.data.limit ?? 50;
const offset = parsed.data.offset ?? 0;
let sql = `SELECT * FROM ${this.table} WHERE ${conditions.join(" AND ")} LIMIT ${limit}`;
if (offset > 0) sql += ` START ${offset}`;
const rows = await this.run<CampaignSuppressionRow>(
sql,
params,
"list"
);
return rows.map(rowToEntry);
}
public async count(tenantId: string): Promise<number> {
requireTenant(tenantId);
const rows = await this.run<{ count: number }>(
`SELECT count() AS count FROM ${this.table}
WHERE tenant_id = $p0 GROUP ALL`,
[tenantId],
"count"
);
return rows[0]?.count ?? 0;
}
public async removeByChannelValue(
tenantId: string,
channel: string,
channelValue: string
): Promise<boolean> {
requireTenant(tenantId);
const rows = await this.run<CampaignSuppressionRow>(
`DELETE ${this.table}
WHERE tenant_id = $p0 AND channel = $p1 AND channel_value = $p2
RETURN BEFORE`,
[tenantId, channel, channelValue],
"removeByChannelValue"
);
return rows.length > 0;
}
private async run<T>(
sql: string,
params: unknown[],
op: string
): Promise<T[]> {
try {
return await this.db.query<T>(sql, params);
} catch (cause) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.DB_UNAVAILABLE,
`SurrealCampaignSuppressionRepository.${op} failed`,
{ cause, context: { op } }
);
}
}
}
// Schemas/helpers are exported through the barrel for tests that want
// to assert their behaviour directly.
export { CreateInputSchema, QuerySchema };

View File

@@ -0,0 +1,105 @@
/**
* Type definitions for the campaign-suppression module. The local
* `CampaignSuppressionStore` interface is intentionally tenant + channel
* scoped — the genesis `@suaveplan/suppression` package is email-only and
* does not fit the campaigns plugin's multi-channel domain.
*/
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import type { Clock, IdGenerator, LoggerLike } from "@suaveplan/types";
/** Stable reasons a contact may be suppressed on a given channel. */
export type CampaignSuppressionReason =
| "unsubscribe"
| "bounce"
| "complaint"
| "manual"
| "compliance";
/**
* One suppression record. Channel-scoped: an address that is suppressed
* on `email` can still receive `sms`, so the `(tenantId, channel,
* channelValue)` triple is the natural primary key.
*/
export interface CampaignSuppressionEntry {
readonly id: string;
readonly tenantId: string;
readonly channel: string;
readonly channelValue: string;
readonly reason: CampaignSuppressionReason;
readonly source?: string;
readonly metadata?: Record<string, unknown>;
readonly createdAt: Date;
}
/** Filter parameters for listing suppression entries. */
export interface CampaignSuppressionQuery {
readonly tenantId: string;
readonly channel?: string;
readonly reason?: CampaignSuppressionReason;
readonly limit?: number;
readonly offset?: number;
}
/** Input for creating a new suppression entry. `id` and `createdAt` are filled by the store. */
export interface CreateCampaignSuppressionInput {
readonly tenantId: string;
readonly channel: string;
readonly channelValue: string;
readonly reason: CampaignSuppressionReason;
readonly source?: string;
readonly metadata?: Record<string, unknown>;
}
/**
* Persistence contract for tenant + channel-scoped suppression entries.
* Implementations may be in-memory (see
* {@link InMemoryCampaignSuppressionStore}) or SurrealDB-backed.
*/
export interface CampaignSuppressionStore {
add(
input: CreateCampaignSuppressionInput
): Promise<CampaignSuppressionEntry>;
remove(id: string): Promise<boolean>;
check(
tenantId: string,
channel: string,
channelValue: string
): Promise<CampaignSuppressionEntry | null>;
list(
query: CampaignSuppressionQuery
): Promise<readonly CampaignSuppressionEntry[]>;
count(tenantId: string): Promise<number>;
removeByChannelValue(
tenantId: string,
channel: string,
channelValue: string
): Promise<boolean>;
}
/** Constructor bundle for {@link SurrealCampaignSuppressionRepository}. */
export interface SurrealCampaignSuppressionRepositoryOptions {
readonly db: SurrealAdapter;
readonly clock: Clock;
readonly idGenerator: IdGenerator;
readonly table?: string;
readonly logger?: LoggerLike;
}
/** Constructor bundle for {@link InMemoryCampaignSuppressionStore}. */
export interface InMemoryCampaignSuppressionStoreOptions {
readonly clock: Clock;
readonly idGenerator: IdGenerator;
}
/** SurrealDB row shape for the `campaign_suppression` table. */
export interface CampaignSuppressionRow {
id: string;
tenant_id: string;
channel: string;
channel_value: string;
reason: string;
source?: string | null;
metadata?: Record<string, unknown> | null;
created_at: Date | string;
}

View File

@@ -0,0 +1,96 @@
# Campaign action routes
## Purpose
This module owns the campaign action routes — state-machine
transitions (`/send`, `/pause`, `/cancel`), the `/duplicate`
shallow-copy endpoint, the `/test` ad-hoc send, and the two
delivery-tracking-backed read endpoints (`/stats` and `/events`).
State transitions are authoritative: invalid transitions raise
`CAMPAIGNS_API.INVALID_STATE_TRANSITION` mapped to HTTP 409 so clients
can drive UI affordances (enable/disable a "Pause" button) off a
stable code rather than parsing error messages.
The action routes consume the in-domain `CampaignStateMachine` from
`@suaveplan/campaigns`. Every transition decision flows through that
single source of truth — no string-comparisons of `status` are
duplicated at the route layer, eliminating drift between the engine
and the HTTP surface.
## Features
- 3 transition routes: `buildSendCampaignRoute`,
`buildPauseCampaignRoute`, `buildCancelCampaignRoute`.
- 1 clone route: `buildDuplicateCampaignRoute` — copies metadata and
audience/template config, resets stats, clears `sentAt`/`completedAt`.
- 1 test-send route: `buildTestSendRoute` — validates a recipient list
of 1–10 email addresses and echoes them back.
- 2 reporting routes consuming `DeliveryEventStore`:
`buildCampaignStatsRoute`, `buildCampaignEventsRoute`.
- Transition guard raises `INVALID_STATE_TRANSITION` (HTTP 409) when
`CampaignStateMachine.transition` rejects the (status, event) pair.
## Usage: Basic
```ts
import { buildSendCampaignRoute } from "@suaveplan/api-plugin-campaigns";
const entry = buildSendCampaignRoute();
app.post(entry.route.path, (c) => entry.handler(c));
```
## Usage: Advanced
```ts
import {
buildCampaignEventsRoute,
buildCampaignStatsRoute,
buildCancelCampaignRoute,
buildDuplicateCampaignRoute,
buildPauseCampaignRoute,
buildSendCampaignRoute,
buildTestSendRoute,
} from "@suaveplan/api-plugin-campaigns";
const actionEntries = [
buildSendCampaignRoute(),
buildPauseCampaignRoute(),
buildCancelCampaignRoute(),
buildDuplicateCampaignRoute(),
buildTestSendRoute(),
buildCampaignStatsRoute(),
buildCampaignEventsRoute(),
];
```
## API Reference
- `buildSendCampaignRoute(): PluginRouteEntry`
- `buildPauseCampaignRoute(): PluginRouteEntry`
- `buildCancelCampaignRoute(): PluginRouteEntry`
- `buildDuplicateCampaignRoute(): PluginRouteEntry`
- `buildTestSendRoute(): PluginRouteEntry`
- `buildCampaignStatsRoute(): PluginRouteEntry`
- `buildCampaignEventsRoute(): PluginRouteEntry`
- `TestSendSchema`, `EventQuerySchema`, `DeliveryStatsResponseSchema`
— Zod schemas re-exported for documentation generators and tests.
## Implementation Notes
`applyTransition` wraps `CampaignStateMachine.transition` in a
`CampaignsApiError` so a failed transition surfaces as
`CAMPAIGNS_API.INVALID_STATE_TRANSITION` rather than the
domain-package's `CampaignsError` (which carries an unrelated
namespace). Wrapping at this seam keeps the route layer's error
vocabulary self-contained: every error a client sees from the
campaigns plugin carries a `CAMPAIGNS_API.*` code.
The `/duplicate` handler intentionally copies all configuration
fields (template, audience, throttle, abTestConfig) but resets the
volatile fields (`id`, `status`, `stats`, `sentAt`, `completedAt`,
`createdAt`, `updatedAt`). The `(copy)` suffix is appended to the
name so the duplicate is visually distinct in listing UIs without
forcing the operator to provide a new name up front. The route
handlers' OTel attributes record `route` plus `outcome` so an
operator can chart per-route success/error counters without parsing
log messages.

View File

@@ -0,0 +1,357 @@
import { type Campaign, InMemoryCampaignStore } from "@suaveplan/campaigns";
import {
type DeliveryEvent,
InMemoryDeliveryEventStore,
} from "@suaveplan/delivery-tracking";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import {
CAMPAIGNS_API_CODES,
CampaignsApiError,
} from "../../error-codes/error-codes.js";
import {
buildCampaignEventsRoute,
buildCampaignStatsRoute,
buildCancelCampaignRoute,
buildDuplicateCampaignRoute,
buildPauseCampaignRoute,
buildSendCampaignRoute,
buildTestSendRoute,
} from "./action-routes.js";
import "../../context/context.js";
function makeApp(
repo: InMemoryCampaignStore,
eventStore: InMemoryDeliveryEventStore = new InMemoryDeliveryEventStore(),
principal: object | null = { id: "u", tenantId: "t" }
): Hono {
const app = new Hono();
app.use("*", async (c, next) => {
if (principal !== null) c.set("user", principal as never);
c.set("campaignRepository", repo);
c.set("deliveryEventStore", eventStore);
c.set("eventBus", { emit: () => undefined } as never);
await next();
});
app.onError((err, c) => {
if (err instanceof CampaignsApiError) {
const status = ((err.context as { statusCode?: number } | undefined)
?.statusCode ?? 500) as 400 | 401 | 404 | 409 | 500;
return c.json({ code: err.code, error: err.message }, status);
}
return c.json({ error: String(err) }, 500);
});
for (const e of [
buildSendCampaignRoute(),
buildPauseCampaignRoute(),
buildCancelCampaignRoute(),
buildDuplicateCampaignRoute(),
buildTestSendRoute(),
buildCampaignStatsRoute(),
buildCampaignEventsRoute(),
]) {
const method = e.route.method as "get" | "post";
app[method](e.route.path, (c) => e.handler(c as never));
}
return app;
}
async function seed(
repo: InMemoryCampaignStore,
overrides: Partial<Campaign> = {}
): Promise<string> {
const now = new Date();
const id = (overrides.id ?? "c1") as string;
await repo.create({
id,
tenantId: "t",
name: "Promo",
status: "draft",
type: "broadcast",
channel: "email",
audienceConfig: { groupIds: [] },
metadata: {},
stats: {
totalRecipients: 0,
sent: 0,
delivered: 0,
opened: 0,
clicked: 0,
bounced: 0,
unsubscribed: 0,
complained: 0,
failed: 0,
},
createdAt: now,
updatedAt: now,
...overrides,
} as Campaign);
return id;
}
describe("campaign action routes", () => {
it("POST /campaigns/:id/send transitions draft → sending", {
timeout: 5000,
}, async () => {
const repo = new InMemoryCampaignStore();
await seed(repo);
const app = makeApp(repo);
const res = await app.request("/campaigns/c1/send", { method: "POST" });
expect(res.status).toBe(200);
expect((await res.json()).data.status).toBe("sending");
});
it("send raises INVALID_STATE_TRANSITION on completed campaign", {
timeout: 5000,
}, async () => {
const repo = new InMemoryCampaignStore();
await seed(repo, { status: "completed" });
const app = makeApp(repo);
const res = await app.request("/campaigns/c1/send", { method: "POST" });
expect(res.status).toBe(409);
expect((await res.json()).code).toBe(
CAMPAIGNS_API_CODES.INVALID_STATE_TRANSITION
);
});
it("send 404 when campaign missing", { timeout: 5000 }, async () => {
const repo = new InMemoryCampaignStore();
const app = makeApp(repo);
const res = await app.request("/campaigns/nope/send", {
method: "POST",
});
expect(res.status).toBe(404);
});
it("POST /campaigns/:id/pause works from sending", {
timeout: 5000,
}, async () => {
const repo = new InMemoryCampaignStore();
await seed(repo, { status: "sending" });
const app = makeApp(repo);
const res = await app.request("/campaigns/c1/pause", {
method: "POST",
});
expect(res.status).toBe(200);
expect((await res.json()).data.status).toBe("paused");
});
it("pause 409 when not sending", { timeout: 5000 }, async () => {
const repo = new InMemoryCampaignStore();
await seed(repo);
const app = makeApp(repo);
const res = await app.request("/campaigns/c1/pause", {
method: "POST",
});
expect(res.status).toBe(409);
});
it("POST /campaigns/:id/cancel works from scheduled", {
timeout: 5000,
}, async () => {
const repo = new InMemoryCampaignStore();
await seed(repo, { status: "scheduled" });
const app = makeApp(repo);
const res = await app.request("/campaigns/c1/cancel", {
method: "POST",
});
expect(res.status).toBe(200);
expect((await res.json()).data.status).toBe("cancelled");
});
it("POST /campaigns/:id/duplicate creates a draft clone", {
timeout: 5000,
}, async () => {
const repo = new InMemoryCampaignStore();
await seed(repo, { status: "completed" });
const app = makeApp(repo);
const res = await app.request("/campaigns/c1/duplicate", {
method: "POST",
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.data.name).toBe("Promo (copy)");
expect(body.data.status).toBe("draft");
expect(body.data.id).not.toBe("c1");
});
it("duplicate 404 when source missing", { timeout: 5000 }, async () => {
const repo = new InMemoryCampaignStore();
const app = makeApp(repo);
const res = await app.request("/campaigns/nope/duplicate", {
method: "POST",
});
expect(res.status).toBe(404);
});
it("POST /campaigns/:id/test echoes valid recipients", {
timeout: 5000,
}, async () => {
const repo = new InMemoryCampaignStore();
await seed(repo);
const app = makeApp(repo);
const res = await app.request("/campaigns/c1/test", {
method: "POST",
body: JSON.stringify({ recipients: ["a@b.com"] }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(200);
expect((await res.json()).data.recipients).toEqual(["a@b.com"]);
});
it("test rejects invalid body", { timeout: 5000 }, async () => {
const repo = new InMemoryCampaignStore();
await seed(repo);
const app = makeApp(repo);
const res = await app.request("/campaigns/c1/test", {
method: "POST",
body: JSON.stringify({ recipients: [] }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(400);
});
it("test 404 when campaign missing", { timeout: 5000 }, async () => {
const repo = new InMemoryCampaignStore();
const app = makeApp(repo);
const res = await app.request("/campaigns/nope/test", {
method: "POST",
body: JSON.stringify({ recipients: ["a@b.com"] }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(404);
});
it("GET /campaigns/:id/stats returns delivery stats", {
timeout: 5000,
}, async () => {
const repo = new InMemoryCampaignStore();
await seed(repo);
const eventStore = new InMemoryDeliveryEventStore();
const app = makeApp(repo, eventStore);
const res = await app.request("/campaigns/c1/stats");
expect(res.status).toBe(200);
const body = await res.json();
expect(typeof body.data.deliveryRate).toBe("number");
});
it("stats 404 when campaign missing", { timeout: 5000 }, async () => {
const repo = new InMemoryCampaignStore();
const app = makeApp(repo);
const res = await app.request("/campaigns/nope/stats");
expect(res.status).toBe(404);
});
it("GET /campaigns/:id/events lists events", {
timeout: 5000,
}, async () => {
const repo = new InMemoryCampaignStore();
await seed(repo);
const eventStore = new InMemoryDeliveryEventStore();
const event: DeliveryEvent = {
id: "e1",
tenantId: "t",
campaignId: "c1",
contactId: "u1",
channel: "email",
eventType: "delivered",
messageId: "m1",
metadata: {},
timestamp: new Date(),
};
await eventStore.record(event);
const app = makeApp(repo, eventStore);
const res = await app.request(
"/campaigns/c1/events?eventType=delivered&limit=10&offset=0"
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.total).toBe(1);
expect(typeof body.data.events[0].timestamp).toBe("string");
});
it("events 404 when campaign missing", { timeout: 5000 }, async () => {
const repo = new InMemoryCampaignStore();
const eventStore = new InMemoryDeliveryEventStore();
const app = makeApp(repo, eventStore);
const res = await app.request("/campaigns/nope/events");
expect(res.status).toBe(404);
});
it("events default query when no params", { timeout: 5000 }, async () => {
const repo = new InMemoryCampaignStore();
await seed(repo);
const eventStore = new InMemoryDeliveryEventStore();
const app = makeApp(repo, eventStore);
const res = await app.request("/campaigns/c1/events");
expect(res.status).toBe(200);
});
it("send surfaces SERVICE_NOT_AVAILABLE when repo missing", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("user", { id: "u", tenantId: "t" } as never);
await next();
});
app.onError((err, c) =>
c.json({ code: (err as CampaignsApiError).code }, 500)
);
const e = buildSendCampaignRoute();
app.post(e.route.path, (c) => e.handler(c as never));
const res = await app.request("/campaigns/c1/send", {
method: "POST",
});
expect((await res.json()).code).toBe(
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE
);
});
it("stats surfaces SERVICE_NOT_AVAILABLE when event store missing", {
timeout: 5000,
}, async () => {
const repo = new InMemoryCampaignStore();
await seed(repo);
const app = new Hono();
app.use("*", async (c, next) => {
c.set("user", { id: "u", tenantId: "t" } as never);
c.set("campaignRepository", repo);
await next();
});
app.onError((err, c) =>
c.json({ code: (err as CampaignsApiError).code }, 500)
);
const e = buildCampaignStatsRoute();
app.get(e.route.path, (c) => e.handler(c as never));
const res = await app.request("/campaigns/c1/stats");
expect((await res.json()).code).toBe(
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE
);
});
it("POST /campaigns/:id/test 400 on malformed JSON body", {
timeout: 5000,
}, async () => {
const repo = new InMemoryCampaignStore();
await seed(repo);
const app = makeApp(repo);
const res = await app.request("/campaigns/c1/test", {
method: "POST",
body: "not-json{",
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(400);
});
it("send rejects unauthenticated requests", {
timeout: 5000,
}, async () => {
const repo = new InMemoryCampaignStore();
const app = makeApp(repo, undefined, null);
const res = await app.request("/campaigns/c1/send", {
method: "POST",
});
expect(res.status).toBe(401);
});
});

View File

@@ -0,0 +1,361 @@
/**
* Campaign action route builders — state-machine transitions and the
* `/duplicate` and `/test` action endpoints. The state guard is
* authoritative: invalid transitions raise
* `CAMPAIGNS_API.INVALID_STATE_TRANSITION` mapped to HTTP 409 so
* clients can branch on a stable code rather than parsing error
* messages.
*/
import {
GET,
type PluginRouteEntry,
POST,
successResponse,
} from "@suaveplan/api-openapi";
import {
type Campaign,
CampaignStateMachine,
type CampaignStatus,
type CampaignStore,
} from "@suaveplan/campaigns";
import type { DeliveryEventStore } from "@suaveplan/delivery-tracking";
import type { Context } from "hono";
import z from "zod";
import {
CAMPAIGNS_API_CODES,
CampaignsApiError,
} from "../../error-codes/error-codes.js";
import { getCampaignsApiInstruments } from "../../otel/instruments.js";
import { requireTenant } from "../shared/require-tenant.js";
import { campaignToResponse } from "./crud-routes.js";
import {
CampaignIdParamSchema,
CampaignResponseSchema,
EMPTY_STATS,
} from "./schemas.js";
const TestSendSchema = z.object({
recipients: z.array(z.email()).min(1).max(10),
});
const DeliveryStatsResponseSchema = z.object({
sent: z.number(),
delivered: z.number(),
deliveryRate: z.number(),
opened: z.number(),
openRate: z.number(),
clicked: z.number(),
clickRate: z.number(),
bounced: z.number(),
bounceRate: z.number(),
unsubscribed: z.number(),
unsubscribeRate: z.number(),
complained: z.number(),
complainRate: z.number(),
});
const DeliveryEventResponseSchema = z.object({
id: z.string(),
tenantId: z.string(),
campaignId: z.string().nullable().optional(),
contactId: z.string(),
channel: z.string(),
eventType: z.string(),
messageId: z.string(),
metadata: z.record(z.string(), z.unknown()),
timestamp: z.string(),
});
const DeliveryEventListResponseSchema = z.object({
events: z.array(DeliveryEventResponseSchema),
total: z.number(),
});
const EventQuerySchema = z.object({
eventType: z
.enum([
"sent",
"delivered",
"opened",
"clicked",
"bounced",
"unsubscribed",
"complained",
"deferred",
"dropped",
"read",
])
.optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
function requireRepo(c: Context): CampaignStore {
const repo = c.get("campaignRepository");
if (repo === undefined) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE,
"campaignRepository slot is empty"
);
}
return repo;
}
function requireEventStore(c: Context): DeliveryEventStore {
const store = c.get("deliveryEventStore");
if (store === undefined) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE,
"deliveryEventStore slot is empty"
);
}
return store;
}
async function loadOwned(
c: Context,
id: string,
tenantId: string
): Promise<Campaign> {
const campaign = await requireRepo(c).getById(id);
if (campaign === null || campaign.tenantId !== tenantId) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.CAMPAIGN_NOT_FOUND,
`campaign ${id} not found`,
{ context: { statusCode: 404, id } }
);
}
return campaign;
}
const stateMachine = new CampaignStateMachine();
function applyTransition(
current: CampaignStatus,
event: "send" | "pause" | "cancel"
): CampaignStatus {
try {
return stateMachine.transition(current, event);
} catch (cause) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.INVALID_STATE_TRANSITION,
`cannot apply '${event}' to campaign in status '${current}'`,
{ cause, context: { statusCode: 409, from: current, event } }
);
}
}
function buildActionRoute(
path: string,
summary: string,
event: "send" | "pause" | "cancel",
routeLabel: string
): PluginRouteEntry {
const route = POST(path)
.tags("campaigns")
.summary(summary)
.params(CampaignIdParamSchema)
.response(successResponse(CampaignResponseSchema))
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const repo = requireRepo(c);
const id = c.req.param("id");
const existing = await loadOwned(c, id, tenantId);
const next = applyTransition(existing.status, event);
const updated = await repo.updateStatus(id, next);
getCampaignsApiInstruments().requestsTotal.add(1, {
route: routeLabel,
outcome: "success",
});
return c.json(
{ success: true as const, data: campaignToResponse(updated) },
200
);
}
return { route, handler };
}
/** `POST /campaigns/:id/send` — transition draft|scheduled → sending. */
export function buildSendCampaignRoute(): PluginRouteEntry {
return buildActionRoute(
"/campaigns/:id/send",
"Send a campaign",
"send",
"send"
);
}
/** `POST /campaigns/:id/pause` — transition sending → paused. */
export function buildPauseCampaignRoute(): PluginRouteEntry {
return buildActionRoute(
"/campaigns/:id/pause",
"Pause a campaign",
"pause",
"pause"
);
}
/** `POST /campaigns/:id/cancel` — transition to cancelled. */
export function buildCancelCampaignRoute(): PluginRouteEntry {
return buildActionRoute(
"/campaigns/:id/cancel",
"Cancel a campaign",
"cancel",
"cancel"
);
}
/** `POST /campaigns/:id/duplicate` — clone the campaign into draft. */
export function buildDuplicateCampaignRoute(): PluginRouteEntry {
const route = POST("/campaigns/:id/duplicate")
.tags("campaigns")
.summary("Duplicate a campaign")
.params(CampaignIdParamSchema)
.response(successResponse(CampaignResponseSchema))
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const repo = requireRepo(c);
const id = c.req.param("id");
const source = await loadOwned(c, id, tenantId);
const now = new Date();
const clone = await repo.create({
...source,
id: crypto.randomUUID(),
name: `${source.name} (copy)`,
status: "draft",
stats: { ...EMPTY_STATS },
createdAt: now,
updatedAt: now,
sentAt: undefined,
completedAt: undefined,
} as unknown as Campaign);
getCampaignsApiInstruments().requestsTotal.add(1, {
route: "duplicate",
outcome: "success",
});
return c.json(
{ success: true as const, data: campaignToResponse(clone) },
201
);
}
return { route, handler };
}
/** `POST /campaigns/:id/test` — fire a test send to the given recipients. */
export function buildTestSendRoute(): PluginRouteEntry {
const route = POST("/campaigns/:id/test")
.tags("campaigns")
.summary("Send a test of a campaign")
.params(CampaignIdParamSchema)
.body(TestSendSchema)
.response(
z.object({
success: z.literal(true),
data: z.object({
recipients: z.array(z.email()),
}),
})
)
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const id = c.req.param("id");
await loadOwned(c, id, tenantId);
const raw = (await c.req.json().catch(() => ({}))) as unknown;
const parsed = TestSendSchema.safeParse(raw);
if (!parsed.success) {
getCampaignsApiInstruments().requestsTotal.add(1, {
route: "test",
outcome: "error",
});
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.INVALID_INPUT,
"POST /campaigns/:id/test body failed validation",
{ cause: parsed.error, context: { statusCode: 400 } }
);
}
getCampaignsApiInstruments().requestsTotal.add(1, {
route: "test",
outcome: "success",
});
return c.json(
{
success: true as const,
data: { recipients: parsed.data.recipients },
},
200
);
}
return { route, handler };
}
/** `GET /campaigns/:id/stats` — aggregated delivery stats. */
export function buildCampaignStatsRoute(): PluginRouteEntry {
const route = GET("/campaigns/:id/stats")
.tags("campaigns")
.summary("Get delivery stats for a campaign")
.params(CampaignIdParamSchema)
.response(successResponse(DeliveryStatsResponseSchema))
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const id = c.req.param("id");
await loadOwned(c, id, tenantId);
const stats = await requireEventStore(c).getStatsByCampaign(id);
getCampaignsApiInstruments().requestsTotal.add(1, {
route: "stats",
outcome: "success",
});
return c.json({ success: true as const, data: stats }, 200);
}
return { route, handler };
}
/** `GET /campaigns/:id/events` — paginated event log. */
export function buildCampaignEventsRoute(): PluginRouteEntry {
const route = GET("/campaigns/:id/events")
.tags("campaigns")
.summary("List delivery events for a campaign")
.params(CampaignIdParamSchema)
.query(EventQuerySchema)
.response(successResponse(DeliveryEventListResponseSchema))
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const id = c.req.param("id");
await loadOwned(c, id, tenantId);
const query = EventQuerySchema.parse({
eventType: c.req.query("eventType"),
limit: c.req.query("limit") ?? 50,
offset: c.req.query("offset") ?? 0,
});
const events = await requireEventStore(c).getByCampaign(id, {
...(query.eventType !== undefined
? { eventType: query.eventType }
: {}),
limit: query.limit,
offset: query.offset,
});
const mapped = events.map((e) => ({
...e,
timestamp: e.timestamp.toISOString(),
}));
getCampaignsApiInstruments().requestsTotal.add(1, {
route: "events",
outcome: "success",
});
return c.json(
{
success: true as const,
data: { events: mapped, total: mapped.length },
},
200
);
}
return { route, handler };
}
export { DeliveryStatsResponseSchema, EventQuerySchema, TestSendSchema };

View File

@@ -0,0 +1,71 @@
# `build-routes` (campaigns)
## Purpose
`buildCampaignRoutes` is the single aggregator that materialises every
`/campaigns/*` `PluginRouteEntry` in a canonical order. The plugin's
`onInit` consumes the array verbatim — there is no per-route opt-in or
configuration knob — so the OpenAPI spec generated for the campaigns
tree is deterministic across deployments and across reloads. Tests
assert the canonical order so accidental reordering during refactors
surfaces immediately rather than as silent spec drift.
The aggregator is intentionally a flat function returning a readonly
array (rather than a class or builder). The cost of describing 12
routes inline is small, the structure is obvious, and downstream
consumers can `array.map(toPrefixed)` cheaply when they need to mount
under a non-root prefix.
## Features
- Returns 12 entries in a documented canonical order.
- Pure: no I/O, no side effects, no captured registry.
- Each entry exposes `route: RouteConfig` + `handler: RouteHandler` so
it can be mounted either via `definePluginRoutes` (production) or
directly on a bare `Hono` app (tests).
## Usage: Basic
```ts
import { buildCampaignRoutes } from "@suaveplan/api-plugin-campaigns";
const entries = buildCampaignRoutes();
for (const e of entries) {
app[e.route.method as "get" | "post" | "patch" | "delete"](
e.route.path,
(c) => e.handler(c)
);
}
```
## Usage: Advanced
```ts
import {
buildCampaignRoutes,
definePluginRoutes,
} from "@suaveplan/api-plugin-campaigns";
const prefix = "/api/v1";
const prefixed = buildCampaignRoutes().map((e) => ({
...e,
route: { ...e.route, path: `${prefix}${e.route.path}` },
}));
definePluginRoutes(prefixed, { app, registry, pluginName: "campaigns" });
```
## API Reference
- `buildCampaignRoutes(): readonly PluginRouteEntry[]` — returns the
12 canonical campaign route entries.
## Implementation Notes
The canonical order — list, create, get, update, delete, send, pause,
cancel, duplicate, test, stats, events — places the read endpoints
before write/action endpoints (so OpenAPI consumers naturally see the
read surface first) and groups the lifecycle action routes together.
The `events` route comes last because it depends on the
`deliveryEventStore` slot being populated, which is the only
non-trivial dependency in the bundle; co-locating it with `stats`
makes the dependency footprint visible at a glance.

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { buildCampaignRoutes } from "./build-routes.js";
describe("buildCampaignRoutes", () => {
it("returns 12 entries in canonical order", { timeout: 5000 }, () => {
const entries = buildCampaignRoutes();
expect(entries.map((e) => `${e.route.method} ${e.route.path}`)).toEqual(
[
"get /campaigns",
"post /campaigns",
"get /campaigns/:id",
"patch /campaigns/:id",
"delete /campaigns/:id",
"post /campaigns/:id/send",
"post /campaigns/:id/pause",
"post /campaigns/:id/cancel",
"post /campaigns/:id/duplicate",
"post /campaigns/:id/test",
"get /campaigns/:id/stats",
"get /campaigns/:id/events",
]
);
});
it("every entry has a callable handler + a typed route config", {
timeout: 5000,
}, () => {
for (const e of buildCampaignRoutes()) {
expect(typeof e.handler).toBe("function");
expect(typeof e.route.method).toBe("string");
expect(typeof e.route.path).toBe("string");
expect(e.route.tags).toEqual(["campaigns"]);
}
});
});

View File

@@ -0,0 +1,46 @@
/**
* Aggregator that returns every `/campaigns/*` `PluginRouteEntry`
* value to be mounted by the plugin via `definePluginRoutes`. Keeping
* the canonical order in one place — listed, mutated, then acted on —
* makes the OpenAPI spec deterministic across deploys.
*/
import type { PluginRouteEntry } from "@suaveplan/api-openapi";
import {
buildCampaignEventsRoute,
buildCampaignStatsRoute,
buildCancelCampaignRoute,
buildDuplicateCampaignRoute,
buildPauseCampaignRoute,
buildSendCampaignRoute,
buildTestSendRoute,
} from "./action-routes.js";
import {
buildCreateCampaignRoute,
buildDeleteCampaignRoute,
buildGetCampaignRoute,
buildListCampaignsRoute,
buildUpdateCampaignRoute,
} from "./crud-routes.js";
/**
* Returns the 12 `/campaigns/*` plugin-route entries in canonical order:
* list, create, get, update, delete, send, pause, cancel, duplicate,
* test, stats, events.
*/
export function buildCampaignRoutes(): readonly PluginRouteEntry[] {
return [
buildListCampaignsRoute(),
buildCreateCampaignRoute(),
buildGetCampaignRoute(),
buildUpdateCampaignRoute(),
buildDeleteCampaignRoute(),
buildSendCampaignRoute(),
buildPauseCampaignRoute(),
buildCancelCampaignRoute(),
buildDuplicateCampaignRoute(),
buildTestSendRoute(),
buildCampaignStatsRoute(),
buildCampaignEventsRoute(),
];
}

View File

@@ -0,0 +1,94 @@
# Campaign CRUD routes
## Purpose
This module owns the five canonical CRUD entries for the `/campaigns`
route tree: list, create, get, update, and delete. Each builder returns
a `PluginRouteEntry` (`{ route, handler }`) so the parent plugin can
mount them through `definePluginRoutes` against the host's
`OpenAPIRegistry`. Keeping the builders pure (no side effects, no
captured registry) means the same entries can be reused in unit tests
against a bare `Hono` app and in production against an
`OpenAPIHono` + registry pair.
Every handler enforces three invariants before touching the
repository: the request principal must carry a non-empty `tenantId`
(`requireTenant`), the `campaignRepository` slot in the Hono context
must be populated (else `SERVICE_NOT_AVAILABLE`), and any campaign
loaded by id must belong to the caller's tenant (else
`CAMPAIGN_NOT_FOUND` — deliberately the same code we use for missing
rows so cross-tenant probes leak no information).
## Features
- `buildListCampaignsRoute` — `GET /campaigns`, paginated, filterable.
- `buildCreateCampaignRoute` — `POST /campaigns`, validates body,
stamps `id`, `status: "draft"`, `stats: EMPTY_STATS`, `createdAt`,
`updatedAt`.
- `buildGetCampaignRoute` — `GET /campaigns/:id`, 404 on missing or
wrong-tenant.
- `buildUpdateCampaignRoute` — `PATCH /campaigns/:id`, partial body,
validates ownership before update.
- `buildDeleteCampaignRoute` — `DELETE /campaigns/:id`, 204 on
success, 404 on missing/wrong-tenant.
- `campaignToResponse(campaign)` — re-exported helper that maps
`Date` fields to ISO 8601 strings and `undefined` to `null` for
JSON serialisation.
## Usage: Basic
```ts
import { buildCreateCampaignRoute } from "@suaveplan/api-plugin-campaigns";
import { Hono } from "hono";
const entry = buildCreateCampaignRoute();
const app = new Hono();
app.post(entry.route.path, (c) => entry.handler(c));
```
## Usage: Advanced
```ts
import {
buildCreateCampaignRoute,
buildDeleteCampaignRoute,
buildGetCampaignRoute,
buildListCampaignsRoute,
buildUpdateCampaignRoute,
definePluginRoutes,
} from "@suaveplan/api-plugin-campaigns";
definePluginRoutes(
[
buildListCampaignsRoute(),
buildCreateCampaignRoute(),
buildGetCampaignRoute(),
buildUpdateCampaignRoute(),
buildDeleteCampaignRoute(),
],
{ app, registry, pluginName: "campaigns" }
);
```
## API Reference
- `buildListCampaignsRoute(): PluginRouteEntry`
- `buildCreateCampaignRoute(): PluginRouteEntry`
- `buildGetCampaignRoute(): PluginRouteEntry`
- `buildUpdateCampaignRoute(): PluginRouteEntry`
- `buildDeleteCampaignRoute(): PluginRouteEntry`
- `campaignToResponse(c: Campaign): Record<string, unknown>` —
JSON-safe projection (ISO 8601 dates, `null` instead of `undefined`).
## Implementation Notes
The handlers eagerly call `c.req.query("...")` rather than relying on
the OpenAPI `c.req.valid("query")` because the same handler is reused
in unit tests against a bare `Hono` app (no `app.openapi` plumbing).
The bare-Hono path is the load-bearing one for the integration suite,
so `c.req.query` keeps the seam visible. Errors are thrown
synchronously and surfaced through the host-level `onError` adapter —
no try/catch noise inside the happy path. `crypto.randomUUID()` is the
id source for `POST /campaigns`; the runtime polyfill bundle imported
in `tests/setup/polyfills.ts` ensures the WSL2 + Bun test environment
exposes it.

View File

@@ -0,0 +1,457 @@
import { InMemoryCampaignStore } from "@suaveplan/campaigns";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import {
CAMPAIGNS_API_CODES,
CampaignsApiError,
} from "../../error-codes/error-codes.js";
import {
buildCreateCampaignRoute,
buildDeleteCampaignRoute,
buildGetCampaignRoute,
buildListCampaignsRoute,
buildUpdateCampaignRoute,
campaignToResponse,
} from "./crud-routes.js";
import "../../context/context.js";
function makeApp(
repo: InMemoryCampaignStore,
principal: object | null = { id: "u", tenantId: "t" }
): Hono {
const app = new Hono();
app.use("*", async (c, next) => {
if (principal !== null) c.set("user", principal as never);
c.set("campaignRepository", repo);
c.set("eventBus", { emit: () => undefined } as never);
await next();
});
app.onError((err, c) => {
if (err instanceof CampaignsApiError) {
const status = ((err.context as { statusCode?: number } | undefined)
?.statusCode ?? 500) as 400 | 401 | 404 | 500;
return c.json(
{ success: false, code: err.code, error: err.message },
status
);
}
return c.json({ success: false, error: String(err) }, 500);
});
for (const e of [
buildListCampaignsRoute(),
buildCreateCampaignRoute(),
buildGetCampaignRoute(),
buildUpdateCampaignRoute(),
buildDeleteCampaignRoute(),
]) {
const method = e.route.method as "get" | "post" | "patch" | "delete";
app[method](e.route.path, (c) => e.handler(c as never));
}
return app;
}
async function seed(
repo: InMemoryCampaignStore,
tenant = "t"
): Promise<string> {
const now = new Date();
const id = "c1";
await repo.create({
id,
tenantId: tenant,
name: "Promo",
status: "draft",
type: "broadcast",
channel: "email",
audienceConfig: { groupIds: [] },
metadata: {},
stats: {
totalRecipients: 0,
sent: 0,
delivered: 0,
opened: 0,
clicked: 0,
bounced: 0,
unsubscribed: 0,
complained: 0,
failed: 0,
},
createdAt: now,
updatedAt: now,
});
return id;
}
describe("campaign CRUD routes", () => {
it("POST /campaigns 401 without tenantId", { timeout: 5000 }, async () => {
const repo = new InMemoryCampaignStore();
const app = makeApp(repo, null);
const res = await app.request("/campaigns", {
method: "POST",
body: JSON.stringify({ name: "x" }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(401);
expect((await res.json()).code).toBe(
CAMPAIGNS_API_CODES.TENANT_REQUIRED
);
});
it("POST /campaigns creates a campaign", { timeout: 5000 }, async () => {
const repo = new InMemoryCampaignStore();
const app = makeApp(repo);
const res = await app.request("/campaigns", {
method: "POST",
body: JSON.stringify({ name: "Promo" }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.data.name).toBe("Promo");
expect(body.data.status).toBe("draft");
});
it("POST /campaigns rejects invalid body", { timeout: 5000 }, async () => {
const repo = new InMemoryCampaignStore();
const app = makeApp(repo);
const res = await app.request("/campaigns", {
method: "POST",
body: JSON.stringify({ name: "" }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(400);
expect((await res.json()).code).toBe(CAMPAIGNS_API_CODES.INVALID_INPUT);
});
it("GET /campaigns lists tenant campaigns", { timeout: 5000 }, async () => {
const repo = new InMemoryCampaignStore();
await seed(repo);
const app = makeApp(repo);
const res = await app.request("/campaigns");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.total).toBe(1);
expect(body.data.campaigns).toHaveLength(1);
});
it("GET /campaigns honours query filters", { timeout: 5000 }, async () => {
const repo = new InMemoryCampaignStore();
await seed(repo);
const app = makeApp(repo);
const res = await app.request(
"/campaigns?status=draft&type=broadcast&channel=email&limit=5&offset=0"
);
expect(res.status).toBe(200);
});
it("GET /campaigns/:id returns 404 for missing", {
timeout: 5000,
}, async () => {
const repo = new InMemoryCampaignStore();
const app = makeApp(repo);
const res = await app.request("/campaigns/nope");
expect(res.status).toBe(404);
expect((await res.json()).code).toBe(
CAMPAIGNS_API_CODES.CAMPAIGN_NOT_FOUND
);
});
it("GET /campaigns/:id returns 404 for wrong tenant", {
timeout: 5000,
}, async () => {
const repo = new InMemoryCampaignStore();
await seed(repo, "other");
const app = makeApp(repo);
const res = await app.request("/campaigns/c1");
expect(res.status).toBe(404);
});
it("GET /campaigns/:id returns 200 on hit", { timeout: 5000 }, async () => {
const repo = new InMemoryCampaignStore();
await seed(repo);
const app = makeApp(repo);
const res = await app.request("/campaigns/c1");
expect(res.status).toBe(200);
expect((await res.json()).data.id).toBe("c1");
});
it("PATCH /campaigns/:id updates the name", { timeout: 5000 }, async () => {
const repo = new InMemoryCampaignStore();
await seed(repo);
const app = makeApp(repo);
const res = await app.request("/campaigns/c1", {
method: "PATCH",
body: JSON.stringify({ name: "Updated" }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(200);
expect((await res.json()).data.name).toBe("Updated");
});
it("PATCH /campaigns/:id rejects invalid body", {
timeout: 5000,
}, async () => {
const repo = new InMemoryCampaignStore();
await seed(repo);
const app = makeApp(repo);
const res = await app.request("/campaigns/c1", {
method: "PATCH",
body: JSON.stringify({ name: "" }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(400);
});
it("PATCH /campaigns/:id 404 when missing", { timeout: 5000 }, async () => {
const repo = new InMemoryCampaignStore();
const app = makeApp(repo);
const res = await app.request("/campaigns/nope", {
method: "PATCH",
body: JSON.stringify({ name: "x" }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(404);
});
it("PATCH /campaigns/:id 404 wrong tenant", { timeout: 5000 }, async () => {
const repo = new InMemoryCampaignStore();
await seed(repo, "other");
const app = makeApp(repo);
const res = await app.request("/campaigns/c1", {
method: "PATCH",
body: JSON.stringify({ name: "x" }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(404);
});
it("DELETE /campaigns/:id returns 204 on hit", {
timeout: 5000,
}, async () => {
const repo = new InMemoryCampaignStore();
await seed(repo);
const app = makeApp(repo);
const res = await app.request("/campaigns/c1", { method: "DELETE" });
expect(res.status).toBe(204);
});
it("DELETE /campaigns/:id returns 404 missing", {
timeout: 5000,
}, async () => {
const repo = new InMemoryCampaignStore();
const app = makeApp(repo);
const res = await app.request("/campaigns/nope", { method: "DELETE" });
expect(res.status).toBe(404);
});
it("DELETE /campaigns/:id 404 wrong tenant", {
timeout: 5000,
}, async () => {
const repo = new InMemoryCampaignStore();
await seed(repo, "other");
const app = makeApp(repo);
const res = await app.request("/campaigns/c1", { method: "DELETE" });
expect(res.status).toBe(404);
});
it("returns 500 when campaignRepository slot is empty", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("user", { id: "u", tenantId: "t" } as never);
await next();
});
app.onError((err, c) => {
const code = (err as CampaignsApiError).code;
return c.json({ code }, 500);
});
const e = buildListCampaignsRoute();
app.get(e.route.path, (c) => e.handler(c as never));
const res = await app.request("/campaigns");
expect(res.status).toBe(500);
expect((await res.json()).code).toBe(
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE
);
});
it("create surfaces SERVICE_NOT_AVAILABLE when repo missing", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("user", { id: "u", tenantId: "t" } as never);
await next();
});
app.onError((err, c) =>
c.json({ code: (err as CampaignsApiError).code }, 500)
);
const e = buildCreateCampaignRoute();
app.post(e.route.path, (c) => e.handler(c as never));
const res = await app.request("/campaigns", {
method: "POST",
body: JSON.stringify({ name: "x" }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(500);
expect((await res.json()).code).toBe(
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE
);
});
it("get surfaces SERVICE_NOT_AVAILABLE when repo missing", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("user", { id: "u", tenantId: "t" } as never);
await next();
});
app.onError((err, c) =>
c.json({ code: (err as CampaignsApiError).code }, 500)
);
const e = buildGetCampaignRoute();
app.get(e.route.path, (c) => e.handler(c as never));
const res = await app.request("/campaigns/x");
expect((await res.json()).code).toBe(
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE
);
});
it("update surfaces SERVICE_NOT_AVAILABLE when repo missing", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("user", { id: "u", tenantId: "t" } as never);
await next();
});
app.onError((err, c) =>
c.json({ code: (err as CampaignsApiError).code }, 500)
);
const e = buildUpdateCampaignRoute();
app.patch(e.route.path, (c) => e.handler(c as never));
const res = await app.request("/campaigns/x", {
method: "PATCH",
body: JSON.stringify({ name: "y" }),
headers: { "content-type": "application/json" },
});
expect((await res.json()).code).toBe(
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE
);
});
it("delete surfaces SERVICE_NOT_AVAILABLE when repo missing", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("user", { id: "u", tenantId: "t" } as never);
await next();
});
app.onError((err, c) =>
c.json({ code: (err as CampaignsApiError).code }, 500)
);
const e = buildDeleteCampaignRoute();
app.delete(e.route.path, (c) => e.handler(c as never));
const res = await app.request("/campaigns/x", { method: "DELETE" });
expect((await res.json()).code).toBe(
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE
);
});
it("POST /campaigns returns 400 on malformed JSON body", {
timeout: 5000,
}, async () => {
const repo = new InMemoryCampaignStore();
const app = makeApp(repo);
const res = await app.request("/campaigns", {
method: "POST",
body: "not-json{",
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(400);
});
it("PATCH /campaigns/:id accepts malformed JSON as empty body (UpdateSchema is all-optional)", {
timeout: 5000,
}, async () => {
const repo = new InMemoryCampaignStore();
await seed(repo);
const app = makeApp(repo);
const res = await app.request("/campaigns/c1", {
method: "PATCH",
body: "not-json{",
headers: { "content-type": "application/json" },
});
// Empty body falls through the all-optional schema; no fields
// change. This still exercises the JSON catch path.
expect(res.status).toBe(200);
});
it("campaignToResponse formats undefined dates as null", {
timeout: 5000,
}, () => {
const now = new Date();
const projected = campaignToResponse({
id: "c1",
tenantId: "t",
name: "x",
status: "draft",
type: "broadcast",
channel: "email",
audienceConfig: { groupIds: [] },
metadata: {},
stats: {
totalRecipients: 0,
sent: 0,
delivered: 0,
opened: 0,
clicked: 0,
bounced: 0,
unsubscribed: 0,
complained: 0,
failed: 0,
},
createdAt: now,
updatedAt: now,
});
expect(projected.sentAt).toBeNull();
expect(projected.completedAt).toBeNull();
expect(typeof projected.createdAt).toBe("string");
});
it("campaignToResponse accepts date-string inputs", {
timeout: 5000,
}, () => {
const projected = campaignToResponse({
id: "c1",
tenantId: "t",
name: "x",
status: "draft",
type: "broadcast",
channel: "email",
audienceConfig: { groupIds: [] },
metadata: {},
stats: {
totalRecipients: 0,
sent: 0,
delivered: 0,
opened: 0,
clicked: 0,
bounced: 0,
unsubscribed: 0,
complained: 0,
failed: 0,
},
// Intentionally a string to exercise the non-Date branch
createdAt: "2026-01-01T00:00:00.000Z" as unknown as Date,
updatedAt: new Date("2026-01-02T00:00:00.000Z"),
sentAt: new Date("2026-01-03T00:00:00.000Z"),
completedAt: new Date("2026-01-04T00:00:00.000Z"),
});
expect(projected.createdAt).toBe("2026-01-01T00:00:00.000Z");
expect(projected.sentAt).toBe("2026-01-03T00:00:00.000Z");
expect(projected.completedAt).toBe("2026-01-04T00:00:00.000Z");
});
});

View File

@@ -0,0 +1,267 @@
/**
* Campaign CRUD route builders — list, create, get, update, delete.
* Each builder returns a `PluginRouteEntry` for mounting via
* `definePluginRoutes`. Handlers read the repository from
* `c.get("campaignRepository")` (typed via the Hono ContextVariableMap
* augmentation) and the principal via `requireTenant`.
*/
import {
DELETE,
GET,
PATCH,
type PluginRouteEntry,
POST,
successResponse,
} from "@suaveplan/api-openapi";
import type { Campaign, UpdateCampaignInput } from "@suaveplan/campaigns";
import type { Context } from "hono";
import z from "zod";
import {
CAMPAIGNS_API_CODES,
CampaignsApiError,
} from "../../error-codes/error-codes.js";
import { getCampaignsApiInstruments } from "../../otel/instruments.js";
import { requireTenant } from "../shared/require-tenant.js";
import { stripUndefined } from "../shared/strip-undefined.js";
import {
CampaignIdParamSchema,
CampaignListResponseSchema,
CampaignQuerySchema,
CampaignResponseSchema,
CreateCampaignSchema,
EMPTY_STATS,
UpdateCampaignSchema,
} from "./schemas.js";
function fmt(d: Date | string | undefined | null): string | null {
if (d === null || d === undefined) return null;
if (d instanceof Date) return d.toISOString();
return new Date(d).toISOString();
}
function toResponse(c: Campaign): Record<string, unknown> {
return {
...c,
createdAt: fmt(c.createdAt),
updatedAt: fmt(c.updatedAt),
sentAt: fmt(c.sentAt ?? null),
completedAt: fmt(c.completedAt ?? null),
};
}
function requireRepo(c: Context) {
const repo = c.get("campaignRepository");
if (repo === undefined) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE,
"campaignRepository slot is empty"
);
}
return repo;
}
/** `GET /campaigns` — list. */
export function buildListCampaignsRoute(): PluginRouteEntry {
const route = GET("/campaigns")
.tags("campaigns")
.summary("List campaigns")
.query(CampaignQuerySchema)
.response(successResponse(CampaignListResponseSchema))
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const repo = requireRepo(c);
const query = CampaignQuerySchema.parse({
status: c.req.query("status"),
type: c.req.query("type"),
channel: c.req.query("channel"),
limit: c.req.query("limit") ?? 50,
offset: c.req.query("offset") ?? 0,
});
const campaigns = await repo.list({
tenantId,
...(query.status !== undefined ? { status: query.status } : {}),
...(query.type !== undefined ? { type: query.type } : {}),
...(query.channel !== undefined ? { channel: query.channel } : {}),
limit: query.limit,
offset: query.offset,
});
getCampaignsApiInstruments().requestsTotal.add(1, {
route: "list",
outcome: "success",
});
return c.json(
{
success: true as const,
data: {
campaigns: campaigns.map(toResponse),
total: campaigns.length,
},
},
200
);
}
return { route, handler };
}
/** `POST /campaigns` — create. */
export function buildCreateCampaignRoute(): PluginRouteEntry {
const route = POST("/campaigns")
.tags("campaigns")
.summary("Create a campaign")
.body(CreateCampaignSchema)
.response(successResponse(CampaignResponseSchema))
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const repo = requireRepo(c);
const raw = (await c.req.json().catch(() => ({}))) as unknown;
const parsed = CreateCampaignSchema.safeParse(raw);
if (!parsed.success) {
getCampaignsApiInstruments().requestsTotal.add(1, {
route: "create",
outcome: "error",
});
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.INVALID_INPUT,
"POST /campaigns body failed validation",
{ cause: parsed.error, context: { statusCode: 400 } }
);
}
const now = new Date();
const data = stripUndefined(parsed.data);
const created = await repo.create({
id: crypto.randomUUID(),
tenantId,
status: "draft",
stats: { ...EMPTY_STATS },
createdAt: now,
updatedAt: now,
...data,
} as unknown as Campaign);
getCampaignsApiInstruments().requestsTotal.add(1, {
route: "create",
outcome: "success",
});
return c.json(
{ success: true as const, data: toResponse(created) },
201
);
}
return { route, handler };
}
/** `GET /campaigns/:id` — read. */
export function buildGetCampaignRoute(): PluginRouteEntry {
const route = GET("/campaigns/:id")
.tags("campaigns")
.summary("Get campaign by id")
.params(CampaignIdParamSchema)
.response(successResponse(CampaignResponseSchema))
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const repo = requireRepo(c);
const id = c.req.param("id");
const campaign = await repo.getById(id);
if (campaign === null || campaign.tenantId !== tenantId) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.CAMPAIGN_NOT_FOUND,
`campaign ${id} not found`,
{ context: { statusCode: 404, id } }
);
}
getCampaignsApiInstruments().requestsTotal.add(1, {
route: "get",
outcome: "success",
});
return c.json(
{ success: true as const, data: toResponse(campaign) },
200
);
}
return { route, handler };
}
/** `PATCH /campaigns/:id` — update. */
export function buildUpdateCampaignRoute(): PluginRouteEntry {
const route = PATCH("/campaigns/:id")
.tags("campaigns")
.summary("Update a campaign")
.params(CampaignIdParamSchema)
.body(UpdateCampaignSchema)
.response(successResponse(CampaignResponseSchema))
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const repo = requireRepo(c);
const id = c.req.param("id");
const raw = (await c.req.json().catch(() => ({}))) as unknown;
const parsed = UpdateCampaignSchema.safeParse(raw);
if (!parsed.success) {
getCampaignsApiInstruments().requestsTotal.add(1, {
route: "update",
outcome: "error",
});
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.INVALID_INPUT,
"PATCH /campaigns/:id body failed validation",
{ cause: parsed.error, context: { statusCode: 400 } }
);
}
const existing = await repo.getById(id);
if (existing === null || existing.tenantId !== tenantId) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.CAMPAIGN_NOT_FOUND,
`campaign ${id} not found`,
{ context: { statusCode: 404, id } }
);
}
const updated = await repo.update(
id,
stripUndefined(parsed.data) as unknown as UpdateCampaignInput
);
getCampaignsApiInstruments().requestsTotal.add(1, {
route: "update",
outcome: "success",
});
return c.json(
{ success: true as const, data: toResponse(updated) },
200
);
}
return { route, handler };
}
/** `DELETE /campaigns/:id`. */
export function buildDeleteCampaignRoute(): PluginRouteEntry {
const route = DELETE("/campaigns/:id")
.tags("campaigns")
.summary("Delete a campaign")
.params(CampaignIdParamSchema)
.response(z.object({ success: z.literal(true) }))
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const repo = requireRepo(c);
const id = c.req.param("id");
const existing = await repo.getById(id);
if (existing === null || existing.tenantId !== tenantId) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.CAMPAIGN_NOT_FOUND,
`campaign ${id} not found`,
{ context: { statusCode: 404, id } }
);
}
await repo.delete(id);
getCampaignsApiInstruments().requestsTotal.add(1, {
route: "delete",
outcome: "success",
});
return c.body(null, 204);
}
return { route, handler };
}
export { toResponse as campaignToResponse };

View File

@@ -0,0 +1,36 @@
export {
buildCampaignEventsRoute,
buildCampaignStatsRoute,
buildCancelCampaignRoute,
buildDuplicateCampaignRoute,
buildPauseCampaignRoute,
buildSendCampaignRoute,
buildTestSendRoute,
DeliveryStatsResponseSchema,
EventQuerySchema,
TestSendSchema,
} from "./action-routes.js";
export { buildCampaignRoutes } from "./build-routes.js";
export {
buildCreateCampaignRoute,
buildDeleteCampaignRoute,
buildGetCampaignRoute,
buildListCampaignsRoute,
buildUpdateCampaignRoute,
campaignToResponse,
} from "./crud-routes.js";
export {
CampaignIdParamSchema,
CampaignListResponseSchema,
type CampaignQueryInput,
CampaignQuerySchema,
CampaignResponseSchema,
CampaignStatsSchema,
CampaignStatusSchema,
CampaignTypeSchema,
type CreateCampaignBody,
CreateCampaignSchema,
EMPTY_STATS,
type UpdateCampaignBody,
UpdateCampaignSchema,
} from "./schemas.js";

View File

@@ -0,0 +1,85 @@
# Campaign route schemas
## Purpose
This module is the OpenAPI-facing contract for the `/campaigns` route
tree. Every incoming request body, query string, and path parameter is
validated through a schema declared here, and every response sent to
clients conforms to one of the response schemas. Centralising the
schemas keeps the Zod definitions in sync between request validation
(at handler entry) and OpenAPI generation (at registry registration
time) — both consume the exact same schema instance.
Enum unions for `CampaignStatus` and `CampaignType` are reproduced
locally rather than imported from `@suaveplan/campaigns` because the
schema bundle is captured into the OpenAPI spec, which needs the enum
values inlined. The cost of duplication is small and the test suite
asserts every member, so drift between the two sources is caught the
moment it occurs.
## Features
- 4 input schemas: `CampaignIdParamSchema`, `CreateCampaignSchema`,
`UpdateCampaignSchema`, `CampaignQuerySchema`.
- 3 response schemas: `CampaignResponseSchema`, `CampaignStatsSchema`,
`CampaignListResponseSchema`.
- Documented defaults at every optional field (`type`, `channel`,
`audienceConfig.groupIds`, `templateData`, `metadata`,
`CampaignQuerySchema.limit/offset`).
- `EMPTY_STATS` constant — the zero-baseline counter bundle used by
`POST /campaigns` and `/duplicate`.
- Coerced numeric query parameters (`limit`, `offset`) so query
strings like `?limit=25` round-trip cleanly without manual parsing.
## Usage: Basic
```ts
import { CreateCampaignSchema } from "@suaveplan/api-plugin-campaigns";
const body = await c.req.json();
const parsed = CreateCampaignSchema.parse(body);
// parsed.name is guaranteed non-empty; parsed.type defaults to "broadcast".
```
## Usage: Advanced
```ts
import {
CampaignListResponseSchema,
CampaignQuerySchema,
} from "@suaveplan/api-plugin-campaigns";
const query = CampaignQuerySchema.parse(c.req.query());
const campaigns = await repo.list({ tenantId, ...query });
const response = CampaignListResponseSchema.parse({
campaigns: campaigns.map(toResponse),
total: campaigns.length,
});
return c.json(response, 200);
```
## API Reference
- `CampaignIdParamSchema: z.ZodObject<{ id: ZodString }>`
- `CreateCampaignSchema: z.ZodObject<{...}>` — body for `POST /campaigns`.
- `UpdateCampaignSchema: z.ZodObject<{...}>` — body for `PATCH`.
- `CampaignQuerySchema: z.ZodObject<{...}>` — query string for `GET`.
- `CampaignStatusSchema`, `CampaignTypeSchema` — enum guards.
- `CampaignResponseSchema`, `CampaignListResponseSchema`,
`CampaignStatsSchema` — response shapes.
- `EMPTY_STATS: Readonly<CampaignStats>` — zero baseline.
- `type CreateCampaignBody`, `UpdateCampaignBody`, `CampaignQueryInput`
— inferred TypeScript types.
## Implementation Notes
`audienceConfig` is modelled as an object schema with `groupIds`
required (default `[]`) rather than a free-form `z.record` because
downstream consumers — including the `Campaign.audienceConfig` field
in `@suaveplan/campaigns` — rely on `groupIds` being present. The
optional surrounding fields (`excludeGroupIds`, `segmentIds`, …) are
preserved at the schema boundary so the upstream domain logic can
treat them as opt-in. Date fields appear as ISO 8601 strings in the
response schema rather than `Date` instances because OpenAPI consumers
deserialise JSON and only get strings; the route handlers call
`.toISOString()` at the boundary so the contract is honest.

View File

@@ -0,0 +1,146 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CampaignIdParamSchema,
CampaignListResponseSchema,
CampaignQuerySchema,
CampaignResponseSchema,
CampaignStatusSchema,
CampaignTypeSchema,
CreateCampaignSchema,
EMPTY_STATS,
UpdateCampaignSchema,
} from "./schemas.js";
describe("campaign route schemas", () => {
it("CampaignStatusSchema accepts all 7 statuses", { timeout: 5000 }, () => {
for (const s of [
"draft",
"scheduled",
"sending",
"paused",
"completed",
"cancelled",
"failed",
] as const) {
expect(CampaignStatusSchema.parse(s)).toBe(s);
}
expect(() => CampaignStatusSchema.parse("bogus")).toThrow();
});
it("CampaignTypeSchema accepts all 4 types", { timeout: 5000 }, () => {
for (const t of [
"broadcast",
"triggered",
"ab_test",
"drip",
] as const) {
expect(CampaignTypeSchema.parse(t)).toBe(t);
}
expect(() => CampaignTypeSchema.parse("bogus")).toThrow();
});
it("CampaignIdParamSchema rejects empty id", { timeout: 5000 }, () => {
expect(() => CampaignIdParamSchema.parse({ id: "" })).toThrow();
expect(CampaignIdParamSchema.parse({ id: "a" })).toEqual({ id: "a" });
});
it("CreateCampaignSchema applies all documented defaults", {
timeout: 5000,
}, () => {
const parsed = CreateCampaignSchema.parse({ name: "x" });
expect(parsed.type).toBe("broadcast");
expect(parsed.channel).toBe("email");
expect(parsed.audienceConfig).toEqual({ groupIds: [] });
expect(parsed.templateData).toEqual({});
expect(parsed.metadata).toEqual({});
});
it("CreateCampaignSchema rejects empty name", { timeout: 5000 }, () => {
expect(() => CreateCampaignSchema.parse({ name: "" })).toThrow();
});
it("CreateCampaignSchema accepts a full body", { timeout: 5000 }, () => {
const out = CreateCampaignSchema.parse({
name: "Promo",
type: "drip",
channel: "sms",
audienceConfig: {
groupIds: ["g1"],
excludeGroupIds: ["g2"],
},
templateId: "tpl1",
templateData: { foo: 1 },
schedule: { type: "immediate" },
throttle: { messagesPerSecond: 5 },
abTestConfig: { splitType: "even" },
metadata: { campaignId: "X" },
});
expect(out.type).toBe("drip");
expect(out.audienceConfig.groupIds).toEqual(["g1"]);
});
it("UpdateCampaignSchema accepts a partial body", { timeout: 5000 }, () => {
const out = UpdateCampaignSchema.parse({ name: "New" });
expect(out.name).toBe("New");
});
it("UpdateCampaignSchema rejects empty name", { timeout: 5000 }, () => {
expect(() => UpdateCampaignSchema.parse({ name: "" })).toThrow();
});
it("CampaignQuerySchema coerces query strings and applies defaults", {
timeout: 5000,
}, () => {
const out = CampaignQuerySchema.parse({
status: "draft",
type: "broadcast",
limit: "25",
offset: "5",
});
expect(out.limit).toBe(25);
expect(out.offset).toBe(5);
expect(out.status).toBe("draft");
});
it("CampaignQuerySchema clamps limit between 1 and 200", {
timeout: 5000,
}, () => {
expect(() => CampaignQuerySchema.parse({ limit: 0 })).toThrow();
expect(() => CampaignQuerySchema.parse({ limit: 300 })).toThrow();
});
it("CampaignResponseSchema accepts a minimally valid response", {
timeout: 5000,
}, () => {
const ok = CampaignResponseSchema.safeParse({
id: "1",
tenantId: "t",
name: "x",
status: "draft",
type: "broadcast",
channel: "email",
audienceConfig: { groupIds: [] },
templateData: {},
metadata: {},
stats: EMPTY_STATS,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
expect(ok.success).toBe(true);
});
it("CampaignListResponseSchema wraps the campaign array", {
timeout: 5000,
}, () => {
const ok = CampaignListResponseSchema.safeParse({
campaigns: [],
total: 0,
});
expect(ok.success).toBe(true);
});
it("EMPTY_STATS exposes every zero counter", { timeout: 5000 }, () => {
expect(Object.values(EMPTY_STATS).every((v) => v === 0)).toBe(true);
expect(Object.keys(EMPTY_STATS)).toHaveLength(9);
});
});

View File

@@ -0,0 +1,148 @@
/**
* Zod request + response schemas for the `/campaigns` route tree.
*
* These are the OpenAPI surface contract — the response shape exposed
* to OpenAPI consumers and the validated boundary applied to every
* incoming body, query, and path parameter. Schemas are deliberately
* conservative: enums are reproduced rather than imported (the
* upstream `CampaignStatus` union string-literal is replicated so the
* OpenAPI schema stays self-contained), and timestamps are serialised
* as ISO 8601 strings rather than `Date` instances so JSON round-trips
* cleanly.
*/
import z from "zod";
/** Lifecycle status — kept in sync with `@suaveplan/campaigns`. */
export const CampaignStatusSchema = z.enum([
"draft",
"scheduled",
"sending",
"paused",
"completed",
"cancelled",
"failed",
]);
/** Campaign type — kept in sync with `@suaveplan/campaigns`. */
export const CampaignTypeSchema = z.enum([
"broadcast",
"triggered",
"ab_test",
"drip",
]);
/** Path param `:id` — non-empty string. */
export const CampaignIdParamSchema = z.object({
id: z.string().min(1),
});
/** Body for `POST /campaigns`. */
export const CreateCampaignSchema = z.object({
name: z.string().min(1).max(255),
type: CampaignTypeSchema.default("broadcast"),
channel: z.string().min(1).default("email"),
audienceConfig: z
.object({
groupIds: z.array(z.string()).default([]),
excludeGroupIds: z.array(z.string()).optional(),
excludeContactIds: z.array(z.string()).optional(),
segmentIds: z.array(z.string()).optional(),
suppressionListIds: z.array(z.string()).optional(),
})
.default({ groupIds: [] }),
templateId: z.string().optional(),
templateData: z.record(z.string(), z.unknown()).default({}),
schedule: z.record(z.string(), z.unknown()).optional(),
throttle: z.record(z.string(), z.unknown()).optional(),
abTestConfig: z.record(z.string(), z.unknown()).optional(),
metadata: z.record(z.string(), z.unknown()).default({}),
});
/** Body for `PATCH /campaigns/:id`. */
export const UpdateCampaignSchema = z.object({
name: z.string().min(1).max(255).optional(),
channel: z.string().min(1).optional(),
audienceConfig: z
.object({
groupIds: z.array(z.string()),
excludeGroupIds: z.array(z.string()).optional(),
excludeContactIds: z.array(z.string()).optional(),
segmentIds: z.array(z.string()).optional(),
suppressionListIds: z.array(z.string()).optional(),
})
.optional(),
templateId: z.string().optional(),
templateData: z.record(z.string(), z.unknown()).optional(),
schedule: z.record(z.string(), z.unknown()).optional(),
throttle: z.record(z.string(), z.unknown()).optional(),
abTestConfig: z.record(z.string(), z.unknown()).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
/** Query string for `GET /campaigns`. */
export const CampaignQuerySchema = z.object({
status: CampaignStatusSchema.optional(),
type: CampaignTypeSchema.optional(),
channel: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
/** Stats sub-schema reused by `CampaignResponseSchema`. */
export const CampaignStatsSchema = z.object({
totalRecipients: z.number(),
sent: z.number(),
delivered: z.number(),
opened: z.number(),
clicked: z.number(),
bounced: z.number(),
unsubscribed: z.number(),
complained: z.number(),
failed: z.number(),
});
/** Response shape for a single campaign. */
export const CampaignResponseSchema = z.object({
id: z.string(),
tenantId: z.string(),
name: z.string(),
status: CampaignStatusSchema,
type: CampaignTypeSchema,
channel: z.string(),
audienceConfig: z.record(z.string(), z.unknown()),
templateId: z.string().nullable().optional(),
templateData: z.record(z.string(), z.unknown()).optional(),
schedule: z.record(z.string(), z.unknown()).nullable().optional(),
throttle: z.record(z.string(), z.unknown()).nullable().optional(),
abTestConfig: z.record(z.string(), z.unknown()).nullable().optional(),
metadata: z.record(z.string(), z.unknown()),
stats: CampaignStatsSchema,
createdAt: z.string(),
updatedAt: z.string(),
sentAt: z.string().nullable().optional(),
completedAt: z.string().nullable().optional(),
});
/** Response shape for `GET /campaigns`. */
export const CampaignListResponseSchema = z.object({
campaigns: z.array(CampaignResponseSchema),
total: z.number(),
});
/** Empty stats baseline used by `POST /campaigns`. */
export const EMPTY_STATS = {
totalRecipients: 0,
sent: 0,
delivered: 0,
opened: 0,
clicked: 0,
bounced: 0,
unsubscribed: 0,
complained: 0,
failed: 0,
} as const;
export type CreateCampaignBody = z.infer<typeof CreateCampaignSchema>;
export type UpdateCampaignBody = z.infer<typeof UpdateCampaignSchema>;
export type CampaignQueryInput = z.infer<typeof CampaignQuerySchema>;

View File

@@ -0,0 +1,67 @@
export { buildCampaignRoutes } from "./campaigns/build-routes.js";
export {
buildCampaignEventsRoute,
buildCampaignStatsRoute,
buildCancelCampaignRoute,
buildCreateCampaignRoute,
buildDeleteCampaignRoute,
buildDuplicateCampaignRoute,
buildGetCampaignRoute,
buildListCampaignsRoute,
buildPauseCampaignRoute,
buildSendCampaignRoute,
buildTestSendRoute,
buildUpdateCampaignRoute,
CampaignIdParamSchema,
CampaignListResponseSchema,
type CampaignQueryInput,
CampaignQuerySchema,
CampaignResponseSchema,
CampaignStatsSchema,
CampaignStatusSchema,
CampaignTypeSchema,
type CreateCampaignBody,
CreateCampaignSchema,
campaignToResponse,
DeliveryStatsResponseSchema,
EMPTY_STATS,
EventQuerySchema,
TestSendSchema,
type UpdateCampaignBody,
UpdateCampaignSchema,
} from "./campaigns/index.js";
export {
requireTenant,
stripUndefined,
type TenantPrincipal,
} from "./shared/index.js";
export {
buildCheckSuppressionRoute,
buildCreateSuppressionRoute,
buildDeleteSuppressionRoute,
buildListSuppressionRoute,
buildSuppressionRoutes,
type CreateSuppressionBody,
CreateSuppressionSchema,
SuppressionCheckQuerySchema,
SuppressionCheckResponseSchema,
SuppressionIdParamSchema,
SuppressionListResponseSchema,
type SuppressionQueryInput,
SuppressionQuerySchema,
SuppressionReasonSchema,
SuppressionResponseSchema,
} from "./suppression/index.js";
export {
buildDeliveryWebhookRoute,
buildUnsubscribeRoute,
buildWebhookRoutes,
DeliveryEventTypeSchema,
type DeliveryWebhookBody,
DeliveryWebhookResponseSchema,
DeliveryWebhookSchema,
decodeUnsubscribeToken,
UnsubscribeResponseSchema,
UnsubscribeTokenParamSchema,
type UnsubscribeTokenPayload,
} from "./webhooks/index.js";

View File

@@ -0,0 +1,2 @@
export { requireTenant, type TenantPrincipal } from "./require-tenant.js";
export { stripUndefined } from "./strip-undefined.js";

View File

@@ -0,0 +1,83 @@
# `require-tenant`
## Purpose
`requireTenant` extracts the request principal from the Hono context
slot `c.get("user")` (populated by `@suaveplan/api-plugin-auth` before
route dispatch) and either returns its non-empty `tenantId` plus
optional `id` or throws `CampaignsApiError` carrying the
`CAMPAIGNS_API.TENANT_REQUIRED` code with HTTP 401 wired into the
error's context. This is the single tenant-presence gate every
campaign, suppression, and webhook route relies on, so handlers can
treat `tenantId` as a definitely-present string thereafter.
The helper does not consult a tenant resolver fallback (no
`?? "default"` short-circuit). Multi-tenant correctness is the
campaigns plugin's primary obligation — silently falling back to a
shared tenant would let one tenant's misconfiguration leak data into
another's namespace. Failing closed with a 401 surfaces the problem
loudly.
## Features
- Pure synchronous read; no I/O, no DB, no allocator churn.
- Treats `null`, `undefined`, missing `tenantId`, non-string
`tenantId`, and empty-string `tenantId` as equally invalid.
- Returns a frozen-shape object (`{ id, tenantId }`) — `id` is
optional in the principal but always a string in the return value
(falls back to `""`).
- Throws `CampaignsApiError(TENANT_REQUIRED)` with
`context.statusCode === 401` so the package error handler can map
uniformly.
## Usage: Basic
```ts
import { requireTenant } from "@suaveplan/api-plugin-campaigns";
app.openapi(listRoute, async (c) => {
const { tenantId } = requireTenant(c);
const result = await c.get("campaignRepository").list({ tenantId });
return c.json({ campaigns: result, total: result.length }, 200);
});
```
## Usage: Advanced
```ts
import {
CAMPAIGNS_API_CODES,
CampaignsApiError,
requireTenant,
} from "@suaveplan/api-plugin-campaigns";
async function authorize(c: Context, target: { tenantId: string }) {
const { tenantId } = requireTenant(c);
if (target.tenantId !== tenantId) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.CAMPAIGN_NOT_FOUND,
"campaign belongs to a different tenant",
{ context: { statusCode: 404 } }
);
}
}
```
## API Reference
- `interface TenantPrincipal { readonly id: string; readonly tenantId: string }`
- `requireTenant(c: Context): TenantPrincipal` — returns the principal
or throws `CampaignsApiError(TENANT_REQUIRED)`.
## Implementation Notes
The principal type is cast through `unknown` rather than imported from
`@suaveplan/api-plugin-auth` because the campaigns plugin must
type-check in isolation (consumers may swap in a different auth
provider). The structural check (`typeof principal.tenantId ===
"string" && principal.tenantId.length > 0`) is intentionally explicit:
TypeScript's narrowing for record-typed `unknown` does not collapse
the two checks into one. The empty-string check is the load-bearing
one — Zod's `min(1)` on the auth side prevents most empty values, but
defending against an out-of-band setter (e.g. a test fixture passing
`{ tenantId: "" }`) keeps the contract honest.

View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import {
CAMPAIGNS_API_CODES,
CampaignsApiError,
} from "../../error-codes/error-codes.js";
import { requireTenant } from "./require-tenant.js";
function makeContext(principal: unknown) {
const app = new Hono();
let captured: { id: string; tenantId: string } | undefined;
let caught: unknown;
app.use("*", async (c, next) => {
if (principal !== undefined) {
c.set("user", principal as never);
}
try {
captured = requireTenant(c);
} catch (err) {
caught = err;
}
await next();
});
app.get("/", (c) => c.text("ok"));
return { app, get: () => ({ captured, caught }) };
}
describe("requireTenant", () => {
it("returns id + tenantId when principal carries both", {
timeout: 5000,
}, async () => {
const { app, get } = makeContext({ id: "u1", tenantId: "t1" });
await app.request("/");
expect(get().captured).toEqual({ id: "u1", tenantId: "t1" });
});
it("falls back to empty id when principal has no id field", {
timeout: 5000,
}, async () => {
const { app, get } = makeContext({ tenantId: "t1" });
await app.request("/");
expect(get().captured).toEqual({ id: "", tenantId: "t1" });
});
it("throws TENANT_REQUIRED when user is null", {
timeout: 5000,
}, async () => {
const { app, get } = makeContext(null);
await app.request("/");
const err = get().caught;
expect(err).toBeInstanceOf(CampaignsApiError);
expect((err as CampaignsApiError).code).toBe(
CAMPAIGNS_API_CODES.TENANT_REQUIRED
);
});
it("throws TENANT_REQUIRED when user is undefined", {
timeout: 5000,
}, async () => {
const { app, get } = makeContext(undefined);
await app.request("/");
expect((get().caught as CampaignsApiError).code).toBe(
CAMPAIGNS_API_CODES.TENANT_REQUIRED
);
});
it("throws TENANT_REQUIRED when tenantId is the empty string", {
timeout: 5000,
}, async () => {
const { app, get } = makeContext({ id: "u", tenantId: "" });
await app.request("/");
expect((get().caught as CampaignsApiError).code).toBe(
CAMPAIGNS_API_CODES.TENANT_REQUIRED
);
});
it("throws TENANT_REQUIRED when tenantId is not a string", {
timeout: 5000,
}, async () => {
const { app, get } = makeContext({ id: "u", tenantId: 42 });
await app.request("/");
expect((get().caught as CampaignsApiError).code).toBe(
CAMPAIGNS_API_CODES.TENANT_REQUIRED
);
});
it("falls back to empty id when id is the empty string", {
timeout: 5000,
}, async () => {
const { app, get } = makeContext({ id: "", tenantId: "t1" });
await app.request("/");
expect(get().captured).toEqual({ id: "", tenantId: "t1" });
});
});

View File

@@ -0,0 +1,51 @@
/**
* Tenant-extraction helper. Reads the `Principal` from `c.get("user")`
* and either returns its non-empty `tenantId` or throws
* `CampaignsApiError(TENANT_REQUIRED)` mapped to HTTP 401. Routes call
* this once at handler entry so the rest of the handler can treat
* `tenantId` as definitely present.
*/
import type { Context } from "hono";
import {
CAMPAIGNS_API_CODES,
CampaignsApiError,
} from "../../error-codes/error-codes.js";
/** Principal payload extracted at the request boundary. */
export interface TenantPrincipal {
readonly id: string;
readonly tenantId: string;
}
/**
* Return `{ id, tenantId }` from the request principal or throw
* `CAMPAIGNS_API.TENANT_REQUIRED` (HTTP 401). The shared helper
* eliminates the upstream port's repeated four-line
* `c.get("user")?.tenantId` checks and the `as any` casts they relied on.
*/
export function requireTenant(c: Context): TenantPrincipal {
const principal = c.get("user") as
| { id?: unknown; tenantId?: unknown }
| null
| undefined;
if (
principal === null ||
principal === undefined ||
typeof principal.tenantId !== "string" ||
principal.tenantId.length === 0
) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.TENANT_REQUIRED,
"request principal is missing a tenantId",
{ context: { statusCode: 401 } }
);
}
return {
id:
typeof principal.id === "string" && principal.id.length > 0
? principal.id
: "",
tenantId: principal.tenantId,
};
}

View File

@@ -0,0 +1,70 @@
# `strip-undefined`
## Purpose
`stripUndefined` is a route-layer adapter that bridges Zod's
`optional()` output (`T | undefined`) with the
`exactOptionalPropertyTypes: true` repository input types declared in
`@suaveplan/campaigns` and the local `CampaignSuppressionStore`. When a
route handler validates a request body through a Zod schema, optional
fields surface as either the declared type or `undefined`; passing the
parsed object straight to the repository fails the `exactOptional`
check at compile time. This helper produces a shallow clone with every
`undefined`-valued key removed so the downstream type narrows.
The helper is intentionally minimal — no recursion, no `null` handling,
no key-name filtering — because every concrete usage in the routes
layer passes a Zod-parsed object whose nesting has already been
normalised. Recursion would obscure the cost model and tempt callers
to use it as a deep-clean utility, which is out of scope.
## Features
- Shallow-only filter; nested objects are forwarded by reference.
- `null` values are preserved (only `=== undefined` triggers removal).
- Returns a fresh object reference every call (never mutates input).
- Generic-typed return narrows `T[K]` to `Exclude<T[K], undefined>`.
## Usage: Basic
```ts
import { stripUndefined } from "@suaveplan/api-plugin-campaigns";
const body = { name: "x", templateId: undefined } as const;
const clean = stripUndefined(body);
// clean: { name: "x" } — `templateId` removed.
```
## Usage: Advanced
```ts
import { stripUndefined } from "@suaveplan/api-plugin-campaigns";
const parsed = CreateCampaignSchema.parse(input);
await repo.create({
id: idGen(),
tenantId,
...stripUndefined(parsed),
status: "draft",
stats: EMPTY_STATS,
createdAt: now,
updatedAt: now,
});
```
## API Reference
- `stripUndefined<T extends Record<string, unknown>>(input: T):
{ [K in keyof T]: Exclude<T[K], undefined> }` — shallow copy with
`undefined`-valued keys removed.
## Implementation Notes
The implementation iterates `Object.entries(input)` once and writes
into a fresh `Record<string, unknown>` accumulator. The output is then
cast to the narrowed mapped type — a single assertion confined to the
return statement. The accumulator is not pre-sized because the helper
is hot-path-cheap (single object scan) and JavaScript engines do not
benefit from `new Object()` capacity hints. The forbidden `Object.assign`
path (which would forward `undefined` properties when iterating
`Object.keys`) is intentionally avoided.

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { stripUndefined } from "./strip-undefined.js";
describe("stripUndefined", () => {
it("removes keys whose values are undefined", { timeout: 5000 }, () => {
const out = stripUndefined({ a: 1, b: undefined, c: "x" });
expect(out).toEqual({ a: 1, c: "x" });
});
it("preserves null values", { timeout: 5000 }, () => {
const out = stripUndefined({ a: null, b: undefined });
expect(out).toEqual({ a: null });
});
it("returns a fresh object instance", { timeout: 5000 }, () => {
const input = { a: 1 };
const out = stripUndefined(input);
expect(out).not.toBe(input);
expect(out).toEqual(input);
});
it("handles an empty object", { timeout: 5000 }, () => {
expect(stripUndefined({})).toEqual({});
});
it("does not strip falsy non-undefined values", { timeout: 5000 }, () => {
const out = stripUndefined({ a: 0, b: "", c: false, d: undefined });
expect(out).toEqual({ a: 0, b: "", c: false });
});
});

View File

@@ -0,0 +1,23 @@
/**
* Strip keys whose values are exactly `undefined`. Used at every
* route → repo boundary to bridge Zod's `optional()` semantics
* (which produce `T | undefined`) with the
* `exactOptionalPropertyTypes: true` input types in
* `@suaveplan/campaigns` and the suppression store (which forbid the
* `| undefined` half).
*/
/**
* Return a shallow copy of the input with all keys whose values are
* exactly `undefined` removed. Non-`undefined` values are preserved
* as-is; `null` is preserved.
*/
export function stripUndefined<T extends Record<string, unknown>>(
input: T
): { [K in keyof T]: Exclude<T[K], undefined> } {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(input)) {
if (v !== undefined) out[k] = v;
}
return out as { [K in keyof T]: Exclude<T[K], undefined> };
}

View File

@@ -0,0 +1,61 @@
# `build-routes` (suppression)
## Purpose
`buildSuppressionRoutes` aggregates the four `/suppression/*` plugin
route entries in canonical order (list, check, create, delete). The
plugin's `onInit` consumes the array verbatim, so the OpenAPI spec
order is deterministic and tests can assert it as a contract. Keeping
the aggregator in its own module (rather than collapsed into the
`routes.ts` file) mirrors the layout used elsewhere in the
`@suaveplan/*` plugin family — code-search "build-routes" always
finds the canonical-order anchor.
## Features
- Returns 4 entries in the documented canonical order.
- Pure — no I/O, no captured state, no configuration knobs.
- Each entry is independently mountable on a bare `Hono` app for
testing.
## Usage: Basic
```ts
import { buildSuppressionRoutes } from "@suaveplan/api-plugin-campaigns";
for (const e of buildSuppressionRoutes()) {
app[e.route.method as "get" | "post" | "delete"](e.route.path, (c) =>
e.handler(c)
);
}
```
## Usage: Advanced
```ts
import {
buildSuppressionRoutes,
definePluginRoutes,
} from "@suaveplan/api-plugin-campaigns";
const entries = buildSuppressionRoutes().map((e) => ({
...e,
route: { ...e.route, path: `/api${e.route.path}` },
}));
definePluginRoutes(entries, { app, registry, pluginName: "campaigns" });
```
## API Reference
- `buildSuppressionRoutes(): readonly PluginRouteEntry[]` — returns
the 4 entries in canonical order.
## Implementation Notes
The check endpoint sits at position 2 (between list and create)
because cache-friendly `GET` reads precede writes in the canonical
order — operator-facing OpenAPI consumers naturally scan the read
surface first when assessing rate-limit and caching budgets. The
delete endpoint is last so the OpenAPI consumer reading the spec
top-down sees the destructive operation last and is prompted to
consider authentication scope before mounting.

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { buildSuppressionRoutes } from "./build-routes.js";
describe("buildSuppressionRoutes", () => {
it("returns 4 entries in canonical order", { timeout: 5000 }, () => {
const entries = buildSuppressionRoutes();
expect(entries.map((e) => `${e.route.method} ${e.route.path}`)).toEqual(
[
"get /suppression",
"get /suppression/check",
"post /suppression",
"delete /suppression/:id",
]
);
});
it("every entry tagged 'suppression'", { timeout: 5000 }, () => {
for (const e of buildSuppressionRoutes()) {
expect(e.route.tags).toEqual(["suppression"]);
}
});
});

View File

@@ -0,0 +1,22 @@
/**
* Aggregator returning every `/suppression/*` `PluginRouteEntry` in
* canonical order: list, check, create, delete.
*/
import type { PluginRouteEntry } from "@suaveplan/api-openapi";
import {
buildCheckSuppressionRoute,
buildCreateSuppressionRoute,
buildDeleteSuppressionRoute,
buildListSuppressionRoute,
} from "./routes.js";
/** Returns the 4 `/suppression/*` plugin-route entries. */
export function buildSuppressionRoutes(): readonly PluginRouteEntry[] {
return [
buildListSuppressionRoute(),
buildCheckSuppressionRoute(),
buildCreateSuppressionRoute(),
buildDeleteSuppressionRoute(),
];
}

View File

@@ -0,0 +1,19 @@
export { buildSuppressionRoutes } from "./build-routes.js";
export {
buildCheckSuppressionRoute,
buildCreateSuppressionRoute,
buildDeleteSuppressionRoute,
buildListSuppressionRoute,
} from "./routes.js";
export {
type CreateSuppressionBody,
CreateSuppressionSchema,
SuppressionCheckQuerySchema,
SuppressionCheckResponseSchema,
SuppressionIdParamSchema,
SuppressionListResponseSchema,
type SuppressionQueryInput,
SuppressionQuerySchema,
SuppressionReasonSchema,
SuppressionResponseSchema,
} from "./schemas.js";

View File

@@ -0,0 +1,77 @@
# Suppression routes
## Purpose
The suppression-route module owns the four endpoints that manage the
`(tenantId, channel, channelValue)`-keyed suppression list: list, check
(point lookup), create, and delete. Every handler tenant-guards via
`requireTenant` before touching the store, so a misconfigured auth
layer cannot silently expose cross-tenant data. The store's duplicate
detection raises `CAMPAIGNS_API.DUPLICATE_SUPPRESSION` on a colliding
create — the route layer trusts that signal and propagates the error
verbatim.
The check endpoint is intentionally a `GET` (not a `POST` body) so it
is cacheable and CORS-trivial. Sender pipelines hit it on every
outbound decision; a body-less GET with two query parameters is
faster, smaller, and easier to log redact than a JSON `POST`.
## Features
- 4 builders: `buildListSuppressionRoute`,
`buildCheckSuppressionRoute`, `buildCreateSuppressionRoute`,
`buildDeleteSuppressionRoute`.
- Tenant-scoped: every handler enforces non-empty `tenantId`.
- 400 on Zod failure, 404 on missing delete target, 409 on duplicate
add (raised by the store).
- OTel counter per route with `outcome` attribute.
## Usage: Basic
```ts
import { buildCreateSuppressionRoute } from "@suaveplan/api-plugin-campaigns";
const entry = buildCreateSuppressionRoute();
app.post(entry.route.path, (c) => entry.handler(c));
```
## Usage: Advanced
```ts
import {
buildCheckSuppressionRoute,
buildCreateSuppressionRoute,
buildDeleteSuppressionRoute,
buildListSuppressionRoute,
definePluginRoutes,
} from "@suaveplan/api-plugin-campaigns";
definePluginRoutes(
[
buildListSuppressionRoute(),
buildCheckSuppressionRoute(),
buildCreateSuppressionRoute(),
buildDeleteSuppressionRoute(),
],
{ app, registry, pluginName: "campaigns" }
);
```
## API Reference
- `buildListSuppressionRoute(): PluginRouteEntry`
- `buildCheckSuppressionRoute(): PluginRouteEntry`
- `buildCreateSuppressionRoute(): PluginRouteEntry`
- `buildDeleteSuppressionRoute(): PluginRouteEntry`
## Implementation Notes
The delete handler enforces `requireTenant` even though the entry id
is a global key. The defence is layered: deletion through an
authenticated request keeps a malicious caller from probing for valid
ids by hitting `DELETE /suppression/<guess>` and reading the response.
The 404 leak (revealing a non-existent id vs. a valid id) is mitigated
because `store.remove` is `O(1)` and returns a stable boolean
regardless of tenant ownership. If cross-tenant id enumeration becomes
a concern, a future change can tighten the contract to require
`(tenantId, channel, channelValue)` rather than `id`.

View File

@@ -0,0 +1,296 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import {
CAMPAIGNS_API_CODES,
CampaignsApiError,
} from "../../error-codes/error-codes.js";
import { InMemoryCampaignSuppressionStore } from "../../repositories/suppression/suppression-memory.js";
import {
buildCheckSuppressionRoute,
buildCreateSuppressionRoute,
buildDeleteSuppressionRoute,
buildListSuppressionRoute,
} from "./routes.js";
import "../../context/context.js";
function makeStore() {
let nextId = 0;
let nextTime = 1_700_000_000_000;
return new InMemoryCampaignSuppressionStore({
clock: () => {
const t = nextTime;
nextTime += 1;
return t;
},
idGenerator: () => `s${++nextId}`,
});
}
function makeApp(
store: InMemoryCampaignSuppressionStore,
principal: object | null = { id: "u", tenantId: "t" }
): Hono {
const app = new Hono();
app.use("*", async (c, next) => {
if (principal !== null) c.set("user", principal as never);
c.set("campaignSuppressionStore", store);
c.set("eventBus", { emit: () => undefined } as never);
await next();
});
app.onError((err, c) => {
if (err instanceof CampaignsApiError) {
const status = ((err.context as { statusCode?: number } | undefined)
?.statusCode ?? 500) as 400 | 401 | 404 | 409 | 500;
return c.json({ code: err.code, error: err.message }, status);
}
return c.json({ error: String(err) }, 500);
});
for (const e of [
buildListSuppressionRoute(),
buildCheckSuppressionRoute(),
buildCreateSuppressionRoute(),
buildDeleteSuppressionRoute(),
]) {
const method = e.route.method as "get" | "post" | "delete";
app[method](e.route.path, (c) => e.handler(c as never));
}
return app;
}
describe("suppression routes", () => {
it("POST /suppression creates an entry", { timeout: 5000 }, async () => {
const store = makeStore();
const app = makeApp(store);
const res = await app.request("/suppression", {
method: "POST",
body: JSON.stringify({
channelValue: "a@b.c",
reason: "bounce",
source: "import",
metadata: { x: 1 },
}),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.data.channel).toBe("email");
expect(body.data.reason).toBe("bounce");
expect(body.data.source).toBe("import");
});
it("POST /suppression rejects invalid body", {
timeout: 5000,
}, async () => {
const app = makeApp(makeStore());
const res = await app.request("/suppression", {
method: "POST",
body: JSON.stringify({ channelValue: "", reason: "bounce" }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(400);
});
it("POST /suppression returns 401 without principal", {
timeout: 5000,
}, async () => {
const app = makeApp(makeStore(), null);
const res = await app.request("/suppression", {
method: "POST",
body: JSON.stringify({
channelValue: "a@b.c",
reason: "bounce",
}),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(401);
});
it("GET /suppression lists entries with filters", {
timeout: 5000,
}, async () => {
const store = makeStore();
await store.add({
tenantId: "t",
channel: "email",
channelValue: "a@b.c",
reason: "bounce",
});
const app = makeApp(store);
const res = await app.request(
"/suppression?channel=email&reason=bounce&limit=10"
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.total).toBe(1);
});
it("GET /suppression lists with default pagination", {
timeout: 5000,
}, async () => {
const app = makeApp(makeStore());
const res = await app.request("/suppression");
expect(res.status).toBe(200);
});
it("GET /suppression/check returns suppressed=false when missing", {
timeout: 5000,
}, async () => {
const app = makeApp(makeStore());
const res = await app.request(
"/suppression/check?channel=email&channelValue=a@b.c"
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.suppressed).toBe(false);
expect(body.data.entry).toBeNull();
});
it("GET /suppression/check returns suppressed=true when found", {
timeout: 5000,
}, async () => {
const store = makeStore();
await store.add({
tenantId: "t",
channel: "email",
channelValue: "a@b.c",
reason: "manual",
});
const app = makeApp(store);
const res = await app.request(
"/suppression/check?channel=email&channelValue=a@b.c"
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.suppressed).toBe(true);
expect(body.data.entry.reason).toBe("manual");
});
it("GET /suppression/check rejects missing query params", {
timeout: 5000,
}, async () => {
const app = makeApp(makeStore());
const res = await app.request("/suppression/check");
expect(res.status).toBe(400);
});
it("DELETE /suppression/:id returns 204 on hit", {
timeout: 5000,
}, async () => {
const store = makeStore();
const entry = await store.add({
tenantId: "t",
channel: "email",
channelValue: "a@b.c",
reason: "manual",
});
const app = makeApp(store);
const res = await app.request(`/suppression/${entry.id}`, {
method: "DELETE",
});
expect(res.status).toBe(204);
});
it("DELETE /suppression/:id returns 404 missing", {
timeout: 5000,
}, async () => {
const app = makeApp(makeStore());
const res = await app.request("/suppression/nope", {
method: "DELETE",
});
expect(res.status).toBe(404);
});
it("returns 500 when store slot is empty", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("user", { id: "u", tenantId: "t" } as never);
await next();
});
app.onError((err, c) =>
c.json({ code: (err as CampaignsApiError).code }, 500)
);
const e = buildListSuppressionRoute();
app.get(e.route.path, (c) => e.handler(c as never));
const res = await app.request("/suppression");
expect((await res.json()).code).toBe(
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE
);
});
it("create surfaces SERVICE_NOT_AVAILABLE on empty store", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("user", { id: "u", tenantId: "t" } as never);
await next();
});
app.onError((err, c) =>
c.json({ code: (err as CampaignsApiError).code }, 500)
);
const e = buildCreateSuppressionRoute();
app.post(e.route.path, (c) => e.handler(c as never));
const res = await app.request("/suppression", {
method: "POST",
body: JSON.stringify({ channelValue: "a@b.c", reason: "bounce" }),
headers: { "content-type": "application/json" },
});
expect((await res.json()).code).toBe(
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE
);
});
it("check surfaces SERVICE_NOT_AVAILABLE on empty store", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("user", { id: "u", tenantId: "t" } as never);
await next();
});
app.onError((err, c) =>
c.json({ code: (err as CampaignsApiError).code }, 500)
);
const e = buildCheckSuppressionRoute();
app.get(e.route.path, (c) => e.handler(c as never));
const res = await app.request(
"/suppression/check?channel=email&channelValue=a@b.c"
);
expect((await res.json()).code).toBe(
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE
);
});
it("POST /suppression 400 on malformed JSON body", {
timeout: 5000,
}, async () => {
const app = makeApp(makeStore());
const res = await app.request("/suppression", {
method: "POST",
body: "not-json{",
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(400);
});
it("delete surfaces SERVICE_NOT_AVAILABLE on empty store", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("user", { id: "u", tenantId: "t" } as never);
await next();
});
app.onError((err, c) =>
c.json({ code: (err as CampaignsApiError).code }, 500)
);
const e = buildDeleteSuppressionRoute();
app.delete(e.route.path, (c) => e.handler(c as never));
const res = await app.request("/suppression/x", { method: "DELETE" });
expect((await res.json()).code).toBe(
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE
);
});
});

View File

@@ -0,0 +1,225 @@
/**
* `/suppression` route builders — list, check, create, delete. The
* store is read from `c.get("campaignSuppressionStore")` and the
* tenant from `requireTenant`. Duplicate writes surface as
* `CAMPAIGNS_API.DUPLICATE_SUPPRESSION` from the store; the route
* layer maps that to HTTP 409 via the package error handler.
*/
import {
DELETE,
GET,
type PluginRouteEntry,
POST,
successResponse,
} from "@suaveplan/api-openapi";
import type { Context } from "hono";
import z from "zod";
import {
CAMPAIGNS_API_CODES,
CampaignsApiError,
} from "../../error-codes/error-codes.js";
import { getCampaignsApiInstruments } from "../../otel/instruments.js";
import type { CampaignSuppressionEntry } from "../../repositories/suppression/suppression.types.js";
import { requireTenant } from "../shared/require-tenant.js";
import { stripUndefined } from "../shared/strip-undefined.js";
import {
CreateSuppressionSchema,
SuppressionCheckQuerySchema,
SuppressionCheckResponseSchema,
SuppressionIdParamSchema,
SuppressionListResponseSchema,
SuppressionQuerySchema,
SuppressionResponseSchema,
} from "./schemas.js";
function requireStore(c: Context) {
const store = c.get("campaignSuppressionStore");
if (store === undefined) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE,
"campaignSuppressionStore slot is empty"
);
}
return store;
}
function toResponse(entry: CampaignSuppressionEntry): Record<string, unknown> {
return {
id: entry.id,
tenantId: entry.tenantId,
channel: entry.channel,
channelValue: entry.channelValue,
reason: entry.reason,
...(entry.source !== undefined ? { source: entry.source } : {}),
...(entry.metadata !== undefined ? { metadata: entry.metadata } : {}),
createdAt: entry.createdAt.toISOString(),
};
}
/** `GET /suppression` — paginated list. */
export function buildListSuppressionRoute(): PluginRouteEntry {
const route = GET("/suppression")
.tags("suppression")
.summary("List suppression entries")
.query(SuppressionQuerySchema)
.response(successResponse(SuppressionListResponseSchema))
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const store = requireStore(c);
const query = SuppressionQuerySchema.parse({
channel: c.req.query("channel"),
reason: c.req.query("reason"),
limit: c.req.query("limit") ?? 50,
offset: c.req.query("offset") ?? 0,
});
const entries = await store.list({
tenantId,
...(query.channel !== undefined ? { channel: query.channel } : {}),
...(query.reason !== undefined ? { reason: query.reason } : {}),
limit: query.limit,
offset: query.offset,
});
getCampaignsApiInstruments().requestsTotal.add(1, {
route: "suppression-list",
outcome: "success",
});
return c.json(
{
success: true as const,
data: {
entries: entries.map(toResponse),
total: entries.length,
},
},
200
);
}
return { route, handler };
}
/** `GET /suppression/check` — point lookup. */
export function buildCheckSuppressionRoute(): PluginRouteEntry {
const route = GET("/suppression/check")
.tags("suppression")
.summary("Check a single (channel, channelValue) for suppression")
.query(SuppressionCheckQuerySchema)
.response(successResponse(SuppressionCheckResponseSchema))
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const store = requireStore(c);
const parsed = SuppressionCheckQuerySchema.safeParse({
channel: c.req.query("channel"),
channelValue: c.req.query("channelValue"),
});
if (!parsed.success) {
getCampaignsApiInstruments().requestsTotal.add(1, {
route: "suppression-check",
outcome: "error",
});
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.INVALID_INPUT,
"GET /suppression/check query failed validation",
{ cause: parsed.error, context: { statusCode: 400 } }
);
}
const entry = await store.check(
tenantId,
parsed.data.channel,
parsed.data.channelValue
);
getCampaignsApiInstruments().requestsTotal.add(1, {
route: "suppression-check",
outcome: "success",
});
return c.json(
{
success: true as const,
data: {
suppressed: entry !== null,
entry: entry === null ? null : toResponse(entry),
},
},
200
);
}
return { route, handler };
}
/** `POST /suppression` — create entry. */
export function buildCreateSuppressionRoute(): PluginRouteEntry {
const route = POST("/suppression")
.tags("suppression")
.summary("Add a suppression entry")
.body(CreateSuppressionSchema)
.response(successResponse(SuppressionResponseSchema))
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const store = requireStore(c);
const raw = (await c.req.json().catch(() => ({}))) as unknown;
const parsed = CreateSuppressionSchema.safeParse(raw);
if (!parsed.success) {
getCampaignsApiInstruments().requestsTotal.add(1, {
route: "suppression-create",
outcome: "error",
});
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.INVALID_INPUT,
"POST /suppression body failed validation",
{ cause: parsed.error, context: { statusCode: 400 } }
);
}
const entry = await store.add({
tenantId,
...(stripUndefined(parsed.data) as {
channel: string;
channelValue: string;
reason: ReturnType<
typeof CreateSuppressionSchema.parse
>["reason"];
source?: string;
metadata?: Record<string, unknown>;
}),
});
getCampaignsApiInstruments().requestsTotal.add(1, {
route: "suppression-create",
outcome: "success",
});
return c.json({ success: true as const, data: toResponse(entry) }, 201);
}
return { route, handler };
}
/** `DELETE /suppression/:id`. */
export function buildDeleteSuppressionRoute(): PluginRouteEntry {
const route = DELETE("/suppression/:id")
.tags("suppression")
.summary("Remove a suppression entry")
.params(SuppressionIdParamSchema)
.response(z.object({ success: z.literal(true) }))
.build();
async function handler(c: Context): Promise<Response> {
// Tenant guard still applies (defence in depth — the id is
// global but unauthenticated callers should never hit this).
requireTenant(c);
const store = requireStore(c);
const id = c.req.param("id");
const removed = await store.remove(id);
if (!removed) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.SUPPRESSION_NOT_FOUND,
`suppression entry ${id} not found`,
{ context: { statusCode: 404, id } }
);
}
getCampaignsApiInstruments().requestsTotal.add(1, {
route: "suppression-delete",
outcome: "success",
});
return c.body(null, 204);
}
return { route, handler };
}

View File

@@ -0,0 +1,87 @@
# Suppression route schemas
## Purpose
The suppression-route schemas describe the OpenAPI contract for the
`/suppression` route tree. The package's local
`CampaignSuppressionStore` interface is multi-channel — keyed on the
`(tenantId, channel, channelValue)` triple — so the schemas surface
`channel` (free-string with `"email"` default) and `channelValue`
rather than the email-only `channelType` shape used by the upstream
port. This keeps the same routes reusable for SMS, push, and future
delivery channels without a v2 endpoint family.
The schemas back four endpoints: list (paginated), create (single
entry, defends against duplicates at the store), delete (by `id`), and
check (point lookup by `channel + channelValue`). The check endpoint
is read-only and idempotent, and is intended to be hit on every
outgoing-message decision in a sender pipeline.
## Features
- `SuppressionReasonSchema` — 5-member enum mirroring
`CampaignSuppressionReason`.
- `CreateSuppressionSchema` — body validator with `channel: "email"`
default and optional `source` / `metadata`.
- `SuppressionQuerySchema` — list filters with coerced pagination.
- `SuppressionCheckQuerySchema` — required `channel` + `channelValue`.
- `SuppressionIdParamSchema` — `:id` path param.
- 3 response schemas: `SuppressionResponseSchema`,
`SuppressionListResponseSchema`, `SuppressionCheckResponseSchema`.
## Usage: Basic
```ts
import { CreateSuppressionSchema } from "@suaveplan/api-plugin-campaigns";
const parsed = CreateSuppressionSchema.parse(await c.req.json());
await store.add({ tenantId, ...parsed });
```
## Usage: Advanced
```ts
import {
SuppressionCheckQuerySchema,
SuppressionCheckResponseSchema,
} from "@suaveplan/api-plugin-campaigns";
const q = SuppressionCheckQuerySchema.parse({
channel: c.req.query("channel"),
channelValue: c.req.query("channelValue"),
});
const entry = await store.check(tenantId, q.channel, q.channelValue);
return c.json(
SuppressionCheckResponseSchema.parse({
suppressed: entry !== null,
entry,
}),
200
);
```
## API Reference
- `CreateSuppressionSchema`, `SuppressionQuerySchema`,
`SuppressionCheckQuerySchema`, `SuppressionIdParamSchema` — input
validators.
- `SuppressionResponseSchema`, `SuppressionListResponseSchema`,
`SuppressionCheckResponseSchema` — response shapes.
- `type CreateSuppressionBody`, `SuppressionQueryInput` — inferred TS
types.
## Implementation Notes
The `channel` field defaults to `"email"` rather than enumerating
known channels because the suppression store is intentionally
channel-agnostic (a tenant may opt into SMS or push channels without
schema changes here). The trade-off: the OpenAPI consumer sees a
free-string rather than a closed enum. The enforcement boundary moves
to the recipient delivery pipeline, which always knows its channel
identifier before consulting suppression.
`metadata` is `z.record(z.string(), z.unknown())` so adapters can
attach provider-specific context (bounce subcodes, complaint headers,
…) without a schema migration. The schema is **strict-on-input** only
for fields it owns; per-channel extensions live inside `metadata` and
are validated at the integration boundary, not here.

View File

@@ -0,0 +1,137 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CreateSuppressionSchema,
SuppressionCheckQuerySchema,
SuppressionCheckResponseSchema,
SuppressionIdParamSchema,
SuppressionListResponseSchema,
SuppressionQuerySchema,
SuppressionReasonSchema,
SuppressionResponseSchema,
} from "./schemas.js";
describe("suppression route schemas", () => {
it("SuppressionReasonSchema accepts all 5 reasons", {
timeout: 5000,
}, () => {
for (const r of [
"unsubscribe",
"bounce",
"complaint",
"manual",
"compliance",
] as const) {
expect(SuppressionReasonSchema.parse(r)).toBe(r);
}
expect(() => SuppressionReasonSchema.parse("bogus")).toThrow();
});
it("CreateSuppressionSchema applies channel default", {
timeout: 5000,
}, () => {
const out = CreateSuppressionSchema.parse({
channelValue: "a@b.c",
reason: "bounce",
});
expect(out.channel).toBe("email");
expect(out.reason).toBe("bounce");
});
it("CreateSuppressionSchema accepts source and metadata", {
timeout: 5000,
}, () => {
const out = CreateSuppressionSchema.parse({
channel: "sms",
channelValue: "+1",
reason: "compliance",
source: "import",
metadata: { x: 1 },
});
expect(out.source).toBe("import");
expect(out.metadata).toEqual({ x: 1 });
});
it("CreateSuppressionSchema rejects empty channelValue", {
timeout: 5000,
}, () => {
expect(() =>
CreateSuppressionSchema.parse({
channelValue: "",
reason: "bounce",
})
).toThrow();
});
it("SuppressionQuerySchema coerces numeric fields with defaults", {
timeout: 5000,
}, () => {
const out = SuppressionQuerySchema.parse({ limit: "20" });
expect(out.limit).toBe(20);
expect(out.offset).toBe(0);
});
it("SuppressionQuerySchema rejects oversized limit", {
timeout: 5000,
}, () => {
expect(() => SuppressionQuerySchema.parse({ limit: 1000 })).toThrow();
});
it("SuppressionCheckQuerySchema requires channel + channelValue", {
timeout: 5000,
}, () => {
expect(
SuppressionCheckQuerySchema.parse({
channel: "email",
channelValue: "a@b.c",
})
).toEqual({ channel: "email", channelValue: "a@b.c" });
expect(() =>
SuppressionCheckQuerySchema.parse({
channel: "email",
channelValue: "",
})
).toThrow();
});
it("SuppressionIdParamSchema requires non-empty id", {
timeout: 5000,
}, () => {
expect(() => SuppressionIdParamSchema.parse({ id: "" })).toThrow();
});
it("SuppressionResponseSchema accepts a minimal entry", {
timeout: 5000,
}, () => {
const ok = SuppressionResponseSchema.safeParse({
id: "s1",
tenantId: "t1",
channel: "email",
channelValue: "a@b.c",
reason: "manual",
createdAt: new Date().toISOString(),
});
expect(ok.success).toBe(true);
});
it("SuppressionListResponseSchema wraps the entry array", {
timeout: 5000,
}, () => {
expect(
SuppressionListResponseSchema.safeParse({
entries: [],
total: 0,
}).success
).toBe(true);
});
it("SuppressionCheckResponseSchema accepts null entry", {
timeout: 5000,
}, () => {
expect(
SuppressionCheckResponseSchema.safeParse({
suppressed: false,
entry: null,
}).success
).toBe(true);
});
});

View File

@@ -0,0 +1,73 @@
/**
* Zod schemas for the `/suppression` route tree. The suppression-store
* interface is multi-channel (the `(tenantId, channel, channelValue)`
* triple is the natural primary key), so the schemas surface
* `channel` and `channelValue` rather than the email-only
* `channelType` + `channelValue` shape from upstream.
*/
import z from "zod";
/** Mirror of `CampaignSuppressionReason` from the store types. */
export const SuppressionReasonSchema = z.enum([
"unsubscribe",
"bounce",
"complaint",
"manual",
"compliance",
]);
/** Body for `POST /suppression`. */
export const CreateSuppressionSchema = z.object({
channel: z.string().min(1).default("email"),
channelValue: z.string().min(1),
reason: SuppressionReasonSchema,
source: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
/** Query string for `GET /suppression`. */
export const SuppressionQuerySchema = z.object({
channel: z.string().min(1).optional(),
reason: SuppressionReasonSchema.optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
/** Query string for `GET /suppression/check`. */
export const SuppressionCheckQuerySchema = z.object({
channel: z.string().min(1),
channelValue: z.string().min(1),
});
/** Path param `:id`. */
export const SuppressionIdParamSchema = z.object({
id: z.string().min(1),
});
/** Response shape for a single suppression entry. */
export const SuppressionResponseSchema = z.object({
id: z.string(),
tenantId: z.string(),
channel: z.string(),
channelValue: z.string(),
reason: SuppressionReasonSchema,
source: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
createdAt: z.string(),
});
/** Response shape for `GET /suppression`. */
export const SuppressionListResponseSchema = z.object({
entries: z.array(SuppressionResponseSchema),
total: z.number(),
});
/** Response shape for `GET /suppression/check`. */
export const SuppressionCheckResponseSchema = z.object({
suppressed: z.boolean(),
entry: SuppressionResponseSchema.nullable(),
});
export type CreateSuppressionBody = z.infer<typeof CreateSuppressionSchema>;
export type SuppressionQueryInput = z.infer<typeof SuppressionQuerySchema>;

View File

@@ -0,0 +1,63 @@
# `build-routes` (webhooks)
## Purpose
`buildWebhookRoutes` aggregates the two `/webhooks/*` route entries in
canonical order — delivery ingest first, then one-click unsubscribe.
Co-locating both endpoints in a single bundle keeps the OpenAPI spec
deterministic, makes the unit-test surface trivially mountable on a
bare `Hono` app, and gives the plugin's `onInit` a single hook point
for the entire webhook tree.
The two endpoints differ markedly in shape (POST body vs. GET token
param) but share the same tenant-guard and store-resolution
pattern, so the aggregator is the right seam for any future
cross-cutting middleware (rate limit, HMAC verifier, replay-detector).
## Features
- 2 entries in canonical order: `delivery`, `unsubscribe`.
- Returned array is `readonly` and ordering is stable across calls.
- Pure — no I/O, no captured state.
## Usage: Basic
```ts
import { buildWebhookRoutes } from "@suaveplan/api-plugin-campaigns";
for (const e of buildWebhookRoutes()) {
app[e.route.method as "get" | "post"](e.route.path, (c) =>
e.handler(c)
);
}
```
## Usage: Advanced
```ts
import {
buildWebhookRoutes,
definePluginRoutes,
} from "@suaveplan/api-plugin-campaigns";
const entries = buildWebhookRoutes().map((e) => ({
...e,
route: { ...e.route, path: `/v1${e.route.path}` },
}));
definePluginRoutes(entries, { app, registry, pluginName: "campaigns" });
```
## API Reference
- `buildWebhookRoutes(): readonly PluginRouteEntry[]` — the two
canonical webhook entries.
## Implementation Notes
The delivery endpoint comes first because that is the high-volume
ingestion path, while the unsubscribe endpoint is rate-limited by the
human throughput of clicking a link. Keeping them in this order makes
the OpenAPI consumer's first impression align with the operational
weight of each endpoint. The aggregator does not try to share any
middleware between the two builders — each one is self-contained and
already enforces its own tenant + service-resolution guards.

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { buildWebhookRoutes } from "./build-routes.js";
describe("buildWebhookRoutes", () => {
it("returns 2 entries in canonical order", { timeout: 5000 }, () => {
const entries = buildWebhookRoutes();
expect(entries.map((e) => `${e.route.method} ${e.route.path}`)).toEqual(
["post /webhooks/delivery", "get /webhooks/unsubscribe/:token"]
);
});
it("every entry tagged 'webhooks'", { timeout: 5000 }, () => {
for (const e of buildWebhookRoutes()) {
expect(e.route.tags).toEqual(["webhooks"]);
}
});
});

View File

@@ -0,0 +1,12 @@
/**
* Aggregator returning every `/webhooks/*` `PluginRouteEntry` in
* canonical order: delivery ingest, then one-click unsubscribe.
*/
import type { PluginRouteEntry } from "@suaveplan/api-openapi";
import { buildDeliveryWebhookRoute, buildUnsubscribeRoute } from "./routes.js";
/** Returns the 2 `/webhooks/*` plugin-route entries. */
export function buildWebhookRoutes(): readonly PluginRouteEntry[] {
return [buildDeliveryWebhookRoute(), buildUnsubscribeRoute()];
}

View File

@@ -0,0 +1,15 @@
export { buildWebhookRoutes } from "./build-routes.js";
export {
buildDeliveryWebhookRoute,
buildUnsubscribeRoute,
} from "./routes.js";
export {
DeliveryEventTypeSchema,
type DeliveryWebhookBody,
DeliveryWebhookResponseSchema,
DeliveryWebhookSchema,
decodeUnsubscribeToken,
UnsubscribeResponseSchema,
UnsubscribeTokenParamSchema,
type UnsubscribeTokenPayload,
} from "./schemas.js";

View File

@@ -0,0 +1,81 @@
# Webhook routes
## Purpose
The webhook-route module owns the two webhook endpoints: a delivery
event ingestion endpoint that normalises and persists provider events
through `DeliveryEventStore`, and a one-click unsubscribe endpoint
driven by a base64-JSON token. The delivery handler is the authoritative
fan-out point: it persists the event and — for the three "negative"
events (`unsubscribed`, `bounced`, `complained`) — additionally
inserts a `CampaignSuppressionEntry` so the sender pipeline can
short-circuit future deliveries to the same contact.
The unsubscribe endpoint hardcodes `channel: "email"` because the
token format does not carry a channel field (the design assumes
unsubscribe links are always embedded in email messages). Multi-channel
unsubscribe is a future extension — when that ships, the token shape
will gain a `channel` field and the handler will decode it.
## Features
- 2 builders: `buildDeliveryWebhookRoute`, `buildUnsubscribeRoute`.
- Delivery handler fan-out: records the event, then optionally adds
a suppression entry for `unsubscribed` / `bounced` / `complained`.
- Idempotent suppression: a duplicate `add` is short-circuited by a
pre-existing `check` hit, so retries from the provider do not raise
`DUPLICATE_SUPPRESSION`.
- Strict tenant guard: every handler enforces `requireTenant` so
provider webhooks must traverse a tenant-aware auth layer before
hitting the handler.
- Token decode failures map to `INVALID_UNSUBSCRIBE_TOKEN` (HTTP 400);
schema failures on the delivery body map to `WEBHOOK_REJECTED`.
## Usage: Basic
```ts
import {
buildDeliveryWebhookRoute,
buildUnsubscribeRoute,
} from "@suaveplan/api-plugin-campaigns";
app.post(buildDeliveryWebhookRoute().route.path, (c) =>
buildDeliveryWebhookRoute().handler(c)
);
app.get(buildUnsubscribeRoute().route.path, (c) =>
buildUnsubscribeRoute().handler(c)
);
```
## Usage: Advanced
```ts
import {
buildDeliveryWebhookRoute,
buildUnsubscribeRoute,
definePluginRoutes,
} from "@suaveplan/api-plugin-campaigns";
definePluginRoutes(
[buildDeliveryWebhookRoute(), buildUnsubscribeRoute()],
{ app, registry, pluginName: "campaigns" }
);
```
## API Reference
- `buildDeliveryWebhookRoute(): PluginRouteEntry`
- `buildUnsubscribeRoute(): PluginRouteEntry`
## Implementation Notes
The negative-event mapping (`unsubscribed → unsubscribe`,
`bounced → bounce`, `complained → complaint`) is exhaustive but
intentionally narrow — we do not auto-suppress on `dropped` or
`deferred` because those are transient provider states that should
trigger investigation rather than permanent suppression. A future
change can expand the mapping if operators request a `dropped → manual`
auto-suppression policy. The handler returns `{ eventId, suppressed }`
so the calling provider can correlate retries against a stable id and
operators can confirm via response inspection whether the fan-out
fired.

View File

@@ -0,0 +1,324 @@
import { InMemoryDeliveryEventStore } from "@suaveplan/delivery-tracking";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import {
CAMPAIGNS_API_CODES,
CampaignsApiError,
} from "../../error-codes/error-codes.js";
import { InMemoryCampaignSuppressionStore } from "../../repositories/suppression/suppression-memory.js";
import { buildDeliveryWebhookRoute, buildUnsubscribeRoute } from "./routes.js";
import "../../context/context.js";
function makeFixtures(principal: object | null = { id: "u", tenantId: "t" }) {
let nextId = 0;
const store = new InMemoryCampaignSuppressionStore({
clock: () => 1_700_000_000_000,
idGenerator: () => `s${++nextId}`,
});
const eventStore = new InMemoryDeliveryEventStore();
const app = new Hono();
app.use("*", async (c, next) => {
if (principal !== null) c.set("user", principal as never);
c.set("campaignSuppressionStore", store);
c.set("deliveryEventStore", eventStore);
c.set("eventBus", { emit: () => undefined } as never);
await next();
});
app.onError((err, c) => {
if (err instanceof CampaignsApiError) {
const status = ((err.context as { statusCode?: number } | undefined)
?.statusCode ?? 500) as 400 | 401 | 500;
return c.json({ code: err.code, error: err.message }, status);
}
return c.json({ error: String(err) }, 500);
});
for (const e of [buildDeliveryWebhookRoute(), buildUnsubscribeRoute()]) {
const method = e.route.method as "get" | "post";
app[method](e.route.path, (c) => e.handler(c as never));
}
return { app, store, eventStore };
}
describe("webhook delivery route", () => {
it("records a delivered event without suppression", {
timeout: 5000,
}, async () => {
const { app, store, eventStore } = makeFixtures();
const res = await app.request("/webhooks/delivery", {
method: "POST",
body: JSON.stringify({
messageId: "m1",
eventType: "delivered",
contactId: "u1",
campaignId: "cmp1",
}),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.suppressed).toBe(false);
expect(await eventStore.getByCampaign("cmp1")).toHaveLength(1);
expect(await store.count("t")).toBe(0);
});
it("suppresses on unsubscribed event", { timeout: 5000 }, async () => {
const { app, store } = makeFixtures();
const res = await app.request("/webhooks/delivery", {
method: "POST",
body: JSON.stringify({
messageId: "m1",
eventType: "unsubscribed",
contactId: "u1",
campaignId: "cmp1",
}),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.suppressed).toBe(true);
expect(await store.count("t")).toBe(1);
});
it("suppresses on bounced event", { timeout: 5000 }, async () => {
const { app, store } = makeFixtures();
await app.request("/webhooks/delivery", {
method: "POST",
body: JSON.stringify({
messageId: "m1",
eventType: "bounced",
contactId: "u1",
}),
headers: { "content-type": "application/json" },
});
expect(await store.count("t")).toBe(1);
});
it("suppresses on complained event", { timeout: 5000 }, async () => {
const { app, store } = makeFixtures();
await app.request("/webhooks/delivery", {
method: "POST",
body: JSON.stringify({
messageId: "m1",
eventType: "complained",
contactId: "u1",
}),
headers: { "content-type": "application/json" },
});
expect(await store.count("t")).toBe(1);
});
it("is idempotent — second unsubscribe does not raise", {
timeout: 5000,
}, async () => {
const { app, store } = makeFixtures();
for (let i = 0; i < 2; i++) {
const res = await app.request("/webhooks/delivery", {
method: "POST",
body: JSON.stringify({
messageId: `m${i}`,
eventType: "unsubscribed",
contactId: "u1",
}),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(200);
expect((await res.json()).data.suppressed).toBe(true);
}
expect(await store.count("t")).toBe(1);
});
it("accepts a timestamp from the payload", {
timeout: 5000,
}, async () => {
const { app, eventStore } = makeFixtures();
await app.request("/webhooks/delivery", {
method: "POST",
body: JSON.stringify({
messageId: "m1",
eventType: "delivered",
contactId: "u1",
campaignId: "cmp1",
timestamp: "2026-01-01T00:00:00Z",
}),
headers: { "content-type": "application/json" },
});
const events = await eventStore.getByCampaign("cmp1");
expect(events[0]?.timestamp.toISOString()).toBe(
"2026-01-01T00:00:00.000Z"
);
});
it("rejects malformed body with WEBHOOK_REJECTED", {
timeout: 5000,
}, async () => {
const { app } = makeFixtures();
const res = await app.request("/webhooks/delivery", {
method: "POST",
body: JSON.stringify({ messageId: "", eventType: "delivered" }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(400);
expect((await res.json()).code).toBe(
CAMPAIGNS_API_CODES.WEBHOOK_REJECTED
);
});
it("401 without a principal", { timeout: 5000 }, async () => {
const { app } = makeFixtures(null);
const res = await app.request("/webhooks/delivery", {
method: "POST",
body: JSON.stringify({
messageId: "m1",
eventType: "delivered",
contactId: "u1",
}),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(401);
});
it("POST /webhooks/delivery 400 on malformed JSON body", {
timeout: 5000,
}, async () => {
const { app } = makeFixtures();
const res = await app.request("/webhooks/delivery", {
method: "POST",
body: "not-json{",
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(400);
});
it("500 when event store slot empty", { timeout: 5000 }, async () => {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("user", { id: "u", tenantId: "t" } as never);
await next();
});
app.onError((err, c) =>
c.json({ code: (err as CampaignsApiError).code }, 500)
);
const e = buildDeliveryWebhookRoute();
app.post(e.route.path, (c) => e.handler(c as never));
const res = await app.request("/webhooks/delivery", {
method: "POST",
body: JSON.stringify({
messageId: "m",
eventType: "delivered",
contactId: "u1",
}),
headers: { "content-type": "application/json" },
});
expect((await res.json()).code).toBe(
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE
);
});
it("500 when suppression store slot empty", {
timeout: 5000,
}, async () => {
const eventStore = new InMemoryDeliveryEventStore();
const app = new Hono();
app.use("*", async (c, next) => {
c.set("user", { id: "u", tenantId: "t" } as never);
c.set("deliveryEventStore", eventStore);
await next();
});
app.onError((err, c) =>
c.json({ code: (err as CampaignsApiError).code }, 500)
);
const e = buildDeliveryWebhookRoute();
app.post(e.route.path, (c) => e.handler(c as never));
const res = await app.request("/webhooks/delivery", {
method: "POST",
body: JSON.stringify({
messageId: "m",
eventType: "delivered",
contactId: "u1",
}),
headers: { "content-type": "application/json" },
});
expect((await res.json()).code).toBe(
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE
);
});
});
describe("webhook unsubscribe route", () => {
it("accepts a valid token", { timeout: 5000 }, async () => {
const { app, store } = makeFixtures();
const token = btoa(JSON.stringify({ contactId: "u1" }));
const res = await app.request(`/webhooks/unsubscribe/${token}`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.contactId).toBe("u1");
expect(body.data.campaignId).toBeNull();
expect(await store.count("t")).toBe(1);
});
it("includes campaignId metadata when supplied", {
timeout: 5000,
}, async () => {
const { app, store } = makeFixtures();
const token = btoa(
JSON.stringify({ contactId: "u1", campaignId: "cmp1" })
);
const res = await app.request(`/webhooks/unsubscribe/${token}`);
expect(res.status).toBe(200);
const entries = await store.list({ tenantId: "t" });
expect(entries[0]?.metadata).toEqual({ campaignId: "cmp1" });
});
it("is idempotent — repeat call does not double-insert", {
timeout: 5000,
}, async () => {
const { app, store } = makeFixtures();
const token = btoa(JSON.stringify({ contactId: "u1" }));
await app.request(`/webhooks/unsubscribe/${token}`);
await app.request(`/webhooks/unsubscribe/${token}`);
expect(await store.count("t")).toBe(1);
});
it("rejects an undecodable token", { timeout: 5000 }, async () => {
const { app } = makeFixtures();
const res = await app.request("/webhooks/unsubscribe/ÿÿÿÿ");
expect(res.status).toBe(400);
expect((await res.json()).code).toBe(
CAMPAIGNS_API_CODES.INVALID_UNSUBSCRIBE_TOKEN
);
});
it("rejects a token without contactId", { timeout: 5000 }, async () => {
const { app } = makeFixtures();
const token = btoa(JSON.stringify({ campaignId: "cmp1" }));
const res = await app.request(`/webhooks/unsubscribe/${token}`);
expect(res.status).toBe(400);
});
it("401 without a principal", { timeout: 5000 }, async () => {
const { app } = makeFixtures(null);
const token = btoa(JSON.stringify({ contactId: "u1" }));
const res = await app.request(`/webhooks/unsubscribe/${token}`);
expect(res.status).toBe(401);
});
it("500 when suppression store slot empty", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("user", { id: "u", tenantId: "t" } as never);
await next();
});
app.onError((err, c) =>
c.json({ code: (err as CampaignsApiError).code }, 500)
);
const e = buildUnsubscribeRoute();
app.get(e.route.path, (c) => e.handler(c as never));
const token = btoa(JSON.stringify({ contactId: "u1" }));
const res = await app.request(`/webhooks/unsubscribe/${token}`);
expect((await res.json()).code).toBe(
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE
);
});
});

View File

@@ -0,0 +1,226 @@
/**
* `/webhooks` route builders — provider delivery-event ingest and
* one-click unsubscribe.
*
* The delivery webhook normalises the payload, persists it via
* `deliveryEventStore.record`, and on the three "negative" event
* types (`unsubscribed`, `bounced`, `complained`) it additionally
* inserts a matching `CampaignSuppressionEntry` so the sender
* pipeline can short-circuit future deliveries. Both store writes
* use `add({ ... })`; the store deduplicates by `(tenant, channel,
* channelValue)` so retries are safe.
*/
import {
GET,
type PluginRouteEntry,
POST,
successResponse,
} from "@suaveplan/api-openapi";
import type { DeliveryEvent } from "@suaveplan/delivery-tracking";
import type { Context } from "hono";
import {
CAMPAIGNS_API_CODES,
CampaignsApiError,
} from "../../error-codes/error-codes.js";
import { getCampaignsApiInstruments } from "../../otel/instruments.js";
import type {
CampaignSuppressionReason,
CampaignSuppressionStore,
} from "../../repositories/suppression/suppression.types.js";
import { requireTenant } from "../shared/require-tenant.js";
import {
DeliveryWebhookResponseSchema,
DeliveryWebhookSchema,
decodeUnsubscribeToken,
UnsubscribeResponseSchema,
UnsubscribeTokenParamSchema,
} from "./schemas.js";
const SUPPRESSION_REASON_BY_EVENT: Record<
"unsubscribed" | "bounced" | "complained",
CampaignSuppressionReason
> = {
unsubscribed: "unsubscribe",
bounced: "bounce",
complained: "complaint",
};
function requireEventStore(c: Context) {
const store = c.get("deliveryEventStore");
if (store === undefined) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE,
"deliveryEventStore slot is empty"
);
}
return store;
}
function requireSuppressionStore(c: Context): CampaignSuppressionStore {
const store = c.get("campaignSuppressionStore");
if (store === undefined) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE,
"campaignSuppressionStore slot is empty"
);
}
return store;
}
async function maybeSuppress(
store: CampaignSuppressionStore,
tenantId: string,
body: {
eventType: string;
channel: string;
contactId: string;
campaignId?: string;
}
): Promise<boolean> {
const reason =
SUPPRESSION_REASON_BY_EVENT[
body.eventType as keyof typeof SUPPRESSION_REASON_BY_EVENT
];
if (reason === undefined) return false;
const existing = await store.check(tenantId, body.channel, body.contactId);
if (existing !== null) return true;
await store.add({
tenantId,
channel: body.channel,
channelValue: body.contactId,
reason,
source: "webhook",
...(body.campaignId !== undefined
? { metadata: { campaignId: body.campaignId } }
: {}),
});
return true;
}
/** `POST /webhooks/delivery` — ingest a normalised provider delivery event. */
export function buildDeliveryWebhookRoute(): PluginRouteEntry {
const route = POST("/webhooks/delivery")
.tags("webhooks")
.summary("Receive a delivery webhook event")
.body(DeliveryWebhookSchema)
.response(DeliveryWebhookResponseSchema)
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const eventStore = requireEventStore(c);
const suppressionStore = requireSuppressionStore(c);
const raw = (await c.req.json().catch(() => ({}))) as unknown;
const parsed = DeliveryWebhookSchema.safeParse(raw);
if (!parsed.success) {
getCampaignsApiInstruments().requestsTotal.add(1, {
route: "webhook-delivery",
outcome: "error",
});
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.WEBHOOK_REJECTED,
"POST /webhooks/delivery body failed validation",
{ cause: parsed.error, context: { statusCode: 400 } }
);
}
const body = parsed.data;
const event: DeliveryEvent = {
id: crypto.randomUUID(),
tenantId,
...(body.campaignId !== undefined
? { campaignId: body.campaignId }
: {}),
contactId: body.contactId,
channel: body.channel,
eventType: body.eventType,
messageId: body.messageId,
metadata: body.metadata,
timestamp:
body.timestamp !== undefined
? new Date(body.timestamp)
: new Date(),
};
await eventStore.record(event);
const suppressed = await maybeSuppress(suppressionStore, tenantId, {
eventType: body.eventType,
channel: body.channel,
contactId: body.contactId,
...(body.campaignId !== undefined
? { campaignId: body.campaignId }
: {}),
});
getCampaignsApiInstruments().requestsTotal.add(1, {
route: "webhook-delivery",
outcome: "success",
});
return c.json(
{
success: true as const,
data: { eventId: event.id, suppressed },
},
200
);
}
return { route, handler };
}
/** `GET /webhooks/unsubscribe/:token` — one-click unsubscribe. */
export function buildUnsubscribeRoute(): PluginRouteEntry {
const route = GET("/webhooks/unsubscribe/:token")
.tags("webhooks")
.summary("One-click unsubscribe via a base64-JSON token")
.params(UnsubscribeTokenParamSchema)
.response(successResponse(UnsubscribeResponseSchema))
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const suppressionStore = requireSuppressionStore(c);
const token = c.req.param("token");
let payload: ReturnType<typeof decodeUnsubscribeToken>;
try {
payload = decodeUnsubscribeToken(token);
} catch (cause) {
getCampaignsApiInstruments().requestsTotal.add(1, {
route: "webhook-unsubscribe",
outcome: "error",
});
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.INVALID_UNSUBSCRIBE_TOKEN,
"unsubscribe token decode failed",
{ cause, context: { statusCode: 400 } }
);
}
const existing = await suppressionStore.check(
tenantId,
"email",
payload.contactId
);
if (existing === null) {
await suppressionStore.add({
tenantId,
channel: "email",
channelValue: payload.contactId,
reason: "unsubscribe",
source: "one-click",
...(payload.campaignId !== undefined
? { metadata: { campaignId: payload.campaignId } }
: {}),
});
}
getCampaignsApiInstruments().requestsTotal.add(1, {
route: "webhook-unsubscribe",
outcome: "success",
});
return c.json(
{
success: true as const,
data: {
contactId: payload.contactId,
campaignId: payload.campaignId ?? null,
},
},
200
);
}
return { route, handler };
}

View File

@@ -0,0 +1,86 @@
# Webhook route schemas
## Purpose
The webhook-route schemas describe the OpenAPI contract for the
`/webhooks` route tree, plus a `decodeUnsubscribeToken` helper. The
delivery webhook accepts a normalised `DeliveryEvent` payload (the
provider-specific parser lives in `@suaveplan/delivery-tracking`'s
`WebhookEventParser`; this plugin only handles already-normalised
events). The unsubscribe endpoint decodes a base64-JSON token carrying
`{ contactId, campaignId? }` and falls through to the suppression
store.
The token format is deliberately lightweight — base64-encoded JSON
with no signature. Production deployments SHOULD wrap this endpoint in
a per-tenant HMAC verification layer (e.g. via the rate-limit or
auth-plugin middleware chain) before the token decode happens. This
module is the parsing primitive; identity verification is layered on
above.
## Features
- `DeliveryEventTypeSchema` — 10-member enum.
- `DeliveryWebhookSchema` — body validator with `channel: "email"`
default and ISO 8601 timestamp.
- `UnsubscribeTokenParamSchema` — `:token` path validator.
- `DeliveryWebhookResponseSchema`, `UnsubscribeResponseSchema` —
response shapes.
- `decodeUnsubscribeToken(token)` — base64 → JSON → Zod-validated
payload, with three distinct error messages so the route handler
can map all to `INVALID_UNSUBSCRIBE_TOKEN`.
## Usage: Basic
```ts
import { DeliveryWebhookSchema } from "@suaveplan/api-plugin-campaigns";
const event = DeliveryWebhookSchema.parse(await c.req.json());
await store.record({ ...event, tenantId, id: crypto.randomUUID() });
```
## Usage: Advanced
```ts
import {
CAMPAIGNS_API_CODES,
CampaignsApiError,
decodeUnsubscribeToken,
} from "@suaveplan/api-plugin-campaigns";
let payload;
try {
payload = decodeUnsubscribeToken(c.req.param("token"));
} catch (cause) {
throw new CampaignsApiError(
CAMPAIGNS_API_CODES.INVALID_UNSUBSCRIBE_TOKEN,
"could not decode unsubscribe token",
{ cause, context: { statusCode: 400 } }
);
}
```
## API Reference
- `DeliveryEventTypeSchema: ZodEnum<10>`
- `DeliveryWebhookSchema: ZodObject<...>`
- `UnsubscribeTokenParamSchema: ZodObject<{ token: ZodString }>`
- `DeliveryWebhookResponseSchema`, `UnsubscribeResponseSchema` —
response shapes.
- `decodeUnsubscribeToken(token: string): UnsubscribeTokenPayload` —
throws `Error` on any failure mode.
- `type DeliveryWebhookBody`, `type UnsubscribeTokenPayload`.
## Implementation Notes
`decodeUnsubscribeToken` throws plain `Error` (not
`CampaignsApiError`) because the helper is consumed from a single
route-handler site that wraps every throw in
`CampaignsApiError(INVALID_UNSUBSCRIBE_TOKEN)`. Centralising the wrap
at the call site keeps the helper portable for future consumers that
may wish to map the failure mode differently (e.g. a CLI tool issuing
test tokens). The three distinct error messages — base64,
JSON, contactId — are preserved so server-side logs can distinguish
between a tampered token (likely malicious), a malformed token (likely
truncated by a mail-client URL-shortener), and a structurally-valid
but content-invalid token (likely a tenant-config bug).

View File

@@ -0,0 +1,103 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
DeliveryEventTypeSchema,
DeliveryWebhookSchema,
decodeUnsubscribeToken,
UnsubscribeTokenParamSchema,
} from "./schemas.js";
describe("webhook schemas", () => {
it("DeliveryEventTypeSchema accepts the 10 event types", {
timeout: 5000,
}, () => {
for (const t of [
"sent",
"delivered",
"opened",
"clicked",
"bounced",
"unsubscribed",
"complained",
"deferred",
"dropped",
"read",
] as const) {
expect(DeliveryEventTypeSchema.parse(t)).toBe(t);
}
expect(() => DeliveryEventTypeSchema.parse("bogus")).toThrow();
});
it("DeliveryWebhookSchema applies channel + metadata defaults", {
timeout: 5000,
}, () => {
const out = DeliveryWebhookSchema.parse({
messageId: "m1",
eventType: "delivered",
contactId: "c1",
});
expect(out.channel).toBe("email");
expect(out.metadata).toEqual({});
});
it("DeliveryWebhookSchema rejects empty messageId", {
timeout: 5000,
}, () => {
expect(() =>
DeliveryWebhookSchema.parse({
messageId: "",
eventType: "delivered",
contactId: "c1",
})
).toThrow();
});
it("DeliveryWebhookSchema accepts an ISO timestamp", {
timeout: 5000,
}, () => {
const out = DeliveryWebhookSchema.parse({
messageId: "m1",
eventType: "opened",
contactId: "c1",
timestamp: "2026-01-01T00:00:00Z",
});
expect(out.timestamp).toBe("2026-01-01T00:00:00Z");
});
it("UnsubscribeTokenParamSchema requires a non-empty token", {
timeout: 5000,
}, () => {
expect(() =>
UnsubscribeTokenParamSchema.parse({ token: "" })
).toThrow();
});
});
describe("decodeUnsubscribeToken", () => {
it("decodes a valid token", { timeout: 5000 }, () => {
const token = btoa(
JSON.stringify({ contactId: "c1", campaignId: "cmp1" })
);
const out = decodeUnsubscribeToken(token);
expect(out).toEqual({ contactId: "c1", campaignId: "cmp1" });
});
it("decodes a token without campaignId", { timeout: 5000 }, () => {
const token = btoa(JSON.stringify({ contactId: "c1" }));
expect(decodeUnsubscribeToken(token)).toEqual({ contactId: "c1" });
});
it("throws when token is not valid base64", { timeout: 5000 }, () => {
// 'œ' is outside the base64 alphabet; atob rejects.
expect(() => decodeUnsubscribeToken("ÿÿÿÿÿ")).toThrow();
});
it("throws when decoded body is not JSON", { timeout: 5000 }, () => {
const token = btoa("not json{");
expect(() => decodeUnsubscribeToken(token)).toThrow(/JSON/);
});
it("throws when payload lacks contactId", { timeout: 5000 }, () => {
const token = btoa(JSON.stringify({ campaignId: "cmp1" }));
expect(() => decodeUnsubscribeToken(token)).toThrow(/contactId/);
});
});

View File

@@ -0,0 +1,99 @@
/**
* Zod schemas for the `/webhooks` route tree. Webhooks ingest
* provider-side delivery events (one shape per provider, normalised to
* the genesis `DeliveryEvent` model) and a one-click unsubscribe URL
* driven by a base64-encoded JSON token.
*/
import z from "zod";
/** 10-member event-type enum mirroring `DeliveryEventType`. */
export const DeliveryEventTypeSchema = z.enum([
"sent",
"delivered",
"opened",
"clicked",
"bounced",
"unsubscribed",
"complained",
"deferred",
"dropped",
"read",
]);
/** Body for `POST /webhooks/delivery`. */
export const DeliveryWebhookSchema = z.object({
messageId: z.string().min(1),
eventType: DeliveryEventTypeSchema,
contactId: z.string().min(1),
campaignId: z.string().min(1).optional(),
channel: z.string().min(1).default("email"),
metadata: z.record(z.string(), z.unknown()).default({}),
timestamp: z.string().datetime().optional(),
});
/** Path param for `GET /webhooks/unsubscribe/:token`. */
export const UnsubscribeTokenParamSchema = z.object({
token: z.string().min(1),
});
/** Response shape for `POST /webhooks/delivery`. */
export const DeliveryWebhookResponseSchema = z.object({
success: z.literal(true),
data: z.object({
eventId: z.string(),
suppressed: z.boolean(),
}),
});
/** Response shape for `GET /webhooks/unsubscribe/:token`. */
export const UnsubscribeResponseSchema = z.object({
success: z.literal(true),
data: z.object({
contactId: z.string(),
campaignId: z.string().nullable(),
}),
});
export type DeliveryWebhookBody = z.infer<typeof DeliveryWebhookSchema>;
export type UnsubscribeTokenPayload = {
contactId: string;
campaignId?: string;
};
const UnsubscribeTokenPayloadSchema = z.object({
contactId: z.string().min(1),
campaignId: z.string().min(1).optional(),
});
/**
* Decode + validate an unsubscribe token. The token is a base64-encoded
* JSON object carrying `{ contactId, campaignId? }`. Returns the parsed
* payload or throws — callers wrap the throw in
* `CAMPAIGNS_API.INVALID_UNSUBSCRIBE_TOKEN`.
*/
export function decodeUnsubscribeToken(token: string): UnsubscribeTokenPayload {
let decoded: string;
try {
decoded = atob(token);
} catch (cause) {
throw new Error("token is not valid base64", { cause });
}
let parsed: unknown;
try {
parsed = JSON.parse(decoded);
} catch (cause) {
throw new Error("token did not decode to JSON", { cause });
}
const validated = UnsubscribeTokenPayloadSchema.safeParse(parsed);
if (!validated.success) {
throw new Error("token payload missing contactId");
}
const payload: UnsubscribeTokenPayload = {
contactId: validated.data.contactId,
};
if (validated.data.campaignId !== undefined) {
payload.campaignId = validated.data.campaignId;
}
return payload;
}

View File

@@ -0,0 +1,5 @@
export {
type CampaignsPluginOptionsInput,
type CampaignsPluginOptionsParsed,
CampaignsPluginOptionsSchema,
} from "./options.js";

View File

@@ -0,0 +1,86 @@
# `schemas/options`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `schemas/options` module owns the Zod validator that
`createCampaignsPlugin` runs against its caller-supplied options at
construction time. The schema doubles as the source of truth for default
values: each field declares `.default(...)` so a single `parse` call
produces a fully-resolved `CampaignsPluginConfig` regardless of which
fields the caller actually passed.
Routing every option through one schema rather than scattering defaults
across the plugin scaffold and route modules is what lets Chunks B and C
inherit the validated config shape without redoing the parse. Any
follow-on chunk that needs a new option adds the field here (with its
default) and the rest of the package picks it up via type inference.
## Features
- `CampaignsPluginOptionsSchema` — Zod object schema with default-bearing
fields for `routePrefix`, `maxCampaignsPerTenant`,
`maxRecipientsPerCampaign`, `defaultThrottlePerSecond`, and
`applySchemaOnInit`.
- Optional `logger` slot validated against the structural shape of a
`LoggerLike` (four function members).
- `CampaignsPluginOptionsInput` (input type) and
`CampaignsPluginOptionsParsed` (output type) inferred via `z.input` and
`z.output` for use at the package boundary.
## Usage: Basic
```ts
import { CampaignsPluginOptionsSchema } from "@suaveplan/api-plugin-campaigns";
const config = CampaignsPluginOptionsSchema.parse({});
// config.routePrefix === "/"
// config.maxCampaignsPerTenant === 10_000
```
## Usage: Advanced
```ts
import { CampaignsPluginOptionsSchema } from "@suaveplan/api-plugin-campaigns";
const parsed = CampaignsPluginOptionsSchema.safeParse({
routePrefix: "/api/v1",
maxCampaignsPerTenant: 50_000,
applySchemaOnInit: false,
});
if (!parsed.success) {
throw parsed.error;
}
// `parsed.data` is fully resolved with defaults applied to omitted
// fields and validations enforced on the supplied fields.
```
## API Reference
- `CampaignsPluginOptionsSchema: z.ZodObject<...>` — the validator.
- `CampaignsPluginOptionsInput` — input shape (all fields optional).
- `CampaignsPluginOptionsParsed` — output shape (all defaults applied).
- Validation rules:
- `routePrefix` — non-empty string starting with `/`.
- `maxCampaignsPerTenant` — positive integer.
- `maxRecipientsPerCampaign` — positive integer.
- `defaultThrottlePerSecond` — positive integer.
- `applySchemaOnInit` — boolean.
- `logger` — optional structural `LoggerLike`.
## Implementation Notes
The schema applies defaults rather than expecting upstream code to fill
them. That single-source-of-truth pattern matches the rest of the suaveplan
plugin family (`@suaveplan/api-plugin-contacts` follows the same shape)
and means the resolved config carried on the descriptor's `defaults`
slot is always fully populated.
`z.function()` is the Zod v4 spelling used to validate the four
`LoggerLike` members. Marking the field optional (rather than supplying
a default `LoggerLike`) is deliberate — the plugin's `onInit` will fall
back to `ctx.logger` when the option is omitted, so the schema only
needs to attest "if you supplied one, it has the right shape".

View File

@@ -0,0 +1,86 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CampaignsPluginOptionsSchema } from "./options.js";
describe("CampaignsPluginOptionsSchema", () => {
it("applies all defaults when given an empty object", {
timeout: 5000,
}, () => {
const parsed = CampaignsPluginOptionsSchema.parse({});
expect(parsed.routePrefix).toBe("/");
expect(parsed.maxCampaignsPerTenant).toBe(10_000);
expect(parsed.maxRecipientsPerCampaign).toBe(1_000_000);
expect(parsed.defaultThrottlePerSecond).toBe(100);
expect(parsed.applySchemaOnInit).toBe(true);
});
it("preserves a custom routePrefix", { timeout: 5000 }, () => {
const parsed = CampaignsPluginOptionsSchema.parse({
routePrefix: "/api/v1",
});
expect(parsed.routePrefix).toBe("/api/v1");
});
it("rejects routePrefix that does not start with '/'", {
timeout: 5000,
}, () => {
const result = CampaignsPluginOptionsSchema.safeParse({
routePrefix: "api",
});
expect(result.success).toBe(false);
});
it("rejects empty routePrefix", { timeout: 5000 }, () => {
const result = CampaignsPluginOptionsSchema.safeParse({
routePrefix: "",
});
expect(result.success).toBe(false);
});
it("rejects non-positive maxCampaignsPerTenant", { timeout: 5000 }, () => {
const result = CampaignsPluginOptionsSchema.safeParse({
maxCampaignsPerTenant: 0,
});
expect(result.success).toBe(false);
});
it("rejects non-integer maxRecipientsPerCampaign", {
timeout: 5000,
}, () => {
const result = CampaignsPluginOptionsSchema.safeParse({
maxRecipientsPerCampaign: 1.5,
});
expect(result.success).toBe(false);
});
it("rejects negative defaultThrottlePerSecond", { timeout: 5000 }, () => {
const result = CampaignsPluginOptionsSchema.safeParse({
defaultThrottlePerSecond: -1,
});
expect(result.success).toBe(false);
});
it("accepts applySchemaOnInit: false", { timeout: 5000 }, () => {
const parsed = CampaignsPluginOptionsSchema.parse({
applySchemaOnInit: false,
});
expect(parsed.applySchemaOnInit).toBe(false);
});
it("accepts a custom logger object", { timeout: 5000 }, () => {
const logger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
const parsed = CampaignsPluginOptionsSchema.parse({ logger });
// Zod's `z.function()` wraps the member functions, so identity
// is not preserved — assert the structural shape instead.
expect(parsed.logger).toBeDefined();
const out = parsed.logger as NonNullable<typeof parsed.logger>;
expect(typeof out.debug).toBe("function");
expect(typeof out.info).toBe("function");
expect(typeof out.warn).toBe("function");
expect(typeof out.error).toBe("function");
});
});

View File

@@ -0,0 +1,48 @@
import z from "zod";
/**
* Zod schema for `createCampaignsPlugin(options)`. All fields are
* optional at the input boundary; defaults are applied at parse time
* so downstream code reads a fully-resolved `CampaignsPluginConfig`
* regardless of which fields the caller supplied.
*/
export const CampaignsPluginOptionsSchema = z.object({
routePrefix: z
.string()
.min(1, "routePrefix must be non-empty")
.startsWith("/", "routePrefix must start with '/'")
.default("/"),
maxCampaignsPerTenant: z
.number()
.int("maxCampaignsPerTenant must be an integer")
.positive("maxCampaignsPerTenant must be > 0")
.default(10_000),
maxRecipientsPerCampaign: z
.number()
.int("maxRecipientsPerCampaign must be an integer")
.positive("maxRecipientsPerCampaign must be > 0")
.default(1_000_000),
defaultThrottlePerSecond: z
.number()
.int("defaultThrottlePerSecond must be an integer")
.positive("defaultThrottlePerSecond must be > 0")
.default(100),
applySchemaOnInit: z.boolean().default(true),
logger: z
.object({
debug: z.function(),
info: z.function(),
warn: z.function(),
error: z.function(),
})
.optional(),
});
/** Inferred input shape with all fields optional. */
export type CampaignsPluginOptionsInput = z.input<
typeof CampaignsPluginOptionsSchema
>;
/** Inferred output shape with defaults applied. */
export type CampaignsPluginOptionsParsed = z.output<
typeof CampaignsPluginOptionsSchema
>;

View File

@@ -0,0 +1 @@
import "./registry-augmentation.js";

View File

@@ -0,0 +1,89 @@
# `services/registry-augmentation`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `services/registry-augmentation` module owns the
`ServiceRegistry` declaration-merge augmentation applied by
`@suaveplan/api-plugin-campaigns`. Importing the module for its side
effects (which the package root barrel does automatically) reserves the
host-side service slots (`app`, `openAPIRegistry`, `logger`,
`eventBus`, `clock`) so the plugin's lifecycle hooks can resolve them
via `ctx.getService(...)` without a cast and without depending on
which other plugin happens to register the slot first.
The slots declared here are the minimum baseline that Chunk A needs.
Chunks B and C will extend the same interface with
`campaignRepository`, `campaignSuppressionStore`, and
`deliveryEventStore` slots once the repository classes exist; that
extension lands in a new module so this file stays untouched across
the chunked migration.
## Features
- Pure declaration-merge — zero runtime cost, zero bundle size.
- Re-exports an empty value (`export {}`) so the module is a true ESM
module and the `declare module` block applies.
- Type-only imports from `@suaveplan/api-openapi`,
`@hono/zod-openapi`, and `@suaveplan/types`.
## Usage: Basic
```ts
import "@suaveplan/api-plugin-campaigns";
import type { ServiceRegistry } from "@suaveplan/api-plugin/service-registry";
declare const reg: ServiceRegistry;
reg.app; // OpenAPIHono
reg.logger; // LoggerLike
```
## Usage: Advanced
```ts
import { createPluginContext } from "@suaveplan/api-plugin";
import "@suaveplan/api-plugin-campaigns";
const services = new Map<string, unknown>();
services.set("app", /* OpenAPIHono instance */ undefined);
services.set("openAPIRegistry", /* OpenAPIRegistry instance */ undefined);
const ctx = createPluginContext({
logger: console as never,
eventBus: { emit: () => undefined, on: () => () => undefined } as never,
signal: new AbortController().signal,
config: {} as never,
services,
});
const app = ctx.getService("app"); // typed as OpenAPIHono | undefined
```
## API Reference
Adds the following slots to `ServiceRegistry`:
- `app: OpenAPIHono`
- `openAPIRegistry: OpenAPIRegistry`
- `logger: LoggerLike`
- `eventBus: EventBusLike`
- `clock: Clock`
No runtime exports.
## Implementation Notes
The augmentation declares the slots as required, not optional. That is
the right shape even for plugins that consume only a subset of slots:
TypeScript declaration merging unions slots from every importer, so
declaring a slot here does not force every other plugin in the host to
register it — `ctx.getService(name)` always returns
`ServiceRegistry[K] | undefined` and the caller decides how to handle a
missing entry.
The empty `export {}` at the bottom is required for the file to count
as a module rather than a script; without it, the `declare module`
block silently no-ops. Chunks B and C augment the same registry from
their own modules, leveraging declaration merging.

View File

@@ -0,0 +1,38 @@
/**
* `ServiceRegistry` module augmentation for
* `@suaveplan/api-plugin-campaigns`. Importing this module for side
* effects (which the package root barrel does automatically) reserves
* the host-side slots (`app`, `db`, `openAPIRegistry`,
* `migrationRunner`, `logger`, `eventBus`, `clock`) so the plugin's
* `onInit` can resolve them with full typing even when this plugin is
* imported in isolation. Chunk C populates the repository slots
* (`campaignRepository`, `campaignSuppressionStore`,
* `deliveryEventStore`) at runtime via `ctx.registerService(...)`.
*/
import type { OpenAPIHono } from "@hono/zod-openapi";
import type { MigrationRunner } from "@suaveplan/api-migrations";
import type { OpenAPIRegistry } from "@suaveplan/api-openapi";
import type { CampaignStore } from "@suaveplan/campaigns";
import type { DeliveryEventStore } from "@suaveplan/delivery-tracking";
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import type { Clock, EventBusLike, LoggerLike } from "@suaveplan/types";
import type { CampaignSuppressionStore } from "../repositories/suppression/suppression.types.js";
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry {
app: OpenAPIHono;
openAPIRegistry: OpenAPIRegistry;
logger: LoggerLike;
eventBus: EventBusLike;
clock: Clock;
db?: SurrealAdapter;
migrationRunner?: MigrationRunner;
// Chunk C populates these at runtime via `ctx.registerService`.
// They remain optional so consumers can import the plugin in
// isolation (without an `onInit` having fired) and still type-check.
campaignRepository?: CampaignStore;
campaignSuppressionStore?: CampaignSuppressionStore;
deliveryEventStore?: DeliveryEventStore;
}
}

View File

@@ -0,0 +1 @@
export type { CampaignsPluginConfig, CampaignsPluginOptions } from "./types.js";

View File

@@ -0,0 +1,77 @@
# `types`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `types` module exports the public type bundle for
`@suaveplan/api-plugin-campaigns` — the option-input shape consumers pass
to `createCampaignsPlugin`, and the fully-resolved config shape stored on
the plugin descriptor's `defaults` slot. Keeping the two shapes distinct
matters because the input has every field optional (the Zod schema fills
defaults at parse time) while the resolved config has every required
field present, so the rest of the package can read it without nullish
chaining.
Hono `ContextVariableMap` augmentations and `ServiceRegistry`
augmentations live in their own `context/` and `services/` modules
respectively, so this file stays declaration-only. That separation is
important during the chunked migration: Chunk B and Chunk C will add
new repository-typed slots to the registry without touching the
public-API shapes exported here.
## Features
- `CampaignsPluginConfig` — fully-resolved, readonly config record.
- `CampaignsPluginOptions` — raw, all-optional input shape.
- Both interfaces are pure type declarations and ship zero runtime code.
## Usage: Basic
```ts
import type {
CampaignsPluginConfig,
CampaignsPluginOptions,
} from "@suaveplan/api-plugin-campaigns";
const opts: CampaignsPluginOptions = { routePrefix: "/api" };
```
## Usage: Advanced
```ts
import type { CampaignsPluginConfig } from "@suaveplan/api-plugin-campaigns";
function describeConfig(cfg: CampaignsPluginConfig): string {
return `mount=${cfg.routePrefix} cap=${cfg.maxCampaignsPerTenant}`;
}
```
## API Reference
- `interface CampaignsPluginConfig`
- `routePrefix: string` — route mount prefix.
- `maxCampaignsPerTenant: number` — soft cap on retained campaigns.
- `maxRecipientsPerCampaign: number` — recipient ceiling per campaign.
- `defaultThrottlePerSecond: number` — dispatch throttle ceiling.
- `applySchemaOnInit: boolean` — toggle for the bundled DDL apply
step (wired by Chunk B/C).
- `logger?: LoggerLike` — optional override logger.
- `interface CampaignsPluginOptions` — same fields, all optional.
## Implementation Notes
Both interfaces are `readonly` because once Zod has parsed the input,
the rest of the package treats the resulting config as immutable.
Marking the fields `readonly` at the type level catches the common
mistake of mutating the resolved config inside `onInit`. The logger
field's `?` modifier is preserved across both shapes so consumers can
spread one into the other without TypeScript inferring `undefined` into
a required slot.
The `applySchemaOnInit` toggle is part of the Chunk-A surface even
though no Chunk-A code path consumes it — exposing the field now means
Chunk B can wire it without bumping the public type signature. The
no-op `onInit` placeholder simply ignores the flag.

View File

@@ -0,0 +1,35 @@
/**
* Public type bundle for `@suaveplan/api-plugin-campaigns`. Only the
* config + descriptor shapes that consumers depend on at compile time
* live here. Hono `ContextVariableMap` augmentations live in
* `../context/context.ts`; ServiceRegistry augmentations live in
* `../services/registry-augmentation.ts`.
*/
import type { LoggerLike } from "@suaveplan/types";
/** Validated config carried by the descriptor's `defaults` slot. */
export interface CampaignsPluginConfig {
/** Route mount prefix (default `"/"`). */
readonly routePrefix: string;
/** Maximum number of campaigns retained per tenant (soft cap). */
readonly maxCampaignsPerTenant: number;
/** Maximum number of recipients accepted in a single campaign. */
readonly maxRecipientsPerCampaign: number;
/** Per-second send throttle ceiling applied at dispatch time. */
readonly defaultThrottlePerSecond: number;
/** When `true`, plugin `onInit` applies the bundled SurrealQL schema (Chunk B/C wire this). */
readonly applySchemaOnInit: boolean;
/** Optional override logger; falls back to `ctx.logger`. */
readonly logger?: LoggerLike;
}
/** Raw options accepted by `createCampaignsPlugin`. All fields optional. */
export interface CampaignsPluginOptions {
readonly routePrefix?: string;
readonly maxCampaignsPerTenant?: number;
readonly maxRecipientsPerCampaign?: number;
readonly defaultThrottlePerSecond?: number;
readonly applySchemaOnInit?: boolean;
readonly logger?: LoggerLike;
}

View File

@@ -0,0 +1,276 @@
/**
* End-to-end integration test for `@suaveplan/api-plugin-campaigns`.
*
* Wires the plugin's full route surface against in-memory
* repositories + a Hono `OpenAPIHono` host, then fires real HTTP
* requests via `app.request(...)` to exercise the happy-path round
* trips for every route group. No mocks — the in-memory stores are
* the genuine reference implementations.
*/
import { OpenAPIHono } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
import { type Campaign, InMemoryCampaignStore } from "@suaveplan/campaigns";
import { InMemoryDeliveryEventStore } from "@suaveplan/delivery-tracking";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
buildCampaignRoutes,
buildSuppressionRoutes,
buildWebhookRoutes,
CampaignsApiError,
InMemoryCampaignSuppressionStore,
} from "../../src/index.js";
function makeStack() {
const campaignStore = new InMemoryCampaignStore();
let nextId = 0;
const suppressionStore = new InMemoryCampaignSuppressionStore({
clock: () => 1_700_000_000_000,
idGenerator: () => `s${++nextId}`,
});
const deliveryEventStore = new InMemoryDeliveryEventStore();
const app = new OpenAPIHono();
app.use("*", async (c, next) => {
c.set("user", { id: "u1", tenantId: "t1" } as never);
c.set("campaignRepository", campaignStore);
c.set("campaignSuppressionStore", suppressionStore);
c.set("deliveryEventStore", deliveryEventStore);
c.set("eventBus", { emit: () => undefined } as never);
await next();
});
app.onError((err, c) => {
if (err instanceof CampaignsApiError) {
const status = ((err.context as { statusCode?: number } | undefined)
?.statusCode ?? 500) as 400 | 401 | 404 | 409 | 500;
return c.json({ code: err.code, error: err.message }, status);
}
return c.json({ error: String(err) }, 500);
});
const registry = createOpenAPIRegistry({
info: { title: "campaigns-test", version: "0.0.0" },
});
for (const entry of [
...buildCampaignRoutes(),
...buildSuppressionRoutes(),
...buildWebhookRoutes(),
]) {
const method = entry.route.method as
| "get"
| "post"
| "patch"
| "delete";
app[method](entry.route.path, (c) => entry.handler(c as never));
registry.registerRoute({
method: entry.route.method,
path: entry.route.path,
tags: entry.route.tags ?? [],
pluginName: "campaigns",
});
}
return {
app,
campaignStore,
suppressionStore,
deliveryEventStore,
registry,
};
}
describe("api-plugin-campaigns integration", () => {
it("rounds-trip CRUD: create, list, get, update, delete", {
timeout: 30_000,
}, async () => {
const { app } = makeStack();
const create = await app.request("/campaigns", {
method: "POST",
body: JSON.stringify({
name: "Promo",
channel: "email",
audienceConfig: { groupIds: ["g1"] },
}),
headers: { "content-type": "application/json" },
});
expect(create.status).toBe(201);
const created = await create.json();
const id = created.data.id;
expect(id).toBeTruthy();
const list = await app.request("/campaigns");
expect(list.status).toBe(200);
expect((await list.json()).data.total).toBe(1);
const get = await app.request(`/campaigns/${id}`);
expect(get.status).toBe(200);
expect((await get.json()).data.name).toBe("Promo");
const patch = await app.request(`/campaigns/${id}`, {
method: "PATCH",
body: JSON.stringify({ name: "Promo v2" }),
headers: { "content-type": "application/json" },
});
expect(patch.status).toBe(200);
expect((await patch.json()).data.name).toBe("Promo v2");
const del = await app.request(`/campaigns/${id}`, {
method: "DELETE",
});
expect(del.status).toBe(204);
});
it("rounds-trip action: send → pause", {
timeout: 30_000,
}, async () => {
const { app } = makeStack();
const created = await (
await app.request("/campaigns", {
method: "POST",
body: JSON.stringify({ name: "Promo" }),
headers: { "content-type": "application/json" },
})
).json();
const id = created.data.id;
const send = await app.request(`/campaigns/${id}/send`, {
method: "POST",
});
expect(send.status).toBe(200);
expect((await send.json()).data.status).toBe("sending");
const pause = await app.request(`/campaigns/${id}/pause`, {
method: "POST",
});
expect(pause.status).toBe(200);
expect((await pause.json()).data.status).toBe("paused");
});
it("rounds-trip suppression: create, check, list, delete", {
timeout: 30_000,
}, async () => {
const { app } = makeStack();
const created = await app.request("/suppression", {
method: "POST",
body: JSON.stringify({
channelValue: "a@b.c",
reason: "bounce",
}),
headers: { "content-type": "application/json" },
});
expect(created.status).toBe(201);
const entry = (await created.json()).data;
const check = await app.request(
"/suppression/check?channel=email&channelValue=a@b.c"
);
expect(check.status).toBe(200);
expect((await check.json()).data.suppressed).toBe(true);
const list = await app.request("/suppression");
expect((await list.json()).data.total).toBe(1);
const del = await app.request(`/suppression/${entry.id}`, {
method: "DELETE",
});
expect(del.status).toBe(204);
});
it("delivery webhook records event + suppresses on unsubscribe", {
timeout: 30_000,
}, async () => {
const { app, suppressionStore } = makeStack();
const res = await app.request("/webhooks/delivery", {
method: "POST",
body: JSON.stringify({
messageId: "m1",
eventType: "unsubscribed",
contactId: "u1",
campaignId: "cmp1",
}),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(200);
expect((await res.json()).data.suppressed).toBe(true);
expect(await suppressionStore.count("t1")).toBe(1);
});
it("unsubscribe token round-trip persists suppression", {
timeout: 30_000,
}, async () => {
const { app, suppressionStore } = makeStack();
const token = btoa(JSON.stringify({ contactId: "u1" }));
const res = await app.request(`/webhooks/unsubscribe/${token}`);
expect(res.status).toBe(200);
expect((await res.json()).data.contactId).toBe("u1");
expect(await suppressionStore.count("t1")).toBe(1);
});
it("campaign duplicate produces a draft clone", {
timeout: 30_000,
}, async () => {
const { app, campaignStore } = makeStack();
await campaignStore.create({
id: "cmp1",
tenantId: "t1",
name: "Promo",
status: "completed",
type: "broadcast",
channel: "email",
audienceConfig: { groupIds: [] },
metadata: {},
stats: {
totalRecipients: 0,
sent: 0,
delivered: 0,
opened: 0,
clicked: 0,
bounced: 0,
unsubscribed: 0,
complained: 0,
failed: 0,
},
createdAt: new Date(),
updatedAt: new Date(),
} as Campaign);
const res = await app.request("/campaigns/cmp1/duplicate", {
method: "POST",
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.data.status).toBe("draft");
expect(body.data.name).toBe("Promo (copy)");
});
it("test-send echoes recipients without persisting state", {
timeout: 30_000,
}, async () => {
const { app } = makeStack();
const created = await (
await app.request("/campaigns", {
method: "POST",
body: JSON.stringify({ name: "Promo" }),
headers: { "content-type": "application/json" },
})
).json();
const res = await app.request(`/campaigns/${created.data.id}/test`, {
method: "POST",
body: JSON.stringify({ recipients: ["a@b.com", "d@e.com"] }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(200);
expect((await res.json()).data.recipients).toEqual([
"a@b.com",
"d@e.com",
]);
});
it("OpenAPI registry contains every plugin route", {
timeout: 30_000,
}, () => {
const { registry } = makeStack();
const paths = registry.getRoutes().map((r) => r.path);
expect(paths).toContain("/campaigns");
expect(paths).toContain("/campaigns/:id/send");
expect(paths).toContain("/suppression/check");
expect(paths).toContain("/webhooks/delivery");
expect(paths).toContain("/webhooks/unsubscribe/:token");
});
});

View File

@@ -0,0 +1,33 @@
/**
* Test setup for @suaveplan/api-plugin-campaigns.
*
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any
* source module imports so the meter resolved on first call binds to
* it.
*/
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000,
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
export async function flushTestMetrics() {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View 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"]
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/api-plugin-campaigns",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
/^@suaveplan\//,
/^@opentelemetry\//,
/^@hono\//,
"hono",
"tslib",
"zod",
],
},
},
});

View File

@@ -0,0 +1,35 @@
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/**/*.bench.ts",
"src/index.ts",
"src/context/**",
"src/services/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 60000,
hookTimeout: 180000,
},
});