Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

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).