Initial Commit
This commit is contained in:
298
openspec/specs/api-plugin-campaigns/spec.md
Normal file
298
openspec/specs/api-plugin-campaigns/spec.md
Normal file
@@ -0,0 +1,298 @@
|
||||
# `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<T>(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`.
|
||||
Reference in New Issue
Block a user