first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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.