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-campaignsinvocation - 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_CODESis 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 freshMeterProvideris installed andresetCampaignsApiInstruments()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
CampaignsApiErrorcarrying codeCAMPAIGNS_API.INVALID_OPTIONSwith the originalZodErrorattached ascause.
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
onInitSHALL complete without registering repositories or routes, and that no-op behaviour SHALL be documented inplugin.mdas 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'stenantIdSHALL appear atparams[1].
Scenario: Adapter-error wrapping
- GIVEN the adapter throws on a SELECT
- WHEN
getById("c1")runs - THEN the call SHALL throw
CampaignsApiErrorcarryingCAMPAIGNS_API.DB_UNAVAILABLEwith the original error attached ascause.
Scenario: Missing-row semantics for updateStats
- GIVEN the SurrealDB UPDATE returns no rows
- WHEN
updateStats(id, stats)runs - THEN the call SHALL throw
CampaignsApiErrorcarryingCAMPAIGNS_API.CAMPAIGN_NOT_FOUNDrather than a genericError.
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
CampaignsApiErrorcarryingCAMPAIGNS_API.DUPLICATE_SUPPRESSION, AND a subsequentadd({ 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 withtenantId === "" - THEN the call SHALL throw
CampaignsApiErrorcarryingCAMPAIGNS_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, ANDObject.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/sendruns - 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
tissuesGET /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.comruns - 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/deliverywith body{ messageId: "m1", eventType: "unsubscribed", contactId: "u1", channel: "email" } - WHEN the request completes
- THEN the response SHALL be HTTP 200 with
data.suppressed === true, ANDcampaignSuppressionStore.check("t", "email", "u1")SHALL return a non-null entry withreason === "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
appregistered but nodbslot - WHEN the campaigns plugin's
onInitruns - THEN the call SHALL throw
CampaignsApiErrorcarryingCAMPAIGNS_API.SERVICE_NOT_AVAILABLEwithcontext.missing === ["db"].
Scenario: Route-prefix collision
- GIVEN an
OpenAPIRegistryalready carrying routes under/campaigns - WHEN the campaigns plugin's
onInitruns against that registry - THEN the call SHALL throw
CampaignsApiErrorcarryingCAMPAIGNS_API.ROUTE_PREFIX_COLLISION.