first commit
This commit is contained in:
68
docs/api-stack/api/api-plugin-campaigns/README.md
Normal file
68
docs/api-stack/api/api-plugin-campaigns/README.md
Normal 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
101
docs/api-stack/api/api-plugin-campaigns/src/plugin/plugin.md
Normal file
101
docs/api-stack/api/api-plugin-campaigns/src/plugin/plugin.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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".
|
||||
@@ -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.
|
||||
77
docs/api-stack/api/api-plugin-campaigns/src/types/types.md
Normal file
77
docs/api-stack/api/api-plugin-campaigns/src/types/types.md
Normal 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.
|
||||
Reference in New Issue
Block a user