Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
# Migrate `@suaveplan/api-plugin-campaigns` (chunked: A → B → C)
## Why
`@suaveplan/api-plugin-campaigns` mounts the campaigns / suppression / webhooks
HTTP surface (consumed by `@suaveplan/campaigns`, `@suaveplan/delivery-tracking`,
and a per-tenant `CampaignSuppressionStore`) onto the genesis API host. Two
prior single-agent migration attempts halted at the size of the full port, so
this change ships the package in three sequential, independently-greenable
chunks. Each chunk passes lint/typecheck/test/coverage on its own subset.
## What changes
Add the package `@suaveplan/api-plugin-campaigns@0.1.0` under
`packages/api-stack/api/api-plugin-campaigns/`, server-tier, sharing the shape
of the genesis-already-shipped `@suaveplan/api-plugin-contacts`.
- **Chunk A (this change body)** — foundation: package shell + vite/vitest/ts
configs, error-code namespace + `CampaignsApiError`, OTel instrument bundle,
Zod options schema, shared option/config types, plugin scaffold via
`definePlugin` from `@suaveplan/api-plugin` with a no-op `onInit` (B/C wire
the real lifecycle), package barrel.
- **Chunk B (follow-up)** — repositories: `SurrealCampaignRepository`,
`CampaignSuppressionStore` interface + `SurrealCampaignSuppressionStore`,
consumption of `@suaveplan/delivery-tracking`'s
`SurrealDeliveryEventStore`, schema DDL bundle, repository registration
inside `onInit`.
- **Chunk C (follow-up)** — routes: `/campaigns` CRUD + lifecycle endpoints,
`/suppression` list/add/remove, `/webhooks/:provider` ingestion, full Hono
route wiring with OpenAPI registry, integration tests against the SurrealDB
testcontainer.
## Impact
- **Affected specs**: new capability `api-plugin-campaigns`.
- **Affected code**: new package only.
- **Dependencies pulled**: `@suaveplan/api-plugin`, `@suaveplan/api-openapi`,
`@suaveplan/api-plugin-auth`, `@suaveplan/api-plugin-database`,
`@suaveplan/api-plugin-contacts`, `@suaveplan/campaigns`,
`@suaveplan/contacts`, `@suaveplan/delivery-tracking`,
`@suaveplan/surrealdb`, `@suaveplan/events`, `@suaveplan/error`,
`@suaveplan/types`, `@suaveplan/logger`, `@opentelemetry/api`, `hono`,
`@hono/zod-openapi`, `tslib`, `zod`.
- **Quality gates per chunk**: `bunx turbo lint typecheck test --filter` green
and `bunx turbo test:coverage --filter` 100/100/100/100 on the modules
shipped in that chunk.
## Why chunked
Two prior single-agent attempts halted at the full-package size. Splitting at
the dependency boundaries (foundation ↔ data ↔ routes) keeps each chunk under
the limit while leaving every intermediate state green.

View 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`.

View File

@@ -0,0 +1,98 @@
# Tasks — `@suaveplan/api-plugin-campaigns`
## §0 Pre-flight (every dispatched agent, every chunk)
- [x] `pwd && git worktree list && git status` — confirm we are inside
`.claude/worktrees/agent-*` and NOT in `/home/paul/web/genesis/` directly.
- [x] §0a Sync — fast-forward worktree to the orchestrator-supplied
`EXPECTED_BASE` SHA; STOP if `git merge-base --is-ancestor` returns
non-zero.
## §A Chunk A — foundation (this dispatch)
- [x] A.1 Package shell — `package.json` (`@suaveplan/api-plugin-campaigns@0.1.0`,
`"sideEffects": false`, subpath exports per feature folder), `vite.config.ts`,
`vitest.config.ts`, `tsconfig.json` + `tsconfig.lib.json`, `tests/setup/polyfills.ts`,
`README.md`.
- [x] A.2 `src/error-codes/` — `CAMPAIGNS_API_CODES` (Chunk-A subset of the registered
codes; B/C extend) + `CampaignsApiError` with explicit public constructor.
- [x] A.3 `src/otel/` — meter binding + counter (`campaigns_api.requests_total`) +
histogram (`campaigns_api.request_duration_ms`); cached + resettable.
- [x] A.4 `src/types/` — `CampaignsPluginOptions`, `CampaignsPluginConfig`.
- [x] A.5 `src/schemas/` — `CampaignsPluginOptionsSchema` Zod validator.
- [x] A.6 `src/context/` + `src/services/` — Hono `ContextVariableMap` and
`ServiceRegistry` declaration-merge augmentations (Chunk-A baseline; B will
add repo slots).
- [x] A.7 `src/plugin/` — `createCampaignsPlugin` via
`definePlugin` from `@suaveplan/api-plugin`. `onInit` is a no-op
Chunk-A-correct placeholder; documented in `plugin.md`.
- [x] A.8 `src/index.ts` — explicit named re-exports for Chunk-A surface.
- [x] A.9 Per-file `.md` siblings with the six required sections.
- [x] A.10 Per-file `.test.ts` siblings; runner imports from
`@suaveplan/testing/runner` only.
- [x] A.11 Verification gate green:
`bunx turbo lint typecheck test --filter=@suaveplan/api-plugin-campaigns`,
`bunx turbo test:coverage --filter=...` 100/100/100/100,
`bunx turbo build --filter=...`, feature-layout check empty.
- [x] A.12 `bun scripts/folder-reorg/generate-manifest.ts` re-runs cleanly.
## §B Chunk B — repositories (follow-up)
- [x] B.1 `src/repositories/campaign/` — `SurrealCampaignRepository`
implementing `CampaignStore` from `@suaveplan/campaigns`; uses positional
`$p0` placeholders against the genesis `SurrealAdapter`. The package
also re-exports `InMemoryCampaignStore` from `@suaveplan/campaigns`
as the in-memory reference implementation.
- [x] B.2 `src/repositories/suppression/` — local `CampaignSuppressionStore`
interface + `SurrealCampaignSuppressionRepository` + reference
`InMemoryCampaignSuppressionStore` (tenant + channel shape; NOT a
wrapper over `@suaveplan/suppression`, which is email-only).
- [ ] B.3 `src/repositories/delivery-events/` — wrapper that consumes the
already-shipped `SurrealDeliveryEventStore` from
`@suaveplan/delivery-tracking`. (Out of scope for this dispatch per
orchestrator's Chunk-B scope; rolls into a follow-up.)
- [x] B.4 Extended `CAMPAIGNS_API_CODES` registry — added six codes:
`CAMPAIGN_NOT_FOUND`, `SUPPRESSION_NOT_FOUND`, `INVALID_INPUT`,
`DB_UNAVAILABLE`, `DUPLICATE_SUPPRESSION`, `TENANT_REQUIRED`.
- [ ] B.5 Bundled SurrealQL DDL strings. (Deferred to Chunk C; plugin's
`applySchemaOnInit` is wired there.)
- [ ] B.6 Plugin `onInit` registers the repositories on the
`ServiceRegistry` and Hono `ContextVariableMap`. (Deferred to Chunk C
which owns the lifecycle; service-registry slots are reserved here
as optional fields.)
- [x] B.7 100/100/100/100 coverage on the full package (Chunk A + Chunk B
modules); verified via `bunx turbo test:coverage`.
## §C Chunk C — routes (this dispatch)
- [x] C.1 `src/routes/campaigns/` — 12 routes total: list/create/get/update/delete
plus send/pause/cancel/duplicate/test action routes and stats/events
reporting routes. State guard uses `CampaignStateMachine` from
`@suaveplan/campaigns`; invalid transitions raise
`CAMPAIGNS_API.INVALID_STATE_TRANSITION` (HTTP 409).
- [x] C.2 `src/routes/suppression/` — 4 routes: list/check/add/remove with
tenant scoping and channel-keyed dedup.
- [x] C.3 `src/routes/webhooks/` — 2 routes: provider delivery-event ingest
(auto-suppress on unsubscribed/bounced/complained) and base64-JSON
one-click unsubscribe with `INVALID_UNSUBSCRIBE_TOKEN` mapping.
- [x] C.4 Plugin `onInit` resolves `app` + `db` + `openAPIRegistry` from the
`ServiceRegistry`, constructs `SurrealCampaignRepository`,
`SurrealCampaignSuppressionRepository`, and `SurrealDeliveryEventStore`,
injects them on the Hono `ContextVariableMap`, and mounts every route
via `definePluginRoutes`. Collision guard raises
`CAMPAIGNS_API.ROUTE_PREFIX_COLLISION`. Missing-service guard raises
`CAMPAIGNS_API.SERVICE_NOT_AVAILABLE`.
- [x] C.5 Integration tests in `tests/integration/plugin-integration.test.ts`
against a real `OpenAPIHono` host with in-memory repositories; 8
happy-path round-trips covering every route group.
- [x] C.6 100/100/100/100 coverage on the full package (Chunks A + B + C).
- [x] C.7 Extended `CAMPAIGNS_API_CODES` with five Chunk-C codes:
`INVALID_STATE_TRANSITION`, `INVALID_UNSUBSCRIBE_TOKEN`,
`ROUTE_PREFIX_COLLISION`, `WEBHOOK_REJECTED`, `SERVICE_NOT_AVAILABLE`.
## Post-C orchestrator steps (NOT a chunk)
- [ ] Fold the spec delta into `openspec/specs/api-plugin-capaigns/spec.md`.
- [ ] Move `openspec/changes/api-plugin-campaigns/` to
`openspec/archive/<yyyy-mm-dd>-api-plugin-campaigns/`.
- [ ] Update `MANIFEST.md` and `CLAUDE.md` package roster.