Files
suaveplan/openspec/specs/api-plugin-campaigns/spec.md
2026-05-27 23:29:53 +01:00

13 KiB

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.