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:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
Reference in New Issue
Block a user