# `api-plugin-campaigns` capability ## ADDED Requirements ### Requirement: Package shape and tier The package `@suaveplan/api-plugin-campaigns` SHALL be a server-tier Hono + OpenAPI plugin published under the genesis monorepo at `packages/api-stack/api/api-plugin-campaigns/`, version `0.1.0`, `"sideEffects": false`. #### Scenario: Workspace discovery - **GIVEN** a `bunx turbo --filter=@suaveplan/api-plugin-campaigns` invocation - **THEN** the workspace SHALL resolve to `packages/api-stack/api/api-plugin-campaigns/`. ### Requirement: Error namespace `CAMPAIGNS_API.*` The package SHALL export a `CAMPAIGNS_API_CODES` registry built via `defineCodes` from `@suaveplan/error`, and a `CampaignsApiError` subclass of `SuaveplanError` with an explicit public constructor delegating to `super`. #### Scenario: Code namespacing - **WHEN** any entry of `CAMPAIGNS_API_CODES` is read - **THEN** the value SHALL begin with the prefix `"CAMPAIGNS_API."`. ### Requirement: OTel instrument bundle The package SHALL bind an OpenTelemetry meter under `"@suaveplan/api-plugin-campaigns"` exposing at minimum a request-count counter and a request-duration histogram, with a cached factory function and a reset hook for tests. #### Scenario: Lazy binding - **WHEN** `getCampaignsApiInstruments()` is called after a fresh `MeterProvider` is installed and `resetCampaignsApiInstruments()` has been invoked - **THEN** the returned bundle SHALL be bound to the new provider. ### Requirement: Plugin factory The package SHALL export `createCampaignsPlugin(options)` returning a `PluginDescriptor` from `@suaveplan/api-plugin` with `name === "campaigns"`, `version === "0.1.0"`, and `dependencies` including at minimum `"database"`, `"auth"`, and `"contacts"`. #### Scenario: Option validation - **WHEN** `createCampaignsPlugin(options)` is called with options that fail the Zod options schema - **THEN** the function SHALL throw `CampaignsApiError` carrying code `CAMPAIGNS_API.INVALID_OPTIONS` with the original `ZodError` attached as `cause`. ### Requirement: Chunked migration discipline The migration SHALL ship in three chunks (foundation A, repositories B, routes C). At every chunk boundary the package SHALL pass lint, typecheck, test, and 100/100/100/100 coverage on the modules shipped to date. #### Scenario: Chunk-A `onInit` is a no-op placeholder - **GIVEN** the Chunk-A milestone - **WHEN** the plugin is registered on a host - **THEN** `onInit` SHALL complete without registering repositories or routes, and that no-op behaviour SHALL be documented in `plugin.md` as the Chunk-A-correct state. ### Requirement: Campaign repository (Chunk B) The package SHALL export a `SurrealCampaignRepository` class implementing the `CampaignStore` contract from `@suaveplan/campaigns`. The repository SHALL issue every database call through `SurrealAdapter.query(sql, params)` using positional `$p0`/`$p1`/... placeholders per the genesis Surreal idiom, and SHALL wrap every adapter exception in `CampaignsApiError` with one of `CAMPAIGNS_API.DB_UNAVAILABLE`, `CAMPAIGNS_API.CAMPAIGN_NOT_FOUND`, or `CAMPAIGNS_API.INVALID_INPUT`. **Verified by:** `packages/api-stack/api/api-plugin-campaigns/src/repositories/campaign/campaign.test.ts` (24 cases covering INSERT, SELECT-by-id, UPDATE, DELETE, listing with paging, `updateStats` MERGE, `updateStatus` SET, schema validation, custom table overrides, and adapter-error wrapping). #### Scenario: Positional SurQL binding - **WHEN** `SurrealCampaignRepository.create(campaign)` runs - **THEN** the resulting SurQL SHALL contain `INSERT INTO campaign`, the placeholder list SHALL use `$p0`/`$p1`/... in registration order, and the campaign's `tenantId` SHALL appear at `params[1]`. #### Scenario: Adapter-error wrapping - **GIVEN** the adapter throws on a SELECT - **WHEN** `getById("c1")` runs - **THEN** the call SHALL throw `CampaignsApiError` carrying `CAMPAIGNS_API.DB_UNAVAILABLE` with the original error attached as `cause`. #### Scenario: Missing-row semantics for updateStats - **GIVEN** the SurrealDB UPDATE returns no rows - **WHEN** `updateStats(id, stats)` runs - **THEN** the call SHALL throw `CampaignsApiError` carrying `CAMPAIGNS_API.CAMPAIGN_NOT_FOUND` rather than a generic `Error`. ### Requirement: Campaign suppression store (Chunk B) The package SHALL export a local `CampaignSuppressionStore` interface and a `SurrealCampaignSuppressionRepository` implementation, plus a reference `InMemoryCampaignSuppressionStore`. The local interface SHALL be tenant + channel scoped — the `(tenantId, channel, channelValue)` triple is the natural primary key — and the package SHALL NOT depend on `@suaveplan/suppression`, which is email-only and therefore not a fit for the campaigns plugin's multi-channel domain. **Verified by:** `packages/api-stack/api/api-plugin-campaigns/src/repositories/suppression/suppression.test.ts` (SurrealDB implementation) and `packages/api-stack/api/api-plugin-campaigns/src/repositories/suppression/suppression-memory.test.ts` (in-memory reference) — together covering `add`, `remove`, `check`, `list`, `count`, `removeByChannelValue`, duplicate detection, tenant guards, and schema validation across both backends. #### Scenario: Channel-scoped duplicate detection - **GIVEN** an entry exists for `(t1, email, user@example.com)` - **WHEN** `add({ tenantId: "t1", channel: "email", channelValue: "user@example.com", reason: "bounce" })` runs - **THEN** the call SHALL throw `CampaignsApiError` carrying `CAMPAIGNS_API.DUPLICATE_SUPPRESSION`, AND a subsequent `add({ tenantId: "t1", channel: "sms", channelValue: "user@example.com", reason: "bounce" })` SHALL succeed (different channel, same value). #### Scenario: Empty tenant rejection - **WHEN** any read method (`check`, `list`, `count`, `removeByChannelValue`) is invoked with `tenantId === ""` - **THEN** the call SHALL throw `CampaignsApiError` carrying `CAMPAIGNS_API.TENANT_REQUIRED`. ### Requirement: Error namespace extension (Chunk B) The Chunk-B repositories SHALL extend `CAMPAIGNS_API_CODES` additively with six new codes: `CAMPAIGN_NOT_FOUND`, `SUPPRESSION_NOT_FOUND`, `INVALID_INPUT`, `DB_UNAVAILABLE`, `DUPLICATE_SUPPRESSION`, `TENANT_REQUIRED`. The registry MUST remain a single `defineCodes` call — no parallel namespace. **Verified by:** `packages/api-stack/api/api-plugin-campaigns/src/error-codes/error-codes.test.ts` asserts the full code key set and that every new code begins with `"CAMPAIGNS_API."`. #### Scenario: Single registry, additive extension - **WHEN** any new code is added in Chunks B or C - **THEN** the code SHALL appear inside the existing `defineCodes("CAMPAIGNS_API", [...])` call, AND `Object.keys(CAMPAIGNS_API_CODES)` SHALL equal the union of every registered key — no second registry exists. ### Requirement: Campaign route surface (Chunk C) The package SHALL expose a `buildCampaignRoutes` aggregator returning a readonly array of `PluginRouteEntry` values mounting the 12 canonical `/campaigns/*` endpoints (list, create, get, update, delete, send, pause, cancel, duplicate, test, stats, events) in a deterministic, documented order. Every CRUD handler SHALL enforce tenant scoping through `requireTenant`, cross-tenant lookups SHALL surface as `CAMPAIGNS_API.CAMPAIGN_NOT_FOUND` (HTTP 404), and Zod validation failures on request bodies SHALL surface as `CAMPAIGNS_API.INVALID_INPUT` (HTTP 400). Action routes (`/send`, `/pause`, `/cancel`) SHALL drive transitions through `CampaignStateMachine` from `@suaveplan/campaigns`; invalid transitions SHALL surface as `CAMPAIGNS_API.INVALID_STATE_TRANSITION` (HTTP 409). Reporting routes (`/stats`, `/events`) SHALL consume the `deliveryEventStore` context slot. **Verified by:** `packages/api-stack/api/api-plugin-campaigns/src/routes/campaigns/crud-routes.test.ts`, `packages/api-stack/api/api-plugin-campaigns/src/routes/campaigns/action-routes.test.ts`, `packages/api-stack/api/api-plugin-campaigns/src/routes/campaigns/build-routes.test.ts`, `packages/api-stack/api/api-plugin-campaigns/src/routes/campaigns/schemas.test.ts` (80+ cases together covering happy paths, 401/404/409/400/500 surfaces, state-machine transitions, duplicate semantics, delivery-event reporting, and canonical-order assertions). #### Scenario: Invalid state transition - **GIVEN** a campaign in status `completed` - **WHEN** `POST /campaigns/:id/send` runs - **THEN** the response SHALL be HTTP 409 carrying body `{ code: "CAMPAIGNS_API.INVALID_STATE_TRANSITION", ... }`. #### Scenario: Cross-tenant lookup leak prevention - **GIVEN** a campaign owned by tenant `other` - **WHEN** the caller authenticated as tenant `t` issues `GET /campaigns/:id` - **THEN** the response SHALL be HTTP 404 carrying body `{ code: "CAMPAIGNS_API.CAMPAIGN_NOT_FOUND", ... }` — identical to the response for a non-existent id, so no information about cross-tenant campaigns leaks. ### Requirement: Suppression route surface (Chunk C) The package SHALL expose a `buildSuppressionRoutes` aggregator returning the 4 canonical `/suppression/*` endpoints in canonical order (list, check, create, delete). Every handler SHALL enforce `requireTenant` before touching the suppression store. The `GET /suppression/check` endpoint SHALL accept `channel` and `channelValue` as required query parameters and return `{ suppressed: boolean, entry: ... | null }` so sender pipelines can short-circuit deliveries with a single read. `DELETE /suppression/:id` SHALL return HTTP 404 carrying `CAMPAIGNS_API.SUPPRESSION_NOT_FOUND` when the entry does not exist. **Verified by:** `packages/api-stack/api/api-plugin-campaigns/src/routes/suppression/routes.test.ts`, `packages/api-stack/api/api-plugin-campaigns/src/routes/suppression/schemas.test.ts`, `packages/api-stack/api/api-plugin-campaigns/src/routes/suppression/build-routes.test.ts` (20+ cases covering list/check/add/delete happy paths, 401/400/404/500 surfaces, and canonical-order assertions). #### Scenario: Point-lookup suppression check - **GIVEN** an entry exists for `(t1, email, user@example.com)` - **WHEN** `GET /suppression/check?channel=email&channelValue=user@example.com` runs - **THEN** the response SHALL be HTTP 200 with body `{ data: { suppressed: true, entry: { ... } } }`. ### Requirement: Webhook route surface (Chunk C) The package SHALL expose a `buildWebhookRoutes` aggregator returning the 2 canonical `/webhooks/*` endpoints (delivery ingest and unsubscribe token decoder). `POST /webhooks/delivery` SHALL persist the event via the `deliveryEventStore` and additionally insert a `CampaignSuppressionEntry` when the event type is `unsubscribed`, `bounced`, or `complained`. The fan-out SHALL be idempotent — a duplicate event SHALL NOT raise `DUPLICATE_SUPPRESSION` at the route layer. Zod-validation failure on the delivery body SHALL surface as `CAMPAIGNS_API.WEBHOOK_REJECTED`; token decode failure on the unsubscribe path SHALL surface as `CAMPAIGNS_API.INVALID_UNSUBSCRIBE_TOKEN`. **Verified by:** `packages/api-stack/api/api-plugin-campaigns/src/routes/webhooks/routes.test.ts`, `packages/api-stack/api/api-plugin-campaigns/src/routes/webhooks/schemas.test.ts`, `packages/api-stack/api/api-plugin-campaigns/src/routes/webhooks/build-routes.test.ts` (20+ cases covering the three negative-event paths, idempotent retries, token-decode failure modes, and registry-less fallback). #### Scenario: Negative-event auto-suppression - **GIVEN** a `POST /webhooks/delivery` with body `{ messageId: "m1", eventType: "unsubscribed", contactId: "u1", channel: "email" }` - **WHEN** the request completes - **THEN** the response SHALL be HTTP 200 with `data.suppressed === true`, AND `campaignSuppressionStore.check("t", "email", "u1")` SHALL return a non-null entry with `reason === "unsubscribe"`. ### Requirement: Plugin onInit wiring (Chunk C) The plugin's `onInit` SHALL resolve `app` and `db` from the host `ServiceRegistry`, construct the SurrealDB-backed `CampaignStore`, `CampaignSuppressionStore`, and `DeliveryEventStore` implementations, register them on the registry plus the Hono `ContextVariableMap` via a single mount-pattern middleware, and mount every route through `definePluginRoutes` when an `openAPIRegistry` slot is available. Missing `app` or `db` slots SHALL raise `CAMPAIGNS_API.SERVICE_NOT_AVAILABLE`. A registered registry with pre-existing routes under the configured prefix SHALL raise `CAMPAIGNS_API.ROUTE_PREFIX_COLLISION`. **Verified by:** `packages/api-stack/api/api-plugin-campaigns/src/plugin/plugin.test.ts` (18 cases covering happy-path onInit, both SERVICE_NOT_AVAILABLE branches, registry warning fallback, ROUTE_PREFIX_COLLISION re-run, custom-prefix routing, and idGenerator/clock default exercise) and `packages/api-stack/api/api-plugin-campaigns/tests/integration/plugin-integration.test.ts` (8 end-to-end cases against a real `OpenAPIHono` host with in-memory repositories — no mocks). #### Scenario: Missing db service - **GIVEN** a host with `app` registered but no `db` slot - **WHEN** the campaigns plugin's `onInit` runs - **THEN** the call SHALL throw `CampaignsApiError` carrying `CAMPAIGNS_API.SERVICE_NOT_AVAILABLE` with `context.missing === ["db"]`. #### Scenario: Route-prefix collision - **GIVEN** an `OpenAPIRegistry` already carrying routes under `/campaigns` - **WHEN** the campaigns plugin's `onInit` runs against that registry - **THEN** the call SHALL throw `CampaignsApiError` carrying `CAMPAIGNS_API.ROUTE_PREFIX_COLLISION`.