Initial Commit
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
# `context`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `context` module owns the Hono `ContextVariableMap` declaration-merge
|
||||
augmentation applied by `@suaveplan/api-plugin-campaigns`. Importing the
|
||||
module for its side effects (which the package root barrel does
|
||||
automatically) adds the typed slots the plugin's route handlers read with
|
||||
`c.get(...)`, eliminating the `as any` casts that pile up in untyped Hono
|
||||
middleware chains.
|
||||
|
||||
In Chunk A the only slot the plugin commits to is `eventBus`, because
|
||||
that is the only context entry the no-op `onInit` placeholder would set if
|
||||
it had a body. Chunks B and C will extend the declaration with the
|
||||
repository handles (`campaignRepository`, `campaignSuppressionStore`,
|
||||
`deliveryEventStore`) once those classes exist; consumers do not need to
|
||||
re-import this module to pick the extensions up.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure declaration-merge — zero runtime cost, zero bundle size.
|
||||
- Re-exports an empty value (`export {}`) so the module participates in
|
||||
ES-module side-effect ordering without polluting the consumer's
|
||||
namespace.
|
||||
- Slot type drawn from `@suaveplan/types` so the augmentation does not
|
||||
drag in the api-plugin-campaigns implementation surface.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { Hono } from "hono";
|
||||
import "@suaveplan/api-plugin-campaigns"; // also pulls this module via the root barrel
|
||||
|
||||
const app = new Hono();
|
||||
app.get("/", (c) => {
|
||||
const bus = c.get("eventBus");
|
||||
bus.emit("campaign.read", { id: "x" });
|
||||
return c.text("ok");
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import "@suaveplan/api-plugin-campaigns/plugin/plugin"; // ensures the augmentation
|
||||
import type { ContextVariableMap } from "hono";
|
||||
|
||||
type EventBus = ContextVariableMap["eventBus"];
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- Adds `eventBus: EventBusLike` to Hono `ContextVariableMap`.
|
||||
- No runtime exports.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Module augmentations must live in a file with at least one `export`
|
||||
statement to be picked up as a module rather than a script. The empty
|
||||
`export {}` at the bottom is the standard idiom; removing it would
|
||||
silently demote the file to a script and the `declare module "hono"`
|
||||
block would stop applying.
|
||||
|
||||
Chunks B and C augment the same `ContextVariableMap` interface from
|
||||
their own context modules, relying on TypeScript declaration merging to
|
||||
union the slots together. That means agents working on later chunks
|
||||
never edit this file — they add new files with their own
|
||||
`declare module "hono"` blocks.
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Hono `ContextVariableMap` module augmentation for
|
||||
* `@suaveplan/api-plugin-campaigns`. Importing this module for side
|
||||
* effects (which the package root barrel does automatically) reserves
|
||||
* the slots the plugin's `onInit` middleware populates on every Hono
|
||||
* request so route handlers can read them via `c.get(...)` with full
|
||||
* typing.
|
||||
*
|
||||
* Chunk C adds the three repository slots (`campaignRepository`,
|
||||
* `campaignSuppressionStore`, `deliveryEventStore`) alongside the
|
||||
* `eventBus` baseline declared in Chunk A. Slots are typed as the
|
||||
* structural store interfaces — `CampaignStore`,
|
||||
* `CampaignSuppressionStore`, `DeliveryEventStore` — so route handlers
|
||||
* stay agnostic of the concrete (in-memory vs. SurrealDB) backend.
|
||||
*/
|
||||
|
||||
import type { CampaignStore } from "@suaveplan/campaigns";
|
||||
import type { DeliveryEventStore } from "@suaveplan/delivery-tracking";
|
||||
import type { EventBusLike } from "@suaveplan/types";
|
||||
import type { CampaignSuppressionStore } from "../repositories/suppression/suppression.types.js";
|
||||
|
||||
declare module "hono" {
|
||||
interface ContextVariableMap {
|
||||
eventBus: EventBusLike;
|
||||
campaignRepository: CampaignStore;
|
||||
campaignSuppressionStore: CampaignSuppressionStore;
|
||||
deliveryEventStore: DeliveryEventStore;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import "./context.js";
|
||||
@@ -0,0 +1,102 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The error-codes module owns the stable `CAMPAIGNS_API.*` namespace used by
|
||||
every failure path inside `@suaveplan/api-plugin-campaigns`. Consumers
|
||||
branch on the string `code` value (rather than `instanceof` checks against
|
||||
private subclasses) so the namespace doubles as both an internal vocabulary
|
||||
and a public-API contract surface.
|
||||
|
||||
This is the foundation module for the chunked migration. Chunk A registers
|
||||
only the codes that the foundation surface can actually throw; Chunks B
|
||||
(repositories) and C (routes) extend the same `defineCodes` call rather than
|
||||
introducing parallel namespaces. The intentional minimalism keeps coverage
|
||||
at 100% across the package boundary at every chunk milestone.
|
||||
|
||||
## Features
|
||||
|
||||
- `CAMPAIGNS_API_CODES` — frozen `defineCodes` registry mapping reason names
|
||||
to `"CAMPAIGNS_API.<REASON>"` string values.
|
||||
- `CampaignsApiError` — concrete subclass of `SuaveplanError` with an
|
||||
explicit public constructor delegating to `super`, plus a typed
|
||||
`campaignsApiCode` field that narrows to `CampaignsApiErrorCode`.
|
||||
- `CampaignsApiErrorCode` — discriminated string-literal union of every
|
||||
registered code.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
CAMPAIGNS_API_CODES,
|
||||
CampaignsApiError,
|
||||
} from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
try {
|
||||
createCampaignsPlugin({ routePrefix: 42 as never });
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof CampaignsApiError &&
|
||||
err.code === CAMPAIGNS_API_CODES.INVALID_OPTIONS
|
||||
) {
|
||||
// The ZodError is attached as `cause`.
|
||||
console.error(err.cause);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
CAMPAIGNS_API_CODES,
|
||||
CampaignsApiError,
|
||||
} from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
function rethrowAsCampaigns(cause: unknown): never {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.INTERNAL_ERROR,
|
||||
"unhandled internal failure inside campaigns plugin",
|
||||
{
|
||||
cause: cause instanceof Error ? cause : new Error(String(cause)),
|
||||
context: { statusCode: 500 },
|
||||
}
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CAMPAIGNS_API_CODES` — frozen record with one key per registered reason.
|
||||
Chunk-A keys: `INVALID_OPTIONS`, `INTERNAL_ERROR`. Chunk-B additions:
|
||||
`CAMPAIGN_NOT_FOUND`, `SUPPRESSION_NOT_FOUND`, `INVALID_INPUT`,
|
||||
`DB_UNAVAILABLE`, `DUPLICATE_SUPPRESSION`, `TENANT_REQUIRED`. Chunk-C
|
||||
additions: `INVALID_STATE_TRANSITION`, `INVALID_UNSUBSCRIBE_TOKEN`,
|
||||
`ROUTE_PREFIX_COLLISION`, `WEBHOOK_REJECTED`, `SERVICE_NOT_AVAILABLE`.
|
||||
Every value is the literal string `"CAMPAIGNS_API.<KEY>"`.
|
||||
- `class CampaignsApiError extends SuaveplanError<CampaignsApiErrorCode>`
|
||||
- `new CampaignsApiError(code, message, options?)`
|
||||
- `error.code` — the namespaced code string.
|
||||
- `error.campaignsApiCode` — same as `code`, typed narrowly.
|
||||
- `error.cause` / `error.context` — inherited from `SuaveplanError`.
|
||||
- `type CampaignsApiErrorCode` — string-literal union over every value of
|
||||
`CAMPAIGNS_API_CODES` (currently eight codes spanning Chunks A and B).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The constructor is `public` rather than `protected` because consumers
|
||||
(test suites and integration scaffolds) construct the error directly
|
||||
without subclassing. `defineCodes` returns a frozen `Object.create(null)`,
|
||||
so `Object.isFrozen(CAMPAIGNS_API_CODES) === true` is an invariant the
|
||||
test suite enforces — accidentally swapping it for a plain object literal
|
||||
would silently weaken the API contract and tests would catch it.
|
||||
|
||||
The Chunk-A registration deliberately excludes codes that Chunks B and C
|
||||
will need. Adding unthrown codes here now would either depress branch
|
||||
coverage or force placeholder tests that exercise dead code paths;
|
||||
incremental registration aligned with each chunk's actual throw sites
|
||||
keeps the verification gate honest.
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CAMPAIGNS_API_CODES, CampaignsApiError } from "./error-codes.js";
|
||||
|
||||
describe("CAMPAIGNS_API_CODES", () => {
|
||||
it("every registered code starts with the CAMPAIGNS_API namespace", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const value of Object.values(CAMPAIGNS_API_CODES)) {
|
||||
expect(value.startsWith("CAMPAIGNS_API.")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("registers the Chunk-A+B+C code set", { timeout: 5000 }, () => {
|
||||
expect(Object.keys(CAMPAIGNS_API_CODES).sort()).toEqual([
|
||||
"CAMPAIGN_NOT_FOUND",
|
||||
"DB_UNAVAILABLE",
|
||||
"DUPLICATE_SUPPRESSION",
|
||||
"INTERNAL_ERROR",
|
||||
"INVALID_INPUT",
|
||||
"INVALID_OPTIONS",
|
||||
"INVALID_STATE_TRANSITION",
|
||||
"INVALID_UNSUBSCRIBE_TOKEN",
|
||||
"ROUTE_PREFIX_COLLISION",
|
||||
"SERVICE_NOT_AVAILABLE",
|
||||
"SUPPRESSION_NOT_FOUND",
|
||||
"TENANT_REQUIRED",
|
||||
"WEBHOOK_REJECTED",
|
||||
]);
|
||||
});
|
||||
|
||||
it("Chunk-C codes carry the CAMPAIGNS_API. prefix", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(CAMPAIGNS_API_CODES.INVALID_STATE_TRANSITION).toBe(
|
||||
"CAMPAIGNS_API.INVALID_STATE_TRANSITION"
|
||||
);
|
||||
expect(CAMPAIGNS_API_CODES.INVALID_UNSUBSCRIBE_TOKEN).toBe(
|
||||
"CAMPAIGNS_API.INVALID_UNSUBSCRIBE_TOKEN"
|
||||
);
|
||||
expect(CAMPAIGNS_API_CODES.ROUTE_PREFIX_COLLISION).toBe(
|
||||
"CAMPAIGNS_API.ROUTE_PREFIX_COLLISION"
|
||||
);
|
||||
expect(CAMPAIGNS_API_CODES.WEBHOOK_REJECTED).toBe(
|
||||
"CAMPAIGNS_API.WEBHOOK_REJECTED"
|
||||
);
|
||||
expect(CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE).toBe(
|
||||
"CAMPAIGNS_API.SERVICE_NOT_AVAILABLE"
|
||||
);
|
||||
});
|
||||
|
||||
it("Chunk-B codes carry the CAMPAIGNS_API. prefix", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(CAMPAIGNS_API_CODES.CAMPAIGN_NOT_FOUND).toBe(
|
||||
"CAMPAIGNS_API.CAMPAIGN_NOT_FOUND"
|
||||
);
|
||||
expect(CAMPAIGNS_API_CODES.SUPPRESSION_NOT_FOUND).toBe(
|
||||
"CAMPAIGNS_API.SUPPRESSION_NOT_FOUND"
|
||||
);
|
||||
expect(CAMPAIGNS_API_CODES.INVALID_INPUT).toBe(
|
||||
"CAMPAIGNS_API.INVALID_INPUT"
|
||||
);
|
||||
expect(CAMPAIGNS_API_CODES.DB_UNAVAILABLE).toBe(
|
||||
"CAMPAIGNS_API.DB_UNAVAILABLE"
|
||||
);
|
||||
expect(CAMPAIGNS_API_CODES.DUPLICATE_SUPPRESSION).toBe(
|
||||
"CAMPAIGNS_API.DUPLICATE_SUPPRESSION"
|
||||
);
|
||||
expect(CAMPAIGNS_API_CODES.TENANT_REQUIRED).toBe(
|
||||
"CAMPAIGNS_API.TENANT_REQUIRED"
|
||||
);
|
||||
});
|
||||
|
||||
it("registry is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(CAMPAIGNS_API_CODES)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CampaignsApiError", () => {
|
||||
it("preserves cause", { timeout: 5000 }, () => {
|
||||
const cause = new Error("boom");
|
||||
const err = new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.INVALID_OPTIONS,
|
||||
"bad options",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.code).toBe(CAMPAIGNS_API_CODES.INVALID_OPTIONS);
|
||||
expect(err.campaignsApiCode).toBe(CAMPAIGNS_API_CODES.INVALID_OPTIONS);
|
||||
});
|
||||
|
||||
it("carries context", { timeout: 5000 }, () => {
|
||||
const err = new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.INTERNAL_ERROR,
|
||||
"internal",
|
||||
{ context: { statusCode: 500, request: "x" } }
|
||||
);
|
||||
expect(err.context).toEqual({ statusCode: 500, request: "x" });
|
||||
});
|
||||
|
||||
it("is instanceof CampaignsApiError + Error", { timeout: 5000 }, () => {
|
||||
const err = new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.INTERNAL_ERROR,
|
||||
"x"
|
||||
);
|
||||
expect(err).toBeInstanceOf(CampaignsApiError);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("accepts no options argument (default undefined)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.INVALID_OPTIONS,
|
||||
"x"
|
||||
);
|
||||
expect(err.cause).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/api-plugin-campaigns`.
|
||||
*
|
||||
* Chunked migration: Chunk A registers only the codes thrown by the
|
||||
* foundation surface (`createCampaignsPlugin` option validation and the
|
||||
* no-op `onInit` placeholder's catch-all). Chunks B and C MUST extend
|
||||
* this `defineCodes` call rather than introducing a parallel namespace —
|
||||
* see `tasks.md §B.4` for the follow-on additions
|
||||
* (`CAMPAIGN_NOT_FOUND`, `SUPPRESSION_NOT_FOUND`,
|
||||
* `INVALID_STATE_TRANSITION`, `INVALID_UNSUBSCRIBE_TOKEN`,
|
||||
* `ROUTE_PREFIX_COLLISION`, `DUPLICATE_SUPPRESSION`, `TENANT_REQUIRED`,
|
||||
* `WEBHOOK_REJECTED`, `DB_UNAVAILABLE`).
|
||||
*
|
||||
* Each `CampaignsApiError` carries one of the `CAMPAIGNS_API.*` codes;
|
||||
* consumers branch on the string value
|
||||
* (`error.code === CAMPAIGNS_API_CODES.INVALID_OPTIONS`).
|
||||
*
|
||||
* - `INVALID_OPTIONS` — `createCampaignsPlugin(options)` received an
|
||||
* `options` object that failed `CampaignsPluginOptionsSchema`
|
||||
* validation. `cause` holds the `ZodError`.
|
||||
* - `INTERNAL_ERROR` — unrecoverable internal failure raised by the
|
||||
* plugin scaffold; used for catch-all paths that should never trigger
|
||||
* in healthy operation.
|
||||
* - `CAMPAIGN_NOT_FOUND` / `SUPPRESSION_NOT_FOUND` — lookups against
|
||||
* the campaign or suppression repositories returned no row, or the
|
||||
* row belonged to a different tenant.
|
||||
* - `INVALID_INPUT` — Zod validation at a repository or route boundary
|
||||
* rejected user input.
|
||||
* - `DB_UNAVAILABLE` — the underlying `SurrealAdapter` threw — wrapped
|
||||
* so callers can branch on a stable code.
|
||||
* - `DUPLICATE_SUPPRESSION` — `(tenantId, channel, channelValue)` was
|
||||
* already present when `add` ran.
|
||||
* - `TENANT_REQUIRED` — a repository read was called with an empty
|
||||
* tenant string.
|
||||
* - `INVALID_STATE_TRANSITION` (Chunk C) — a campaign action route
|
||||
* (`/send`, `/pause`, `/cancel`, …) was invoked against a campaign
|
||||
* whose current status forbids that event.
|
||||
* - `INVALID_UNSUBSCRIBE_TOKEN` (Chunk C) — `GET /webhooks/unsubscribe/:token`
|
||||
* received a token that could not be decoded or lacked a `contactId`.
|
||||
* - `ROUTE_PREFIX_COLLISION` (Chunk C) — the configured `routePrefix`
|
||||
* collides with routes already registered on the host's
|
||||
* `OpenAPIRegistry`.
|
||||
* - `WEBHOOK_REJECTED` (Chunk C) — webhook ingestion rejected the
|
||||
* request (malformed body, signature failure, etc.).
|
||||
* - `SERVICE_NOT_AVAILABLE` (Chunk C) — `onInit` could not resolve a
|
||||
* required ServiceRegistry slot, or a Hono handler found its repo
|
||||
* context slot empty.
|
||||
*/
|
||||
export const CAMPAIGNS_API_CODES = defineCodes("CAMPAIGNS_API", [
|
||||
"INVALID_OPTIONS",
|
||||
"INTERNAL_ERROR",
|
||||
"CAMPAIGN_NOT_FOUND",
|
||||
"SUPPRESSION_NOT_FOUND",
|
||||
"INVALID_INPUT",
|
||||
"DB_UNAVAILABLE",
|
||||
"DUPLICATE_SUPPRESSION",
|
||||
"TENANT_REQUIRED",
|
||||
"INVALID_STATE_TRANSITION",
|
||||
"INVALID_UNSUBSCRIBE_TOKEN",
|
||||
"ROUTE_PREFIX_COLLISION",
|
||||
"WEBHOOK_REJECTED",
|
||||
"SERVICE_NOT_AVAILABLE",
|
||||
] as const);
|
||||
|
||||
/** Discriminated string-literal union of every `CAMPAIGNS_API.*` code. */
|
||||
export type CampaignsApiErrorCode =
|
||||
(typeof CAMPAIGNS_API_CODES)[keyof typeof CAMPAIGNS_API_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class for the api-plugin-campaigns package. Extends
|
||||
* `SuaveplanError` so `instanceof CampaignsApiError` narrows the `code`
|
||||
* field to `CampaignsApiErrorCode`. The constructor is intentionally
|
||||
* explicit (delegating straight to `super`) so consumers can subclass
|
||||
* or stub the error in tests without TypeScript inferring a private
|
||||
* constructor.
|
||||
*/
|
||||
export class CampaignsApiError extends SuaveplanError<CampaignsApiErrorCode> {
|
||||
public readonly campaignsApiCode: CampaignsApiErrorCode;
|
||||
|
||||
public constructor(
|
||||
code: CampaignsApiErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.campaignsApiCode = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
CAMPAIGNS_API_CODES,
|
||||
CampaignsApiError,
|
||||
type CampaignsApiErrorCode,
|
||||
} from "./error-codes.js";
|
||||
131
packages/api-stack/api/api-plugin-campaigns/src/index.ts
Normal file
131
packages/api-stack/api/api-plugin-campaigns/src/index.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* `@suaveplan/api-plugin-campaigns` — Campaigns + suppression +
|
||||
* delivery-webhooks HTTP plugin for `@suaveplan/api`. Chunk C wires
|
||||
* the full route surface: `/campaigns`, `/suppression`, `/webhooks`
|
||||
* mounted via `definePluginRoutes` against the host's
|
||||
* `OpenAPIRegistry`.
|
||||
*/
|
||||
|
||||
// Side-effect imports — register the error-codes namespace and the
|
||||
// ServiceRegistry / ContextVariableMap slots for every consumer.
|
||||
import "./error-codes/error-codes.js";
|
||||
import "./context/context.js";
|
||||
import "./services/registry-augmentation.js";
|
||||
|
||||
// Error codes
|
||||
export {
|
||||
CAMPAIGNS_API_CODES,
|
||||
CampaignsApiError,
|
||||
type CampaignsApiErrorCode,
|
||||
} from "./error-codes/error-codes.js";
|
||||
|
||||
// OpenTelemetry instruments
|
||||
export {
|
||||
type CampaignsApiInstruments,
|
||||
getCampaignsApiInstruments,
|
||||
resetCampaignsApiInstruments,
|
||||
} from "./otel/instruments.js";
|
||||
|
||||
// Plugin factory + lifecycle
|
||||
export { createCampaignsPlugin, runOnInit } from "./plugin/plugin.js";
|
||||
|
||||
// Repositories
|
||||
export {
|
||||
InMemoryCampaignStore,
|
||||
SurrealCampaignRepository,
|
||||
} from "./repositories/campaign/campaign.js";
|
||||
export type {
|
||||
CampaignRow,
|
||||
SurrealCampaignRepositoryOptions,
|
||||
} from "./repositories/campaign/campaign.types.js";
|
||||
export {
|
||||
CreateInputSchema as SuppressionCreateInputSchema,
|
||||
QuerySchema as SuppressionQuerySchema,
|
||||
SurrealCampaignSuppressionRepository,
|
||||
} from "./repositories/suppression/suppression.js";
|
||||
export type {
|
||||
CampaignSuppressionEntry,
|
||||
CampaignSuppressionQuery,
|
||||
CampaignSuppressionReason,
|
||||
CampaignSuppressionRow,
|
||||
CampaignSuppressionStore,
|
||||
CreateCampaignSuppressionInput,
|
||||
InMemoryCampaignSuppressionStoreOptions,
|
||||
SurrealCampaignSuppressionRepositoryOptions,
|
||||
} from "./repositories/suppression/suppression.types.js";
|
||||
export { InMemoryCampaignSuppressionStore } from "./repositories/suppression/suppression-memory.js";
|
||||
|
||||
// Routes
|
||||
export {
|
||||
buildCampaignEventsRoute,
|
||||
buildCampaignRoutes,
|
||||
buildCampaignStatsRoute,
|
||||
buildCancelCampaignRoute,
|
||||
buildCheckSuppressionRoute,
|
||||
buildCreateCampaignRoute,
|
||||
buildCreateSuppressionRoute,
|
||||
buildDeleteCampaignRoute,
|
||||
buildDeleteSuppressionRoute,
|
||||
buildDeliveryWebhookRoute,
|
||||
buildDuplicateCampaignRoute,
|
||||
buildGetCampaignRoute,
|
||||
buildListCampaignsRoute,
|
||||
buildListSuppressionRoute,
|
||||
buildPauseCampaignRoute,
|
||||
buildSendCampaignRoute,
|
||||
buildSuppressionRoutes,
|
||||
buildTestSendRoute,
|
||||
buildUnsubscribeRoute,
|
||||
buildUpdateCampaignRoute,
|
||||
buildWebhookRoutes,
|
||||
CampaignIdParamSchema,
|
||||
CampaignListResponseSchema,
|
||||
type CampaignQueryInput,
|
||||
CampaignQuerySchema,
|
||||
CampaignResponseSchema,
|
||||
CampaignStatsSchema,
|
||||
CampaignStatusSchema,
|
||||
CampaignTypeSchema,
|
||||
type CreateCampaignBody,
|
||||
CreateCampaignSchema,
|
||||
type CreateSuppressionBody,
|
||||
CreateSuppressionSchema,
|
||||
campaignToResponse,
|
||||
DeliveryEventTypeSchema,
|
||||
DeliveryStatsResponseSchema,
|
||||
type DeliveryWebhookBody,
|
||||
DeliveryWebhookResponseSchema,
|
||||
DeliveryWebhookSchema,
|
||||
decodeUnsubscribeToken,
|
||||
EMPTY_STATS,
|
||||
EventQuerySchema,
|
||||
requireTenant,
|
||||
SuppressionCheckQuerySchema,
|
||||
SuppressionCheckResponseSchema,
|
||||
SuppressionIdParamSchema,
|
||||
SuppressionListResponseSchema,
|
||||
type SuppressionQueryInput,
|
||||
SuppressionQuerySchema as SuppressionRouteQuerySchema,
|
||||
SuppressionReasonSchema,
|
||||
SuppressionResponseSchema,
|
||||
stripUndefined,
|
||||
type TenantPrincipal,
|
||||
TestSendSchema,
|
||||
UnsubscribeResponseSchema,
|
||||
UnsubscribeTokenParamSchema,
|
||||
type UnsubscribeTokenPayload,
|
||||
type UpdateCampaignBody,
|
||||
UpdateCampaignSchema,
|
||||
} from "./routes/index.js";
|
||||
|
||||
// Schemas
|
||||
export {
|
||||
type CampaignsPluginOptionsInput,
|
||||
type CampaignsPluginOptionsParsed,
|
||||
CampaignsPluginOptionsSchema,
|
||||
} from "./schemas/options.js";
|
||||
// Public types
|
||||
export type {
|
||||
CampaignsPluginConfig,
|
||||
CampaignsPluginOptions,
|
||||
} from "./types/types.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type CampaignsApiInstruments,
|
||||
getCampaignsApiInstruments,
|
||||
resetCampaignsApiInstruments,
|
||||
} from "./instruments.js";
|
||||
@@ -0,0 +1,90 @@
|
||||
# `otel/instruments`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `otel/instruments` module owns the OpenTelemetry instrument bundle
|
||||
emitted by `@suaveplan/api-plugin-campaigns`. It binds a meter under the
|
||||
`"@suaveplan/api-plugin-campaigns"` instrumentation-scope name against the
|
||||
process-global `MeterProvider` and exposes a lazily-cached factory so the
|
||||
plugin's route handlers, repositories, and lifecycle hooks share a single
|
||||
counter / histogram pair without re-resolving the meter on every call.
|
||||
|
||||
The bundle is intentionally small in Chunk A: a request-count counter and a
|
||||
request-duration histogram covering every route mounted by the plugin.
|
||||
Chunks B and C extend it (in their own modules) with per-domain instruments
|
||||
for campaign writes, suppression checks, and webhook ingestion outcomes.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy, cached meter binding — first call resolves the global meter; later
|
||||
calls return the cached bundle so handlers can record metrics in their
|
||||
hot path without provider lookup overhead.
|
||||
- `resetCampaignsApiInstruments()` hook for tests that install a fresh
|
||||
`MeterProvider` after the first cache write.
|
||||
- Counter + histogram primitives only; aggregation strategy and views are
|
||||
owned by the host's `MeterProvider`, not this package.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { getCampaignsApiInstruments } from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
const { requestsTotal, requestDurationMs } = getCampaignsApiInstruments();
|
||||
|
||||
const start = performance.now();
|
||||
try {
|
||||
await handler();
|
||||
requestsTotal.add(1, { route: "/campaigns", outcome: "ok" });
|
||||
} catch (err) {
|
||||
requestsTotal.add(1, { route: "/campaigns", outcome: "error" });
|
||||
throw err;
|
||||
} finally {
|
||||
requestDurationMs.record(performance.now() - start, {
|
||||
route: "/campaigns",
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
getCampaignsApiInstruments,
|
||||
resetCampaignsApiInstruments,
|
||||
} from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
// Test set-up: rebind to a freshly-installed provider.
|
||||
const provider = new MeterProvider({ readers: [] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
resetCampaignsApiInstruments();
|
||||
|
||||
// The next resolve binds to `provider`, not the implicit global from
|
||||
// before the rebind.
|
||||
const bundle = getCampaignsApiInstruments();
|
||||
bundle.requestsTotal.add(1, { route: "/x", outcome: "ok" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface CampaignsApiInstruments { readonly requestsTotal: Counter; readonly requestDurationMs: Histogram }`
|
||||
- `getCampaignsApiInstruments(): CampaignsApiInstruments` — cached factory.
|
||||
- `resetCampaignsApiInstruments(): void` — drops the cache.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The cache is a module-level mutable binding. That is intentional and
|
||||
matches the upstream pattern in `@suaveplan/api-plugin-contacts`: an
|
||||
inert no-op metric is cheap to recreate, but creating one per request is
|
||||
not, so caching plus an explicit reset hook for tests is the simplest
|
||||
honest design.
|
||||
|
||||
The bundle is `readonly` so handlers cannot accidentally mutate it; the
|
||||
cache itself is mutable but only the reset hook reaches it. Reset is
|
||||
test-only — production code never invalidates the cache because the
|
||||
global provider is installed once at process start.
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
getCampaignsApiInstruments,
|
||||
resetCampaignsApiInstruments,
|
||||
} from "./instruments.js";
|
||||
|
||||
describe("getCampaignsApiInstruments", () => {
|
||||
it("returns a bundle with both required instruments", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetCampaignsApiInstruments();
|
||||
const bundle = getCampaignsApiInstruments();
|
||||
expect(bundle.requestsTotal).toBeDefined();
|
||||
expect(bundle.requestDurationMs).toBeDefined();
|
||||
});
|
||||
|
||||
it("caches the bundle across calls", { timeout: 5000 }, () => {
|
||||
resetCampaignsApiInstruments();
|
||||
const a = getCampaignsApiInstruments();
|
||||
const b = getCampaignsApiInstruments();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("rebinds after resetCampaignsApiInstruments()", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetCampaignsApiInstruments();
|
||||
const a = getCampaignsApiInstruments();
|
||||
resetCampaignsApiInstruments();
|
||||
const b = getCampaignsApiInstruments();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("instruments accept record/add calls without throwing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetCampaignsApiInstruments();
|
||||
const bundle = getCampaignsApiInstruments();
|
||||
expect(() =>
|
||||
bundle.requestsTotal.add(1, { route: "/x", outcome: "ok" })
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
bundle.requestDurationMs.record(42, { route: "/x" })
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Bundle of OpenTelemetry instruments emitted by the campaigns API
|
||||
* plugin. Chunk A defines the two cross-cutting baseline instruments
|
||||
* shared by every route group; Chunks B and C extend this interface
|
||||
* with per-domain counters (campaign writes, suppression checks,
|
||||
* webhook ingestion, etc.) by augmenting the bundle in their own
|
||||
* follow-up modules.
|
||||
*
|
||||
* - `requestsTotal{route,outcome}` — every HTTP request handled by the
|
||||
* plugin's route trees, partitioned by route and outcome.
|
||||
* - `requestDurationMs{route}` — wall-time histogram of every request
|
||||
* handled by the plugin, in milliseconds.
|
||||
*/
|
||||
export interface CampaignsApiInstruments {
|
||||
readonly requestsTotal: Counter;
|
||||
readonly requestDurationMs: Histogram;
|
||||
}
|
||||
|
||||
let cached: CampaignsApiInstruments | undefined;
|
||||
|
||||
/**
|
||||
* Lazily build (and cache) the campaigns-plugin instrument bundle
|
||||
* against the current global `MeterProvider`. Call
|
||||
* {@link resetCampaignsApiInstruments} in test setup to force a re-bind
|
||||
* against a freshly installed provider.
|
||||
*/
|
||||
export function getCampaignsApiInstruments(): CampaignsApiInstruments {
|
||||
if (cached !== undefined) return cached;
|
||||
const meter = metrics.getMeter("@suaveplan/api-plugin-campaigns");
|
||||
cached = {
|
||||
requestsTotal: meter.createCounter("campaigns_api.requests_total", {
|
||||
description:
|
||||
"Total HTTP requests handled by the campaigns plugin, partitioned by route + outcome",
|
||||
}),
|
||||
requestDurationMs: meter.createHistogram(
|
||||
"campaigns_api.request_duration_ms",
|
||||
{
|
||||
description:
|
||||
"Wall-time of campaigns-plugin requests in milliseconds, partitioned by route",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached instrument bundle so the next
|
||||
* {@link getCampaignsApiInstruments} call rebinds against the global
|
||||
* `MeterProvider` in effect at that point. Tests call this after
|
||||
* installing a fresh `MeterProvider`.
|
||||
*/
|
||||
export function resetCampaignsApiInstruments(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createCampaignsPlugin, runOnInit } from "./plugin.js";
|
||||
101
packages/api-stack/api/api-plugin-campaigns/src/plugin/plugin.md
Normal file
101
packages/api-stack/api/api-plugin-campaigns/src/plugin/plugin.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# `plugin`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `plugin` module exports `createCampaignsPlugin` — the
|
||||
`PluginDescriptor` factory consumed by `@suaveplan/api` hosts. It owns
|
||||
the option-validation, dependency-declaration, and lifecycle-hook
|
||||
seam for the campaigns plugin. As of Chunk C the `onInit` hook is
|
||||
fully wired: it resolves the required services from the host
|
||||
`ServiceRegistry`, constructs the SurrealDB-backed `CampaignStore`,
|
||||
`CampaignSuppressionStore`, and `DeliveryEventStore` implementations,
|
||||
registers them on both the registry and the Hono `ContextVariableMap`
|
||||
via a single mount-pattern middleware, and mounts every route group
|
||||
through `definePluginRoutes`.
|
||||
|
||||
The descriptor declares `dependencies: ["database", "auth", "contacts"]`
|
||||
so the host plugin registry topo-sorts the campaigns plugin after the
|
||||
three prerequisites — without `database` the `db` service slot is
|
||||
empty (and `onInit` throws `SERVICE_NOT_AVAILABLE`); without `auth`
|
||||
the per-request `requireTenant` guard rejects every call.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod-validated options via `CampaignsPluginOptionsSchema`; invalid
|
||||
input surfaces as `CampaignsApiError(INVALID_OPTIONS)` with the
|
||||
`ZodError` attached as `cause`.
|
||||
- Service resolution: requires `app` and `db` slots; throws
|
||||
`CampaignsApiError(SERVICE_NOT_AVAILABLE)` if either is empty.
|
||||
- Route-prefix collision detection: throws
|
||||
`CampaignsApiError(ROUTE_PREFIX_COLLISION)` if the host
|
||||
`OpenAPIRegistry` already carries routes under the configured prefix.
|
||||
- Hono context injection: a single `app.use(mountPattern, ...)`
|
||||
middleware populates `campaignRepository`,
|
||||
`campaignSuppressionStore`, `deliveryEventStore`, and `eventBus` on
|
||||
every request inside the prefix.
|
||||
- Route mounting: 18 routes across `/campaigns`, `/suppression`, and
|
||||
`/webhooks` are registered through `definePluginRoutes` (when
|
||||
`openAPIRegistry` is available) or directly via `app[method]`
|
||||
(registry-less fallback for test scaffolds).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createCampaignsPlugin } from "@suaveplan/api-plugin-campaigns";
|
||||
import { createPluginRegistry } from "@suaveplan/api-plugin";
|
||||
|
||||
const registry = createPluginRegistry({ /* host config */ });
|
||||
registry.register(createCampaignsPlugin());
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createCampaignsPlugin } from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
const descriptor = createCampaignsPlugin({
|
||||
routePrefix: "/api/v1",
|
||||
maxCampaignsPerTenant: 50_000,
|
||||
maxRecipientsPerCampaign: 5_000_000,
|
||||
defaultThrottlePerSecond: 250,
|
||||
applySchemaOnInit: false,
|
||||
logger: {
|
||||
debug: (msg) => process.stderr.write(`[debug] ${msg}\n`),
|
||||
info: (msg) => process.stderr.write(`[info ] ${msg}\n`),
|
||||
warn: (msg) => process.stderr.write(`[warn ] ${msg}\n`),
|
||||
error: (msg) => process.stderr.write(`[error] ${msg}\n`),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createCampaignsPlugin(options?: CampaignsPluginOptions): PluginDescriptor<CampaignsPluginConfig>`
|
||||
- `runOnInit(ctx: PluginContext<CampaignsPluginConfig>, config: CampaignsPluginConfig): Promise<void>`
|
||||
- Exported so tests can drive the hook directly without going
|
||||
through the registry.
|
||||
- Throws `CampaignsApiError(CAMPAIGNS_API.INVALID_OPTIONS)` on a Zod
|
||||
parse failure.
|
||||
- Throws `CampaignsApiError(CAMPAIGNS_API.SERVICE_NOT_AVAILABLE)` if
|
||||
`app` or `db` services are not registered.
|
||||
- Throws `CampaignsApiError(CAMPAIGNS_API.ROUTE_PREFIX_COLLISION)` if
|
||||
the host registry already has routes under the configured prefix.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The mount-pattern middleware uses `*` for the root prefix (`"/"`) and
|
||||
`<prefix>/*` for any non-root prefix. This is the same pattern used by
|
||||
`@suaveplan/api-plugin-contacts` so plugins compose cleanly when both
|
||||
are loaded with the same prefix — Hono's middleware chain dispatches
|
||||
the matching pattern, not a globally-shared closure.
|
||||
|
||||
Logger resolution follows the pattern in `@suaveplan/api-plugin-contacts`:
|
||||
caller-supplied `config.logger` takes precedence over the host-supplied
|
||||
`ctx.logger`. The warn-level message in the registry-less fallback is
|
||||
the only non-debug log this plugin emits during init; it documents the
|
||||
fact that the plugin mounted but its routes are not in the OpenAPI
|
||||
spec, which is almost always operationally significant.
|
||||
@@ -0,0 +1,297 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
CAMPAIGNS_API_CODES,
|
||||
CampaignsApiError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { createCampaignsPlugin, runOnInit } from "./plugin.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
function makeFakeDb(): SurrealAdapter {
|
||||
return {
|
||||
async execute() {},
|
||||
async query() {
|
||||
return [];
|
||||
},
|
||||
} as unknown as SurrealAdapter;
|
||||
}
|
||||
|
||||
function makeCtx(
|
||||
overrides: {
|
||||
app?: OpenAPIHono | undefined;
|
||||
db?: SurrealAdapter | undefined;
|
||||
registry?: ReturnType<typeof createOpenAPIRegistry> | undefined;
|
||||
logger?: typeof noopLogger;
|
||||
} = {}
|
||||
) {
|
||||
const app = overrides.app ?? new OpenAPIHono();
|
||||
const services = new Map<string, unknown>();
|
||||
if (app !== undefined) services.set("app", app);
|
||||
if (overrides.db !== undefined) services.set("db", overrides.db);
|
||||
if (overrides.registry !== undefined) {
|
||||
services.set("openAPIRegistry", overrides.registry);
|
||||
}
|
||||
const ctx = createPluginContext({
|
||||
logger: overrides.logger ?? noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: {} as never,
|
||||
services,
|
||||
});
|
||||
return { ctx, app, services };
|
||||
}
|
||||
|
||||
describe("createCampaignsPlugin", () => {
|
||||
it("returns a typed descriptor with default config", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const desc = createCampaignsPlugin();
|
||||
expect(desc.name).toBe("campaigns");
|
||||
expect(desc.version).toBe("0.1.0");
|
||||
expect(desc.dependencies).toEqual(["database", "auth", "contacts"]);
|
||||
const defaults = desc.defaults as NonNullable<typeof desc.defaults>;
|
||||
expect(defaults.routePrefix).toBe("/");
|
||||
expect(defaults.maxCampaignsPerTenant).toBe(10_000);
|
||||
expect(defaults.maxRecipientsPerCampaign).toBe(1_000_000);
|
||||
expect(defaults.defaultThrottlePerSecond).toBe(100);
|
||||
expect(defaults.applySchemaOnInit).toBe(true);
|
||||
});
|
||||
|
||||
it("throws CAMPAIGNS_API.INVALID_OPTIONS on invalid options", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
createCampaignsPlugin({ maxCampaignsPerTenant: -1 } as never)
|
||||
).toThrowError(CampaignsApiError);
|
||||
});
|
||||
|
||||
it("INVALID_OPTIONS error carries the ZodError as cause", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
createCampaignsPlugin({ routePrefix: "" } as never);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CampaignsApiError);
|
||||
const ce = err as CampaignsApiError;
|
||||
expect(ce.code).toBe(CAMPAIGNS_API_CODES.INVALID_OPTIONS);
|
||||
expect(ce.cause).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("honours a custom routePrefix", { timeout: 5000 }, () => {
|
||||
const desc = createCampaignsPlugin({ routePrefix: "/api/v1" });
|
||||
const defaults = desc.defaults as NonNullable<typeof desc.defaults>;
|
||||
expect(defaults.routePrefix).toBe("/api/v1");
|
||||
});
|
||||
|
||||
it("descriptor advertises name, dependencies, version, tags", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const desc = createCampaignsPlugin();
|
||||
expect(desc.tags).toEqual([
|
||||
"campaigns",
|
||||
"suppression",
|
||||
"webhooks",
|
||||
"delivery",
|
||||
]);
|
||||
});
|
||||
|
||||
it("stores a logger on the resolved config when supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const desc = createCampaignsPlugin({ logger: { ...noopLogger } });
|
||||
const defaults = desc.defaults as NonNullable<typeof desc.defaults>;
|
||||
const out = defaults.logger as NonNullable<typeof defaults.logger>;
|
||||
expect(typeof out.debug).toBe("function");
|
||||
expect(typeof out.info).toBe("function");
|
||||
expect(typeof out.warn).toBe("function");
|
||||
expect(typeof out.error).toBe("function");
|
||||
});
|
||||
|
||||
it("omits the logger slot when none supplied", { timeout: 5000 }, () => {
|
||||
const desc = createCampaignsPlugin();
|
||||
const defaults = desc.defaults as NonNullable<typeof desc.defaults>;
|
||||
expect("logger" in defaults).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin onInit (Chunk C wiring)", () => {
|
||||
it("throws SERVICE_NOT_AVAILABLE when app slot missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("db", makeFakeDb());
|
||||
const ctx = createPluginContext({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: {} as never,
|
||||
services,
|
||||
});
|
||||
const desc = createCampaignsPlugin();
|
||||
await expect(
|
||||
runOnInit(ctx as never, desc.defaults as never)
|
||||
).rejects.toMatchObject({
|
||||
code: CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws SERVICE_NOT_AVAILABLE when db slot missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { ctx } = makeCtx({});
|
||||
const desc = createCampaignsPlugin();
|
||||
await expect(
|
||||
runOnInit(ctx as never, desc.defaults as never)
|
||||
).rejects.toMatchObject({
|
||||
code: CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE,
|
||||
});
|
||||
});
|
||||
|
||||
it("registers campaign + suppression + delivery-event services", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { ctx } = makeCtx({ db: makeFakeDb() });
|
||||
const desc = createCampaignsPlugin();
|
||||
await runOnInit(ctx as never, desc.defaults as never);
|
||||
expect(ctx.getService("campaignRepository")).toBeDefined();
|
||||
expect(ctx.getService("campaignSuppressionStore")).toBeDefined();
|
||||
expect(ctx.getService("deliveryEventStore")).toBeDefined();
|
||||
});
|
||||
|
||||
it("warns when openAPIRegistry is absent and still mounts handlers", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const messages: string[] = [];
|
||||
const logger = {
|
||||
...noopLogger,
|
||||
warn: (msg: string) => messages.push(msg),
|
||||
};
|
||||
const { ctx } = makeCtx({ db: makeFakeDb(), logger });
|
||||
const desc = createCampaignsPlugin();
|
||||
await runOnInit(ctx as never, desc.defaults as never);
|
||||
expect(messages.some((m) => m.includes("openAPIRegistry"))).toBe(true);
|
||||
});
|
||||
|
||||
it("registers routes through the OpenAPI registry when provided", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "t", version: "0.0.0" },
|
||||
});
|
||||
const { ctx } = makeCtx({ db: makeFakeDb(), registry });
|
||||
const desc = createCampaignsPlugin();
|
||||
await runOnInit(ctx as never, desc.defaults as never);
|
||||
const paths = registry.getRoutes().map((r) => r.path);
|
||||
expect(paths).toContain("/campaigns");
|
||||
expect(paths).toContain("/suppression");
|
||||
expect(paths).toContain("/webhooks/delivery");
|
||||
});
|
||||
|
||||
it("raises ROUTE_PREFIX_COLLISION on re-run with same registry", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "t", version: "0.0.0" },
|
||||
});
|
||||
const { ctx } = makeCtx({ db: makeFakeDb(), registry });
|
||||
const desc = createCampaignsPlugin();
|
||||
await runOnInit(ctx as never, desc.defaults as never);
|
||||
await expect(
|
||||
runOnInit(ctx as never, desc.defaults as never)
|
||||
).rejects.toMatchObject({
|
||||
code: CAMPAIGNS_API_CODES.ROUTE_PREFIX_COLLISION,
|
||||
});
|
||||
});
|
||||
|
||||
it("honours a custom routePrefix when no registry is available", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { ctx, app } = makeCtx({ db: makeFakeDb() });
|
||||
const desc = createCampaignsPlugin({ routePrefix: "/api" });
|
||||
await runOnInit(ctx as never, desc.defaults as never);
|
||||
const res = await app.request("/api/campaigns");
|
||||
expect([200, 401, 500]).toContain(res.status);
|
||||
});
|
||||
|
||||
it("prefixes registry-mounted routes when routePrefix is non-root", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "t", version: "0.0.0" },
|
||||
});
|
||||
const { ctx } = makeCtx({ db: makeFakeDb(), registry });
|
||||
const desc = createCampaignsPlugin({ routePrefix: "/api" });
|
||||
await runOnInit(ctx as never, desc.defaults as never);
|
||||
const paths = registry.getRoutes().map((r) => r.path);
|
||||
expect(paths).toContain("/api/campaigns");
|
||||
expect(paths).toContain("/api/suppression");
|
||||
});
|
||||
|
||||
it("descriptor onInit hook reaches runOnInit", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { ctx } = makeCtx({ db: makeFakeDb() });
|
||||
const desc = createCampaignsPlugin();
|
||||
const hook = desc.onInit;
|
||||
expect(hook).toBeDefined();
|
||||
await expect(
|
||||
(hook as NonNullable<typeof hook>)(ctx as never)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("exercises the default clock + idGenerator via store.add", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: Array<{ sql: string; params: readonly unknown[] }> = [];
|
||||
const db = {
|
||||
async execute() {},
|
||||
async query(sql: string, params: readonly unknown[]) {
|
||||
calls.push({ sql, params });
|
||||
return [];
|
||||
},
|
||||
} as unknown as SurrealAdapter;
|
||||
const { ctx } = makeCtx({ db });
|
||||
const desc = createCampaignsPlugin();
|
||||
await runOnInit(ctx as never, desc.defaults as never);
|
||||
const store = ctx.getService("campaignSuppressionStore");
|
||||
if (store === undefined) throw new Error("store missing");
|
||||
await store.add({
|
||||
tenantId: "t",
|
||||
channel: "email",
|
||||
channelValue: "a@b.com",
|
||||
reason: "manual",
|
||||
});
|
||||
// idGenerator (params[0]) and clock-stamped createdAt (params[7])
|
||||
// should both be set by the defaults.
|
||||
const addCall = calls.find((c) => c.sql.startsWith("INSERT"));
|
||||
expect(addCall).toBeDefined();
|
||||
expect(typeof addCall?.params[0]).toBe("string");
|
||||
expect(addCall?.params[7]).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("config.logger overrides ctx.logger for warn path", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const messages: string[] = [];
|
||||
const logger = {
|
||||
...noopLogger,
|
||||
warn: (msg: string) => messages.push(msg),
|
||||
};
|
||||
const { ctx } = makeCtx({ db: makeFakeDb() });
|
||||
const desc = createCampaignsPlugin({ logger });
|
||||
await runOnInit(ctx as never, desc.defaults as never);
|
||||
expect(messages.some((m) => m.includes("openAPIRegistry"))).toBe(true);
|
||||
});
|
||||
});
|
||||
235
packages/api-stack/api/api-plugin-campaigns/src/plugin/plugin.ts
Normal file
235
packages/api-stack/api/api-plugin-campaigns/src/plugin/plugin.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* `createCampaignsPlugin` — `PluginDescriptor` factory for
|
||||
* `@suaveplan/api`. Chunk C wires the full `onInit`: option
|
||||
* validation, descriptor wiring, ServiceRegistry resolution,
|
||||
* repository + delivery-event-store construction, Hono context
|
||||
* injection, and route mounting via `definePluginRoutes`.
|
||||
*/
|
||||
|
||||
import type { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import {
|
||||
definePluginRoutes,
|
||||
type OpenAPIRegistry,
|
||||
} from "@suaveplan/api-openapi";
|
||||
import {
|
||||
definePlugin,
|
||||
type PluginContext,
|
||||
type PluginDescriptor,
|
||||
} from "@suaveplan/api-plugin";
|
||||
import type { CampaignStore } from "@suaveplan/campaigns";
|
||||
import {
|
||||
type DeliveryEventStore,
|
||||
SurrealDeliveryEventStore,
|
||||
} from "@suaveplan/delivery-tracking";
|
||||
import {
|
||||
CAMPAIGNS_API_CODES,
|
||||
CampaignsApiError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { SurrealCampaignRepository } from "../repositories/campaign/campaign.js";
|
||||
import { SurrealCampaignSuppressionRepository } from "../repositories/suppression/suppression.js";
|
||||
import type { CampaignSuppressionStore } from "../repositories/suppression/suppression.types.js";
|
||||
import { buildCampaignRoutes } from "../routes/campaigns/build-routes.js";
|
||||
import { buildSuppressionRoutes } from "../routes/suppression/build-routes.js";
|
||||
import { buildWebhookRoutes } from "../routes/webhooks/build-routes.js";
|
||||
import { CampaignsPluginOptionsSchema } from "../schemas/options.js";
|
||||
import type {
|
||||
CampaignsPluginConfig,
|
||||
CampaignsPluginOptions,
|
||||
} from "../types/types.js";
|
||||
import "../context/context.js";
|
||||
import "../services/registry-augmentation.js";
|
||||
|
||||
/**
|
||||
* Factory — returns the typed `PluginDescriptor`. Zod-validates
|
||||
* `options` at construction time; invalid options surface as
|
||||
* `CampaignsApiError(INVALID_OPTIONS)` with the `ZodError` carried as
|
||||
* `cause`. The descriptor declares
|
||||
* `dependencies: ["database", "auth", "contacts"]` so the host
|
||||
* topo-sorts after the three prerequisite plugins.
|
||||
*/
|
||||
export function createCampaignsPlugin(
|
||||
options: CampaignsPluginOptions = {}
|
||||
): PluginDescriptor<CampaignsPluginConfig> {
|
||||
const parsed = CampaignsPluginOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.INVALID_OPTIONS,
|
||||
"createCampaignsPlugin received invalid options",
|
||||
{ cause: parsed.error, context: { statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
const resolved: CampaignsPluginConfig = {
|
||||
routePrefix: parsed.data.routePrefix,
|
||||
maxCampaignsPerTenant: parsed.data.maxCampaignsPerTenant,
|
||||
maxRecipientsPerCampaign: parsed.data.maxRecipientsPerCampaign,
|
||||
defaultThrottlePerSecond: parsed.data.defaultThrottlePerSecond,
|
||||
applySchemaOnInit: parsed.data.applySchemaOnInit,
|
||||
...(parsed.data.logger !== undefined
|
||||
? { logger: parsed.data.logger }
|
||||
: {}),
|
||||
};
|
||||
|
||||
return definePlugin<CampaignsPluginConfig>({
|
||||
name: "campaigns",
|
||||
version: "0.1.0",
|
||||
description:
|
||||
"Campaigns + suppression + delivery-webhooks HTTP plugin for @suaveplan/api. Mounts /campaigns, /suppression, and /webhooks on a Hono OpenAPI registry, backed by SurrealDB-backed CampaignStore + CampaignSuppressionStore + DeliveryEventStore.",
|
||||
dependencies: ["database", "auth", "contacts"],
|
||||
defaults: resolved,
|
||||
tags: ["campaigns", "suppression", "webhooks", "delivery"],
|
||||
onInit: async (ctx) => runOnInit(ctx, resolved),
|
||||
});
|
||||
}
|
||||
|
||||
/** Default wall-clock used when no `clock` service is registered. */
|
||||
const defaultClock = (): number => Date.now();
|
||||
/** Default id-generator used to stamp suppression-entry primary keys. */
|
||||
const defaultIdGenerator = (): string => crypto.randomUUID();
|
||||
|
||||
interface ResolvedServices {
|
||||
readonly app: OpenAPIHono;
|
||||
readonly openAPIRegistry: OpenAPIRegistry | undefined;
|
||||
readonly campaignRepository: CampaignStore;
|
||||
readonly suppressionStore: CampaignSuppressionStore;
|
||||
readonly deliveryEventStore: DeliveryEventStore;
|
||||
}
|
||||
|
||||
function resolveServices(
|
||||
ctx: PluginContext<CampaignsPluginConfig>
|
||||
): ResolvedServices {
|
||||
const app = ctx.getService("app");
|
||||
if (app === undefined) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE,
|
||||
"campaigns plugin requires the `app` service in ServiceRegistry",
|
||||
{ context: { missing: ["app"], statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
const db = ctx.getService("db");
|
||||
const openAPIRegistry = ctx.getService("openAPIRegistry");
|
||||
if (db === undefined) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE,
|
||||
"campaigns plugin requires the `db` service — load @suaveplan/api-plugin-database first",
|
||||
{ context: { missing: ["db"], statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
const clock = ctx.getService("clock") ?? defaultClock;
|
||||
const idGenerator = defaultIdGenerator;
|
||||
const campaignRepository = new SurrealCampaignRepository({
|
||||
db,
|
||||
clock,
|
||||
});
|
||||
const suppressionStore = new SurrealCampaignSuppressionRepository({
|
||||
db,
|
||||
clock,
|
||||
idGenerator,
|
||||
});
|
||||
const deliveryEventStore = new SurrealDeliveryEventStore(db);
|
||||
return {
|
||||
app,
|
||||
openAPIRegistry,
|
||||
campaignRepository,
|
||||
suppressionStore,
|
||||
deliveryEventStore,
|
||||
};
|
||||
}
|
||||
|
||||
function detectCollision(registry: OpenAPIRegistry, routePrefix: string): void {
|
||||
const collisionPrefix =
|
||||
routePrefix === "/" ? "/campaigns" : `${routePrefix}/campaigns`;
|
||||
const existing = registry
|
||||
.getRoutes()
|
||||
.filter((r) => r.path.startsWith(collisionPrefix));
|
||||
if (existing.length > 0) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.ROUTE_PREFIX_COLLISION,
|
||||
`Route prefix '${routePrefix}' collides with previously registered routes`,
|
||||
{
|
||||
context: {
|
||||
prefix: routePrefix,
|
||||
existing: existing.map((r) => r.path),
|
||||
statusCode: 500,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk-C `onInit`. Resolves required services from the host
|
||||
* `ServiceRegistry`, constructs the campaign + suppression +
|
||||
* delivery-event-store implementations, registers them on both the
|
||||
* registry and the Hono `ContextVariableMap` via a single
|
||||
* mount-pattern middleware, and finally mounts every route group.
|
||||
*/
|
||||
export async function runOnInit(
|
||||
ctx: PluginContext<CampaignsPluginConfig>,
|
||||
config: CampaignsPluginConfig
|
||||
): Promise<void> {
|
||||
const services = resolveServices(ctx);
|
||||
const {
|
||||
app,
|
||||
openAPIRegistry,
|
||||
campaignRepository,
|
||||
suppressionStore,
|
||||
deliveryEventStore,
|
||||
} = services;
|
||||
const logger = config.logger ?? ctx.logger;
|
||||
|
||||
ctx.registerService("campaignRepository", campaignRepository);
|
||||
ctx.registerService("campaignSuppressionStore", suppressionStore);
|
||||
ctx.registerService("deliveryEventStore", deliveryEventStore);
|
||||
|
||||
const eventBus = ctx.eventBus;
|
||||
const prefix = config.routePrefix;
|
||||
const mountPattern = prefix === "/" ? "*" : `${prefix}/*`;
|
||||
app.use(mountPattern, async (c, next) => {
|
||||
c.set("campaignRepository", campaignRepository);
|
||||
c.set("campaignSuppressionStore", suppressionStore);
|
||||
c.set("deliveryEventStore", deliveryEventStore);
|
||||
c.set("eventBus", eventBus);
|
||||
await next();
|
||||
});
|
||||
|
||||
if (openAPIRegistry !== undefined) {
|
||||
detectCollision(openAPIRegistry, prefix);
|
||||
}
|
||||
|
||||
const entries = [
|
||||
...buildCampaignRoutes(),
|
||||
...buildSuppressionRoutes(),
|
||||
...buildWebhookRoutes(),
|
||||
];
|
||||
if (openAPIRegistry !== undefined) {
|
||||
const prefixedEntries = entries.map((entry) => ({
|
||||
...entry,
|
||||
route: {
|
||||
...entry.route,
|
||||
path: `${prefix === "/" ? "" : prefix}${entry.route.path}`,
|
||||
},
|
||||
}));
|
||||
definePluginRoutes(prefixedEntries, {
|
||||
app,
|
||||
registry: openAPIRegistry,
|
||||
pluginName: "campaigns",
|
||||
});
|
||||
return;
|
||||
}
|
||||
logger.warn(
|
||||
"campaigns plugin: openAPIRegistry not registered — routes will not appear in /openapi.json"
|
||||
);
|
||||
for (const entry of entries) {
|
||||
const method = entry.route.method as
|
||||
| "get"
|
||||
| "post"
|
||||
| "put"
|
||||
| "patch"
|
||||
| "delete";
|
||||
const path = `${prefix === "/" ? "" : prefix}${entry.route.path}`;
|
||||
app[method](
|
||||
path,
|
||||
async (c) => (await entry.handler(c as never)) as Response
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
# `repositories/campaign`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the SurrealDB-backed implementation of the campaign
|
||||
persistence contract for `@suaveplan/api-plugin-campaigns`. It implements
|
||||
the `CampaignStore` interface exported by `@suaveplan/campaigns` so the
|
||||
in-process `CampaignEngine` and the HTTP route layer (Chunk C) can both
|
||||
read and write campaign records through a single, swappable port.
|
||||
|
||||
The repository follows the suaveplan SurrealDB idiom established by
|
||||
`@suaveplan/delivery-tracking`: every database call is a single
|
||||
`SurrealAdapter.query<T>(sql, params)` invocation using positional
|
||||
`$p0`/`$p1`/... placeholders, the SQL is auditable at a glance, and every
|
||||
adapter exception is wrapped in a `CampaignsApiError` with a stable
|
||||
`CAMPAIGNS_API.*` code so callers can branch on string equality rather
|
||||
than inspecting driver internals.
|
||||
|
||||
## Features
|
||||
|
||||
- Positional SurQL parameter binding (`$p0`, `$p1`, ...) — no named-var
|
||||
drift, no driver-specific quirks bleeding into our error messages.
|
||||
- Snake-case column ↔ camelCase property mapping handled in a single
|
||||
`rowToCampaign` helper so the column vocabulary lives in exactly one
|
||||
place.
|
||||
- Zod guards (`IdSchema`, `CampaignQuerySchema`) at the repository boundary
|
||||
— invalid input throws `INVALID_INPUT` before any SurQL is issued.
|
||||
- Idiomatic `update()` short-circuit: an empty `UpdateCampaignInput` skips
|
||||
the write and re-reads via `getById`, mirroring the upstream behaviour
|
||||
but with proper `CAMPAIGN_NOT_FOUND` semantics.
|
||||
- `InMemoryCampaignStore` re-export so consumers always have a single
|
||||
import surface regardless of backend choice.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SurrealCampaignRepository } from "@suaveplan/api-plugin-campaigns/repositories/campaign/campaign";
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
|
||||
declare const db: SurrealAdapter;
|
||||
const repo = new SurrealCampaignRepository({ db, clock: () => Date.now() });
|
||||
const campaign = await repo.getById("c-123");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SurrealCampaignRepository } from "@suaveplan/api-plugin-campaigns/repositories/campaign/campaign";
|
||||
import { CAMPAIGNS_API_CODES, CampaignsApiError } from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
const repo = new SurrealCampaignRepository({
|
||||
db,
|
||||
clock: () => Date.now(),
|
||||
table: "campaign_v2", // override for blue/green deploys
|
||||
});
|
||||
|
||||
try {
|
||||
await repo.update("c-123", { name: "Renamed" });
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof CampaignsApiError &&
|
||||
err.code === CAMPAIGNS_API_CODES.CAMPAIGN_NOT_FOUND
|
||||
) {
|
||||
// ... return a 404 from the HTTP layer
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface SurrealCampaignRepositoryOptions` — constructor bundle:
|
||||
`db`, `clock`, optional `table`, optional `logger`.
|
||||
- `class SurrealCampaignRepository implements CampaignStore` — exposes
|
||||
`create`, `getById`, `update`, `delete`, `list`, `updateStats`,
|
||||
`updateStatus`. Every method either returns a domain `Campaign` (or
|
||||
`Campaign[]`, `boolean`, `null`) or throws `CampaignsApiError`.
|
||||
- `InMemoryCampaignStore` — re-export from `@suaveplan/campaigns` for
|
||||
tests and prototype workloads.
|
||||
- `interface CampaignRow` — SurrealDB row shape consumed by
|
||||
`rowToCampaign`; exported from `./campaign.types.ts` for tests.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `update` short-circuit (no fields → `getById`) mirrors the upstream
|
||||
contract but raises `CAMPAIGN_NOT_FOUND` rather than a generic `Error`
|
||||
when the record is missing. SurrealDB rejects empty `SET` clauses, so the
|
||||
short-circuit is necessary to keep the public API ergonomic.
|
||||
|
||||
Optional fields land on the wire as explicit `null` rather than
|
||||
`undefined` because SurrealDB treats absent keys as "leave alone" on
|
||||
`MERGE`, which would surprise callers using the `Partial<CampaignStats>`
|
||||
input. Returning rows are normalised back to `undefined` in
|
||||
`rowToCampaign` so the in-memory and SurrealDB stores are
|
||||
indistinguishable from above.
|
||||
|
||||
`Zod` validation is intentionally narrow — only `id` and the listing
|
||||
`CampaignQuery` are validated. Full `Campaign` shape validation belongs in
|
||||
the route layer (Chunk C); the repository trusts callers to honour the
|
||||
upstream type contract while still defending against the two boundary
|
||||
shapes that come from untrusted HTTP input.
|
||||
@@ -0,0 +1,433 @@
|
||||
import type { Campaign } from "@suaveplan/campaigns";
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
CAMPAIGNS_API_CODES,
|
||||
CampaignsApiError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import {
|
||||
InMemoryCampaignStore,
|
||||
SurrealCampaignRepository,
|
||||
} from "./campaign.js";
|
||||
|
||||
type QueryFn = (sql: string, params?: readonly unknown[]) => Promise<unknown>;
|
||||
|
||||
function adapter(queryFn: QueryFn): SurrealAdapter {
|
||||
return { query: queryFn } as unknown as SurrealAdapter;
|
||||
}
|
||||
|
||||
function campaign(overrides: Partial<Campaign> = {}): Campaign {
|
||||
return {
|
||||
id: "c1",
|
||||
tenantId: "t1",
|
||||
name: "Test",
|
||||
status: "draft",
|
||||
type: "broadcast",
|
||||
channel: "email",
|
||||
audienceConfig: { groupIds: ["g1"] },
|
||||
metadata: {},
|
||||
stats: {
|
||||
totalRecipients: 0,
|
||||
sent: 0,
|
||||
delivered: 0,
|
||||
opened: 0,
|
||||
clicked: 0,
|
||||
bounced: 0,
|
||||
unsubscribed: 0,
|
||||
complained: 0,
|
||||
failed: 0,
|
||||
},
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function row(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
id: "c1",
|
||||
tenant_id: "t1",
|
||||
name: "Test",
|
||||
status: "draft",
|
||||
type: "broadcast",
|
||||
channel: "email",
|
||||
audience_config: { groupIds: ["g1"] },
|
||||
template_id: null,
|
||||
template_data: null,
|
||||
schedule: null,
|
||||
throttle: null,
|
||||
ab_test_config: null,
|
||||
metadata: { k: "v" },
|
||||
stats: { sent: 0 },
|
||||
created_at: "2026-01-01T00:00:00.000Z",
|
||||
updated_at: "2026-01-01T00:00:00.000Z",
|
||||
sent_at: null,
|
||||
completed_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function repo(queryFn: QueryFn, table?: string): SurrealCampaignRepository {
|
||||
return new SurrealCampaignRepository({
|
||||
db: adapter(queryFn),
|
||||
clock: () => 0,
|
||||
table,
|
||||
logger: { debug() {}, info() {}, warn() {}, error() {} } as never,
|
||||
});
|
||||
}
|
||||
|
||||
describe("SurrealCampaignRepository.create", () => {
|
||||
it("emits a positional INSERT with the campaign payload", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: { sql: string; params: readonly unknown[] }[] = [];
|
||||
const r = repo(async (sql, params) => {
|
||||
calls.push({ sql, params: params ?? [] });
|
||||
return [];
|
||||
});
|
||||
const c = campaign({ templateId: "tpl", templateData: { x: 1 } });
|
||||
const result = await r.create(c);
|
||||
expect(result).toBe(c);
|
||||
expect(calls[0]!.sql).toContain("INSERT INTO campaign");
|
||||
expect(calls[0]!.params[0]).toBe("c1");
|
||||
expect(calls[0]!.params[7]).toBe("tpl");
|
||||
expect(calls[0]!.params[8]).toEqual({ x: 1 });
|
||||
});
|
||||
|
||||
it("wraps adapter errors in DB_UNAVAILABLE", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = repo(async () => {
|
||||
throw new Error("driver-down");
|
||||
});
|
||||
let captured: unknown;
|
||||
try {
|
||||
await r.create(campaign());
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect(captured).toBeInstanceOf(CampaignsApiError);
|
||||
expect((captured as CampaignsApiError).code).toBe(
|
||||
CAMPAIGNS_API_CODES.DB_UNAVAILABLE
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SurrealCampaignRepository.getById", () => {
|
||||
it("returns null for an empty result", { timeout: 5000 }, async () => {
|
||||
const r = repo(async () => []);
|
||||
expect(await r.getById("missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("maps the row, parses Date strings and includes the WHERE clause", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: string[] = [];
|
||||
const r = repo(async (sql) => {
|
||||
calls.push(sql);
|
||||
return [row({ sent_at: "2026-02-01T00:00:00.000Z" })];
|
||||
});
|
||||
const c = await r.getById("c1");
|
||||
expect(c?.id).toBe("c1");
|
||||
expect(c?.createdAt).toBeInstanceOf(Date);
|
||||
expect(c?.sentAt).toBeInstanceOf(Date);
|
||||
expect(calls[0]).toContain("WHERE id = $p0");
|
||||
expect(calls[0]).toContain("FROM campaign");
|
||||
});
|
||||
|
||||
it("preserves a Date passed through (not a string)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ts = new Date("2026-06-01T12:00:00.000Z");
|
||||
const r = repo(async () => [row({ created_at: ts, updated_at: ts })]);
|
||||
const c = await r.getById("c1");
|
||||
expect(c?.createdAt).toEqual(ts);
|
||||
expect(c?.updatedAt).toEqual(ts);
|
||||
});
|
||||
|
||||
it("uses {} for missing metadata and undefined for null optionals", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = repo(async () => [
|
||||
row({
|
||||
metadata: null,
|
||||
schedule: { type: "immediate" },
|
||||
throttle: { messagesPerSecond: 1 },
|
||||
ab_test_config: { variants: [], splitType: "even" },
|
||||
template_id: "tpl",
|
||||
template_data: { x: 1 },
|
||||
completed_at: "2026-03-01",
|
||||
}),
|
||||
]);
|
||||
const c = await r.getById("c1");
|
||||
expect(c?.metadata).toEqual({});
|
||||
expect(c?.templateId).toBe("tpl");
|
||||
expect(c?.schedule).toEqual({ type: "immediate" });
|
||||
expect(c?.throttle).toEqual({ messagesPerSecond: 1 });
|
||||
expect(c?.abTestConfig).toEqual({ variants: [], splitType: "even" });
|
||||
expect(c?.completedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("falls back to epoch when timestamps are absent on the row", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = repo(async () => [
|
||||
row({ created_at: null as never, updated_at: null as never }),
|
||||
]);
|
||||
const c = await r.getById("c1");
|
||||
expect(c?.createdAt.getTime()).toBe(0);
|
||||
expect(c?.updatedAt.getTime()).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects an empty id with INVALID_INPUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = repo(async () => []);
|
||||
let captured: unknown;
|
||||
try {
|
||||
await r.getById("");
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect((captured as CampaignsApiError).code).toBe(
|
||||
CAMPAIGNS_API_CODES.INVALID_INPUT
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps query errors in DB_UNAVAILABLE", { timeout: 5000 }, async () => {
|
||||
const r = repo(async () => {
|
||||
throw new Error("network");
|
||||
});
|
||||
let captured: unknown;
|
||||
try {
|
||||
await r.getById("c1");
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect((captured as CampaignsApiError).code).toBe(
|
||||
CAMPAIGNS_API_CODES.DB_UNAVAILABLE
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SurrealCampaignRepository.update", () => {
|
||||
it("issues an UPDATE with each provided field bound positionally", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: { sql: string; params: readonly unknown[] }[] = [];
|
||||
const r = repo(async (sql, params) => {
|
||||
calls.push({ sql, params: params ?? [] });
|
||||
return [row({ name: "Renamed" })];
|
||||
});
|
||||
const result = await r.update("c1", {
|
||||
name: "Renamed",
|
||||
channel: "sms",
|
||||
audienceConfig: { groupIds: ["g2"] },
|
||||
templateId: "tpl-2",
|
||||
templateData: { y: 2 },
|
||||
schedule: { type: "immediate" },
|
||||
throttle: { messagesPerSecond: 1 },
|
||||
abTestConfig: { variants: [], splitType: "even" },
|
||||
metadata: { z: 3 },
|
||||
});
|
||||
expect(result.name).toBe("Renamed");
|
||||
expect(calls[0]!.sql).toContain("name = $p1");
|
||||
expect(calls[0]!.sql).toContain("channel = $p2");
|
||||
expect(calls[0]!.sql).toContain("metadata = $p9");
|
||||
expect(calls[0]!.sql).toContain("WHERE id = $p0");
|
||||
});
|
||||
|
||||
it("short-circuits to getById when no fields supplied (found)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let getCalls = 0;
|
||||
const r = repo(async (sql) => {
|
||||
if (sql.startsWith("SELECT")) {
|
||||
getCalls++;
|
||||
return [row()];
|
||||
}
|
||||
throw new Error("should not have run UPDATE");
|
||||
});
|
||||
const result = await r.update("c1", {});
|
||||
expect(result.id).toBe("c1");
|
||||
expect(getCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("short-circuits to getById when no fields supplied (missing)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = repo(async () => []);
|
||||
let captured: unknown;
|
||||
try {
|
||||
await r.update("c1", {});
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect((captured as CampaignsApiError).code).toBe(
|
||||
CAMPAIGNS_API_CODES.CAMPAIGN_NOT_FOUND
|
||||
);
|
||||
});
|
||||
|
||||
it("throws CAMPAIGN_NOT_FOUND when UPDATE returns no rows", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = repo(async () => []);
|
||||
let captured: unknown;
|
||||
try {
|
||||
await r.update("c1", { name: "x" });
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect((captured as CampaignsApiError).code).toBe(
|
||||
CAMPAIGNS_API_CODES.CAMPAIGN_NOT_FOUND
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SurrealCampaignRepository.delete + list + updateStats + updateStatus", () => {
|
||||
it("delete returns true when DELETE returns a row", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = repo(async () => [row()]);
|
||||
expect(await r.delete("c1")).toBe(true);
|
||||
});
|
||||
|
||||
it("delete returns false when DELETE returns nothing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = repo(async () => []);
|
||||
expect(await r.delete("c1")).toBe(false);
|
||||
});
|
||||
|
||||
it("list threads filters + paging into SQL and binds them positionally", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: { sql: string; params: readonly unknown[] }[] = [];
|
||||
const r = repo(async (sql, params) => {
|
||||
calls.push({ sql, params: params ?? [] });
|
||||
return [row(), row({ id: "c2" })];
|
||||
});
|
||||
const result = await r.list({
|
||||
tenantId: "t1",
|
||||
status: "draft",
|
||||
type: "broadcast",
|
||||
channel: "email",
|
||||
limit: 25,
|
||||
offset: 5,
|
||||
});
|
||||
expect(result).toHaveLength(2);
|
||||
expect(calls[0]!.sql).toContain("tenant_id = $p0");
|
||||
expect(calls[0]!.sql).toContain("status = $p1");
|
||||
expect(calls[0]!.sql).toContain("type = $p2");
|
||||
expect(calls[0]!.sql).toContain("channel = $p3");
|
||||
expect(calls[0]!.sql).toContain("LIMIT 25");
|
||||
expect(calls[0]!.sql).toContain("START 5");
|
||||
});
|
||||
|
||||
it("list applies defaults when limit/offset omitted and no filters", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: string[] = [];
|
||||
const r = repo(async (sql) => {
|
||||
calls.push(sql);
|
||||
return [];
|
||||
});
|
||||
await r.list({});
|
||||
expect(calls[0]).toContain("LIMIT 50");
|
||||
expect(calls[0]).not.toContain("WHERE");
|
||||
expect(calls[0]).not.toContain("START");
|
||||
});
|
||||
|
||||
it("list rejects invalid query (negative limit)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = repo(async () => []);
|
||||
let captured: unknown;
|
||||
try {
|
||||
await r.list({ limit: -1 });
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect((captured as CampaignsApiError).code).toBe(
|
||||
CAMPAIGNS_API_CODES.INVALID_INPUT
|
||||
);
|
||||
});
|
||||
|
||||
it("updateStats MERGEs and returns the mapped row", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: { sql: string; params: readonly unknown[] }[] = [];
|
||||
const r = repo(async (sql, params) => {
|
||||
calls.push({ sql, params: params ?? [] });
|
||||
return [row({ stats: { sent: 5 } })];
|
||||
});
|
||||
const result = await r.updateStats("c1", { sent: 5 });
|
||||
expect(result.stats).toEqual({ sent: 5 });
|
||||
expect(calls[0]!.sql).toContain("MERGE { stats: $p1 }");
|
||||
expect(calls[0]!.params[1]).toEqual({ sent: 5 });
|
||||
});
|
||||
|
||||
it("updateStats throws CAMPAIGN_NOT_FOUND on empty result", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = repo(async () => []);
|
||||
let captured: unknown;
|
||||
try {
|
||||
await r.updateStats("c1", { sent: 1 });
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect((captured as CampaignsApiError).code).toBe(
|
||||
CAMPAIGNS_API_CODES.CAMPAIGN_NOT_FOUND
|
||||
);
|
||||
});
|
||||
|
||||
it("updateStatus sets the new status positionally", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: { sql: string; params: readonly unknown[] }[] = [];
|
||||
const r = repo(async (sql, params) => {
|
||||
calls.push({ sql, params: params ?? [] });
|
||||
return [row({ status: "sending" })];
|
||||
});
|
||||
const c = await r.updateStatus("c1", "sending");
|
||||
expect(c.status).toBe("sending");
|
||||
expect(calls[0]!.sql).toContain("SET status = $p1");
|
||||
expect(calls[0]!.params[1]).toBe("sending");
|
||||
});
|
||||
|
||||
it("updateStatus throws CAMPAIGN_NOT_FOUND on empty result", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = repo(async () => []);
|
||||
let captured: unknown;
|
||||
try {
|
||||
await r.updateStatus("c1", "sending");
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect((captured as CampaignsApiError).code).toBe(
|
||||
CAMPAIGNS_API_CODES.CAMPAIGN_NOT_FOUND
|
||||
);
|
||||
});
|
||||
|
||||
it("respects a custom table name", { timeout: 5000 }, async () => {
|
||||
const calls: string[] = [];
|
||||
const r = repo(async (sql) => {
|
||||
calls.push(sql);
|
||||
return [];
|
||||
}, "custom_campaigns");
|
||||
await r.list({});
|
||||
expect(calls[0]).toContain("FROM custom_campaigns");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InMemoryCampaignStore re-export", () => {
|
||||
it("is exported from the same module entry point", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(typeof InMemoryCampaignStore).toBe("function");
|
||||
const store = new InMemoryCampaignStore();
|
||||
expect(store).toBeInstanceOf(InMemoryCampaignStore);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* SurrealDB-backed implementation of the `CampaignStore` contract from
|
||||
* `@suaveplan/campaigns`. Uses positional `$p0`/`$p1`/... placeholders
|
||||
* against `SurrealAdapter.query` per the genesis idiom established by
|
||||
* `@suaveplan/delivery-tracking`. The in-memory implementation is exposed
|
||||
* as a re-export of `@suaveplan/campaigns` `InMemoryCampaignStore`.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Campaign,
|
||||
CampaignQuery,
|
||||
CampaignStats,
|
||||
CampaignStatus,
|
||||
CampaignStore,
|
||||
UpdateCampaignInput,
|
||||
} from "@suaveplan/campaigns";
|
||||
import { InMemoryCampaignStore } from "@suaveplan/campaigns";
|
||||
import z from "zod";
|
||||
import {
|
||||
CAMPAIGNS_API_CODES,
|
||||
CampaignsApiError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import type {
|
||||
CampaignRow,
|
||||
SurrealCampaignRepositoryOptions,
|
||||
} from "./campaign.types.js";
|
||||
|
||||
/** Zod guard at the repository boundary for the `id` argument. */
|
||||
const IdSchema = z.string().min(1, "campaign id must be a non-empty string");
|
||||
|
||||
/** Zod guard at the repository boundary for the listing `CampaignQuery`. */
|
||||
const CampaignQuerySchema = z
|
||||
.object({
|
||||
tenantId: z.string().min(1).optional(),
|
||||
status: z.string().min(1).optional(),
|
||||
type: z.string().min(1).optional(),
|
||||
channel: z.string().min(1).optional(),
|
||||
limit: z.number().int().min(1).max(1000).optional(),
|
||||
offset: z.number().int().min(0).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
function toDate(value: Date | string | null | undefined): Date | undefined {
|
||||
if (value === null || value === undefined) return undefined;
|
||||
return value instanceof Date ? value : new Date(value);
|
||||
}
|
||||
|
||||
function rowToCampaign(row: CampaignRow): Campaign {
|
||||
const templateId = row.template_id ?? undefined;
|
||||
const templateData = row.template_data ?? undefined;
|
||||
const schedule = (row.schedule ?? undefined) as Campaign["schedule"];
|
||||
const throttle = (row.throttle ?? undefined) as Campaign["throttle"];
|
||||
const abTestConfig = (row.ab_test_config ??
|
||||
undefined) as Campaign["abTestConfig"];
|
||||
const sentAt = toDate(row.sent_at);
|
||||
const completedAt = toDate(row.completed_at);
|
||||
return {
|
||||
id: row.id,
|
||||
tenantId: row.tenant_id,
|
||||
name: row.name,
|
||||
status: row.status as CampaignStatus,
|
||||
type: row.type as Campaign["type"],
|
||||
channel: row.channel,
|
||||
audienceConfig: row.audience_config as Campaign["audienceConfig"],
|
||||
...(templateId !== undefined ? { templateId } : {}),
|
||||
...(templateData !== undefined ? { templateData } : {}),
|
||||
...(schedule !== undefined ? { schedule } : {}),
|
||||
...(throttle !== undefined ? { throttle } : {}),
|
||||
...(abTestConfig !== undefined ? { abTestConfig } : {}),
|
||||
metadata: row.metadata ?? {},
|
||||
stats: row.stats as CampaignStats,
|
||||
createdAt: toDate(row.created_at) ?? new Date(0),
|
||||
updatedAt: toDate(row.updated_at) ?? new Date(0),
|
||||
...(sentAt !== undefined ? { sentAt } : {}),
|
||||
...(completedAt !== undefined ? { completedAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SurrealDB-backed `CampaignStore`. All Surreal calls go through the
|
||||
* adapter's `query` method with a positional parameter list so the SQL is
|
||||
* auditable and identical across drivers. Errors are wrapped in
|
||||
* `CampaignsApiError` with the appropriate `CAMPAIGNS_API.*` code so route
|
||||
* handlers can branch on stable strings.
|
||||
*/
|
||||
export class SurrealCampaignRepository implements CampaignStore {
|
||||
private readonly db: SurrealCampaignRepositoryOptions["db"];
|
||||
private readonly table: string;
|
||||
|
||||
public constructor(options: SurrealCampaignRepositoryOptions) {
|
||||
this.db = options.db;
|
||||
this.table = options.table ?? "campaign";
|
||||
}
|
||||
|
||||
public async create(campaign: Campaign): Promise<Campaign> {
|
||||
const params: unknown[] = [
|
||||
campaign.id,
|
||||
campaign.tenantId,
|
||||
campaign.name,
|
||||
campaign.status,
|
||||
campaign.type,
|
||||
campaign.channel,
|
||||
campaign.audienceConfig,
|
||||
campaign.templateId ?? null,
|
||||
campaign.templateData ?? null,
|
||||
campaign.schedule ?? null,
|
||||
campaign.throttle ?? null,
|
||||
campaign.abTestConfig ?? null,
|
||||
campaign.metadata,
|
||||
campaign.stats,
|
||||
campaign.createdAt,
|
||||
campaign.updatedAt,
|
||||
campaign.sentAt ?? null,
|
||||
campaign.completedAt ?? null,
|
||||
];
|
||||
const sql = `INSERT INTO ${this.table} {
|
||||
id: $p0, tenant_id: $p1, name: $p2, status: $p3, type: $p4,
|
||||
channel: $p5, audience_config: $p6, template_id: $p7,
|
||||
template_data: $p8, schedule: $p9, throttle: $p10,
|
||||
ab_test_config: $p11, metadata: $p12, stats: $p13,
|
||||
created_at: $p14, updated_at: $p15, sent_at: $p16,
|
||||
completed_at: $p17
|
||||
}`;
|
||||
await this.run<CampaignRow>(sql, params, "create");
|
||||
return campaign;
|
||||
}
|
||||
|
||||
public async getById(id: string): Promise<Campaign | null> {
|
||||
const parsed = IdSchema.safeParse(id);
|
||||
if (!parsed.success) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.INVALID_INPUT,
|
||||
"campaign id is required",
|
||||
{ cause: parsed.error, context: { op: "getById" } }
|
||||
);
|
||||
}
|
||||
const rows = await this.run<CampaignRow>(
|
||||
`SELECT * FROM ${this.table} WHERE id = $p0 LIMIT 1`,
|
||||
[id],
|
||||
"getById"
|
||||
);
|
||||
return rows[0] !== undefined ? rowToCampaign(rows[0]) : null;
|
||||
}
|
||||
|
||||
public async update(
|
||||
id: string,
|
||||
input: UpdateCampaignInput
|
||||
): Promise<Campaign> {
|
||||
const sets: string[] = [];
|
||||
const params: unknown[] = [id];
|
||||
const push = (col: string, value: unknown): void => {
|
||||
params.push(value);
|
||||
sets.push(`${col} = $p${params.length - 1}`);
|
||||
};
|
||||
if (input.name !== undefined) push("name", input.name);
|
||||
if (input.channel !== undefined) push("channel", input.channel);
|
||||
if (input.audienceConfig !== undefined) {
|
||||
push("audience_config", input.audienceConfig);
|
||||
}
|
||||
if (input.templateId !== undefined) {
|
||||
push("template_id", input.templateId);
|
||||
}
|
||||
if (input.templateData !== undefined) {
|
||||
push("template_data", input.templateData);
|
||||
}
|
||||
if (input.schedule !== undefined) push("schedule", input.schedule);
|
||||
if (input.throttle !== undefined) push("throttle", input.throttle);
|
||||
if (input.abTestConfig !== undefined) {
|
||||
push("ab_test_config", input.abTestConfig);
|
||||
}
|
||||
if (input.metadata !== undefined) push("metadata", input.metadata);
|
||||
if (sets.length === 0) {
|
||||
const existing = await this.getById(id);
|
||||
if (existing === null) throw this.notFound(id, "update");
|
||||
return existing;
|
||||
}
|
||||
const sql = `UPDATE ${this.table} SET ${sets.join(", ")} WHERE id = $p0 RETURN AFTER`;
|
||||
const rows = await this.run<CampaignRow>(sql, params, "update");
|
||||
if (rows[0] === undefined) throw this.notFound(id, "update");
|
||||
return rowToCampaign(rows[0]);
|
||||
}
|
||||
|
||||
public async delete(id: string): Promise<boolean> {
|
||||
const rows = await this.run<CampaignRow>(
|
||||
`DELETE ${this.table} WHERE id = $p0 RETURN BEFORE`,
|
||||
[id],
|
||||
"delete"
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
public async list(query: CampaignQuery): Promise<Campaign[]> {
|
||||
const parsed = CampaignQuerySchema.safeParse(query);
|
||||
if (!parsed.success) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.INVALID_INPUT,
|
||||
"invalid campaign query",
|
||||
{ cause: parsed.error, context: { op: "list" } }
|
||||
);
|
||||
}
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
const push = (col: string, value: unknown): void => {
|
||||
params.push(value);
|
||||
conditions.push(`${col} = $p${params.length - 1}`);
|
||||
};
|
||||
if (query.tenantId !== undefined) push("tenant_id", query.tenantId);
|
||||
if (query.status !== undefined) push("status", query.status);
|
||||
if (query.type !== undefined) push("type", query.type);
|
||||
if (query.channel !== undefined) push("channel", query.channel);
|
||||
const where =
|
||||
conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
|
||||
const limit = query.limit ?? 50;
|
||||
const offset = query.offset ?? 0;
|
||||
let sql = `SELECT * FROM ${this.table}${where} LIMIT ${limit}`;
|
||||
if (offset > 0) sql += ` START ${offset}`;
|
||||
const rows = await this.run<CampaignRow>(sql, params, "list");
|
||||
return rows.map(rowToCampaign);
|
||||
}
|
||||
|
||||
public async updateStats(
|
||||
id: string,
|
||||
stats: Partial<CampaignStats>
|
||||
): Promise<Campaign> {
|
||||
const rows = await this.run<CampaignRow>(
|
||||
`UPDATE ${this.table} MERGE { stats: $p1 } WHERE id = $p0 RETURN AFTER`,
|
||||
[id, stats],
|
||||
"updateStats"
|
||||
);
|
||||
if (rows[0] === undefined) throw this.notFound(id, "updateStats");
|
||||
return rowToCampaign(rows[0]);
|
||||
}
|
||||
|
||||
public async updateStatus(
|
||||
id: string,
|
||||
status: CampaignStatus
|
||||
): Promise<Campaign> {
|
||||
const rows = await this.run<CampaignRow>(
|
||||
`UPDATE ${this.table} SET status = $p1 WHERE id = $p0 RETURN AFTER`,
|
||||
[id, status],
|
||||
"updateStatus"
|
||||
);
|
||||
if (rows[0] === undefined) throw this.notFound(id, "updateStatus");
|
||||
return rowToCampaign(rows[0]);
|
||||
}
|
||||
|
||||
private async run<T>(
|
||||
sql: string,
|
||||
params: unknown[],
|
||||
op: string
|
||||
): Promise<T[]> {
|
||||
try {
|
||||
return await this.db.query<T>(sql, params);
|
||||
} catch (cause) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.DB_UNAVAILABLE,
|
||||
`SurrealCampaignRepository.${op} failed`,
|
||||
{ cause, context: { op } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private notFound(id: string, op: string): CampaignsApiError {
|
||||
return new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.CAMPAIGN_NOT_FOUND,
|
||||
`campaign not found: ${id}`,
|
||||
{ context: { op, id } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { InMemoryCampaignStore };
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Type definitions for the campaign repository module. Concentrated here
|
||||
* so the implementation file stays under the 300-line hard cap and so
|
||||
* external callers can `import type` without pulling in the SurrealDB
|
||||
* adapter symbol.
|
||||
*/
|
||||
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import type { Clock, LoggerLike } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Constructor bundle for {@link SurrealCampaignRepository}. `db` is the
|
||||
* connected `SurrealAdapter`; `clock` is the injectable wall-clock used to
|
||||
* stamp `updatedAt` on Surreal-side `MERGE` paths; `table` overrides the
|
||||
* default `"campaign"` SurrealDB table name for test isolation.
|
||||
*/
|
||||
export interface SurrealCampaignRepositoryOptions {
|
||||
readonly db: SurrealAdapter;
|
||||
readonly clock: Clock;
|
||||
readonly table?: string;
|
||||
readonly logger?: LoggerLike;
|
||||
}
|
||||
|
||||
/**
|
||||
* Row shape returned by SurrealDB for the `campaign` table. The columns
|
||||
* mirror the `@suaveplan/campaigns` `Campaign` interface but use
|
||||
* SurrealDB-friendly null sentinels instead of `undefined` for optional
|
||||
* fields, and Date strings on read.
|
||||
*/
|
||||
export interface CampaignRow {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
type: string;
|
||||
channel: string;
|
||||
audience_config: unknown;
|
||||
template_id?: string | null;
|
||||
template_data?: Record<string, unknown> | null;
|
||||
schedule?: unknown;
|
||||
throttle?: unknown;
|
||||
ab_test_config?: unknown;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
stats: unknown;
|
||||
created_at: Date | string;
|
||||
updated_at: Date | string;
|
||||
sent_at?: Date | string | null;
|
||||
completed_at?: Date | string | null;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
InMemoryCampaignStore,
|
||||
SurrealCampaignRepository,
|
||||
} from "./campaign.js";
|
||||
export type {
|
||||
CampaignRow,
|
||||
SurrealCampaignRepositoryOptions,
|
||||
} from "./campaign.types.js";
|
||||
@@ -0,0 +1,24 @@
|
||||
export {
|
||||
InMemoryCampaignStore,
|
||||
SurrealCampaignRepository,
|
||||
} from "./campaign/campaign.js";
|
||||
export type {
|
||||
CampaignRow,
|
||||
SurrealCampaignRepositoryOptions,
|
||||
} from "./campaign/campaign.types.js";
|
||||
export {
|
||||
CreateInputSchema as SuppressionCreateInputSchema,
|
||||
QuerySchema as SuppressionQuerySchema,
|
||||
SurrealCampaignSuppressionRepository,
|
||||
} from "./suppression/suppression.js";
|
||||
export type {
|
||||
CampaignSuppressionEntry,
|
||||
CampaignSuppressionQuery,
|
||||
CampaignSuppressionReason,
|
||||
CampaignSuppressionRow,
|
||||
CampaignSuppressionStore,
|
||||
CreateCampaignSuppressionInput,
|
||||
InMemoryCampaignSuppressionStoreOptions,
|
||||
SurrealCampaignSuppressionRepositoryOptions,
|
||||
} from "./suppression/suppression.types.js";
|
||||
export { InMemoryCampaignSuppressionStore } from "./suppression/suppression-memory.js";
|
||||
@@ -0,0 +1,16 @@
|
||||
export {
|
||||
CreateInputSchema,
|
||||
QuerySchema,
|
||||
SurrealCampaignSuppressionRepository,
|
||||
} from "./suppression.js";
|
||||
export type {
|
||||
CampaignSuppressionEntry,
|
||||
CampaignSuppressionQuery,
|
||||
CampaignSuppressionReason,
|
||||
CampaignSuppressionRow,
|
||||
CampaignSuppressionStore,
|
||||
CreateCampaignSuppressionInput,
|
||||
InMemoryCampaignSuppressionStoreOptions,
|
||||
SurrealCampaignSuppressionRepositoryOptions,
|
||||
} from "./suppression.types.js";
|
||||
export { InMemoryCampaignSuppressionStore } from "./suppression-memory.js";
|
||||
@@ -0,0 +1,88 @@
|
||||
# `repositories/suppression/suppression-internals`
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the validation and mapping primitives shared by the two
|
||||
`CampaignSuppressionStore` implementations — `ReasonSchema`,
|
||||
`CreateInputSchema`, `QuerySchema`, the `requireTenant` guard, the
|
||||
`rowToEntry` row mapper, and the `validateCreate` wrapper. Keeping these
|
||||
helpers in a dedicated sibling module is what allows both the SurrealDB
|
||||
implementation (`./suppression.ts`) and the in-memory reference
|
||||
(`./suppression-memory.ts`) to stay under the 300-line hard cap without
|
||||
duplicating their entry-validation logic.
|
||||
|
||||
## Features
|
||||
|
||||
- `ReasonSchema` — Zod enum guard over the five canonical
|
||||
`CampaignSuppressionReason` values (`unsubscribe`, `bounce`,
|
||||
`complaint`, `manual`, `compliance`).
|
||||
- `CreateInputSchema` / `QuerySchema` — strict Zod object guards for the
|
||||
two public input shapes.
|
||||
- `requireTenant(tenantId)` — boundary check that throws
|
||||
`CAMPAIGNS_API.TENANT_REQUIRED` for an empty string.
|
||||
- `validateCreate(input)` — parses with `CreateInputSchema` and throws
|
||||
`CAMPAIGNS_API.INVALID_INPUT` on failure with the underlying `ZodError`
|
||||
attached as `cause`.
|
||||
- `rowToEntry(row)` — maps `CampaignSuppressionRow` ↔ `CampaignSuppressionEntry`
|
||||
with `Date` normalisation and `null` → `undefined` collapsing.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { validateCreate, requireTenant } from "./suppression-internals.js";
|
||||
|
||||
requireTenant("tenant-1");
|
||||
const data = validateCreate({
|
||||
tenantId: "tenant-1",
|
||||
channel: "email",
|
||||
channelValue: "user@example.com",
|
||||
reason: "unsubscribe",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { QuerySchema, rowToEntry } from "./suppression-internals.js";
|
||||
|
||||
const parsed = QuerySchema.safeParse({
|
||||
tenantId: "t1",
|
||||
channel: "sms",
|
||||
limit: 25,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
// handle ZodError without going through the repository
|
||||
}
|
||||
|
||||
const entry = rowToEntry({
|
||||
id: "s1",
|
||||
tenant_id: "t1",
|
||||
channel: "sms",
|
||||
channel_value: "+15551234567",
|
||||
reason: "complaint",
|
||||
metadata: null,
|
||||
created_at: "2026-05-24T00:00:00.000Z",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `const ReasonSchema: z.ZodEnum`
|
||||
- `const CreateInputSchema: z.ZodObject`
|
||||
- `const QuerySchema: z.ZodObject`
|
||||
- `function requireTenant(tenantId: string): void` — throws on empty.
|
||||
- `function rowToEntry(row: CampaignSuppressionRow): CampaignSuppressionEntry`
|
||||
- `function validateCreate(input: CreateCampaignSuppressionInput): CreateCampaignSuppressionInput`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`CreateInputSchema` and `QuerySchema` use `.strict()` so unknown keys
|
||||
fail validation rather than silently flowing through to SurrealDB —
|
||||
this keeps the repository contract honest against evolving HTTP payloads
|
||||
and stops typos from becoming silent feature requests.
|
||||
|
||||
The schemas live here (not in `../../schemas/`) because they are
|
||||
implementation details of the suppression repository, not part of the
|
||||
plugin's public option surface. The plugin's `options` schema in
|
||||
`../../schemas/options.ts` is the user-facing configuration contract; the
|
||||
repository schemas are private guards at a different boundary.
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Internal helpers shared by `SurrealCampaignSuppressionRepository` and
|
||||
* `InMemoryCampaignSuppressionStore`: Zod schemas guarding the
|
||||
* repository boundary, the tenant-presence check, the row → entry
|
||||
* mapper, and the `validateCreate` helper. Kept in a sibling module
|
||||
* rather than inlined so each implementation file stays under the
|
||||
* 300-line hard cap.
|
||||
*/
|
||||
|
||||
import z from "zod";
|
||||
import {
|
||||
CAMPAIGNS_API_CODES,
|
||||
CampaignsApiError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import type {
|
||||
CampaignSuppressionEntry,
|
||||
CampaignSuppressionReason,
|
||||
CampaignSuppressionRow,
|
||||
CreateCampaignSuppressionInput,
|
||||
} from "./suppression.types.js";
|
||||
|
||||
/** Zod enum guard for `CampaignSuppressionReason`. */
|
||||
export const ReasonSchema = z.enum([
|
||||
"unsubscribe",
|
||||
"bounce",
|
||||
"complaint",
|
||||
"manual",
|
||||
"compliance",
|
||||
]);
|
||||
|
||||
/** Zod guard for `CreateCampaignSuppressionInput`. */
|
||||
export const CreateInputSchema = z
|
||||
.object({
|
||||
tenantId: z.string().min(1, "tenantId is required"),
|
||||
channel: z.string().min(1, "channel is required"),
|
||||
channelValue: z.string().min(1, "channelValue is required"),
|
||||
reason: ReasonSchema,
|
||||
source: z.string().optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Zod guard for `CampaignSuppressionQuery`. */
|
||||
export const QuerySchema = z
|
||||
.object({
|
||||
tenantId: z.string().min(1, "tenantId is required"),
|
||||
channel: z.string().min(1).optional(),
|
||||
reason: ReasonSchema.optional(),
|
||||
limit: z.number().int().min(1).max(1000).optional(),
|
||||
offset: z.number().int().min(0).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Throws `TENANT_REQUIRED` when `tenantId` is the empty string. */
|
||||
export function requireTenant(tenantId: string): void {
|
||||
if (tenantId.length === 0) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.TENANT_REQUIRED,
|
||||
"tenantId is required"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Maps a Surreal row to a domain `CampaignSuppressionEntry`. */
|
||||
export function rowToEntry(
|
||||
row: CampaignSuppressionRow
|
||||
): CampaignSuppressionEntry {
|
||||
const source = row.source ?? undefined;
|
||||
const metadata = row.metadata ?? undefined;
|
||||
return {
|
||||
id: row.id,
|
||||
tenantId: row.tenant_id,
|
||||
channel: row.channel,
|
||||
channelValue: row.channel_value,
|
||||
reason: row.reason as CampaignSuppressionReason,
|
||||
...(source !== undefined ? { source } : {}),
|
||||
...(metadata !== undefined ? { metadata } : {}),
|
||||
createdAt:
|
||||
row.created_at instanceof Date
|
||||
? row.created_at
|
||||
: new Date(row.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
/** Validates a create-input or throws `INVALID_INPUT`. */
|
||||
export function validateCreate(
|
||||
input: CreateCampaignSuppressionInput
|
||||
): CreateCampaignSuppressionInput {
|
||||
const parsed = CreateInputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.INVALID_INPUT,
|
||||
"invalid suppression input",
|
||||
{ cause: parsed.error, context: { op: "add" } }
|
||||
);
|
||||
}
|
||||
const data = parsed.data;
|
||||
return {
|
||||
tenantId: data.tenantId,
|
||||
channel: data.channel,
|
||||
channelValue: data.channelValue,
|
||||
reason: data.reason,
|
||||
...(data.source !== undefined ? { source: data.source } : {}),
|
||||
...(data.metadata !== undefined ? { metadata: data.metadata } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
# `repositories/suppression/suppression-memory`
|
||||
|
||||
## Purpose
|
||||
|
||||
In-memory reference implementation of the tenant + channel-scoped
|
||||
`CampaignSuppressionStore` interface. Intended for unit tests, prototype
|
||||
workloads, and the default in-process backend before a SurrealDB
|
||||
connection is wired up. Mirrors the public surface of the SurrealDB
|
||||
implementation byte-for-byte so swapping between the two is a pure
|
||||
constructor change.
|
||||
|
||||
Records are stored in a `Map` keyed by entry id; every read returns a
|
||||
shallow clone so callers cannot mutate internal records by reference.
|
||||
The implementation shares its Zod boundary guards with the SurrealDB
|
||||
repository via `./suppression-internals.ts`, ensuring both
|
||||
implementations reject invalid inputs identically.
|
||||
|
||||
## Features
|
||||
|
||||
- `Map`-backed storage with O(1) lookups by id and linear scans for
|
||||
`(tenantId, channel, channelValue)` checks.
|
||||
- Defensive cloning on read — `{ ...entry }` everywhere a record leaves
|
||||
the store.
|
||||
- Duplicate detection against the `(tenantId, channel, channelValue)`
|
||||
triple — throws `CAMPAIGNS_API.DUPLICATE_SUPPRESSION` rather than
|
||||
silently overwriting.
|
||||
- Injectable `Clock` for `createdAt` stamping and `IdGenerator` for entry
|
||||
ids so tests can produce deterministic fixtures.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { InMemoryCampaignSuppressionStore } from "@suaveplan/api-plugin-campaigns/repositories/suppression/suppression";
|
||||
|
||||
const store = new InMemoryCampaignSuppressionStore({
|
||||
clock: () => Date.now(),
|
||||
idGenerator: () => crypto.randomUUID(),
|
||||
});
|
||||
|
||||
await store.add({
|
||||
tenantId: "t1",
|
||||
channel: "email",
|
||||
channelValue: "user@example.com",
|
||||
reason: "unsubscribe",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { InMemoryCampaignSuppressionStore } from "@suaveplan/api-plugin-campaigns/repositories/suppression/suppression";
|
||||
|
||||
let now = 1_700_000_000_000;
|
||||
let n = 0;
|
||||
const store = new InMemoryCampaignSuppressionStore({
|
||||
clock: () => now,
|
||||
idGenerator: () => `s-${n++}`,
|
||||
});
|
||||
|
||||
await store.add({
|
||||
tenantId: "t1",
|
||||
channel: "sms",
|
||||
channelValue: "+15551234567",
|
||||
reason: "complaint",
|
||||
});
|
||||
const entries = await store.list({ tenantId: "t1", channel: "sms" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class InMemoryCampaignSuppressionStore implements CampaignSuppressionStore`
|
||||
- `new InMemoryCampaignSuppressionStore({ clock, idGenerator })`
|
||||
- `add`, `remove`, `check`, `list`, `count`, `removeByChannelValue` —
|
||||
all match the interface signature exactly.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The constructor is deliberately required-arguments (no defaults) so
|
||||
unit tests never accidentally rely on the wall-clock or
|
||||
`crypto.randomUUID`. This mirrors the rest of Suaveplan's
|
||||
convention for injectable seams: opt-in only, never magical.
|
||||
|
||||
The duplicate-check is performed by re-using `check()` rather than a
|
||||
separate code path so the in-memory and SurrealDB implementations share
|
||||
identical duplicate-detection semantics — a regression in one is a
|
||||
regression in the other, and the test suite picks it up immediately.
|
||||
|
||||
This module intentionally re-uses the `QuerySchema` from
|
||||
`./suppression-internals.ts` rather than re-parsing inline; the schema
|
||||
is treated as the single source of truth for input validation across
|
||||
both backends, and any drift between in-memory and SurrealDB behaviour
|
||||
is detectable by reading just one file.
|
||||
@@ -0,0 +1,306 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
CAMPAIGNS_API_CODES,
|
||||
type CampaignsApiError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { InMemoryCampaignSuppressionStore } from "./suppression-memory.js";
|
||||
|
||||
function store(): InMemoryCampaignSuppressionStore {
|
||||
let n = 0;
|
||||
return new InMemoryCampaignSuppressionStore({
|
||||
clock: () => 1_700_000_000_000,
|
||||
idGenerator: () => `mem-${n++}`,
|
||||
});
|
||||
}
|
||||
|
||||
describe("InMemoryCampaignSuppressionStore.add + check + remove", () => {
|
||||
it("adds and checks an entry round-trip", { timeout: 5000 }, async () => {
|
||||
const s = store();
|
||||
const entry = await s.add({
|
||||
tenantId: "t1",
|
||||
channel: "email",
|
||||
channelValue: "u@e.com",
|
||||
reason: "unsubscribe",
|
||||
});
|
||||
expect(entry.id).toBe("mem-0");
|
||||
const found = await s.check("t1", "email", "u@e.com");
|
||||
expect(found?.id).toBe("mem-0");
|
||||
});
|
||||
|
||||
it("returns a defensive clone from check + add", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const s = store();
|
||||
const added = await s.add({
|
||||
tenantId: "t1",
|
||||
channel: "email",
|
||||
channelValue: "u@e.com",
|
||||
reason: "unsubscribe",
|
||||
});
|
||||
const a = await s.check("t1", "email", "u@e.com");
|
||||
expect(a).not.toBe(added);
|
||||
});
|
||||
|
||||
it("returns null on miss", { timeout: 5000 }, async () => {
|
||||
const s = store();
|
||||
expect(await s.check("t1", "email", "nope@e.com")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects duplicate (tenant, channel, value) with DUPLICATE_SUPPRESSION", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const s = store();
|
||||
await s.add({
|
||||
tenantId: "t1",
|
||||
channel: "email",
|
||||
channelValue: "u@e.com",
|
||||
reason: "unsubscribe",
|
||||
});
|
||||
let captured: unknown;
|
||||
try {
|
||||
await s.add({
|
||||
tenantId: "t1",
|
||||
channel: "email",
|
||||
channelValue: "u@e.com",
|
||||
reason: "bounce",
|
||||
});
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect((captured as CampaignsApiError).code).toBe(
|
||||
CAMPAIGNS_API_CODES.DUPLICATE_SUPPRESSION
|
||||
);
|
||||
});
|
||||
|
||||
it("remove returns true once then false", { timeout: 5000 }, async () => {
|
||||
const s = store();
|
||||
const entry = await s.add({
|
||||
tenantId: "t1",
|
||||
channel: "email",
|
||||
channelValue: "u@e.com",
|
||||
reason: "unsubscribe",
|
||||
});
|
||||
expect(await s.remove(entry.id)).toBe(true);
|
||||
expect(await s.remove(entry.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("remove rejects an empty id with INVALID_INPUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const s = store();
|
||||
let captured: unknown;
|
||||
try {
|
||||
await s.remove("");
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect((captured as CampaignsApiError).code).toBe(
|
||||
CAMPAIGNS_API_CODES.INVALID_INPUT
|
||||
);
|
||||
});
|
||||
|
||||
it("add rejects invalid input via the shared schema", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const s = store();
|
||||
let captured: unknown;
|
||||
try {
|
||||
await s.add({
|
||||
tenantId: "",
|
||||
channel: "email",
|
||||
channelValue: "u@e.com",
|
||||
reason: "unsubscribe",
|
||||
});
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect((captured as CampaignsApiError).code).toBe(
|
||||
CAMPAIGNS_API_CODES.INVALID_INPUT
|
||||
);
|
||||
});
|
||||
|
||||
it("check rejects an empty tenantId with TENANT_REQUIRED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const s = store();
|
||||
let captured: unknown;
|
||||
try {
|
||||
await s.check("", "email", "u@e.com");
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect((captured as CampaignsApiError).code).toBe(
|
||||
CAMPAIGNS_API_CODES.TENANT_REQUIRED
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("InMemoryCampaignSuppressionStore.list + count + removeByChannelValue", () => {
|
||||
it("lists entries scoped to a tenant", { timeout: 5000 }, async () => {
|
||||
const s = store();
|
||||
await s.add({
|
||||
tenantId: "t1",
|
||||
channel: "email",
|
||||
channelValue: "a@e.com",
|
||||
reason: "unsubscribe",
|
||||
});
|
||||
await s.add({
|
||||
tenantId: "t1",
|
||||
channel: "sms",
|
||||
channelValue: "+1",
|
||||
reason: "complaint",
|
||||
});
|
||||
await s.add({
|
||||
tenantId: "t2",
|
||||
channel: "email",
|
||||
channelValue: "b@e.com",
|
||||
reason: "manual",
|
||||
});
|
||||
const all = await s.list({ tenantId: "t1" });
|
||||
expect(all).toHaveLength(2);
|
||||
const onlyEmail = await s.list({ tenantId: "t1", channel: "email" });
|
||||
expect(onlyEmail).toHaveLength(1);
|
||||
expect(onlyEmail[0]!.channel).toBe("email");
|
||||
const onlyComplaint = await s.list({
|
||||
tenantId: "t1",
|
||||
reason: "complaint",
|
||||
});
|
||||
expect(onlyComplaint).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("paginates via limit + offset", { timeout: 5000 }, async () => {
|
||||
const s = store();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await s.add({
|
||||
tenantId: "t1",
|
||||
channel: "email",
|
||||
channelValue: `u${i}@e.com`,
|
||||
reason: "unsubscribe",
|
||||
});
|
||||
}
|
||||
const page = await s.list({ tenantId: "t1", limit: 2, offset: 2 });
|
||||
expect(page).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("rejects invalid list query (negative offset)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const s = store();
|
||||
let captured: unknown;
|
||||
try {
|
||||
await s.list({ tenantId: "t1", offset: -1 });
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect((captured as CampaignsApiError).code).toBe(
|
||||
CAMPAIGNS_API_CODES.INVALID_INPUT
|
||||
);
|
||||
});
|
||||
|
||||
it("count scopes by tenant", { timeout: 5000 }, async () => {
|
||||
const s = store();
|
||||
await s.add({
|
||||
tenantId: "t1",
|
||||
channel: "email",
|
||||
channelValue: "a@e.com",
|
||||
reason: "unsubscribe",
|
||||
});
|
||||
await s.add({
|
||||
tenantId: "t2",
|
||||
channel: "email",
|
||||
channelValue: "b@e.com",
|
||||
reason: "unsubscribe",
|
||||
});
|
||||
expect(await s.count("t1")).toBe(1);
|
||||
expect(await s.count("t2")).toBe(1);
|
||||
expect(await s.count("t3")).toBe(0);
|
||||
});
|
||||
|
||||
it("count rejects an empty tenantId", { timeout: 5000 }, async () => {
|
||||
const s = store();
|
||||
let captured: unknown;
|
||||
try {
|
||||
await s.count("");
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect((captured as CampaignsApiError).code).toBe(
|
||||
CAMPAIGNS_API_CODES.TENANT_REQUIRED
|
||||
);
|
||||
});
|
||||
|
||||
it("removeByChannelValue deletes and returns true; false on miss", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const s = store();
|
||||
await s.add({
|
||||
tenantId: "t1",
|
||||
channel: "email",
|
||||
channelValue: "u@e.com",
|
||||
reason: "unsubscribe",
|
||||
});
|
||||
expect(await s.removeByChannelValue("t1", "email", "u@e.com")).toBe(
|
||||
true
|
||||
);
|
||||
expect(await s.removeByChannelValue("t1", "email", "u@e.com")).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("removeByChannelValue iterates past non-matching entries", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const s = store();
|
||||
await s.add({
|
||||
tenantId: "t1",
|
||||
channel: "email",
|
||||
channelValue: "a@e.com",
|
||||
reason: "unsubscribe",
|
||||
});
|
||||
await s.add({
|
||||
tenantId: "t1",
|
||||
channel: "email",
|
||||
channelValue: "b@e.com",
|
||||
reason: "unsubscribe",
|
||||
});
|
||||
// first entry doesn't match `b@e.com`; deletion targets the second
|
||||
expect(await s.removeByChannelValue("t1", "email", "b@e.com")).toBe(
|
||||
true
|
||||
);
|
||||
expect(await s.list({ tenantId: "t1" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("add persists source and metadata when present", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const s = store();
|
||||
const entry = await s.add({
|
||||
tenantId: "t1",
|
||||
channel: "email",
|
||||
channelValue: "u@e.com",
|
||||
reason: "unsubscribe",
|
||||
source: "ops",
|
||||
metadata: { ip: "1.2.3.4" },
|
||||
});
|
||||
expect(entry.source).toBe("ops");
|
||||
expect(entry.metadata).toEqual({ ip: "1.2.3.4" });
|
||||
const found = await s.check("t1", "email", "u@e.com");
|
||||
expect(found?.source).toBe("ops");
|
||||
expect(found?.metadata).toEqual({ ip: "1.2.3.4" });
|
||||
});
|
||||
|
||||
it("removeByChannelValue rejects an empty tenantId", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const s = store();
|
||||
let captured: unknown;
|
||||
try {
|
||||
await s.removeByChannelValue("", "email", "u@e.com");
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect((captured as CampaignsApiError).code).toBe(
|
||||
CAMPAIGNS_API_CODES.TENANT_REQUIRED
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* In-memory reference implementation of the local
|
||||
* `CampaignSuppressionStore` interface. Intended for unit tests and
|
||||
* prototype workloads — every mutation returns a fresh object so callers
|
||||
* cannot tamper with internal records by reference.
|
||||
*/
|
||||
|
||||
import {
|
||||
CAMPAIGNS_API_CODES,
|
||||
CampaignsApiError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import type {
|
||||
CampaignSuppressionEntry,
|
||||
CampaignSuppressionQuery,
|
||||
CampaignSuppressionStore,
|
||||
CreateCampaignSuppressionInput,
|
||||
InMemoryCampaignSuppressionStoreOptions,
|
||||
} from "./suppression.types.js";
|
||||
import {
|
||||
QuerySchema,
|
||||
requireTenant,
|
||||
validateCreate,
|
||||
} from "./suppression-internals.js";
|
||||
|
||||
/** Reference `CampaignSuppressionStore` backed by a `Map`. */
|
||||
export class InMemoryCampaignSuppressionStore
|
||||
implements CampaignSuppressionStore
|
||||
{
|
||||
private readonly entries = new Map<string, CampaignSuppressionEntry>();
|
||||
private readonly clock: InMemoryCampaignSuppressionStoreOptions["clock"];
|
||||
private readonly idGenerator: InMemoryCampaignSuppressionStoreOptions["idGenerator"];
|
||||
|
||||
public constructor(options: InMemoryCampaignSuppressionStoreOptions) {
|
||||
this.clock = options.clock;
|
||||
this.idGenerator = options.idGenerator;
|
||||
}
|
||||
|
||||
public async add(
|
||||
input: CreateCampaignSuppressionInput
|
||||
): Promise<CampaignSuppressionEntry> {
|
||||
const data = validateCreate(input);
|
||||
const existing = await this.check(
|
||||
data.tenantId,
|
||||
data.channel,
|
||||
data.channelValue
|
||||
);
|
||||
if (existing !== null) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.DUPLICATE_SUPPRESSION,
|
||||
`suppression already exists for ${data.channel}:${data.channelValue}`,
|
||||
{ context: { op: "add", existingId: existing.id } }
|
||||
);
|
||||
}
|
||||
const entry: CampaignSuppressionEntry = {
|
||||
id: this.idGenerator(),
|
||||
tenantId: data.tenantId,
|
||||
channel: data.channel,
|
||||
channelValue: data.channelValue,
|
||||
reason: data.reason,
|
||||
...(data.source !== undefined ? { source: data.source } : {}),
|
||||
...(data.metadata !== undefined ? { metadata: data.metadata } : {}),
|
||||
createdAt: new Date(this.clock()),
|
||||
};
|
||||
this.entries.set(entry.id, entry);
|
||||
return { ...entry };
|
||||
}
|
||||
|
||||
public async remove(id: string): Promise<boolean> {
|
||||
if (id.length === 0) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.INVALID_INPUT,
|
||||
"suppression id is required"
|
||||
);
|
||||
}
|
||||
return this.entries.delete(id);
|
||||
}
|
||||
|
||||
public async check(
|
||||
tenantId: string,
|
||||
channel: string,
|
||||
channelValue: string
|
||||
): Promise<CampaignSuppressionEntry | null> {
|
||||
requireTenant(tenantId);
|
||||
for (const entry of this.entries.values()) {
|
||||
if (
|
||||
entry.tenantId === tenantId &&
|
||||
entry.channel === channel &&
|
||||
entry.channelValue === channelValue
|
||||
) {
|
||||
return { ...entry };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async list(
|
||||
query: CampaignSuppressionQuery
|
||||
): Promise<readonly CampaignSuppressionEntry[]> {
|
||||
const parsed = QuerySchema.safeParse(query);
|
||||
if (!parsed.success) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.INVALID_INPUT,
|
||||
"invalid suppression query",
|
||||
{ cause: parsed.error, context: { op: "list" } }
|
||||
);
|
||||
}
|
||||
let results = Array.from(this.entries.values()).filter(
|
||||
(e) => e.tenantId === parsed.data.tenantId
|
||||
);
|
||||
if (parsed.data.channel !== undefined) {
|
||||
const ch = parsed.data.channel;
|
||||
results = results.filter((e) => e.channel === ch);
|
||||
}
|
||||
if (parsed.data.reason !== undefined) {
|
||||
const rsn = parsed.data.reason;
|
||||
results = results.filter((e) => e.reason === rsn);
|
||||
}
|
||||
const limit = parsed.data.limit ?? 50;
|
||||
const offset = parsed.data.offset ?? 0;
|
||||
return results.slice(offset, offset + limit).map((e) => ({ ...e }));
|
||||
}
|
||||
|
||||
public async count(tenantId: string): Promise<number> {
|
||||
requireTenant(tenantId);
|
||||
let n = 0;
|
||||
for (const e of this.entries.values()) {
|
||||
if (e.tenantId === tenantId) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
public async removeByChannelValue(
|
||||
tenantId: string,
|
||||
channel: string,
|
||||
channelValue: string
|
||||
): Promise<boolean> {
|
||||
requireTenant(tenantId);
|
||||
for (const [id, entry] of this.entries.entries()) {
|
||||
if (
|
||||
entry.tenantId === tenantId &&
|
||||
entry.channel === channel &&
|
||||
entry.channelValue === channelValue
|
||||
) {
|
||||
this.entries.delete(id);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
# `repositories/suppression`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the SurrealDB-backed implementation of the tenant +
|
||||
channel-scoped `CampaignSuppressionStore` interface for
|
||||
`@suaveplan/api-plugin-campaigns`. It tracks contacts who should be
|
||||
excluded from a tenant's campaign sends — for example because they
|
||||
unsubscribed, hard-bounced, complained, or were manually opted out.
|
||||
|
||||
The local interface is intentional and not a wrapper over
|
||||
`@suaveplan/suppression`: the suaveplan suppression package is email-only
|
||||
and keyed on `(tenantId, channelValue)`, while a campaigns plugin must
|
||||
support multi-channel delivery (email, SMS, push, ...). The
|
||||
`(tenantId, channel, channelValue)` triple is therefore the natural
|
||||
primary key, with `channel = "email"` representing the legacy case.
|
||||
|
||||
## Features
|
||||
|
||||
- Positional SurQL parameter binding (`$p0`, `$p1`, ...) per the suaveplan
|
||||
Surreal idiom.
|
||||
- Snake-case ↔ camelCase column mapping isolated in `rowToEntry` from
|
||||
`./suppression-internals.ts`.
|
||||
- Application-layer duplicate detection — `add` issues a `check` before
|
||||
the INSERT and throws `CAMPAIGNS_API.DUPLICATE_SUPPRESSION` if the
|
||||
triple already exists.
|
||||
- Zod-guarded input boundaries (`CreateInputSchema`, `QuerySchema`) so
|
||||
invalid HTTP payloads fail with `CAMPAIGNS_API.INVALID_INPUT` before
|
||||
any SurQL is issued.
|
||||
- Errors wrapped with stable `CAMPAIGNS_API.*` codes (`DB_UNAVAILABLE`,
|
||||
`INVALID_INPUT`, `TENANT_REQUIRED`, `DUPLICATE_SUPPRESSION`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SurrealCampaignSuppressionRepository } from "@suaveplan/api-plugin-campaigns/repositories/suppression/suppression";
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
|
||||
declare const db: SurrealAdapter;
|
||||
const store = new SurrealCampaignSuppressionRepository({
|
||||
db,
|
||||
clock: () => Date.now(),
|
||||
idGenerator: () => crypto.randomUUID(),
|
||||
});
|
||||
|
||||
await store.add({
|
||||
tenantId: "t1",
|
||||
channel: "email",
|
||||
channelValue: "user@example.com",
|
||||
reason: "unsubscribe",
|
||||
});
|
||||
|
||||
const blocked = await store.check("t1", "email", "user@example.com");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SurrealCampaignSuppressionRepository } from "@suaveplan/api-plugin-campaigns/repositories/suppression/suppression";
|
||||
import { CAMPAIGNS_API_CODES, CampaignsApiError } from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
const store = new SurrealCampaignSuppressionRepository({
|
||||
db,
|
||||
clock: () => Date.now(),
|
||||
idGenerator: () => crypto.randomUUID(),
|
||||
table: "campaign_suppression_v2",
|
||||
});
|
||||
|
||||
try {
|
||||
await store.add({
|
||||
tenantId: "t1",
|
||||
channel: "sms",
|
||||
channelValue: "+15551234567",
|
||||
reason: "complaint",
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof CampaignsApiError &&
|
||||
err.code === CAMPAIGNS_API_CODES.DUPLICATE_SUPPRESSION
|
||||
) {
|
||||
// ... already suppressed, treat as success
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface CampaignSuppressionStore` — local persistence contract:
|
||||
`add`, `remove`, `check`, `list`, `count`, `removeByChannelValue`.
|
||||
- `class SurrealCampaignSuppressionRepository implements CampaignSuppressionStore`
|
||||
- `new SurrealCampaignSuppressionRepository({ db, clock, idGenerator, table?, logger? })`
|
||||
- `interface CampaignSuppressionEntry` — domain row shape.
|
||||
- `type CampaignSuppressionReason` — one of `"unsubscribe"`, `"bounce"`,
|
||||
`"complaint"`, `"manual"`, `"compliance"`.
|
||||
- `const CreateInputSchema`, `const QuerySchema` — re-exported Zod
|
||||
schemas for callers who want to validate at a different boundary.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The local interface is intentional and is NOT a wrapper over
|
||||
`@suaveplan/suppression` — that package is email-only and would force a
|
||||
lossy mapping. The repository uses `channel` as a first-class column so a
|
||||
single tenant can suppress different addresses on different channels
|
||||
without collision.
|
||||
|
||||
The Surreal `add` issues an application-layer `check` before the
|
||||
INSERT. A Surreal-side `UNIQUE INDEX` on `(tenant_id, channel,
|
||||
channel_value)` is recommended as defence in depth, but the application
|
||||
check is the primary gate — it gives us a clean
|
||||
`CAMPAIGNS_API.DUPLICATE_SUPPRESSION` error rather than a driver-specific
|
||||
constraint violation that would have to be string-matched.
|
||||
|
||||
Errors are wrapped via the `run<T>` helper, which uniformly turns
|
||||
adapter exceptions into `CampaignsApiError(DB_UNAVAILABLE)` with the
|
||||
operation name attached as `context.op`. This keeps the failure modes
|
||||
auditable from logs without leaking driver internals.
|
||||
@@ -0,0 +1,380 @@
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
CAMPAIGNS_API_CODES,
|
||||
type CampaignsApiError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import {
|
||||
CreateInputSchema,
|
||||
QuerySchema,
|
||||
SurrealCampaignSuppressionRepository,
|
||||
} from "./suppression.js";
|
||||
|
||||
type QueryFn = (sql: string, params?: readonly unknown[]) => Promise<unknown>;
|
||||
|
||||
function adapter(queryFn: QueryFn): SurrealAdapter {
|
||||
return { query: queryFn } as unknown as SurrealAdapter;
|
||||
}
|
||||
|
||||
function row(over: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
id: "s1",
|
||||
tenant_id: "t1",
|
||||
channel: "email",
|
||||
channel_value: "u@e.com",
|
||||
reason: "unsubscribe",
|
||||
source: null,
|
||||
metadata: null,
|
||||
created_at: "2026-01-01T00:00:00.000Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function repo(
|
||||
queryFn: QueryFn,
|
||||
over: { clock?: () => number; id?: () => string; table?: string } = {}
|
||||
): SurrealCampaignSuppressionRepository {
|
||||
let n = 0;
|
||||
return new SurrealCampaignSuppressionRepository({
|
||||
db: adapter(queryFn),
|
||||
clock: over.clock ?? (() => 1_700_000_000_000),
|
||||
idGenerator: over.id ?? (() => `gen-${n++}`),
|
||||
table: over.table,
|
||||
});
|
||||
}
|
||||
|
||||
describe("SurrealCampaignSuppressionRepository.add", () => {
|
||||
it("validates input and inserts when not already suppressed", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: { sql: string; params: readonly unknown[] }[] = [];
|
||||
const r = repo(async (sql, params) => {
|
||||
calls.push({ sql, params: params ?? [] });
|
||||
// first call is the SELECT in check(); return empty
|
||||
if (sql.startsWith("SELECT")) return [];
|
||||
return [];
|
||||
});
|
||||
const entry = await r.add({
|
||||
tenantId: "t1",
|
||||
channel: "email",
|
||||
channelValue: "u@e.com",
|
||||
reason: "unsubscribe",
|
||||
source: "user-action",
|
||||
metadata: { foo: "bar" },
|
||||
});
|
||||
expect(entry.id).toBe("gen-0");
|
||||
expect(entry.tenantId).toBe("t1");
|
||||
expect(entry.createdAt).toBeInstanceOf(Date);
|
||||
expect(calls[1]!.sql).toContain("INSERT INTO campaign_suppression");
|
||||
expect(calls[1]!.params[0]).toBe("gen-0");
|
||||
expect(calls[1]!.params[5]).toBe("user-action");
|
||||
expect(calls[1]!.params[6]).toEqual({ foo: "bar" });
|
||||
});
|
||||
|
||||
it("nulls absent source and metadata in the positional params", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: { params: readonly unknown[] }[] = [];
|
||||
const r = repo(async (sql, params) => {
|
||||
if (sql.startsWith("INSERT")) {
|
||||
calls.push({ params: params ?? [] });
|
||||
}
|
||||
return [];
|
||||
});
|
||||
await r.add({
|
||||
tenantId: "t1",
|
||||
channel: "email",
|
||||
channelValue: "u@e.com",
|
||||
reason: "unsubscribe",
|
||||
});
|
||||
expect(calls[0]!.params[5]).toBeNull();
|
||||
expect(calls[0]!.params[6]).toBeNull();
|
||||
});
|
||||
|
||||
it("throws DUPLICATE_SUPPRESSION when check() returns an existing entry", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = repo(async (sql) => {
|
||||
if (sql.startsWith("SELECT")) return [row({ id: "existing" })];
|
||||
return [];
|
||||
});
|
||||
let captured: unknown;
|
||||
try {
|
||||
await r.add({
|
||||
tenantId: "t1",
|
||||
channel: "email",
|
||||
channelValue: "u@e.com",
|
||||
reason: "unsubscribe",
|
||||
});
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect((captured as CampaignsApiError).code).toBe(
|
||||
CAMPAIGNS_API_CODES.DUPLICATE_SUPPRESSION
|
||||
);
|
||||
});
|
||||
|
||||
it("throws INVALID_INPUT for missing tenantId", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = repo(async () => []);
|
||||
let captured: unknown;
|
||||
try {
|
||||
await r.add({
|
||||
tenantId: "",
|
||||
channel: "email",
|
||||
channelValue: "u@e.com",
|
||||
reason: "unsubscribe",
|
||||
});
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect((captured as CampaignsApiError).code).toBe(
|
||||
CAMPAIGNS_API_CODES.INVALID_INPUT
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps adapter errors during INSERT in DB_UNAVAILABLE", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = repo(async (sql) => {
|
||||
if (sql.startsWith("SELECT")) return [];
|
||||
throw new Error("driver-down");
|
||||
});
|
||||
let captured: unknown;
|
||||
try {
|
||||
await r.add({
|
||||
tenantId: "t1",
|
||||
channel: "email",
|
||||
channelValue: "u@e.com",
|
||||
reason: "unsubscribe",
|
||||
});
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect((captured as CampaignsApiError).code).toBe(
|
||||
CAMPAIGNS_API_CODES.DB_UNAVAILABLE
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SurrealCampaignSuppressionRepository.remove + check", () => {
|
||||
it("remove returns true when DELETE returns a row", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = repo(async () => [row()]);
|
||||
expect(await r.remove("s1")).toBe(true);
|
||||
});
|
||||
|
||||
it("remove returns false when DELETE returns nothing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = repo(async () => []);
|
||||
expect(await r.remove("s1")).toBe(false);
|
||||
});
|
||||
|
||||
it("remove rejects an empty id with INVALID_INPUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = repo(async () => []);
|
||||
let captured: unknown;
|
||||
try {
|
||||
await r.remove("");
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect((captured as CampaignsApiError).code).toBe(
|
||||
CAMPAIGNS_API_CODES.INVALID_INPUT
|
||||
);
|
||||
});
|
||||
|
||||
it("check returns null when no row matches", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = repo(async () => []);
|
||||
expect(await r.check("t1", "email", "u@e.com")).toBeNull();
|
||||
});
|
||||
|
||||
it("check maps a row and binds the triple positionally", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: { params: readonly unknown[] }[] = [];
|
||||
const r = repo(async (_sql, params) => {
|
||||
calls.push({ params: params ?? [] });
|
||||
return [row({ source: "x", metadata: { y: 1 } })];
|
||||
});
|
||||
const entry = await r.check("t1", "email", "u@e.com");
|
||||
expect(entry?.id).toBe("s1");
|
||||
expect(entry?.source).toBe("x");
|
||||
expect(entry?.metadata).toEqual({ y: 1 });
|
||||
expect(entry?.createdAt).toBeInstanceOf(Date);
|
||||
expect(calls[0]!.params).toEqual(["t1", "email", "u@e.com"]);
|
||||
});
|
||||
|
||||
it("check preserves a Date timestamp from the row", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ts = new Date("2026-04-01T00:00:00.000Z");
|
||||
const r = repo(async () => [row({ created_at: ts })]);
|
||||
const entry = await r.check("t1", "email", "u@e.com");
|
||||
expect(entry?.createdAt).toEqual(ts);
|
||||
});
|
||||
|
||||
it("check rejects an empty tenantId with TENANT_REQUIRED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = repo(async () => []);
|
||||
let captured: unknown;
|
||||
try {
|
||||
await r.check("", "email", "u@e.com");
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect((captured as CampaignsApiError).code).toBe(
|
||||
CAMPAIGNS_API_CODES.TENANT_REQUIRED
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SurrealCampaignSuppressionRepository.list + count + removeByChannelValue", () => {
|
||||
it("list threads filters + paging into SQL positionally", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: { sql: string; params: readonly unknown[] }[] = [];
|
||||
const r = repo(async (sql, params) => {
|
||||
calls.push({ sql, params: params ?? [] });
|
||||
return [row(), row({ id: "s2" })];
|
||||
});
|
||||
const result = await r.list({
|
||||
tenantId: "t1",
|
||||
channel: "email",
|
||||
reason: "bounce",
|
||||
limit: 10,
|
||||
offset: 5,
|
||||
});
|
||||
expect(result).toHaveLength(2);
|
||||
expect(calls[0]!.sql).toContain("tenant_id = $p0");
|
||||
expect(calls[0]!.sql).toContain("channel = $p1");
|
||||
expect(calls[0]!.sql).toContain("reason = $p2");
|
||||
expect(calls[0]!.sql).toContain("LIMIT 10");
|
||||
expect(calls[0]!.sql).toContain("START 5");
|
||||
});
|
||||
|
||||
it("list applies defaults and omits START when offset is 0", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: string[] = [];
|
||||
const r = repo(async (sql) => {
|
||||
calls.push(sql);
|
||||
return [];
|
||||
});
|
||||
await r.list({ tenantId: "t1" });
|
||||
expect(calls[0]).toContain("LIMIT 50");
|
||||
expect(calls[0]).not.toContain("START");
|
||||
});
|
||||
|
||||
it("list rejects invalid query (missing tenantId)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = repo(async () => []);
|
||||
let captured: unknown;
|
||||
try {
|
||||
await r.list({ tenantId: "" });
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect((captured as CampaignsApiError).code).toBe(
|
||||
CAMPAIGNS_API_CODES.INVALID_INPUT
|
||||
);
|
||||
});
|
||||
|
||||
it("count returns the GROUP ALL count or zero", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const a = repo(async () => [{ count: 3 }]);
|
||||
expect(await a.count("t1")).toBe(3);
|
||||
const b = repo(async () => []);
|
||||
expect(await b.count("t1")).toBe(0);
|
||||
});
|
||||
|
||||
it("count rejects an empty tenantId", { timeout: 5000 }, async () => {
|
||||
const r = repo(async () => []);
|
||||
let captured: unknown;
|
||||
try {
|
||||
await r.count("");
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect((captured as CampaignsApiError).code).toBe(
|
||||
CAMPAIGNS_API_CODES.TENANT_REQUIRED
|
||||
);
|
||||
});
|
||||
|
||||
it("removeByChannelValue returns true when DELETE returns a row", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = repo(async () => [row()]);
|
||||
expect(await r.removeByChannelValue("t1", "email", "u@e.com")).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("removeByChannelValue returns false when DELETE returns nothing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = repo(async () => []);
|
||||
expect(await r.removeByChannelValue("t1", "email", "u@e.com")).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("removeByChannelValue rejects an empty tenantId", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = repo(async () => []);
|
||||
let captured: unknown;
|
||||
try {
|
||||
await r.removeByChannelValue("", "email", "u@e.com");
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect((captured as CampaignsApiError).code).toBe(
|
||||
CAMPAIGNS_API_CODES.TENANT_REQUIRED
|
||||
);
|
||||
});
|
||||
|
||||
it("respects a custom table name on every operation", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: string[] = [];
|
||||
const r = repo(
|
||||
async (sql) => {
|
||||
calls.push(sql);
|
||||
return [];
|
||||
},
|
||||
{ table: "custom_supp" }
|
||||
);
|
||||
await r.list({ tenantId: "t1" });
|
||||
expect(calls[0]).toContain("FROM custom_supp");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Re-exported schemas", () => {
|
||||
it("CreateInputSchema rejects unknown keys", { timeout: 5000 }, () => {
|
||||
const r = CreateInputSchema.safeParse({
|
||||
tenantId: "t1",
|
||||
channel: "email",
|
||||
channelValue: "u@e.com",
|
||||
reason: "unsubscribe",
|
||||
unknown: "boom",
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("QuerySchema accepts a minimal valid input", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = QuerySchema.safeParse({ tenantId: "t1" });
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* SurrealDB-backed implementation of the tenant + channel-scoped
|
||||
* `CampaignSuppressionStore` interface for
|
||||
* `@suaveplan/api-plugin-campaigns`. The local interface (and its
|
||||
* in-memory reference implementation in `./suppression-memory.ts`) is
|
||||
* intentionally channel-scoped: the genesis `@suaveplan/suppression`
|
||||
* package is email-only and does not fit the multi-channel campaigns
|
||||
* domain.
|
||||
*/
|
||||
|
||||
import {
|
||||
CAMPAIGNS_API_CODES,
|
||||
CampaignsApiError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import type {
|
||||
CampaignSuppressionEntry,
|
||||
CampaignSuppressionQuery,
|
||||
CampaignSuppressionRow,
|
||||
CampaignSuppressionStore,
|
||||
CreateCampaignSuppressionInput,
|
||||
SurrealCampaignSuppressionRepositoryOptions,
|
||||
} from "./suppression.types.js";
|
||||
import {
|
||||
CreateInputSchema,
|
||||
QuerySchema,
|
||||
requireTenant,
|
||||
rowToEntry,
|
||||
validateCreate,
|
||||
} from "./suppression-internals.js";
|
||||
|
||||
/**
|
||||
* SurrealDB-backed `CampaignSuppressionStore`. Uses positional `$p0/$p1`
|
||||
* parameters and wraps adapter errors in `CampaignsApiError` with the
|
||||
* `DB_UNAVAILABLE` code. Duplicate `(tenant, channel, value)` adds are
|
||||
* rejected at the application layer with `DUPLICATE_SUPPRESSION` so the
|
||||
* Surreal-side unique index is defence-in-depth rather than the primary
|
||||
* gate.
|
||||
*/
|
||||
export class SurrealCampaignSuppressionRepository
|
||||
implements CampaignSuppressionStore
|
||||
{
|
||||
private readonly db: SurrealCampaignSuppressionRepositoryOptions["db"];
|
||||
private readonly clock: SurrealCampaignSuppressionRepositoryOptions["clock"];
|
||||
private readonly idGenerator: SurrealCampaignSuppressionRepositoryOptions["idGenerator"];
|
||||
private readonly table: string;
|
||||
|
||||
public constructor(options: SurrealCampaignSuppressionRepositoryOptions) {
|
||||
this.db = options.db;
|
||||
this.clock = options.clock;
|
||||
this.idGenerator = options.idGenerator;
|
||||
this.table = options.table ?? "campaign_suppression";
|
||||
}
|
||||
|
||||
public async add(
|
||||
input: CreateCampaignSuppressionInput
|
||||
): Promise<CampaignSuppressionEntry> {
|
||||
const data = validateCreate(input);
|
||||
const existing = await this.check(
|
||||
data.tenantId,
|
||||
data.channel,
|
||||
data.channelValue
|
||||
);
|
||||
if (existing !== null) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.DUPLICATE_SUPPRESSION,
|
||||
`suppression already exists for ${data.channel}:${data.channelValue}`,
|
||||
{ context: { op: "add", existingId: existing.id } }
|
||||
);
|
||||
}
|
||||
const entry: CampaignSuppressionEntry = {
|
||||
id: this.idGenerator(),
|
||||
tenantId: data.tenantId,
|
||||
channel: data.channel,
|
||||
channelValue: data.channelValue,
|
||||
reason: data.reason,
|
||||
...(data.source !== undefined ? { source: data.source } : {}),
|
||||
...(data.metadata !== undefined ? { metadata: data.metadata } : {}),
|
||||
createdAt: new Date(this.clock()),
|
||||
};
|
||||
const sql = `INSERT INTO ${this.table} {
|
||||
id: $p0, tenant_id: $p1, channel: $p2, channel_value: $p3,
|
||||
reason: $p4, source: $p5, metadata: $p6, created_at: $p7
|
||||
}`;
|
||||
await this.run<CampaignSuppressionRow>(
|
||||
sql,
|
||||
[
|
||||
entry.id,
|
||||
entry.tenantId,
|
||||
entry.channel,
|
||||
entry.channelValue,
|
||||
entry.reason,
|
||||
entry.source ?? null,
|
||||
entry.metadata ?? null,
|
||||
entry.createdAt,
|
||||
],
|
||||
"add"
|
||||
);
|
||||
return entry;
|
||||
}
|
||||
|
||||
public async remove(id: string): Promise<boolean> {
|
||||
if (id.length === 0) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.INVALID_INPUT,
|
||||
"suppression id is required"
|
||||
);
|
||||
}
|
||||
const rows = await this.run<CampaignSuppressionRow>(
|
||||
`DELETE ${this.table} WHERE id = $p0 RETURN BEFORE`,
|
||||
[id],
|
||||
"remove"
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
public async check(
|
||||
tenantId: string,
|
||||
channel: string,
|
||||
channelValue: string
|
||||
): Promise<CampaignSuppressionEntry | null> {
|
||||
requireTenant(tenantId);
|
||||
const rows = await this.run<CampaignSuppressionRow>(
|
||||
`SELECT * FROM ${this.table}
|
||||
WHERE tenant_id = $p0 AND channel = $p1 AND channel_value = $p2
|
||||
LIMIT 1`,
|
||||
[tenantId, channel, channelValue],
|
||||
"check"
|
||||
);
|
||||
return rows[0] !== undefined ? rowToEntry(rows[0]) : null;
|
||||
}
|
||||
|
||||
public async list(
|
||||
query: CampaignSuppressionQuery
|
||||
): Promise<readonly CampaignSuppressionEntry[]> {
|
||||
const parsed = QuerySchema.safeParse(query);
|
||||
if (!parsed.success) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.INVALID_INPUT,
|
||||
"invalid suppression query",
|
||||
{ cause: parsed.error, context: { op: "list" } }
|
||||
);
|
||||
}
|
||||
const conditions: string[] = ["tenant_id = $p0"];
|
||||
const params: unknown[] = [parsed.data.tenantId];
|
||||
const push = (col: string, value: unknown): void => {
|
||||
params.push(value);
|
||||
conditions.push(`${col} = $p${params.length - 1}`);
|
||||
};
|
||||
if (parsed.data.channel !== undefined) {
|
||||
push("channel", parsed.data.channel);
|
||||
}
|
||||
if (parsed.data.reason !== undefined) {
|
||||
push("reason", parsed.data.reason);
|
||||
}
|
||||
const limit = parsed.data.limit ?? 50;
|
||||
const offset = parsed.data.offset ?? 0;
|
||||
let sql = `SELECT * FROM ${this.table} WHERE ${conditions.join(" AND ")} LIMIT ${limit}`;
|
||||
if (offset > 0) sql += ` START ${offset}`;
|
||||
const rows = await this.run<CampaignSuppressionRow>(
|
||||
sql,
|
||||
params,
|
||||
"list"
|
||||
);
|
||||
return rows.map(rowToEntry);
|
||||
}
|
||||
|
||||
public async count(tenantId: string): Promise<number> {
|
||||
requireTenant(tenantId);
|
||||
const rows = await this.run<{ count: number }>(
|
||||
`SELECT count() AS count FROM ${this.table}
|
||||
WHERE tenant_id = $p0 GROUP ALL`,
|
||||
[tenantId],
|
||||
"count"
|
||||
);
|
||||
return rows[0]?.count ?? 0;
|
||||
}
|
||||
|
||||
public async removeByChannelValue(
|
||||
tenantId: string,
|
||||
channel: string,
|
||||
channelValue: string
|
||||
): Promise<boolean> {
|
||||
requireTenant(tenantId);
|
||||
const rows = await this.run<CampaignSuppressionRow>(
|
||||
`DELETE ${this.table}
|
||||
WHERE tenant_id = $p0 AND channel = $p1 AND channel_value = $p2
|
||||
RETURN BEFORE`,
|
||||
[tenantId, channel, channelValue],
|
||||
"removeByChannelValue"
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
private async run<T>(
|
||||
sql: string,
|
||||
params: unknown[],
|
||||
op: string
|
||||
): Promise<T[]> {
|
||||
try {
|
||||
return await this.db.query<T>(sql, params);
|
||||
} catch (cause) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.DB_UNAVAILABLE,
|
||||
`SurrealCampaignSuppressionRepository.${op} failed`,
|
||||
{ cause, context: { op } }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Schemas/helpers are exported through the barrel for tests that want
|
||||
// to assert their behaviour directly.
|
||||
export { CreateInputSchema, QuerySchema };
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Type definitions for the campaign-suppression module. The local
|
||||
* `CampaignSuppressionStore` interface is intentionally tenant + channel
|
||||
* scoped — the genesis `@suaveplan/suppression` package is email-only and
|
||||
* does not fit the campaigns plugin's multi-channel domain.
|
||||
*/
|
||||
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import type { Clock, IdGenerator, LoggerLike } from "@suaveplan/types";
|
||||
|
||||
/** Stable reasons a contact may be suppressed on a given channel. */
|
||||
export type CampaignSuppressionReason =
|
||||
| "unsubscribe"
|
||||
| "bounce"
|
||||
| "complaint"
|
||||
| "manual"
|
||||
| "compliance";
|
||||
|
||||
/**
|
||||
* One suppression record. Channel-scoped: an address that is suppressed
|
||||
* on `email` can still receive `sms`, so the `(tenantId, channel,
|
||||
* channelValue)` triple is the natural primary key.
|
||||
*/
|
||||
export interface CampaignSuppressionEntry {
|
||||
readonly id: string;
|
||||
readonly tenantId: string;
|
||||
readonly channel: string;
|
||||
readonly channelValue: string;
|
||||
readonly reason: CampaignSuppressionReason;
|
||||
readonly source?: string;
|
||||
readonly metadata?: Record<string, unknown>;
|
||||
readonly createdAt: Date;
|
||||
}
|
||||
|
||||
/** Filter parameters for listing suppression entries. */
|
||||
export interface CampaignSuppressionQuery {
|
||||
readonly tenantId: string;
|
||||
readonly channel?: string;
|
||||
readonly reason?: CampaignSuppressionReason;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
}
|
||||
|
||||
/** Input for creating a new suppression entry. `id` and `createdAt` are filled by the store. */
|
||||
export interface CreateCampaignSuppressionInput {
|
||||
readonly tenantId: string;
|
||||
readonly channel: string;
|
||||
readonly channelValue: string;
|
||||
readonly reason: CampaignSuppressionReason;
|
||||
readonly source?: string;
|
||||
readonly metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistence contract for tenant + channel-scoped suppression entries.
|
||||
* Implementations may be in-memory (see
|
||||
* {@link InMemoryCampaignSuppressionStore}) or SurrealDB-backed.
|
||||
*/
|
||||
export interface CampaignSuppressionStore {
|
||||
add(
|
||||
input: CreateCampaignSuppressionInput
|
||||
): Promise<CampaignSuppressionEntry>;
|
||||
remove(id: string): Promise<boolean>;
|
||||
check(
|
||||
tenantId: string,
|
||||
channel: string,
|
||||
channelValue: string
|
||||
): Promise<CampaignSuppressionEntry | null>;
|
||||
list(
|
||||
query: CampaignSuppressionQuery
|
||||
): Promise<readonly CampaignSuppressionEntry[]>;
|
||||
count(tenantId: string): Promise<number>;
|
||||
removeByChannelValue(
|
||||
tenantId: string,
|
||||
channel: string,
|
||||
channelValue: string
|
||||
): Promise<boolean>;
|
||||
}
|
||||
|
||||
/** Constructor bundle for {@link SurrealCampaignSuppressionRepository}. */
|
||||
export interface SurrealCampaignSuppressionRepositoryOptions {
|
||||
readonly db: SurrealAdapter;
|
||||
readonly clock: Clock;
|
||||
readonly idGenerator: IdGenerator;
|
||||
readonly table?: string;
|
||||
readonly logger?: LoggerLike;
|
||||
}
|
||||
|
||||
/** Constructor bundle for {@link InMemoryCampaignSuppressionStore}. */
|
||||
export interface InMemoryCampaignSuppressionStoreOptions {
|
||||
readonly clock: Clock;
|
||||
readonly idGenerator: IdGenerator;
|
||||
}
|
||||
|
||||
/** SurrealDB row shape for the `campaign_suppression` table. */
|
||||
export interface CampaignSuppressionRow {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
channel: string;
|
||||
channel_value: string;
|
||||
reason: string;
|
||||
source?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
created_at: Date | string;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
# Campaign action routes
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the campaign action routes — state-machine
|
||||
transitions (`/send`, `/pause`, `/cancel`), the `/duplicate`
|
||||
shallow-copy endpoint, the `/test` ad-hoc send, and the two
|
||||
delivery-tracking-backed read endpoints (`/stats` and `/events`).
|
||||
State transitions are authoritative: invalid transitions raise
|
||||
`CAMPAIGNS_API.INVALID_STATE_TRANSITION` mapped to HTTP 409 so clients
|
||||
can drive UI affordances (enable/disable a "Pause" button) off a
|
||||
stable code rather than parsing error messages.
|
||||
|
||||
The action routes consume the in-domain `CampaignStateMachine` from
|
||||
`@suaveplan/campaigns`. Every transition decision flows through that
|
||||
single source of truth — no string-comparisons of `status` are
|
||||
duplicated at the route layer, eliminating drift between the engine
|
||||
and the HTTP surface.
|
||||
|
||||
## Features
|
||||
|
||||
- 3 transition routes: `buildSendCampaignRoute`,
|
||||
`buildPauseCampaignRoute`, `buildCancelCampaignRoute`.
|
||||
- 1 clone route: `buildDuplicateCampaignRoute` — copies metadata and
|
||||
audience/template config, resets stats, clears `sentAt`/`completedAt`.
|
||||
- 1 test-send route: `buildTestSendRoute` — validates a recipient list
|
||||
of 1–10 email addresses and echoes them back.
|
||||
- 2 reporting routes consuming `DeliveryEventStore`:
|
||||
`buildCampaignStatsRoute`, `buildCampaignEventsRoute`.
|
||||
- Transition guard raises `INVALID_STATE_TRANSITION` (HTTP 409) when
|
||||
`CampaignStateMachine.transition` rejects the (status, event) pair.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildSendCampaignRoute } from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
const entry = buildSendCampaignRoute();
|
||||
app.post(entry.route.path, (c) => entry.handler(c));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildCampaignEventsRoute,
|
||||
buildCampaignStatsRoute,
|
||||
buildCancelCampaignRoute,
|
||||
buildDuplicateCampaignRoute,
|
||||
buildPauseCampaignRoute,
|
||||
buildSendCampaignRoute,
|
||||
buildTestSendRoute,
|
||||
} from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
const actionEntries = [
|
||||
buildSendCampaignRoute(),
|
||||
buildPauseCampaignRoute(),
|
||||
buildCancelCampaignRoute(),
|
||||
buildDuplicateCampaignRoute(),
|
||||
buildTestSendRoute(),
|
||||
buildCampaignStatsRoute(),
|
||||
buildCampaignEventsRoute(),
|
||||
];
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `buildSendCampaignRoute(): PluginRouteEntry`
|
||||
- `buildPauseCampaignRoute(): PluginRouteEntry`
|
||||
- `buildCancelCampaignRoute(): PluginRouteEntry`
|
||||
- `buildDuplicateCampaignRoute(): PluginRouteEntry`
|
||||
- `buildTestSendRoute(): PluginRouteEntry`
|
||||
- `buildCampaignStatsRoute(): PluginRouteEntry`
|
||||
- `buildCampaignEventsRoute(): PluginRouteEntry`
|
||||
- `TestSendSchema`, `EventQuerySchema`, `DeliveryStatsResponseSchema`
|
||||
— Zod schemas re-exported for documentation generators and tests.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`applyTransition` wraps `CampaignStateMachine.transition` in a
|
||||
`CampaignsApiError` so a failed transition surfaces as
|
||||
`CAMPAIGNS_API.INVALID_STATE_TRANSITION` rather than the
|
||||
domain-package's `CampaignsError` (which carries an unrelated
|
||||
namespace). Wrapping at this seam keeps the route layer's error
|
||||
vocabulary self-contained: every error a client sees from the
|
||||
campaigns plugin carries a `CAMPAIGNS_API.*` code.
|
||||
|
||||
The `/duplicate` handler intentionally copies all configuration
|
||||
fields (template, audience, throttle, abTestConfig) but resets the
|
||||
volatile fields (`id`, `status`, `stats`, `sentAt`, `completedAt`,
|
||||
`createdAt`, `updatedAt`). The `(copy)` suffix is appended to the
|
||||
name so the duplicate is visually distinct in listing UIs without
|
||||
forcing the operator to provide a new name up front. The route
|
||||
handlers' OTel attributes record `route` plus `outcome` so an
|
||||
operator can chart per-route success/error counters without parsing
|
||||
log messages.
|
||||
@@ -0,0 +1,357 @@
|
||||
import { type Campaign, InMemoryCampaignStore } from "@suaveplan/campaigns";
|
||||
import {
|
||||
type DeliveryEvent,
|
||||
InMemoryDeliveryEventStore,
|
||||
} from "@suaveplan/delivery-tracking";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
CAMPAIGNS_API_CODES,
|
||||
CampaignsApiError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import {
|
||||
buildCampaignEventsRoute,
|
||||
buildCampaignStatsRoute,
|
||||
buildCancelCampaignRoute,
|
||||
buildDuplicateCampaignRoute,
|
||||
buildPauseCampaignRoute,
|
||||
buildSendCampaignRoute,
|
||||
buildTestSendRoute,
|
||||
} from "./action-routes.js";
|
||||
import "../../context/context.js";
|
||||
|
||||
function makeApp(
|
||||
repo: InMemoryCampaignStore,
|
||||
eventStore: InMemoryDeliveryEventStore = new InMemoryDeliveryEventStore(),
|
||||
principal: object | null = { id: "u", tenantId: "t" }
|
||||
): Hono {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
if (principal !== null) c.set("user", principal as never);
|
||||
c.set("campaignRepository", repo);
|
||||
c.set("deliveryEventStore", eventStore);
|
||||
c.set("eventBus", { emit: () => undefined } as never);
|
||||
await next();
|
||||
});
|
||||
app.onError((err, c) => {
|
||||
if (err instanceof CampaignsApiError) {
|
||||
const status = ((err.context as { statusCode?: number } | undefined)
|
||||
?.statusCode ?? 500) as 400 | 401 | 404 | 409 | 500;
|
||||
return c.json({ code: err.code, error: err.message }, status);
|
||||
}
|
||||
return c.json({ error: String(err) }, 500);
|
||||
});
|
||||
for (const e of [
|
||||
buildSendCampaignRoute(),
|
||||
buildPauseCampaignRoute(),
|
||||
buildCancelCampaignRoute(),
|
||||
buildDuplicateCampaignRoute(),
|
||||
buildTestSendRoute(),
|
||||
buildCampaignStatsRoute(),
|
||||
buildCampaignEventsRoute(),
|
||||
]) {
|
||||
const method = e.route.method as "get" | "post";
|
||||
app[method](e.route.path, (c) => e.handler(c as never));
|
||||
}
|
||||
return app;
|
||||
}
|
||||
|
||||
async function seed(
|
||||
repo: InMemoryCampaignStore,
|
||||
overrides: Partial<Campaign> = {}
|
||||
): Promise<string> {
|
||||
const now = new Date();
|
||||
const id = (overrides.id ?? "c1") as string;
|
||||
await repo.create({
|
||||
id,
|
||||
tenantId: "t",
|
||||
name: "Promo",
|
||||
status: "draft",
|
||||
type: "broadcast",
|
||||
channel: "email",
|
||||
audienceConfig: { groupIds: [] },
|
||||
metadata: {},
|
||||
stats: {
|
||||
totalRecipients: 0,
|
||||
sent: 0,
|
||||
delivered: 0,
|
||||
opened: 0,
|
||||
clicked: 0,
|
||||
bounced: 0,
|
||||
unsubscribed: 0,
|
||||
complained: 0,
|
||||
failed: 0,
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides,
|
||||
} as Campaign);
|
||||
return id;
|
||||
}
|
||||
|
||||
describe("campaign action routes", () => {
|
||||
it("POST /campaigns/:id/send transitions draft → sending", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
await seed(repo);
|
||||
const app = makeApp(repo);
|
||||
const res = await app.request("/campaigns/c1/send", { method: "POST" });
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).data.status).toBe("sending");
|
||||
});
|
||||
|
||||
it("send raises INVALID_STATE_TRANSITION on completed campaign", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
await seed(repo, { status: "completed" });
|
||||
const app = makeApp(repo);
|
||||
const res = await app.request("/campaigns/c1/send", { method: "POST" });
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).code).toBe(
|
||||
CAMPAIGNS_API_CODES.INVALID_STATE_TRANSITION
|
||||
);
|
||||
});
|
||||
|
||||
it("send 404 when campaign missing", { timeout: 5000 }, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
const app = makeApp(repo);
|
||||
const res = await app.request("/campaigns/nope/send", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("POST /campaigns/:id/pause works from sending", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
await seed(repo, { status: "sending" });
|
||||
const app = makeApp(repo);
|
||||
const res = await app.request("/campaigns/c1/pause", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).data.status).toBe("paused");
|
||||
});
|
||||
|
||||
it("pause 409 when not sending", { timeout: 5000 }, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
await seed(repo);
|
||||
const app = makeApp(repo);
|
||||
const res = await app.request("/campaigns/c1/pause", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("POST /campaigns/:id/cancel works from scheduled", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
await seed(repo, { status: "scheduled" });
|
||||
const app = makeApp(repo);
|
||||
const res = await app.request("/campaigns/c1/cancel", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).data.status).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("POST /campaigns/:id/duplicate creates a draft clone", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
await seed(repo, { status: "completed" });
|
||||
const app = makeApp(repo);
|
||||
const res = await app.request("/campaigns/c1/duplicate", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.data.name).toBe("Promo (copy)");
|
||||
expect(body.data.status).toBe("draft");
|
||||
expect(body.data.id).not.toBe("c1");
|
||||
});
|
||||
|
||||
it("duplicate 404 when source missing", { timeout: 5000 }, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
const app = makeApp(repo);
|
||||
const res = await app.request("/campaigns/nope/duplicate", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("POST /campaigns/:id/test echoes valid recipients", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
await seed(repo);
|
||||
const app = makeApp(repo);
|
||||
const res = await app.request("/campaigns/c1/test", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ recipients: ["a@b.com"] }),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).data.recipients).toEqual(["a@b.com"]);
|
||||
});
|
||||
|
||||
it("test rejects invalid body", { timeout: 5000 }, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
await seed(repo);
|
||||
const app = makeApp(repo);
|
||||
const res = await app.request("/campaigns/c1/test", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ recipients: [] }),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("test 404 when campaign missing", { timeout: 5000 }, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
const app = makeApp(repo);
|
||||
const res = await app.request("/campaigns/nope/test", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ recipients: ["a@b.com"] }),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("GET /campaigns/:id/stats returns delivery stats", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
await seed(repo);
|
||||
const eventStore = new InMemoryDeliveryEventStore();
|
||||
const app = makeApp(repo, eventStore);
|
||||
const res = await app.request("/campaigns/c1/stats");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(typeof body.data.deliveryRate).toBe("number");
|
||||
});
|
||||
|
||||
it("stats 404 when campaign missing", { timeout: 5000 }, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
const app = makeApp(repo);
|
||||
const res = await app.request("/campaigns/nope/stats");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("GET /campaigns/:id/events lists events", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
await seed(repo);
|
||||
const eventStore = new InMemoryDeliveryEventStore();
|
||||
const event: DeliveryEvent = {
|
||||
id: "e1",
|
||||
tenantId: "t",
|
||||
campaignId: "c1",
|
||||
contactId: "u1",
|
||||
channel: "email",
|
||||
eventType: "delivered",
|
||||
messageId: "m1",
|
||||
metadata: {},
|
||||
timestamp: new Date(),
|
||||
};
|
||||
await eventStore.record(event);
|
||||
const app = makeApp(repo, eventStore);
|
||||
const res = await app.request(
|
||||
"/campaigns/c1/events?eventType=delivered&limit=10&offset=0"
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.total).toBe(1);
|
||||
expect(typeof body.data.events[0].timestamp).toBe("string");
|
||||
});
|
||||
|
||||
it("events 404 when campaign missing", { timeout: 5000 }, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
const eventStore = new InMemoryDeliveryEventStore();
|
||||
const app = makeApp(repo, eventStore);
|
||||
const res = await app.request("/campaigns/nope/events");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("events default query when no params", { timeout: 5000 }, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
await seed(repo);
|
||||
const eventStore = new InMemoryDeliveryEventStore();
|
||||
const app = makeApp(repo, eventStore);
|
||||
const res = await app.request("/campaigns/c1/events");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("send surfaces SERVICE_NOT_AVAILABLE when repo missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", { id: "u", tenantId: "t" } as never);
|
||||
await next();
|
||||
});
|
||||
app.onError((err, c) =>
|
||||
c.json({ code: (err as CampaignsApiError).code }, 500)
|
||||
);
|
||||
const e = buildSendCampaignRoute();
|
||||
app.post(e.route.path, (c) => e.handler(c as never));
|
||||
const res = await app.request("/campaigns/c1/send", {
|
||||
method: "POST",
|
||||
});
|
||||
expect((await res.json()).code).toBe(
|
||||
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE
|
||||
);
|
||||
});
|
||||
|
||||
it("stats surfaces SERVICE_NOT_AVAILABLE when event store missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
await seed(repo);
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", { id: "u", tenantId: "t" } as never);
|
||||
c.set("campaignRepository", repo);
|
||||
await next();
|
||||
});
|
||||
app.onError((err, c) =>
|
||||
c.json({ code: (err as CampaignsApiError).code }, 500)
|
||||
);
|
||||
const e = buildCampaignStatsRoute();
|
||||
app.get(e.route.path, (c) => e.handler(c as never));
|
||||
const res = await app.request("/campaigns/c1/stats");
|
||||
expect((await res.json()).code).toBe(
|
||||
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE
|
||||
);
|
||||
});
|
||||
|
||||
it("POST /campaigns/:id/test 400 on malformed JSON body", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
await seed(repo);
|
||||
const app = makeApp(repo);
|
||||
const res = await app.request("/campaigns/c1/test", {
|
||||
method: "POST",
|
||||
body: "not-json{",
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("send rejects unauthenticated requests", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
const app = makeApp(repo, undefined, null);
|
||||
const res = await app.request("/campaigns/c1/send", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* Campaign action route builders — state-machine transitions and the
|
||||
* `/duplicate` and `/test` action endpoints. The state guard is
|
||||
* authoritative: invalid transitions raise
|
||||
* `CAMPAIGNS_API.INVALID_STATE_TRANSITION` mapped to HTTP 409 so
|
||||
* clients can branch on a stable code rather than parsing error
|
||||
* messages.
|
||||
*/
|
||||
|
||||
import {
|
||||
GET,
|
||||
type PluginRouteEntry,
|
||||
POST,
|
||||
successResponse,
|
||||
} from "@suaveplan/api-openapi";
|
||||
import {
|
||||
type Campaign,
|
||||
CampaignStateMachine,
|
||||
type CampaignStatus,
|
||||
type CampaignStore,
|
||||
} from "@suaveplan/campaigns";
|
||||
import type { DeliveryEventStore } from "@suaveplan/delivery-tracking";
|
||||
import type { Context } from "hono";
|
||||
import z from "zod";
|
||||
import {
|
||||
CAMPAIGNS_API_CODES,
|
||||
CampaignsApiError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { getCampaignsApiInstruments } from "../../otel/instruments.js";
|
||||
import { requireTenant } from "../shared/require-tenant.js";
|
||||
import { campaignToResponse } from "./crud-routes.js";
|
||||
import {
|
||||
CampaignIdParamSchema,
|
||||
CampaignResponseSchema,
|
||||
EMPTY_STATS,
|
||||
} from "./schemas.js";
|
||||
|
||||
const TestSendSchema = z.object({
|
||||
recipients: z.array(z.email()).min(1).max(10),
|
||||
});
|
||||
|
||||
const DeliveryStatsResponseSchema = z.object({
|
||||
sent: z.number(),
|
||||
delivered: z.number(),
|
||||
deliveryRate: z.number(),
|
||||
opened: z.number(),
|
||||
openRate: z.number(),
|
||||
clicked: z.number(),
|
||||
clickRate: z.number(),
|
||||
bounced: z.number(),
|
||||
bounceRate: z.number(),
|
||||
unsubscribed: z.number(),
|
||||
unsubscribeRate: z.number(),
|
||||
complained: z.number(),
|
||||
complainRate: z.number(),
|
||||
});
|
||||
|
||||
const DeliveryEventResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
tenantId: z.string(),
|
||||
campaignId: z.string().nullable().optional(),
|
||||
contactId: z.string(),
|
||||
channel: z.string(),
|
||||
eventType: z.string(),
|
||||
messageId: z.string(),
|
||||
metadata: z.record(z.string(), z.unknown()),
|
||||
timestamp: z.string(),
|
||||
});
|
||||
|
||||
const DeliveryEventListResponseSchema = z.object({
|
||||
events: z.array(DeliveryEventResponseSchema),
|
||||
total: z.number(),
|
||||
});
|
||||
|
||||
const EventQuerySchema = z.object({
|
||||
eventType: z
|
||||
.enum([
|
||||
"sent",
|
||||
"delivered",
|
||||
"opened",
|
||||
"clicked",
|
||||
"bounced",
|
||||
"unsubscribed",
|
||||
"complained",
|
||||
"deferred",
|
||||
"dropped",
|
||||
"read",
|
||||
])
|
||||
.optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
function requireRepo(c: Context): CampaignStore {
|
||||
const repo = c.get("campaignRepository");
|
||||
if (repo === undefined) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE,
|
||||
"campaignRepository slot is empty"
|
||||
);
|
||||
}
|
||||
return repo;
|
||||
}
|
||||
|
||||
function requireEventStore(c: Context): DeliveryEventStore {
|
||||
const store = c.get("deliveryEventStore");
|
||||
if (store === undefined) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE,
|
||||
"deliveryEventStore slot is empty"
|
||||
);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
async function loadOwned(
|
||||
c: Context,
|
||||
id: string,
|
||||
tenantId: string
|
||||
): Promise<Campaign> {
|
||||
const campaign = await requireRepo(c).getById(id);
|
||||
if (campaign === null || campaign.tenantId !== tenantId) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.CAMPAIGN_NOT_FOUND,
|
||||
`campaign ${id} not found`,
|
||||
{ context: { statusCode: 404, id } }
|
||||
);
|
||||
}
|
||||
return campaign;
|
||||
}
|
||||
|
||||
const stateMachine = new CampaignStateMachine();
|
||||
|
||||
function applyTransition(
|
||||
current: CampaignStatus,
|
||||
event: "send" | "pause" | "cancel"
|
||||
): CampaignStatus {
|
||||
try {
|
||||
return stateMachine.transition(current, event);
|
||||
} catch (cause) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.INVALID_STATE_TRANSITION,
|
||||
`cannot apply '${event}' to campaign in status '${current}'`,
|
||||
{ cause, context: { statusCode: 409, from: current, event } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildActionRoute(
|
||||
path: string,
|
||||
summary: string,
|
||||
event: "send" | "pause" | "cancel",
|
||||
routeLabel: string
|
||||
): PluginRouteEntry {
|
||||
const route = POST(path)
|
||||
.tags("campaigns")
|
||||
.summary(summary)
|
||||
.params(CampaignIdParamSchema)
|
||||
.response(successResponse(CampaignResponseSchema))
|
||||
.build();
|
||||
async function handler(c: Context): Promise<Response> {
|
||||
const { tenantId } = requireTenant(c);
|
||||
const repo = requireRepo(c);
|
||||
const id = c.req.param("id");
|
||||
const existing = await loadOwned(c, id, tenantId);
|
||||
const next = applyTransition(existing.status, event);
|
||||
const updated = await repo.updateStatus(id, next);
|
||||
getCampaignsApiInstruments().requestsTotal.add(1, {
|
||||
route: routeLabel,
|
||||
outcome: "success",
|
||||
});
|
||||
return c.json(
|
||||
{ success: true as const, data: campaignToResponse(updated) },
|
||||
200
|
||||
);
|
||||
}
|
||||
return { route, handler };
|
||||
}
|
||||
|
||||
/** `POST /campaigns/:id/send` — transition draft|scheduled → sending. */
|
||||
export function buildSendCampaignRoute(): PluginRouteEntry {
|
||||
return buildActionRoute(
|
||||
"/campaigns/:id/send",
|
||||
"Send a campaign",
|
||||
"send",
|
||||
"send"
|
||||
);
|
||||
}
|
||||
|
||||
/** `POST /campaigns/:id/pause` — transition sending → paused. */
|
||||
export function buildPauseCampaignRoute(): PluginRouteEntry {
|
||||
return buildActionRoute(
|
||||
"/campaigns/:id/pause",
|
||||
"Pause a campaign",
|
||||
"pause",
|
||||
"pause"
|
||||
);
|
||||
}
|
||||
|
||||
/** `POST /campaigns/:id/cancel` — transition to cancelled. */
|
||||
export function buildCancelCampaignRoute(): PluginRouteEntry {
|
||||
return buildActionRoute(
|
||||
"/campaigns/:id/cancel",
|
||||
"Cancel a campaign",
|
||||
"cancel",
|
||||
"cancel"
|
||||
);
|
||||
}
|
||||
|
||||
/** `POST /campaigns/:id/duplicate` — clone the campaign into draft. */
|
||||
export function buildDuplicateCampaignRoute(): PluginRouteEntry {
|
||||
const route = POST("/campaigns/:id/duplicate")
|
||||
.tags("campaigns")
|
||||
.summary("Duplicate a campaign")
|
||||
.params(CampaignIdParamSchema)
|
||||
.response(successResponse(CampaignResponseSchema))
|
||||
.build();
|
||||
async function handler(c: Context): Promise<Response> {
|
||||
const { tenantId } = requireTenant(c);
|
||||
const repo = requireRepo(c);
|
||||
const id = c.req.param("id");
|
||||
const source = await loadOwned(c, id, tenantId);
|
||||
const now = new Date();
|
||||
const clone = await repo.create({
|
||||
...source,
|
||||
id: crypto.randomUUID(),
|
||||
name: `${source.name} (copy)`,
|
||||
status: "draft",
|
||||
stats: { ...EMPTY_STATS },
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
sentAt: undefined,
|
||||
completedAt: undefined,
|
||||
} as unknown as Campaign);
|
||||
getCampaignsApiInstruments().requestsTotal.add(1, {
|
||||
route: "duplicate",
|
||||
outcome: "success",
|
||||
});
|
||||
return c.json(
|
||||
{ success: true as const, data: campaignToResponse(clone) },
|
||||
201
|
||||
);
|
||||
}
|
||||
return { route, handler };
|
||||
}
|
||||
|
||||
/** `POST /campaigns/:id/test` — fire a test send to the given recipients. */
|
||||
export function buildTestSendRoute(): PluginRouteEntry {
|
||||
const route = POST("/campaigns/:id/test")
|
||||
.tags("campaigns")
|
||||
.summary("Send a test of a campaign")
|
||||
.params(CampaignIdParamSchema)
|
||||
.body(TestSendSchema)
|
||||
.response(
|
||||
z.object({
|
||||
success: z.literal(true),
|
||||
data: z.object({
|
||||
recipients: z.array(z.email()),
|
||||
}),
|
||||
})
|
||||
)
|
||||
.build();
|
||||
async function handler(c: Context): Promise<Response> {
|
||||
const { tenantId } = requireTenant(c);
|
||||
const id = c.req.param("id");
|
||||
await loadOwned(c, id, tenantId);
|
||||
const raw = (await c.req.json().catch(() => ({}))) as unknown;
|
||||
const parsed = TestSendSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
getCampaignsApiInstruments().requestsTotal.add(1, {
|
||||
route: "test",
|
||||
outcome: "error",
|
||||
});
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.INVALID_INPUT,
|
||||
"POST /campaigns/:id/test body failed validation",
|
||||
{ cause: parsed.error, context: { statusCode: 400 } }
|
||||
);
|
||||
}
|
||||
getCampaignsApiInstruments().requestsTotal.add(1, {
|
||||
route: "test",
|
||||
outcome: "success",
|
||||
});
|
||||
return c.json(
|
||||
{
|
||||
success: true as const,
|
||||
data: { recipients: parsed.data.recipients },
|
||||
},
|
||||
200
|
||||
);
|
||||
}
|
||||
return { route, handler };
|
||||
}
|
||||
|
||||
/** `GET /campaigns/:id/stats` — aggregated delivery stats. */
|
||||
export function buildCampaignStatsRoute(): PluginRouteEntry {
|
||||
const route = GET("/campaigns/:id/stats")
|
||||
.tags("campaigns")
|
||||
.summary("Get delivery stats for a campaign")
|
||||
.params(CampaignIdParamSchema)
|
||||
.response(successResponse(DeliveryStatsResponseSchema))
|
||||
.build();
|
||||
async function handler(c: Context): Promise<Response> {
|
||||
const { tenantId } = requireTenant(c);
|
||||
const id = c.req.param("id");
|
||||
await loadOwned(c, id, tenantId);
|
||||
const stats = await requireEventStore(c).getStatsByCampaign(id);
|
||||
getCampaignsApiInstruments().requestsTotal.add(1, {
|
||||
route: "stats",
|
||||
outcome: "success",
|
||||
});
|
||||
return c.json({ success: true as const, data: stats }, 200);
|
||||
}
|
||||
return { route, handler };
|
||||
}
|
||||
|
||||
/** `GET /campaigns/:id/events` — paginated event log. */
|
||||
export function buildCampaignEventsRoute(): PluginRouteEntry {
|
||||
const route = GET("/campaigns/:id/events")
|
||||
.tags("campaigns")
|
||||
.summary("List delivery events for a campaign")
|
||||
.params(CampaignIdParamSchema)
|
||||
.query(EventQuerySchema)
|
||||
.response(successResponse(DeliveryEventListResponseSchema))
|
||||
.build();
|
||||
async function handler(c: Context): Promise<Response> {
|
||||
const { tenantId } = requireTenant(c);
|
||||
const id = c.req.param("id");
|
||||
await loadOwned(c, id, tenantId);
|
||||
const query = EventQuerySchema.parse({
|
||||
eventType: c.req.query("eventType"),
|
||||
limit: c.req.query("limit") ?? 50,
|
||||
offset: c.req.query("offset") ?? 0,
|
||||
});
|
||||
const events = await requireEventStore(c).getByCampaign(id, {
|
||||
...(query.eventType !== undefined
|
||||
? { eventType: query.eventType }
|
||||
: {}),
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
});
|
||||
const mapped = events.map((e) => ({
|
||||
...e,
|
||||
timestamp: e.timestamp.toISOString(),
|
||||
}));
|
||||
getCampaignsApiInstruments().requestsTotal.add(1, {
|
||||
route: "events",
|
||||
outcome: "success",
|
||||
});
|
||||
return c.json(
|
||||
{
|
||||
success: true as const,
|
||||
data: { events: mapped, total: mapped.length },
|
||||
},
|
||||
200
|
||||
);
|
||||
}
|
||||
return { route, handler };
|
||||
}
|
||||
|
||||
export { DeliveryStatsResponseSchema, EventQuerySchema, TestSendSchema };
|
||||
@@ -0,0 +1,71 @@
|
||||
# `build-routes` (campaigns)
|
||||
|
||||
## Purpose
|
||||
|
||||
`buildCampaignRoutes` is the single aggregator that materialises every
|
||||
`/campaigns/*` `PluginRouteEntry` in a canonical order. The plugin's
|
||||
`onInit` consumes the array verbatim — there is no per-route opt-in or
|
||||
configuration knob — so the OpenAPI spec generated for the campaigns
|
||||
tree is deterministic across deployments and across reloads. Tests
|
||||
assert the canonical order so accidental reordering during refactors
|
||||
surfaces immediately rather than as silent spec drift.
|
||||
|
||||
The aggregator is intentionally a flat function returning a readonly
|
||||
array (rather than a class or builder). The cost of describing 12
|
||||
routes inline is small, the structure is obvious, and downstream
|
||||
consumers can `array.map(toPrefixed)` cheaply when they need to mount
|
||||
under a non-root prefix.
|
||||
|
||||
## Features
|
||||
|
||||
- Returns 12 entries in a documented canonical order.
|
||||
- Pure: no I/O, no side effects, no captured registry.
|
||||
- Each entry exposes `route: RouteConfig` + `handler: RouteHandler` so
|
||||
it can be mounted either via `definePluginRoutes` (production) or
|
||||
directly on a bare `Hono` app (tests).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildCampaignRoutes } from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
const entries = buildCampaignRoutes();
|
||||
for (const e of entries) {
|
||||
app[e.route.method as "get" | "post" | "patch" | "delete"](
|
||||
e.route.path,
|
||||
(c) => e.handler(c)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildCampaignRoutes,
|
||||
definePluginRoutes,
|
||||
} from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
const prefix = "/api/v1";
|
||||
const prefixed = buildCampaignRoutes().map((e) => ({
|
||||
...e,
|
||||
route: { ...e.route, path: `${prefix}${e.route.path}` },
|
||||
}));
|
||||
definePluginRoutes(prefixed, { app, registry, pluginName: "campaigns" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `buildCampaignRoutes(): readonly PluginRouteEntry[]` — returns the
|
||||
12 canonical campaign route entries.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The canonical order — list, create, get, update, delete, send, pause,
|
||||
cancel, duplicate, test, stats, events — places the read endpoints
|
||||
before write/action endpoints (so OpenAPI consumers naturally see the
|
||||
read surface first) and groups the lifecycle action routes together.
|
||||
The `events` route comes last because it depends on the
|
||||
`deliveryEventStore` slot being populated, which is the only
|
||||
non-trivial dependency in the bundle; co-locating it with `stats`
|
||||
makes the dependency footprint visible at a glance.
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { buildCampaignRoutes } from "./build-routes.js";
|
||||
|
||||
describe("buildCampaignRoutes", () => {
|
||||
it("returns 12 entries in canonical order", { timeout: 5000 }, () => {
|
||||
const entries = buildCampaignRoutes();
|
||||
expect(entries.map((e) => `${e.route.method} ${e.route.path}`)).toEqual(
|
||||
[
|
||||
"get /campaigns",
|
||||
"post /campaigns",
|
||||
"get /campaigns/:id",
|
||||
"patch /campaigns/:id",
|
||||
"delete /campaigns/:id",
|
||||
"post /campaigns/:id/send",
|
||||
"post /campaigns/:id/pause",
|
||||
"post /campaigns/:id/cancel",
|
||||
"post /campaigns/:id/duplicate",
|
||||
"post /campaigns/:id/test",
|
||||
"get /campaigns/:id/stats",
|
||||
"get /campaigns/:id/events",
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("every entry has a callable handler + a typed route config", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const e of buildCampaignRoutes()) {
|
||||
expect(typeof e.handler).toBe("function");
|
||||
expect(typeof e.route.method).toBe("string");
|
||||
expect(typeof e.route.path).toBe("string");
|
||||
expect(e.route.tags).toEqual(["campaigns"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Aggregator that returns every `/campaigns/*` `PluginRouteEntry`
|
||||
* value to be mounted by the plugin via `definePluginRoutes`. Keeping
|
||||
* the canonical order in one place — listed, mutated, then acted on —
|
||||
* makes the OpenAPI spec deterministic across deploys.
|
||||
*/
|
||||
|
||||
import type { PluginRouteEntry } from "@suaveplan/api-openapi";
|
||||
import {
|
||||
buildCampaignEventsRoute,
|
||||
buildCampaignStatsRoute,
|
||||
buildCancelCampaignRoute,
|
||||
buildDuplicateCampaignRoute,
|
||||
buildPauseCampaignRoute,
|
||||
buildSendCampaignRoute,
|
||||
buildTestSendRoute,
|
||||
} from "./action-routes.js";
|
||||
import {
|
||||
buildCreateCampaignRoute,
|
||||
buildDeleteCampaignRoute,
|
||||
buildGetCampaignRoute,
|
||||
buildListCampaignsRoute,
|
||||
buildUpdateCampaignRoute,
|
||||
} from "./crud-routes.js";
|
||||
|
||||
/**
|
||||
* Returns the 12 `/campaigns/*` plugin-route entries in canonical order:
|
||||
* list, create, get, update, delete, send, pause, cancel, duplicate,
|
||||
* test, stats, events.
|
||||
*/
|
||||
export function buildCampaignRoutes(): readonly PluginRouteEntry[] {
|
||||
return [
|
||||
buildListCampaignsRoute(),
|
||||
buildCreateCampaignRoute(),
|
||||
buildGetCampaignRoute(),
|
||||
buildUpdateCampaignRoute(),
|
||||
buildDeleteCampaignRoute(),
|
||||
buildSendCampaignRoute(),
|
||||
buildPauseCampaignRoute(),
|
||||
buildCancelCampaignRoute(),
|
||||
buildDuplicateCampaignRoute(),
|
||||
buildTestSendRoute(),
|
||||
buildCampaignStatsRoute(),
|
||||
buildCampaignEventsRoute(),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
# Campaign CRUD routes
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the five canonical CRUD entries for the `/campaigns`
|
||||
route tree: list, create, get, update, and delete. Each builder returns
|
||||
a `PluginRouteEntry` (`{ route, handler }`) so the parent plugin can
|
||||
mount them through `definePluginRoutes` against the host's
|
||||
`OpenAPIRegistry`. Keeping the builders pure (no side effects, no
|
||||
captured registry) means the same entries can be reused in unit tests
|
||||
against a bare `Hono` app and in production against an
|
||||
`OpenAPIHono` + registry pair.
|
||||
|
||||
Every handler enforces three invariants before touching the
|
||||
repository: the request principal must carry a non-empty `tenantId`
|
||||
(`requireTenant`), the `campaignRepository` slot in the Hono context
|
||||
must be populated (else `SERVICE_NOT_AVAILABLE`), and any campaign
|
||||
loaded by id must belong to the caller's tenant (else
|
||||
`CAMPAIGN_NOT_FOUND` — deliberately the same code we use for missing
|
||||
rows so cross-tenant probes leak no information).
|
||||
|
||||
## Features
|
||||
|
||||
- `buildListCampaignsRoute` — `GET /campaigns`, paginated, filterable.
|
||||
- `buildCreateCampaignRoute` — `POST /campaigns`, validates body,
|
||||
stamps `id`, `status: "draft"`, `stats: EMPTY_STATS`, `createdAt`,
|
||||
`updatedAt`.
|
||||
- `buildGetCampaignRoute` — `GET /campaigns/:id`, 404 on missing or
|
||||
wrong-tenant.
|
||||
- `buildUpdateCampaignRoute` — `PATCH /campaigns/:id`, partial body,
|
||||
validates ownership before update.
|
||||
- `buildDeleteCampaignRoute` — `DELETE /campaigns/:id`, 204 on
|
||||
success, 404 on missing/wrong-tenant.
|
||||
- `campaignToResponse(campaign)` — re-exported helper that maps
|
||||
`Date` fields to ISO 8601 strings and `undefined` to `null` for
|
||||
JSON serialisation.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildCreateCampaignRoute } from "@suaveplan/api-plugin-campaigns";
|
||||
import { Hono } from "hono";
|
||||
|
||||
const entry = buildCreateCampaignRoute();
|
||||
const app = new Hono();
|
||||
app.post(entry.route.path, (c) => entry.handler(c));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildCreateCampaignRoute,
|
||||
buildDeleteCampaignRoute,
|
||||
buildGetCampaignRoute,
|
||||
buildListCampaignsRoute,
|
||||
buildUpdateCampaignRoute,
|
||||
definePluginRoutes,
|
||||
} from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
definePluginRoutes(
|
||||
[
|
||||
buildListCampaignsRoute(),
|
||||
buildCreateCampaignRoute(),
|
||||
buildGetCampaignRoute(),
|
||||
buildUpdateCampaignRoute(),
|
||||
buildDeleteCampaignRoute(),
|
||||
],
|
||||
{ app, registry, pluginName: "campaigns" }
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `buildListCampaignsRoute(): PluginRouteEntry`
|
||||
- `buildCreateCampaignRoute(): PluginRouteEntry`
|
||||
- `buildGetCampaignRoute(): PluginRouteEntry`
|
||||
- `buildUpdateCampaignRoute(): PluginRouteEntry`
|
||||
- `buildDeleteCampaignRoute(): PluginRouteEntry`
|
||||
- `campaignToResponse(c: Campaign): Record<string, unknown>` —
|
||||
JSON-safe projection (ISO 8601 dates, `null` instead of `undefined`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The handlers eagerly call `c.req.query("...")` rather than relying on
|
||||
the OpenAPI `c.req.valid("query")` because the same handler is reused
|
||||
in unit tests against a bare `Hono` app (no `app.openapi` plumbing).
|
||||
The bare-Hono path is the load-bearing one for the integration suite,
|
||||
so `c.req.query` keeps the seam visible. Errors are thrown
|
||||
synchronously and surfaced through the host-level `onError` adapter —
|
||||
no try/catch noise inside the happy path. `crypto.randomUUID()` is the
|
||||
id source for `POST /campaigns`; the runtime polyfill bundle imported
|
||||
in `tests/setup/polyfills.ts` ensures the WSL2 + Bun test environment
|
||||
exposes it.
|
||||
@@ -0,0 +1,457 @@
|
||||
import { InMemoryCampaignStore } from "@suaveplan/campaigns";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
CAMPAIGNS_API_CODES,
|
||||
CampaignsApiError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import {
|
||||
buildCreateCampaignRoute,
|
||||
buildDeleteCampaignRoute,
|
||||
buildGetCampaignRoute,
|
||||
buildListCampaignsRoute,
|
||||
buildUpdateCampaignRoute,
|
||||
campaignToResponse,
|
||||
} from "./crud-routes.js";
|
||||
import "../../context/context.js";
|
||||
|
||||
function makeApp(
|
||||
repo: InMemoryCampaignStore,
|
||||
principal: object | null = { id: "u", tenantId: "t" }
|
||||
): Hono {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
if (principal !== null) c.set("user", principal as never);
|
||||
c.set("campaignRepository", repo);
|
||||
c.set("eventBus", { emit: () => undefined } as never);
|
||||
await next();
|
||||
});
|
||||
app.onError((err, c) => {
|
||||
if (err instanceof CampaignsApiError) {
|
||||
const status = ((err.context as { statusCode?: number } | undefined)
|
||||
?.statusCode ?? 500) as 400 | 401 | 404 | 500;
|
||||
return c.json(
|
||||
{ success: false, code: err.code, error: err.message },
|
||||
status
|
||||
);
|
||||
}
|
||||
return c.json({ success: false, error: String(err) }, 500);
|
||||
});
|
||||
for (const e of [
|
||||
buildListCampaignsRoute(),
|
||||
buildCreateCampaignRoute(),
|
||||
buildGetCampaignRoute(),
|
||||
buildUpdateCampaignRoute(),
|
||||
buildDeleteCampaignRoute(),
|
||||
]) {
|
||||
const method = e.route.method as "get" | "post" | "patch" | "delete";
|
||||
app[method](e.route.path, (c) => e.handler(c as never));
|
||||
}
|
||||
return app;
|
||||
}
|
||||
|
||||
async function seed(
|
||||
repo: InMemoryCampaignStore,
|
||||
tenant = "t"
|
||||
): Promise<string> {
|
||||
const now = new Date();
|
||||
const id = "c1";
|
||||
await repo.create({
|
||||
id,
|
||||
tenantId: tenant,
|
||||
name: "Promo",
|
||||
status: "draft",
|
||||
type: "broadcast",
|
||||
channel: "email",
|
||||
audienceConfig: { groupIds: [] },
|
||||
metadata: {},
|
||||
stats: {
|
||||
totalRecipients: 0,
|
||||
sent: 0,
|
||||
delivered: 0,
|
||||
opened: 0,
|
||||
clicked: 0,
|
||||
bounced: 0,
|
||||
unsubscribed: 0,
|
||||
complained: 0,
|
||||
failed: 0,
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
describe("campaign CRUD routes", () => {
|
||||
it("POST /campaigns 401 without tenantId", { timeout: 5000 }, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
const app = makeApp(repo, null);
|
||||
const res = await app.request("/campaigns", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: "x" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect((await res.json()).code).toBe(
|
||||
CAMPAIGNS_API_CODES.TENANT_REQUIRED
|
||||
);
|
||||
});
|
||||
|
||||
it("POST /campaigns creates a campaign", { timeout: 5000 }, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
const app = makeApp(repo);
|
||||
const res = await app.request("/campaigns", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: "Promo" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.data.name).toBe("Promo");
|
||||
expect(body.data.status).toBe("draft");
|
||||
});
|
||||
|
||||
it("POST /campaigns rejects invalid body", { timeout: 5000 }, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
const app = makeApp(repo);
|
||||
const res = await app.request("/campaigns", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: "" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).code).toBe(CAMPAIGNS_API_CODES.INVALID_INPUT);
|
||||
});
|
||||
|
||||
it("GET /campaigns lists tenant campaigns", { timeout: 5000 }, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
await seed(repo);
|
||||
const app = makeApp(repo);
|
||||
const res = await app.request("/campaigns");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.total).toBe(1);
|
||||
expect(body.data.campaigns).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("GET /campaigns honours query filters", { timeout: 5000 }, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
await seed(repo);
|
||||
const app = makeApp(repo);
|
||||
const res = await app.request(
|
||||
"/campaigns?status=draft&type=broadcast&channel=email&limit=5&offset=0"
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("GET /campaigns/:id returns 404 for missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
const app = makeApp(repo);
|
||||
const res = await app.request("/campaigns/nope");
|
||||
expect(res.status).toBe(404);
|
||||
expect((await res.json()).code).toBe(
|
||||
CAMPAIGNS_API_CODES.CAMPAIGN_NOT_FOUND
|
||||
);
|
||||
});
|
||||
|
||||
it("GET /campaigns/:id returns 404 for wrong tenant", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
await seed(repo, "other");
|
||||
const app = makeApp(repo);
|
||||
const res = await app.request("/campaigns/c1");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("GET /campaigns/:id returns 200 on hit", { timeout: 5000 }, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
await seed(repo);
|
||||
const app = makeApp(repo);
|
||||
const res = await app.request("/campaigns/c1");
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).data.id).toBe("c1");
|
||||
});
|
||||
|
||||
it("PATCH /campaigns/:id updates the name", { timeout: 5000 }, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
await seed(repo);
|
||||
const app = makeApp(repo);
|
||||
const res = await app.request("/campaigns/c1", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ name: "Updated" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).data.name).toBe("Updated");
|
||||
});
|
||||
|
||||
it("PATCH /campaigns/:id rejects invalid body", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
await seed(repo);
|
||||
const app = makeApp(repo);
|
||||
const res = await app.request("/campaigns/c1", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ name: "" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("PATCH /campaigns/:id 404 when missing", { timeout: 5000 }, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
const app = makeApp(repo);
|
||||
const res = await app.request("/campaigns/nope", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ name: "x" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("PATCH /campaigns/:id 404 wrong tenant", { timeout: 5000 }, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
await seed(repo, "other");
|
||||
const app = makeApp(repo);
|
||||
const res = await app.request("/campaigns/c1", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ name: "x" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("DELETE /campaigns/:id returns 204 on hit", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
await seed(repo);
|
||||
const app = makeApp(repo);
|
||||
const res = await app.request("/campaigns/c1", { method: "DELETE" });
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it("DELETE /campaigns/:id returns 404 missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
const app = makeApp(repo);
|
||||
const res = await app.request("/campaigns/nope", { method: "DELETE" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("DELETE /campaigns/:id 404 wrong tenant", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
await seed(repo, "other");
|
||||
const app = makeApp(repo);
|
||||
const res = await app.request("/campaigns/c1", { method: "DELETE" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 500 when campaignRepository slot is empty", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", { id: "u", tenantId: "t" } as never);
|
||||
await next();
|
||||
});
|
||||
app.onError((err, c) => {
|
||||
const code = (err as CampaignsApiError).code;
|
||||
return c.json({ code }, 500);
|
||||
});
|
||||
const e = buildListCampaignsRoute();
|
||||
app.get(e.route.path, (c) => e.handler(c as never));
|
||||
const res = await app.request("/campaigns");
|
||||
expect(res.status).toBe(500);
|
||||
expect((await res.json()).code).toBe(
|
||||
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE
|
||||
);
|
||||
});
|
||||
|
||||
it("create surfaces SERVICE_NOT_AVAILABLE when repo missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", { id: "u", tenantId: "t" } as never);
|
||||
await next();
|
||||
});
|
||||
app.onError((err, c) =>
|
||||
c.json({ code: (err as CampaignsApiError).code }, 500)
|
||||
);
|
||||
const e = buildCreateCampaignRoute();
|
||||
app.post(e.route.path, (c) => e.handler(c as never));
|
||||
const res = await app.request("/campaigns", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: "x" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect(res.status).toBe(500);
|
||||
expect((await res.json()).code).toBe(
|
||||
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE
|
||||
);
|
||||
});
|
||||
|
||||
it("get surfaces SERVICE_NOT_AVAILABLE when repo missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", { id: "u", tenantId: "t" } as never);
|
||||
await next();
|
||||
});
|
||||
app.onError((err, c) =>
|
||||
c.json({ code: (err as CampaignsApiError).code }, 500)
|
||||
);
|
||||
const e = buildGetCampaignRoute();
|
||||
app.get(e.route.path, (c) => e.handler(c as never));
|
||||
const res = await app.request("/campaigns/x");
|
||||
expect((await res.json()).code).toBe(
|
||||
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE
|
||||
);
|
||||
});
|
||||
|
||||
it("update surfaces SERVICE_NOT_AVAILABLE when repo missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", { id: "u", tenantId: "t" } as never);
|
||||
await next();
|
||||
});
|
||||
app.onError((err, c) =>
|
||||
c.json({ code: (err as CampaignsApiError).code }, 500)
|
||||
);
|
||||
const e = buildUpdateCampaignRoute();
|
||||
app.patch(e.route.path, (c) => e.handler(c as never));
|
||||
const res = await app.request("/campaigns/x", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ name: "y" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect((await res.json()).code).toBe(
|
||||
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE
|
||||
);
|
||||
});
|
||||
|
||||
it("delete surfaces SERVICE_NOT_AVAILABLE when repo missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", { id: "u", tenantId: "t" } as never);
|
||||
await next();
|
||||
});
|
||||
app.onError((err, c) =>
|
||||
c.json({ code: (err as CampaignsApiError).code }, 500)
|
||||
);
|
||||
const e = buildDeleteCampaignRoute();
|
||||
app.delete(e.route.path, (c) => e.handler(c as never));
|
||||
const res = await app.request("/campaigns/x", { method: "DELETE" });
|
||||
expect((await res.json()).code).toBe(
|
||||
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE
|
||||
);
|
||||
});
|
||||
|
||||
it("POST /campaigns returns 400 on malformed JSON body", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
const app = makeApp(repo);
|
||||
const res = await app.request("/campaigns", {
|
||||
method: "POST",
|
||||
body: "not-json{",
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("PATCH /campaigns/:id accepts malformed JSON as empty body (UpdateSchema is all-optional)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const repo = new InMemoryCampaignStore();
|
||||
await seed(repo);
|
||||
const app = makeApp(repo);
|
||||
const res = await app.request("/campaigns/c1", {
|
||||
method: "PATCH",
|
||||
body: "not-json{",
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
// Empty body falls through the all-optional schema; no fields
|
||||
// change. This still exercises the JSON catch path.
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("campaignToResponse formats undefined dates as null", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const now = new Date();
|
||||
const projected = campaignToResponse({
|
||||
id: "c1",
|
||||
tenantId: "t",
|
||||
name: "x",
|
||||
status: "draft",
|
||||
type: "broadcast",
|
||||
channel: "email",
|
||||
audienceConfig: { groupIds: [] },
|
||||
metadata: {},
|
||||
stats: {
|
||||
totalRecipients: 0,
|
||||
sent: 0,
|
||||
delivered: 0,
|
||||
opened: 0,
|
||||
clicked: 0,
|
||||
bounced: 0,
|
||||
unsubscribed: 0,
|
||||
complained: 0,
|
||||
failed: 0,
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
expect(projected.sentAt).toBeNull();
|
||||
expect(projected.completedAt).toBeNull();
|
||||
expect(typeof projected.createdAt).toBe("string");
|
||||
});
|
||||
|
||||
it("campaignToResponse accepts date-string inputs", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const projected = campaignToResponse({
|
||||
id: "c1",
|
||||
tenantId: "t",
|
||||
name: "x",
|
||||
status: "draft",
|
||||
type: "broadcast",
|
||||
channel: "email",
|
||||
audienceConfig: { groupIds: [] },
|
||||
metadata: {},
|
||||
stats: {
|
||||
totalRecipients: 0,
|
||||
sent: 0,
|
||||
delivered: 0,
|
||||
opened: 0,
|
||||
clicked: 0,
|
||||
bounced: 0,
|
||||
unsubscribed: 0,
|
||||
complained: 0,
|
||||
failed: 0,
|
||||
},
|
||||
// Intentionally a string to exercise the non-Date branch
|
||||
createdAt: "2026-01-01T00:00:00.000Z" as unknown as Date,
|
||||
updatedAt: new Date("2026-01-02T00:00:00.000Z"),
|
||||
sentAt: new Date("2026-01-03T00:00:00.000Z"),
|
||||
completedAt: new Date("2026-01-04T00:00:00.000Z"),
|
||||
});
|
||||
expect(projected.createdAt).toBe("2026-01-01T00:00:00.000Z");
|
||||
expect(projected.sentAt).toBe("2026-01-03T00:00:00.000Z");
|
||||
expect(projected.completedAt).toBe("2026-01-04T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Campaign CRUD route builders — list, create, get, update, delete.
|
||||
* Each builder returns a `PluginRouteEntry` for mounting via
|
||||
* `definePluginRoutes`. Handlers read the repository from
|
||||
* `c.get("campaignRepository")` (typed via the Hono ContextVariableMap
|
||||
* augmentation) and the principal via `requireTenant`.
|
||||
*/
|
||||
|
||||
import {
|
||||
DELETE,
|
||||
GET,
|
||||
PATCH,
|
||||
type PluginRouteEntry,
|
||||
POST,
|
||||
successResponse,
|
||||
} from "@suaveplan/api-openapi";
|
||||
import type { Campaign, UpdateCampaignInput } from "@suaveplan/campaigns";
|
||||
import type { Context } from "hono";
|
||||
import z from "zod";
|
||||
import {
|
||||
CAMPAIGNS_API_CODES,
|
||||
CampaignsApiError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { getCampaignsApiInstruments } from "../../otel/instruments.js";
|
||||
import { requireTenant } from "../shared/require-tenant.js";
|
||||
import { stripUndefined } from "../shared/strip-undefined.js";
|
||||
import {
|
||||
CampaignIdParamSchema,
|
||||
CampaignListResponseSchema,
|
||||
CampaignQuerySchema,
|
||||
CampaignResponseSchema,
|
||||
CreateCampaignSchema,
|
||||
EMPTY_STATS,
|
||||
UpdateCampaignSchema,
|
||||
} from "./schemas.js";
|
||||
|
||||
function fmt(d: Date | string | undefined | null): string | null {
|
||||
if (d === null || d === undefined) return null;
|
||||
if (d instanceof Date) return d.toISOString();
|
||||
return new Date(d).toISOString();
|
||||
}
|
||||
|
||||
function toResponse(c: Campaign): Record<string, unknown> {
|
||||
return {
|
||||
...c,
|
||||
createdAt: fmt(c.createdAt),
|
||||
updatedAt: fmt(c.updatedAt),
|
||||
sentAt: fmt(c.sentAt ?? null),
|
||||
completedAt: fmt(c.completedAt ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
function requireRepo(c: Context) {
|
||||
const repo = c.get("campaignRepository");
|
||||
if (repo === undefined) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE,
|
||||
"campaignRepository slot is empty"
|
||||
);
|
||||
}
|
||||
return repo;
|
||||
}
|
||||
|
||||
/** `GET /campaigns` — list. */
|
||||
export function buildListCampaignsRoute(): PluginRouteEntry {
|
||||
const route = GET("/campaigns")
|
||||
.tags("campaigns")
|
||||
.summary("List campaigns")
|
||||
.query(CampaignQuerySchema)
|
||||
.response(successResponse(CampaignListResponseSchema))
|
||||
.build();
|
||||
async function handler(c: Context): Promise<Response> {
|
||||
const { tenantId } = requireTenant(c);
|
||||
const repo = requireRepo(c);
|
||||
const query = CampaignQuerySchema.parse({
|
||||
status: c.req.query("status"),
|
||||
type: c.req.query("type"),
|
||||
channel: c.req.query("channel"),
|
||||
limit: c.req.query("limit") ?? 50,
|
||||
offset: c.req.query("offset") ?? 0,
|
||||
});
|
||||
const campaigns = await repo.list({
|
||||
tenantId,
|
||||
...(query.status !== undefined ? { status: query.status } : {}),
|
||||
...(query.type !== undefined ? { type: query.type } : {}),
|
||||
...(query.channel !== undefined ? { channel: query.channel } : {}),
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
});
|
||||
getCampaignsApiInstruments().requestsTotal.add(1, {
|
||||
route: "list",
|
||||
outcome: "success",
|
||||
});
|
||||
return c.json(
|
||||
{
|
||||
success: true as const,
|
||||
data: {
|
||||
campaigns: campaigns.map(toResponse),
|
||||
total: campaigns.length,
|
||||
},
|
||||
},
|
||||
200
|
||||
);
|
||||
}
|
||||
return { route, handler };
|
||||
}
|
||||
|
||||
/** `POST /campaigns` — create. */
|
||||
export function buildCreateCampaignRoute(): PluginRouteEntry {
|
||||
const route = POST("/campaigns")
|
||||
.tags("campaigns")
|
||||
.summary("Create a campaign")
|
||||
.body(CreateCampaignSchema)
|
||||
.response(successResponse(CampaignResponseSchema))
|
||||
.build();
|
||||
async function handler(c: Context): Promise<Response> {
|
||||
const { tenantId } = requireTenant(c);
|
||||
const repo = requireRepo(c);
|
||||
const raw = (await c.req.json().catch(() => ({}))) as unknown;
|
||||
const parsed = CreateCampaignSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
getCampaignsApiInstruments().requestsTotal.add(1, {
|
||||
route: "create",
|
||||
outcome: "error",
|
||||
});
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.INVALID_INPUT,
|
||||
"POST /campaigns body failed validation",
|
||||
{ cause: parsed.error, context: { statusCode: 400 } }
|
||||
);
|
||||
}
|
||||
const now = new Date();
|
||||
const data = stripUndefined(parsed.data);
|
||||
const created = await repo.create({
|
||||
id: crypto.randomUUID(),
|
||||
tenantId,
|
||||
status: "draft",
|
||||
stats: { ...EMPTY_STATS },
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...data,
|
||||
} as unknown as Campaign);
|
||||
getCampaignsApiInstruments().requestsTotal.add(1, {
|
||||
route: "create",
|
||||
outcome: "success",
|
||||
});
|
||||
return c.json(
|
||||
{ success: true as const, data: toResponse(created) },
|
||||
201
|
||||
);
|
||||
}
|
||||
return { route, handler };
|
||||
}
|
||||
|
||||
/** `GET /campaigns/:id` — read. */
|
||||
export function buildGetCampaignRoute(): PluginRouteEntry {
|
||||
const route = GET("/campaigns/:id")
|
||||
.tags("campaigns")
|
||||
.summary("Get campaign by id")
|
||||
.params(CampaignIdParamSchema)
|
||||
.response(successResponse(CampaignResponseSchema))
|
||||
.build();
|
||||
async function handler(c: Context): Promise<Response> {
|
||||
const { tenantId } = requireTenant(c);
|
||||
const repo = requireRepo(c);
|
||||
const id = c.req.param("id");
|
||||
const campaign = await repo.getById(id);
|
||||
if (campaign === null || campaign.tenantId !== tenantId) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.CAMPAIGN_NOT_FOUND,
|
||||
`campaign ${id} not found`,
|
||||
{ context: { statusCode: 404, id } }
|
||||
);
|
||||
}
|
||||
getCampaignsApiInstruments().requestsTotal.add(1, {
|
||||
route: "get",
|
||||
outcome: "success",
|
||||
});
|
||||
return c.json(
|
||||
{ success: true as const, data: toResponse(campaign) },
|
||||
200
|
||||
);
|
||||
}
|
||||
return { route, handler };
|
||||
}
|
||||
|
||||
/** `PATCH /campaigns/:id` — update. */
|
||||
export function buildUpdateCampaignRoute(): PluginRouteEntry {
|
||||
const route = PATCH("/campaigns/:id")
|
||||
.tags("campaigns")
|
||||
.summary("Update a campaign")
|
||||
.params(CampaignIdParamSchema)
|
||||
.body(UpdateCampaignSchema)
|
||||
.response(successResponse(CampaignResponseSchema))
|
||||
.build();
|
||||
async function handler(c: Context): Promise<Response> {
|
||||
const { tenantId } = requireTenant(c);
|
||||
const repo = requireRepo(c);
|
||||
const id = c.req.param("id");
|
||||
const raw = (await c.req.json().catch(() => ({}))) as unknown;
|
||||
const parsed = UpdateCampaignSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
getCampaignsApiInstruments().requestsTotal.add(1, {
|
||||
route: "update",
|
||||
outcome: "error",
|
||||
});
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.INVALID_INPUT,
|
||||
"PATCH /campaigns/:id body failed validation",
|
||||
{ cause: parsed.error, context: { statusCode: 400 } }
|
||||
);
|
||||
}
|
||||
const existing = await repo.getById(id);
|
||||
if (existing === null || existing.tenantId !== tenantId) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.CAMPAIGN_NOT_FOUND,
|
||||
`campaign ${id} not found`,
|
||||
{ context: { statusCode: 404, id } }
|
||||
);
|
||||
}
|
||||
const updated = await repo.update(
|
||||
id,
|
||||
stripUndefined(parsed.data) as unknown as UpdateCampaignInput
|
||||
);
|
||||
getCampaignsApiInstruments().requestsTotal.add(1, {
|
||||
route: "update",
|
||||
outcome: "success",
|
||||
});
|
||||
return c.json(
|
||||
{ success: true as const, data: toResponse(updated) },
|
||||
200
|
||||
);
|
||||
}
|
||||
return { route, handler };
|
||||
}
|
||||
|
||||
/** `DELETE /campaigns/:id`. */
|
||||
export function buildDeleteCampaignRoute(): PluginRouteEntry {
|
||||
const route = DELETE("/campaigns/:id")
|
||||
.tags("campaigns")
|
||||
.summary("Delete a campaign")
|
||||
.params(CampaignIdParamSchema)
|
||||
.response(z.object({ success: z.literal(true) }))
|
||||
.build();
|
||||
async function handler(c: Context): Promise<Response> {
|
||||
const { tenantId } = requireTenant(c);
|
||||
const repo = requireRepo(c);
|
||||
const id = c.req.param("id");
|
||||
const existing = await repo.getById(id);
|
||||
if (existing === null || existing.tenantId !== tenantId) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.CAMPAIGN_NOT_FOUND,
|
||||
`campaign ${id} not found`,
|
||||
{ context: { statusCode: 404, id } }
|
||||
);
|
||||
}
|
||||
await repo.delete(id);
|
||||
getCampaignsApiInstruments().requestsTotal.add(1, {
|
||||
route: "delete",
|
||||
outcome: "success",
|
||||
});
|
||||
return c.body(null, 204);
|
||||
}
|
||||
return { route, handler };
|
||||
}
|
||||
|
||||
export { toResponse as campaignToResponse };
|
||||
@@ -0,0 +1,36 @@
|
||||
export {
|
||||
buildCampaignEventsRoute,
|
||||
buildCampaignStatsRoute,
|
||||
buildCancelCampaignRoute,
|
||||
buildDuplicateCampaignRoute,
|
||||
buildPauseCampaignRoute,
|
||||
buildSendCampaignRoute,
|
||||
buildTestSendRoute,
|
||||
DeliveryStatsResponseSchema,
|
||||
EventQuerySchema,
|
||||
TestSendSchema,
|
||||
} from "./action-routes.js";
|
||||
export { buildCampaignRoutes } from "./build-routes.js";
|
||||
export {
|
||||
buildCreateCampaignRoute,
|
||||
buildDeleteCampaignRoute,
|
||||
buildGetCampaignRoute,
|
||||
buildListCampaignsRoute,
|
||||
buildUpdateCampaignRoute,
|
||||
campaignToResponse,
|
||||
} from "./crud-routes.js";
|
||||
export {
|
||||
CampaignIdParamSchema,
|
||||
CampaignListResponseSchema,
|
||||
type CampaignQueryInput,
|
||||
CampaignQuerySchema,
|
||||
CampaignResponseSchema,
|
||||
CampaignStatsSchema,
|
||||
CampaignStatusSchema,
|
||||
CampaignTypeSchema,
|
||||
type CreateCampaignBody,
|
||||
CreateCampaignSchema,
|
||||
EMPTY_STATS,
|
||||
type UpdateCampaignBody,
|
||||
UpdateCampaignSchema,
|
||||
} from "./schemas.js";
|
||||
@@ -0,0 +1,85 @@
|
||||
# Campaign route schemas
|
||||
|
||||
## Purpose
|
||||
|
||||
This module is the OpenAPI-facing contract for the `/campaigns` route
|
||||
tree. Every incoming request body, query string, and path parameter is
|
||||
validated through a schema declared here, and every response sent to
|
||||
clients conforms to one of the response schemas. Centralising the
|
||||
schemas keeps the Zod definitions in sync between request validation
|
||||
(at handler entry) and OpenAPI generation (at registry registration
|
||||
time) — both consume the exact same schema instance.
|
||||
|
||||
Enum unions for `CampaignStatus` and `CampaignType` are reproduced
|
||||
locally rather than imported from `@suaveplan/campaigns` because the
|
||||
schema bundle is captured into the OpenAPI spec, which needs the enum
|
||||
values inlined. The cost of duplication is small and the test suite
|
||||
asserts every member, so drift between the two sources is caught the
|
||||
moment it occurs.
|
||||
|
||||
## Features
|
||||
|
||||
- 4 input schemas: `CampaignIdParamSchema`, `CreateCampaignSchema`,
|
||||
`UpdateCampaignSchema`, `CampaignQuerySchema`.
|
||||
- 3 response schemas: `CampaignResponseSchema`, `CampaignStatsSchema`,
|
||||
`CampaignListResponseSchema`.
|
||||
- Documented defaults at every optional field (`type`, `channel`,
|
||||
`audienceConfig.groupIds`, `templateData`, `metadata`,
|
||||
`CampaignQuerySchema.limit/offset`).
|
||||
- `EMPTY_STATS` constant — the zero-baseline counter bundle used by
|
||||
`POST /campaigns` and `/duplicate`.
|
||||
- Coerced numeric query parameters (`limit`, `offset`) so query
|
||||
strings like `?limit=25` round-trip cleanly without manual parsing.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CreateCampaignSchema } from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
const body = await c.req.json();
|
||||
const parsed = CreateCampaignSchema.parse(body);
|
||||
// parsed.name is guaranteed non-empty; parsed.type defaults to "broadcast".
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
CampaignListResponseSchema,
|
||||
CampaignQuerySchema,
|
||||
} from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
const query = CampaignQuerySchema.parse(c.req.query());
|
||||
const campaigns = await repo.list({ tenantId, ...query });
|
||||
const response = CampaignListResponseSchema.parse({
|
||||
campaigns: campaigns.map(toResponse),
|
||||
total: campaigns.length,
|
||||
});
|
||||
return c.json(response, 200);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CampaignIdParamSchema: z.ZodObject<{ id: ZodString }>`
|
||||
- `CreateCampaignSchema: z.ZodObject<{...}>` — body for `POST /campaigns`.
|
||||
- `UpdateCampaignSchema: z.ZodObject<{...}>` — body for `PATCH`.
|
||||
- `CampaignQuerySchema: z.ZodObject<{...}>` — query string for `GET`.
|
||||
- `CampaignStatusSchema`, `CampaignTypeSchema` — enum guards.
|
||||
- `CampaignResponseSchema`, `CampaignListResponseSchema`,
|
||||
`CampaignStatsSchema` — response shapes.
|
||||
- `EMPTY_STATS: Readonly<CampaignStats>` — zero baseline.
|
||||
- `type CreateCampaignBody`, `UpdateCampaignBody`, `CampaignQueryInput`
|
||||
— inferred TypeScript types.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`audienceConfig` is modelled as an object schema with `groupIds`
|
||||
required (default `[]`) rather than a free-form `z.record` because
|
||||
downstream consumers — including the `Campaign.audienceConfig` field
|
||||
in `@suaveplan/campaigns` — rely on `groupIds` being present. The
|
||||
optional surrounding fields (`excludeGroupIds`, `segmentIds`, …) are
|
||||
preserved at the schema boundary so the upstream domain logic can
|
||||
treat them as opt-in. Date fields appear as ISO 8601 strings in the
|
||||
response schema rather than `Date` instances because OpenAPI consumers
|
||||
deserialise JSON and only get strings; the route handlers call
|
||||
`.toISOString()` at the boundary so the contract is honest.
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
CampaignIdParamSchema,
|
||||
CampaignListResponseSchema,
|
||||
CampaignQuerySchema,
|
||||
CampaignResponseSchema,
|
||||
CampaignStatusSchema,
|
||||
CampaignTypeSchema,
|
||||
CreateCampaignSchema,
|
||||
EMPTY_STATS,
|
||||
UpdateCampaignSchema,
|
||||
} from "./schemas.js";
|
||||
|
||||
describe("campaign route schemas", () => {
|
||||
it("CampaignStatusSchema accepts all 7 statuses", { timeout: 5000 }, () => {
|
||||
for (const s of [
|
||||
"draft",
|
||||
"scheduled",
|
||||
"sending",
|
||||
"paused",
|
||||
"completed",
|
||||
"cancelled",
|
||||
"failed",
|
||||
] as const) {
|
||||
expect(CampaignStatusSchema.parse(s)).toBe(s);
|
||||
}
|
||||
expect(() => CampaignStatusSchema.parse("bogus")).toThrow();
|
||||
});
|
||||
|
||||
it("CampaignTypeSchema accepts all 4 types", { timeout: 5000 }, () => {
|
||||
for (const t of [
|
||||
"broadcast",
|
||||
"triggered",
|
||||
"ab_test",
|
||||
"drip",
|
||||
] as const) {
|
||||
expect(CampaignTypeSchema.parse(t)).toBe(t);
|
||||
}
|
||||
expect(() => CampaignTypeSchema.parse("bogus")).toThrow();
|
||||
});
|
||||
|
||||
it("CampaignIdParamSchema rejects empty id", { timeout: 5000 }, () => {
|
||||
expect(() => CampaignIdParamSchema.parse({ id: "" })).toThrow();
|
||||
expect(CampaignIdParamSchema.parse({ id: "a" })).toEqual({ id: "a" });
|
||||
});
|
||||
|
||||
it("CreateCampaignSchema applies all documented defaults", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parsed = CreateCampaignSchema.parse({ name: "x" });
|
||||
expect(parsed.type).toBe("broadcast");
|
||||
expect(parsed.channel).toBe("email");
|
||||
expect(parsed.audienceConfig).toEqual({ groupIds: [] });
|
||||
expect(parsed.templateData).toEqual({});
|
||||
expect(parsed.metadata).toEqual({});
|
||||
});
|
||||
|
||||
it("CreateCampaignSchema rejects empty name", { timeout: 5000 }, () => {
|
||||
expect(() => CreateCampaignSchema.parse({ name: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("CreateCampaignSchema accepts a full body", { timeout: 5000 }, () => {
|
||||
const out = CreateCampaignSchema.parse({
|
||||
name: "Promo",
|
||||
type: "drip",
|
||||
channel: "sms",
|
||||
audienceConfig: {
|
||||
groupIds: ["g1"],
|
||||
excludeGroupIds: ["g2"],
|
||||
},
|
||||
templateId: "tpl1",
|
||||
templateData: { foo: 1 },
|
||||
schedule: { type: "immediate" },
|
||||
throttle: { messagesPerSecond: 5 },
|
||||
abTestConfig: { splitType: "even" },
|
||||
metadata: { campaignId: "X" },
|
||||
});
|
||||
expect(out.type).toBe("drip");
|
||||
expect(out.audienceConfig.groupIds).toEqual(["g1"]);
|
||||
});
|
||||
|
||||
it("UpdateCampaignSchema accepts a partial body", { timeout: 5000 }, () => {
|
||||
const out = UpdateCampaignSchema.parse({ name: "New" });
|
||||
expect(out.name).toBe("New");
|
||||
});
|
||||
|
||||
it("UpdateCampaignSchema rejects empty name", { timeout: 5000 }, () => {
|
||||
expect(() => UpdateCampaignSchema.parse({ name: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("CampaignQuerySchema coerces query strings and applies defaults", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = CampaignQuerySchema.parse({
|
||||
status: "draft",
|
||||
type: "broadcast",
|
||||
limit: "25",
|
||||
offset: "5",
|
||||
});
|
||||
expect(out.limit).toBe(25);
|
||||
expect(out.offset).toBe(5);
|
||||
expect(out.status).toBe("draft");
|
||||
});
|
||||
|
||||
it("CampaignQuerySchema clamps limit between 1 and 200", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => CampaignQuerySchema.parse({ limit: 0 })).toThrow();
|
||||
expect(() => CampaignQuerySchema.parse({ limit: 300 })).toThrow();
|
||||
});
|
||||
|
||||
it("CampaignResponseSchema accepts a minimally valid response", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ok = CampaignResponseSchema.safeParse({
|
||||
id: "1",
|
||||
tenantId: "t",
|
||||
name: "x",
|
||||
status: "draft",
|
||||
type: "broadcast",
|
||||
channel: "email",
|
||||
audienceConfig: { groupIds: [] },
|
||||
templateData: {},
|
||||
metadata: {},
|
||||
stats: EMPTY_STATS,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
expect(ok.success).toBe(true);
|
||||
});
|
||||
|
||||
it("CampaignListResponseSchema wraps the campaign array", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ok = CampaignListResponseSchema.safeParse({
|
||||
campaigns: [],
|
||||
total: 0,
|
||||
});
|
||||
expect(ok.success).toBe(true);
|
||||
});
|
||||
|
||||
it("EMPTY_STATS exposes every zero counter", { timeout: 5000 }, () => {
|
||||
expect(Object.values(EMPTY_STATS).every((v) => v === 0)).toBe(true);
|
||||
expect(Object.keys(EMPTY_STATS)).toHaveLength(9);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Zod request + response schemas for the `/campaigns` route tree.
|
||||
*
|
||||
* These are the OpenAPI surface contract — the response shape exposed
|
||||
* to OpenAPI consumers and the validated boundary applied to every
|
||||
* incoming body, query, and path parameter. Schemas are deliberately
|
||||
* conservative: enums are reproduced rather than imported (the
|
||||
* upstream `CampaignStatus` union string-literal is replicated so the
|
||||
* OpenAPI schema stays self-contained), and timestamps are serialised
|
||||
* as ISO 8601 strings rather than `Date` instances so JSON round-trips
|
||||
* cleanly.
|
||||
*/
|
||||
|
||||
import z from "zod";
|
||||
|
||||
/** Lifecycle status — kept in sync with `@suaveplan/campaigns`. */
|
||||
export const CampaignStatusSchema = z.enum([
|
||||
"draft",
|
||||
"scheduled",
|
||||
"sending",
|
||||
"paused",
|
||||
"completed",
|
||||
"cancelled",
|
||||
"failed",
|
||||
]);
|
||||
|
||||
/** Campaign type — kept in sync with `@suaveplan/campaigns`. */
|
||||
export const CampaignTypeSchema = z.enum([
|
||||
"broadcast",
|
||||
"triggered",
|
||||
"ab_test",
|
||||
"drip",
|
||||
]);
|
||||
|
||||
/** Path param `:id` — non-empty string. */
|
||||
export const CampaignIdParamSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
/** Body for `POST /campaigns`. */
|
||||
export const CreateCampaignSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
type: CampaignTypeSchema.default("broadcast"),
|
||||
channel: z.string().min(1).default("email"),
|
||||
audienceConfig: z
|
||||
.object({
|
||||
groupIds: z.array(z.string()).default([]),
|
||||
excludeGroupIds: z.array(z.string()).optional(),
|
||||
excludeContactIds: z.array(z.string()).optional(),
|
||||
segmentIds: z.array(z.string()).optional(),
|
||||
suppressionListIds: z.array(z.string()).optional(),
|
||||
})
|
||||
.default({ groupIds: [] }),
|
||||
templateId: z.string().optional(),
|
||||
templateData: z.record(z.string(), z.unknown()).default({}),
|
||||
schedule: z.record(z.string(), z.unknown()).optional(),
|
||||
throttle: z.record(z.string(), z.unknown()).optional(),
|
||||
abTestConfig: z.record(z.string(), z.unknown()).optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).default({}),
|
||||
});
|
||||
|
||||
/** Body for `PATCH /campaigns/:id`. */
|
||||
export const UpdateCampaignSchema = z.object({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
channel: z.string().min(1).optional(),
|
||||
audienceConfig: z
|
||||
.object({
|
||||
groupIds: z.array(z.string()),
|
||||
excludeGroupIds: z.array(z.string()).optional(),
|
||||
excludeContactIds: z.array(z.string()).optional(),
|
||||
segmentIds: z.array(z.string()).optional(),
|
||||
suppressionListIds: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
templateId: z.string().optional(),
|
||||
templateData: z.record(z.string(), z.unknown()).optional(),
|
||||
schedule: z.record(z.string(), z.unknown()).optional(),
|
||||
throttle: z.record(z.string(), z.unknown()).optional(),
|
||||
abTestConfig: z.record(z.string(), z.unknown()).optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
/** Query string for `GET /campaigns`. */
|
||||
export const CampaignQuerySchema = z.object({
|
||||
status: CampaignStatusSchema.optional(),
|
||||
type: CampaignTypeSchema.optional(),
|
||||
channel: z.string().min(1).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
/** Stats sub-schema reused by `CampaignResponseSchema`. */
|
||||
export const CampaignStatsSchema = z.object({
|
||||
totalRecipients: z.number(),
|
||||
sent: z.number(),
|
||||
delivered: z.number(),
|
||||
opened: z.number(),
|
||||
clicked: z.number(),
|
||||
bounced: z.number(),
|
||||
unsubscribed: z.number(),
|
||||
complained: z.number(),
|
||||
failed: z.number(),
|
||||
});
|
||||
|
||||
/** Response shape for a single campaign. */
|
||||
export const CampaignResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
tenantId: z.string(),
|
||||
name: z.string(),
|
||||
status: CampaignStatusSchema,
|
||||
type: CampaignTypeSchema,
|
||||
channel: z.string(),
|
||||
audienceConfig: z.record(z.string(), z.unknown()),
|
||||
templateId: z.string().nullable().optional(),
|
||||
templateData: z.record(z.string(), z.unknown()).optional(),
|
||||
schedule: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
throttle: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
abTestConfig: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
metadata: z.record(z.string(), z.unknown()),
|
||||
stats: CampaignStatsSchema,
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
sentAt: z.string().nullable().optional(),
|
||||
completedAt: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
/** Response shape for `GET /campaigns`. */
|
||||
export const CampaignListResponseSchema = z.object({
|
||||
campaigns: z.array(CampaignResponseSchema),
|
||||
total: z.number(),
|
||||
});
|
||||
|
||||
/** Empty stats baseline used by `POST /campaigns`. */
|
||||
export const EMPTY_STATS = {
|
||||
totalRecipients: 0,
|
||||
sent: 0,
|
||||
delivered: 0,
|
||||
opened: 0,
|
||||
clicked: 0,
|
||||
bounced: 0,
|
||||
unsubscribed: 0,
|
||||
complained: 0,
|
||||
failed: 0,
|
||||
} as const;
|
||||
|
||||
export type CreateCampaignBody = z.infer<typeof CreateCampaignSchema>;
|
||||
export type UpdateCampaignBody = z.infer<typeof UpdateCampaignSchema>;
|
||||
export type CampaignQueryInput = z.infer<typeof CampaignQuerySchema>;
|
||||
@@ -0,0 +1,67 @@
|
||||
export { buildCampaignRoutes } from "./campaigns/build-routes.js";
|
||||
export {
|
||||
buildCampaignEventsRoute,
|
||||
buildCampaignStatsRoute,
|
||||
buildCancelCampaignRoute,
|
||||
buildCreateCampaignRoute,
|
||||
buildDeleteCampaignRoute,
|
||||
buildDuplicateCampaignRoute,
|
||||
buildGetCampaignRoute,
|
||||
buildListCampaignsRoute,
|
||||
buildPauseCampaignRoute,
|
||||
buildSendCampaignRoute,
|
||||
buildTestSendRoute,
|
||||
buildUpdateCampaignRoute,
|
||||
CampaignIdParamSchema,
|
||||
CampaignListResponseSchema,
|
||||
type CampaignQueryInput,
|
||||
CampaignQuerySchema,
|
||||
CampaignResponseSchema,
|
||||
CampaignStatsSchema,
|
||||
CampaignStatusSchema,
|
||||
CampaignTypeSchema,
|
||||
type CreateCampaignBody,
|
||||
CreateCampaignSchema,
|
||||
campaignToResponse,
|
||||
DeliveryStatsResponseSchema,
|
||||
EMPTY_STATS,
|
||||
EventQuerySchema,
|
||||
TestSendSchema,
|
||||
type UpdateCampaignBody,
|
||||
UpdateCampaignSchema,
|
||||
} from "./campaigns/index.js";
|
||||
export {
|
||||
requireTenant,
|
||||
stripUndefined,
|
||||
type TenantPrincipal,
|
||||
} from "./shared/index.js";
|
||||
export {
|
||||
buildCheckSuppressionRoute,
|
||||
buildCreateSuppressionRoute,
|
||||
buildDeleteSuppressionRoute,
|
||||
buildListSuppressionRoute,
|
||||
buildSuppressionRoutes,
|
||||
type CreateSuppressionBody,
|
||||
CreateSuppressionSchema,
|
||||
SuppressionCheckQuerySchema,
|
||||
SuppressionCheckResponseSchema,
|
||||
SuppressionIdParamSchema,
|
||||
SuppressionListResponseSchema,
|
||||
type SuppressionQueryInput,
|
||||
SuppressionQuerySchema,
|
||||
SuppressionReasonSchema,
|
||||
SuppressionResponseSchema,
|
||||
} from "./suppression/index.js";
|
||||
export {
|
||||
buildDeliveryWebhookRoute,
|
||||
buildUnsubscribeRoute,
|
||||
buildWebhookRoutes,
|
||||
DeliveryEventTypeSchema,
|
||||
type DeliveryWebhookBody,
|
||||
DeliveryWebhookResponseSchema,
|
||||
DeliveryWebhookSchema,
|
||||
decodeUnsubscribeToken,
|
||||
UnsubscribeResponseSchema,
|
||||
UnsubscribeTokenParamSchema,
|
||||
type UnsubscribeTokenPayload,
|
||||
} from "./webhooks/index.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { requireTenant, type TenantPrincipal } from "./require-tenant.js";
|
||||
export { stripUndefined } from "./strip-undefined.js";
|
||||
@@ -0,0 +1,83 @@
|
||||
# `require-tenant`
|
||||
|
||||
## Purpose
|
||||
|
||||
`requireTenant` extracts the request principal from the Hono context
|
||||
slot `c.get("user")` (populated by `@suaveplan/api-plugin-auth` before
|
||||
route dispatch) and either returns its non-empty `tenantId` plus
|
||||
optional `id` or throws `CampaignsApiError` carrying the
|
||||
`CAMPAIGNS_API.TENANT_REQUIRED` code with HTTP 401 wired into the
|
||||
error's context. This is the single tenant-presence gate every
|
||||
campaign, suppression, and webhook route relies on, so handlers can
|
||||
treat `tenantId` as a definitely-present string thereafter.
|
||||
|
||||
The helper does not consult a tenant resolver fallback (no
|
||||
`?? "default"` short-circuit). Multi-tenant correctness is the
|
||||
campaigns plugin's primary obligation — silently falling back to a
|
||||
shared tenant would let one tenant's misconfiguration leak data into
|
||||
another's namespace. Failing closed with a 401 surfaces the problem
|
||||
loudly.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure synchronous read; no I/O, no DB, no allocator churn.
|
||||
- Treats `null`, `undefined`, missing `tenantId`, non-string
|
||||
`tenantId`, and empty-string `tenantId` as equally invalid.
|
||||
- Returns a frozen-shape object (`{ id, tenantId }`) — `id` is
|
||||
optional in the principal but always a string in the return value
|
||||
(falls back to `""`).
|
||||
- Throws `CampaignsApiError(TENANT_REQUIRED)` with
|
||||
`context.statusCode === 401` so the package error handler can map
|
||||
uniformly.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { requireTenant } from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
app.openapi(listRoute, async (c) => {
|
||||
const { tenantId } = requireTenant(c);
|
||||
const result = await c.get("campaignRepository").list({ tenantId });
|
||||
return c.json({ campaigns: result, total: result.length }, 200);
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
CAMPAIGNS_API_CODES,
|
||||
CampaignsApiError,
|
||||
requireTenant,
|
||||
} from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
async function authorize(c: Context, target: { tenantId: string }) {
|
||||
const { tenantId } = requireTenant(c);
|
||||
if (target.tenantId !== tenantId) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.CAMPAIGN_NOT_FOUND,
|
||||
"campaign belongs to a different tenant",
|
||||
{ context: { statusCode: 404 } }
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface TenantPrincipal { readonly id: string; readonly tenantId: string }`
|
||||
- `requireTenant(c: Context): TenantPrincipal` — returns the principal
|
||||
or throws `CampaignsApiError(TENANT_REQUIRED)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The principal type is cast through `unknown` rather than imported from
|
||||
`@suaveplan/api-plugin-auth` because the campaigns plugin must
|
||||
type-check in isolation (consumers may swap in a different auth
|
||||
provider). The structural check (`typeof principal.tenantId ===
|
||||
"string" && principal.tenantId.length > 0`) is intentionally explicit:
|
||||
TypeScript's narrowing for record-typed `unknown` does not collapse
|
||||
the two checks into one. The empty-string check is the load-bearing
|
||||
one — Zod's `min(1)` on the auth side prevents most empty values, but
|
||||
defending against an out-of-band setter (e.g. a test fixture passing
|
||||
`{ tenantId: "" }`) keeps the contract honest.
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
CAMPAIGNS_API_CODES,
|
||||
CampaignsApiError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { requireTenant } from "./require-tenant.js";
|
||||
|
||||
function makeContext(principal: unknown) {
|
||||
const app = new Hono();
|
||||
let captured: { id: string; tenantId: string } | undefined;
|
||||
let caught: unknown;
|
||||
app.use("*", async (c, next) => {
|
||||
if (principal !== undefined) {
|
||||
c.set("user", principal as never);
|
||||
}
|
||||
try {
|
||||
captured = requireTenant(c);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
await next();
|
||||
});
|
||||
app.get("/", (c) => c.text("ok"));
|
||||
return { app, get: () => ({ captured, caught }) };
|
||||
}
|
||||
|
||||
describe("requireTenant", () => {
|
||||
it("returns id + tenantId when principal carries both", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app, get } = makeContext({ id: "u1", tenantId: "t1" });
|
||||
await app.request("/");
|
||||
expect(get().captured).toEqual({ id: "u1", tenantId: "t1" });
|
||||
});
|
||||
|
||||
it("falls back to empty id when principal has no id field", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app, get } = makeContext({ tenantId: "t1" });
|
||||
await app.request("/");
|
||||
expect(get().captured).toEqual({ id: "", tenantId: "t1" });
|
||||
});
|
||||
|
||||
it("throws TENANT_REQUIRED when user is null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app, get } = makeContext(null);
|
||||
await app.request("/");
|
||||
const err = get().caught;
|
||||
expect(err).toBeInstanceOf(CampaignsApiError);
|
||||
expect((err as CampaignsApiError).code).toBe(
|
||||
CAMPAIGNS_API_CODES.TENANT_REQUIRED
|
||||
);
|
||||
});
|
||||
|
||||
it("throws TENANT_REQUIRED when user is undefined", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app, get } = makeContext(undefined);
|
||||
await app.request("/");
|
||||
expect((get().caught as CampaignsApiError).code).toBe(
|
||||
CAMPAIGNS_API_CODES.TENANT_REQUIRED
|
||||
);
|
||||
});
|
||||
|
||||
it("throws TENANT_REQUIRED when tenantId is the empty string", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app, get } = makeContext({ id: "u", tenantId: "" });
|
||||
await app.request("/");
|
||||
expect((get().caught as CampaignsApiError).code).toBe(
|
||||
CAMPAIGNS_API_CODES.TENANT_REQUIRED
|
||||
);
|
||||
});
|
||||
|
||||
it("throws TENANT_REQUIRED when tenantId is not a string", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app, get } = makeContext({ id: "u", tenantId: 42 });
|
||||
await app.request("/");
|
||||
expect((get().caught as CampaignsApiError).code).toBe(
|
||||
CAMPAIGNS_API_CODES.TENANT_REQUIRED
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to empty id when id is the empty string", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app, get } = makeContext({ id: "", tenantId: "t1" });
|
||||
await app.request("/");
|
||||
expect(get().captured).toEqual({ id: "", tenantId: "t1" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Tenant-extraction helper. Reads the `Principal` from `c.get("user")`
|
||||
* and either returns its non-empty `tenantId` or throws
|
||||
* `CampaignsApiError(TENANT_REQUIRED)` mapped to HTTP 401. Routes call
|
||||
* this once at handler entry so the rest of the handler can treat
|
||||
* `tenantId` as definitely present.
|
||||
*/
|
||||
|
||||
import type { Context } from "hono";
|
||||
import {
|
||||
CAMPAIGNS_API_CODES,
|
||||
CampaignsApiError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
|
||||
/** Principal payload extracted at the request boundary. */
|
||||
export interface TenantPrincipal {
|
||||
readonly id: string;
|
||||
readonly tenantId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return `{ id, tenantId }` from the request principal or throw
|
||||
* `CAMPAIGNS_API.TENANT_REQUIRED` (HTTP 401). The shared helper
|
||||
* eliminates the upstream port's repeated four-line
|
||||
* `c.get("user")?.tenantId` checks and the `as any` casts they relied on.
|
||||
*/
|
||||
export function requireTenant(c: Context): TenantPrincipal {
|
||||
const principal = c.get("user") as
|
||||
| { id?: unknown; tenantId?: unknown }
|
||||
| null
|
||||
| undefined;
|
||||
if (
|
||||
principal === null ||
|
||||
principal === undefined ||
|
||||
typeof principal.tenantId !== "string" ||
|
||||
principal.tenantId.length === 0
|
||||
) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.TENANT_REQUIRED,
|
||||
"request principal is missing a tenantId",
|
||||
{ context: { statusCode: 401 } }
|
||||
);
|
||||
}
|
||||
return {
|
||||
id:
|
||||
typeof principal.id === "string" && principal.id.length > 0
|
||||
? principal.id
|
||||
: "",
|
||||
tenantId: principal.tenantId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# `strip-undefined`
|
||||
|
||||
## Purpose
|
||||
|
||||
`stripUndefined` is a route-layer adapter that bridges Zod's
|
||||
`optional()` output (`T | undefined`) with the
|
||||
`exactOptionalPropertyTypes: true` repository input types declared in
|
||||
`@suaveplan/campaigns` and the local `CampaignSuppressionStore`. When a
|
||||
route handler validates a request body through a Zod schema, optional
|
||||
fields surface as either the declared type or `undefined`; passing the
|
||||
parsed object straight to the repository fails the `exactOptional`
|
||||
check at compile time. This helper produces a shallow clone with every
|
||||
`undefined`-valued key removed so the downstream type narrows.
|
||||
|
||||
The helper is intentionally minimal — no recursion, no `null` handling,
|
||||
no key-name filtering — because every concrete usage in the routes
|
||||
layer passes a Zod-parsed object whose nesting has already been
|
||||
normalised. Recursion would obscure the cost model and tempt callers
|
||||
to use it as a deep-clean utility, which is out of scope.
|
||||
|
||||
## Features
|
||||
|
||||
- Shallow-only filter; nested objects are forwarded by reference.
|
||||
- `null` values are preserved (only `=== undefined` triggers removal).
|
||||
- Returns a fresh object reference every call (never mutates input).
|
||||
- Generic-typed return narrows `T[K]` to `Exclude<T[K], undefined>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { stripUndefined } from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
const body = { name: "x", templateId: undefined } as const;
|
||||
const clean = stripUndefined(body);
|
||||
// clean: { name: "x" } — `templateId` removed.
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { stripUndefined } from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
const parsed = CreateCampaignSchema.parse(input);
|
||||
await repo.create({
|
||||
id: idGen(),
|
||||
tenantId,
|
||||
...stripUndefined(parsed),
|
||||
status: "draft",
|
||||
stats: EMPTY_STATS,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `stripUndefined<T extends Record<string, unknown>>(input: T):
|
||||
{ [K in keyof T]: Exclude<T[K], undefined> }` — shallow copy with
|
||||
`undefined`-valued keys removed.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The implementation iterates `Object.entries(input)` once and writes
|
||||
into a fresh `Record<string, unknown>` accumulator. The output is then
|
||||
cast to the narrowed mapped type — a single assertion confined to the
|
||||
return statement. The accumulator is not pre-sized because the helper
|
||||
is hot-path-cheap (single object scan) and JavaScript engines do not
|
||||
benefit from `new Object()` capacity hints. The forbidden `Object.assign`
|
||||
path (which would forward `undefined` properties when iterating
|
||||
`Object.keys`) is intentionally avoided.
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { stripUndefined } from "./strip-undefined.js";
|
||||
|
||||
describe("stripUndefined", () => {
|
||||
it("removes keys whose values are undefined", { timeout: 5000 }, () => {
|
||||
const out = stripUndefined({ a: 1, b: undefined, c: "x" });
|
||||
expect(out).toEqual({ a: 1, c: "x" });
|
||||
});
|
||||
|
||||
it("preserves null values", { timeout: 5000 }, () => {
|
||||
const out = stripUndefined({ a: null, b: undefined });
|
||||
expect(out).toEqual({ a: null });
|
||||
});
|
||||
|
||||
it("returns a fresh object instance", { timeout: 5000 }, () => {
|
||||
const input = { a: 1 };
|
||||
const out = stripUndefined(input);
|
||||
expect(out).not.toBe(input);
|
||||
expect(out).toEqual(input);
|
||||
});
|
||||
|
||||
it("handles an empty object", { timeout: 5000 }, () => {
|
||||
expect(stripUndefined({})).toEqual({});
|
||||
});
|
||||
|
||||
it("does not strip falsy non-undefined values", { timeout: 5000 }, () => {
|
||||
const out = stripUndefined({ a: 0, b: "", c: false, d: undefined });
|
||||
expect(out).toEqual({ a: 0, b: "", c: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Strip keys whose values are exactly `undefined`. Used at every
|
||||
* route → repo boundary to bridge Zod's `optional()` semantics
|
||||
* (which produce `T | undefined`) with the
|
||||
* `exactOptionalPropertyTypes: true` input types in
|
||||
* `@suaveplan/campaigns` and the suppression store (which forbid the
|
||||
* `| undefined` half).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Return a shallow copy of the input with all keys whose values are
|
||||
* exactly `undefined` removed. Non-`undefined` values are preserved
|
||||
* as-is; `null` is preserved.
|
||||
*/
|
||||
export function stripUndefined<T extends Record<string, unknown>>(
|
||||
input: T
|
||||
): { [K in keyof T]: Exclude<T[K], undefined> } {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(input)) {
|
||||
if (v !== undefined) out[k] = v;
|
||||
}
|
||||
return out as { [K in keyof T]: Exclude<T[K], undefined> };
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
# `build-routes` (suppression)
|
||||
|
||||
## Purpose
|
||||
|
||||
`buildSuppressionRoutes` aggregates the four `/suppression/*` plugin
|
||||
route entries in canonical order (list, check, create, delete). The
|
||||
plugin's `onInit` consumes the array verbatim, so the OpenAPI spec
|
||||
order is deterministic and tests can assert it as a contract. Keeping
|
||||
the aggregator in its own module (rather than collapsed into the
|
||||
`routes.ts` file) mirrors the layout used elsewhere in the
|
||||
`@suaveplan/*` plugin family — code-search "build-routes" always
|
||||
finds the canonical-order anchor.
|
||||
|
||||
## Features
|
||||
|
||||
- Returns 4 entries in the documented canonical order.
|
||||
- Pure — no I/O, no captured state, no configuration knobs.
|
||||
- Each entry is independently mountable on a bare `Hono` app for
|
||||
testing.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildSuppressionRoutes } from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
for (const e of buildSuppressionRoutes()) {
|
||||
app[e.route.method as "get" | "post" | "delete"](e.route.path, (c) =>
|
||||
e.handler(c)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildSuppressionRoutes,
|
||||
definePluginRoutes,
|
||||
} from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
const entries = buildSuppressionRoutes().map((e) => ({
|
||||
...e,
|
||||
route: { ...e.route, path: `/api${e.route.path}` },
|
||||
}));
|
||||
definePluginRoutes(entries, { app, registry, pluginName: "campaigns" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `buildSuppressionRoutes(): readonly PluginRouteEntry[]` — returns
|
||||
the 4 entries in canonical order.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The check endpoint sits at position 2 (between list and create)
|
||||
because cache-friendly `GET` reads precede writes in the canonical
|
||||
order — operator-facing OpenAPI consumers naturally scan the read
|
||||
surface first when assessing rate-limit and caching budgets. The
|
||||
delete endpoint is last so the OpenAPI consumer reading the spec
|
||||
top-down sees the destructive operation last and is prompted to
|
||||
consider authentication scope before mounting.
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { buildSuppressionRoutes } from "./build-routes.js";
|
||||
|
||||
describe("buildSuppressionRoutes", () => {
|
||||
it("returns 4 entries in canonical order", { timeout: 5000 }, () => {
|
||||
const entries = buildSuppressionRoutes();
|
||||
expect(entries.map((e) => `${e.route.method} ${e.route.path}`)).toEqual(
|
||||
[
|
||||
"get /suppression",
|
||||
"get /suppression/check",
|
||||
"post /suppression",
|
||||
"delete /suppression/:id",
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("every entry tagged 'suppression'", { timeout: 5000 }, () => {
|
||||
for (const e of buildSuppressionRoutes()) {
|
||||
expect(e.route.tags).toEqual(["suppression"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Aggregator returning every `/suppression/*` `PluginRouteEntry` in
|
||||
* canonical order: list, check, create, delete.
|
||||
*/
|
||||
|
||||
import type { PluginRouteEntry } from "@suaveplan/api-openapi";
|
||||
import {
|
||||
buildCheckSuppressionRoute,
|
||||
buildCreateSuppressionRoute,
|
||||
buildDeleteSuppressionRoute,
|
||||
buildListSuppressionRoute,
|
||||
} from "./routes.js";
|
||||
|
||||
/** Returns the 4 `/suppression/*` plugin-route entries. */
|
||||
export function buildSuppressionRoutes(): readonly PluginRouteEntry[] {
|
||||
return [
|
||||
buildListSuppressionRoute(),
|
||||
buildCheckSuppressionRoute(),
|
||||
buildCreateSuppressionRoute(),
|
||||
buildDeleteSuppressionRoute(),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export { buildSuppressionRoutes } from "./build-routes.js";
|
||||
export {
|
||||
buildCheckSuppressionRoute,
|
||||
buildCreateSuppressionRoute,
|
||||
buildDeleteSuppressionRoute,
|
||||
buildListSuppressionRoute,
|
||||
} from "./routes.js";
|
||||
export {
|
||||
type CreateSuppressionBody,
|
||||
CreateSuppressionSchema,
|
||||
SuppressionCheckQuerySchema,
|
||||
SuppressionCheckResponseSchema,
|
||||
SuppressionIdParamSchema,
|
||||
SuppressionListResponseSchema,
|
||||
type SuppressionQueryInput,
|
||||
SuppressionQuerySchema,
|
||||
SuppressionReasonSchema,
|
||||
SuppressionResponseSchema,
|
||||
} from "./schemas.js";
|
||||
@@ -0,0 +1,77 @@
|
||||
# Suppression routes
|
||||
|
||||
## Purpose
|
||||
|
||||
The suppression-route module owns the four endpoints that manage the
|
||||
`(tenantId, channel, channelValue)`-keyed suppression list: list, check
|
||||
(point lookup), create, and delete. Every handler tenant-guards via
|
||||
`requireTenant` before touching the store, so a misconfigured auth
|
||||
layer cannot silently expose cross-tenant data. The store's duplicate
|
||||
detection raises `CAMPAIGNS_API.DUPLICATE_SUPPRESSION` on a colliding
|
||||
create — the route layer trusts that signal and propagates the error
|
||||
verbatim.
|
||||
|
||||
The check endpoint is intentionally a `GET` (not a `POST` body) so it
|
||||
is cacheable and CORS-trivial. Sender pipelines hit it on every
|
||||
outbound decision; a body-less GET with two query parameters is
|
||||
faster, smaller, and easier to log redact than a JSON `POST`.
|
||||
|
||||
## Features
|
||||
|
||||
- 4 builders: `buildListSuppressionRoute`,
|
||||
`buildCheckSuppressionRoute`, `buildCreateSuppressionRoute`,
|
||||
`buildDeleteSuppressionRoute`.
|
||||
- Tenant-scoped: every handler enforces non-empty `tenantId`.
|
||||
- 400 on Zod failure, 404 on missing delete target, 409 on duplicate
|
||||
add (raised by the store).
|
||||
- OTel counter per route with `outcome` attribute.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildCreateSuppressionRoute } from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
const entry = buildCreateSuppressionRoute();
|
||||
app.post(entry.route.path, (c) => entry.handler(c));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildCheckSuppressionRoute,
|
||||
buildCreateSuppressionRoute,
|
||||
buildDeleteSuppressionRoute,
|
||||
buildListSuppressionRoute,
|
||||
definePluginRoutes,
|
||||
} from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
definePluginRoutes(
|
||||
[
|
||||
buildListSuppressionRoute(),
|
||||
buildCheckSuppressionRoute(),
|
||||
buildCreateSuppressionRoute(),
|
||||
buildDeleteSuppressionRoute(),
|
||||
],
|
||||
{ app, registry, pluginName: "campaigns" }
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `buildListSuppressionRoute(): PluginRouteEntry`
|
||||
- `buildCheckSuppressionRoute(): PluginRouteEntry`
|
||||
- `buildCreateSuppressionRoute(): PluginRouteEntry`
|
||||
- `buildDeleteSuppressionRoute(): PluginRouteEntry`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The delete handler enforces `requireTenant` even though the entry id
|
||||
is a global key. The defence is layered: deletion through an
|
||||
authenticated request keeps a malicious caller from probing for valid
|
||||
ids by hitting `DELETE /suppression/<guess>` and reading the response.
|
||||
The 404 leak (revealing a non-existent id vs. a valid id) is mitigated
|
||||
because `store.remove` is `O(1)` and returns a stable boolean
|
||||
regardless of tenant ownership. If cross-tenant id enumeration becomes
|
||||
a concern, a future change can tighten the contract to require
|
||||
`(tenantId, channel, channelValue)` rather than `id`.
|
||||
@@ -0,0 +1,296 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
CAMPAIGNS_API_CODES,
|
||||
CampaignsApiError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { InMemoryCampaignSuppressionStore } from "../../repositories/suppression/suppression-memory.js";
|
||||
import {
|
||||
buildCheckSuppressionRoute,
|
||||
buildCreateSuppressionRoute,
|
||||
buildDeleteSuppressionRoute,
|
||||
buildListSuppressionRoute,
|
||||
} from "./routes.js";
|
||||
import "../../context/context.js";
|
||||
|
||||
function makeStore() {
|
||||
let nextId = 0;
|
||||
let nextTime = 1_700_000_000_000;
|
||||
return new InMemoryCampaignSuppressionStore({
|
||||
clock: () => {
|
||||
const t = nextTime;
|
||||
nextTime += 1;
|
||||
return t;
|
||||
},
|
||||
idGenerator: () => `s${++nextId}`,
|
||||
});
|
||||
}
|
||||
|
||||
function makeApp(
|
||||
store: InMemoryCampaignSuppressionStore,
|
||||
principal: object | null = { id: "u", tenantId: "t" }
|
||||
): Hono {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
if (principal !== null) c.set("user", principal as never);
|
||||
c.set("campaignSuppressionStore", store);
|
||||
c.set("eventBus", { emit: () => undefined } as never);
|
||||
await next();
|
||||
});
|
||||
app.onError((err, c) => {
|
||||
if (err instanceof CampaignsApiError) {
|
||||
const status = ((err.context as { statusCode?: number } | undefined)
|
||||
?.statusCode ?? 500) as 400 | 401 | 404 | 409 | 500;
|
||||
return c.json({ code: err.code, error: err.message }, status);
|
||||
}
|
||||
return c.json({ error: String(err) }, 500);
|
||||
});
|
||||
for (const e of [
|
||||
buildListSuppressionRoute(),
|
||||
buildCheckSuppressionRoute(),
|
||||
buildCreateSuppressionRoute(),
|
||||
buildDeleteSuppressionRoute(),
|
||||
]) {
|
||||
const method = e.route.method as "get" | "post" | "delete";
|
||||
app[method](e.route.path, (c) => e.handler(c as never));
|
||||
}
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("suppression routes", () => {
|
||||
it("POST /suppression creates an entry", { timeout: 5000 }, async () => {
|
||||
const store = makeStore();
|
||||
const app = makeApp(store);
|
||||
const res = await app.request("/suppression", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
channelValue: "a@b.c",
|
||||
reason: "bounce",
|
||||
source: "import",
|
||||
metadata: { x: 1 },
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.data.channel).toBe("email");
|
||||
expect(body.data.reason).toBe("bounce");
|
||||
expect(body.data.source).toBe("import");
|
||||
});
|
||||
|
||||
it("POST /suppression rejects invalid body", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = makeApp(makeStore());
|
||||
const res = await app.request("/suppression", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ channelValue: "", reason: "bounce" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("POST /suppression returns 401 without principal", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = makeApp(makeStore(), null);
|
||||
const res = await app.request("/suppression", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
channelValue: "a@b.c",
|
||||
reason: "bounce",
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("GET /suppression lists entries with filters", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = makeStore();
|
||||
await store.add({
|
||||
tenantId: "t",
|
||||
channel: "email",
|
||||
channelValue: "a@b.c",
|
||||
reason: "bounce",
|
||||
});
|
||||
const app = makeApp(store);
|
||||
const res = await app.request(
|
||||
"/suppression?channel=email&reason=bounce&limit=10"
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.total).toBe(1);
|
||||
});
|
||||
|
||||
it("GET /suppression lists with default pagination", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = makeApp(makeStore());
|
||||
const res = await app.request("/suppression");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("GET /suppression/check returns suppressed=false when missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = makeApp(makeStore());
|
||||
const res = await app.request(
|
||||
"/suppression/check?channel=email&channelValue=a@b.c"
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.suppressed).toBe(false);
|
||||
expect(body.data.entry).toBeNull();
|
||||
});
|
||||
|
||||
it("GET /suppression/check returns suppressed=true when found", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = makeStore();
|
||||
await store.add({
|
||||
tenantId: "t",
|
||||
channel: "email",
|
||||
channelValue: "a@b.c",
|
||||
reason: "manual",
|
||||
});
|
||||
const app = makeApp(store);
|
||||
const res = await app.request(
|
||||
"/suppression/check?channel=email&channelValue=a@b.c"
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.suppressed).toBe(true);
|
||||
expect(body.data.entry.reason).toBe("manual");
|
||||
});
|
||||
|
||||
it("GET /suppression/check rejects missing query params", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = makeApp(makeStore());
|
||||
const res = await app.request("/suppression/check");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("DELETE /suppression/:id returns 204 on hit", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = makeStore();
|
||||
const entry = await store.add({
|
||||
tenantId: "t",
|
||||
channel: "email",
|
||||
channelValue: "a@b.c",
|
||||
reason: "manual",
|
||||
});
|
||||
const app = makeApp(store);
|
||||
const res = await app.request(`/suppression/${entry.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it("DELETE /suppression/:id returns 404 missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = makeApp(makeStore());
|
||||
const res = await app.request("/suppression/nope", {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 500 when store slot is empty", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", { id: "u", tenantId: "t" } as never);
|
||||
await next();
|
||||
});
|
||||
app.onError((err, c) =>
|
||||
c.json({ code: (err as CampaignsApiError).code }, 500)
|
||||
);
|
||||
const e = buildListSuppressionRoute();
|
||||
app.get(e.route.path, (c) => e.handler(c as never));
|
||||
const res = await app.request("/suppression");
|
||||
expect((await res.json()).code).toBe(
|
||||
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE
|
||||
);
|
||||
});
|
||||
|
||||
it("create surfaces SERVICE_NOT_AVAILABLE on empty store", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", { id: "u", tenantId: "t" } as never);
|
||||
await next();
|
||||
});
|
||||
app.onError((err, c) =>
|
||||
c.json({ code: (err as CampaignsApiError).code }, 500)
|
||||
);
|
||||
const e = buildCreateSuppressionRoute();
|
||||
app.post(e.route.path, (c) => e.handler(c as never));
|
||||
const res = await app.request("/suppression", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ channelValue: "a@b.c", reason: "bounce" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect((await res.json()).code).toBe(
|
||||
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE
|
||||
);
|
||||
});
|
||||
|
||||
it("check surfaces SERVICE_NOT_AVAILABLE on empty store", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", { id: "u", tenantId: "t" } as never);
|
||||
await next();
|
||||
});
|
||||
app.onError((err, c) =>
|
||||
c.json({ code: (err as CampaignsApiError).code }, 500)
|
||||
);
|
||||
const e = buildCheckSuppressionRoute();
|
||||
app.get(e.route.path, (c) => e.handler(c as never));
|
||||
const res = await app.request(
|
||||
"/suppression/check?channel=email&channelValue=a@b.c"
|
||||
);
|
||||
expect((await res.json()).code).toBe(
|
||||
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE
|
||||
);
|
||||
});
|
||||
|
||||
it("POST /suppression 400 on malformed JSON body", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = makeApp(makeStore());
|
||||
const res = await app.request("/suppression", {
|
||||
method: "POST",
|
||||
body: "not-json{",
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("delete surfaces SERVICE_NOT_AVAILABLE on empty store", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", { id: "u", tenantId: "t" } as never);
|
||||
await next();
|
||||
});
|
||||
app.onError((err, c) =>
|
||||
c.json({ code: (err as CampaignsApiError).code }, 500)
|
||||
);
|
||||
const e = buildDeleteSuppressionRoute();
|
||||
app.delete(e.route.path, (c) => e.handler(c as never));
|
||||
const res = await app.request("/suppression/x", { method: "DELETE" });
|
||||
expect((await res.json()).code).toBe(
|
||||
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* `/suppression` route builders — list, check, create, delete. The
|
||||
* store is read from `c.get("campaignSuppressionStore")` and the
|
||||
* tenant from `requireTenant`. Duplicate writes surface as
|
||||
* `CAMPAIGNS_API.DUPLICATE_SUPPRESSION` from the store; the route
|
||||
* layer maps that to HTTP 409 via the package error handler.
|
||||
*/
|
||||
|
||||
import {
|
||||
DELETE,
|
||||
GET,
|
||||
type PluginRouteEntry,
|
||||
POST,
|
||||
successResponse,
|
||||
} from "@suaveplan/api-openapi";
|
||||
import type { Context } from "hono";
|
||||
import z from "zod";
|
||||
import {
|
||||
CAMPAIGNS_API_CODES,
|
||||
CampaignsApiError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { getCampaignsApiInstruments } from "../../otel/instruments.js";
|
||||
import type { CampaignSuppressionEntry } from "../../repositories/suppression/suppression.types.js";
|
||||
import { requireTenant } from "../shared/require-tenant.js";
|
||||
import { stripUndefined } from "../shared/strip-undefined.js";
|
||||
import {
|
||||
CreateSuppressionSchema,
|
||||
SuppressionCheckQuerySchema,
|
||||
SuppressionCheckResponseSchema,
|
||||
SuppressionIdParamSchema,
|
||||
SuppressionListResponseSchema,
|
||||
SuppressionQuerySchema,
|
||||
SuppressionResponseSchema,
|
||||
} from "./schemas.js";
|
||||
|
||||
function requireStore(c: Context) {
|
||||
const store = c.get("campaignSuppressionStore");
|
||||
if (store === undefined) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE,
|
||||
"campaignSuppressionStore slot is empty"
|
||||
);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
function toResponse(entry: CampaignSuppressionEntry): Record<string, unknown> {
|
||||
return {
|
||||
id: entry.id,
|
||||
tenantId: entry.tenantId,
|
||||
channel: entry.channel,
|
||||
channelValue: entry.channelValue,
|
||||
reason: entry.reason,
|
||||
...(entry.source !== undefined ? { source: entry.source } : {}),
|
||||
...(entry.metadata !== undefined ? { metadata: entry.metadata } : {}),
|
||||
createdAt: entry.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** `GET /suppression` — paginated list. */
|
||||
export function buildListSuppressionRoute(): PluginRouteEntry {
|
||||
const route = GET("/suppression")
|
||||
.tags("suppression")
|
||||
.summary("List suppression entries")
|
||||
.query(SuppressionQuerySchema)
|
||||
.response(successResponse(SuppressionListResponseSchema))
|
||||
.build();
|
||||
async function handler(c: Context): Promise<Response> {
|
||||
const { tenantId } = requireTenant(c);
|
||||
const store = requireStore(c);
|
||||
const query = SuppressionQuerySchema.parse({
|
||||
channel: c.req.query("channel"),
|
||||
reason: c.req.query("reason"),
|
||||
limit: c.req.query("limit") ?? 50,
|
||||
offset: c.req.query("offset") ?? 0,
|
||||
});
|
||||
const entries = await store.list({
|
||||
tenantId,
|
||||
...(query.channel !== undefined ? { channel: query.channel } : {}),
|
||||
...(query.reason !== undefined ? { reason: query.reason } : {}),
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
});
|
||||
getCampaignsApiInstruments().requestsTotal.add(1, {
|
||||
route: "suppression-list",
|
||||
outcome: "success",
|
||||
});
|
||||
return c.json(
|
||||
{
|
||||
success: true as const,
|
||||
data: {
|
||||
entries: entries.map(toResponse),
|
||||
total: entries.length,
|
||||
},
|
||||
},
|
||||
200
|
||||
);
|
||||
}
|
||||
return { route, handler };
|
||||
}
|
||||
|
||||
/** `GET /suppression/check` — point lookup. */
|
||||
export function buildCheckSuppressionRoute(): PluginRouteEntry {
|
||||
const route = GET("/suppression/check")
|
||||
.tags("suppression")
|
||||
.summary("Check a single (channel, channelValue) for suppression")
|
||||
.query(SuppressionCheckQuerySchema)
|
||||
.response(successResponse(SuppressionCheckResponseSchema))
|
||||
.build();
|
||||
async function handler(c: Context): Promise<Response> {
|
||||
const { tenantId } = requireTenant(c);
|
||||
const store = requireStore(c);
|
||||
const parsed = SuppressionCheckQuerySchema.safeParse({
|
||||
channel: c.req.query("channel"),
|
||||
channelValue: c.req.query("channelValue"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
getCampaignsApiInstruments().requestsTotal.add(1, {
|
||||
route: "suppression-check",
|
||||
outcome: "error",
|
||||
});
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.INVALID_INPUT,
|
||||
"GET /suppression/check query failed validation",
|
||||
{ cause: parsed.error, context: { statusCode: 400 } }
|
||||
);
|
||||
}
|
||||
const entry = await store.check(
|
||||
tenantId,
|
||||
parsed.data.channel,
|
||||
parsed.data.channelValue
|
||||
);
|
||||
getCampaignsApiInstruments().requestsTotal.add(1, {
|
||||
route: "suppression-check",
|
||||
outcome: "success",
|
||||
});
|
||||
return c.json(
|
||||
{
|
||||
success: true as const,
|
||||
data: {
|
||||
suppressed: entry !== null,
|
||||
entry: entry === null ? null : toResponse(entry),
|
||||
},
|
||||
},
|
||||
200
|
||||
);
|
||||
}
|
||||
return { route, handler };
|
||||
}
|
||||
|
||||
/** `POST /suppression` — create entry. */
|
||||
export function buildCreateSuppressionRoute(): PluginRouteEntry {
|
||||
const route = POST("/suppression")
|
||||
.tags("suppression")
|
||||
.summary("Add a suppression entry")
|
||||
.body(CreateSuppressionSchema)
|
||||
.response(successResponse(SuppressionResponseSchema))
|
||||
.build();
|
||||
async function handler(c: Context): Promise<Response> {
|
||||
const { tenantId } = requireTenant(c);
|
||||
const store = requireStore(c);
|
||||
const raw = (await c.req.json().catch(() => ({}))) as unknown;
|
||||
const parsed = CreateSuppressionSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
getCampaignsApiInstruments().requestsTotal.add(1, {
|
||||
route: "suppression-create",
|
||||
outcome: "error",
|
||||
});
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.INVALID_INPUT,
|
||||
"POST /suppression body failed validation",
|
||||
{ cause: parsed.error, context: { statusCode: 400 } }
|
||||
);
|
||||
}
|
||||
const entry = await store.add({
|
||||
tenantId,
|
||||
...(stripUndefined(parsed.data) as {
|
||||
channel: string;
|
||||
channelValue: string;
|
||||
reason: ReturnType<
|
||||
typeof CreateSuppressionSchema.parse
|
||||
>["reason"];
|
||||
source?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}),
|
||||
});
|
||||
getCampaignsApiInstruments().requestsTotal.add(1, {
|
||||
route: "suppression-create",
|
||||
outcome: "success",
|
||||
});
|
||||
return c.json({ success: true as const, data: toResponse(entry) }, 201);
|
||||
}
|
||||
return { route, handler };
|
||||
}
|
||||
|
||||
/** `DELETE /suppression/:id`. */
|
||||
export function buildDeleteSuppressionRoute(): PluginRouteEntry {
|
||||
const route = DELETE("/suppression/:id")
|
||||
.tags("suppression")
|
||||
.summary("Remove a suppression entry")
|
||||
.params(SuppressionIdParamSchema)
|
||||
.response(z.object({ success: z.literal(true) }))
|
||||
.build();
|
||||
async function handler(c: Context): Promise<Response> {
|
||||
// Tenant guard still applies (defence in depth — the id is
|
||||
// global but unauthenticated callers should never hit this).
|
||||
requireTenant(c);
|
||||
const store = requireStore(c);
|
||||
const id = c.req.param("id");
|
||||
const removed = await store.remove(id);
|
||||
if (!removed) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.SUPPRESSION_NOT_FOUND,
|
||||
`suppression entry ${id} not found`,
|
||||
{ context: { statusCode: 404, id } }
|
||||
);
|
||||
}
|
||||
getCampaignsApiInstruments().requestsTotal.add(1, {
|
||||
route: "suppression-delete",
|
||||
outcome: "success",
|
||||
});
|
||||
return c.body(null, 204);
|
||||
}
|
||||
return { route, handler };
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
# Suppression route schemas
|
||||
|
||||
## Purpose
|
||||
|
||||
The suppression-route schemas describe the OpenAPI contract for the
|
||||
`/suppression` route tree. The package's local
|
||||
`CampaignSuppressionStore` interface is multi-channel — keyed on the
|
||||
`(tenantId, channel, channelValue)` triple — so the schemas surface
|
||||
`channel` (free-string with `"email"` default) and `channelValue`
|
||||
rather than the email-only `channelType` shape used by the upstream
|
||||
port. This keeps the same routes reusable for SMS, push, and future
|
||||
delivery channels without a v2 endpoint family.
|
||||
|
||||
The schemas back four endpoints: list (paginated), create (single
|
||||
entry, defends against duplicates at the store), delete (by `id`), and
|
||||
check (point lookup by `channel + channelValue`). The check endpoint
|
||||
is read-only and idempotent, and is intended to be hit on every
|
||||
outgoing-message decision in a sender pipeline.
|
||||
|
||||
## Features
|
||||
|
||||
- `SuppressionReasonSchema` — 5-member enum mirroring
|
||||
`CampaignSuppressionReason`.
|
||||
- `CreateSuppressionSchema` — body validator with `channel: "email"`
|
||||
default and optional `source` / `metadata`.
|
||||
- `SuppressionQuerySchema` — list filters with coerced pagination.
|
||||
- `SuppressionCheckQuerySchema` — required `channel` + `channelValue`.
|
||||
- `SuppressionIdParamSchema` — `:id` path param.
|
||||
- 3 response schemas: `SuppressionResponseSchema`,
|
||||
`SuppressionListResponseSchema`, `SuppressionCheckResponseSchema`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CreateSuppressionSchema } from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
const parsed = CreateSuppressionSchema.parse(await c.req.json());
|
||||
await store.add({ tenantId, ...parsed });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
SuppressionCheckQuerySchema,
|
||||
SuppressionCheckResponseSchema,
|
||||
} from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
const q = SuppressionCheckQuerySchema.parse({
|
||||
channel: c.req.query("channel"),
|
||||
channelValue: c.req.query("channelValue"),
|
||||
});
|
||||
const entry = await store.check(tenantId, q.channel, q.channelValue);
|
||||
return c.json(
|
||||
SuppressionCheckResponseSchema.parse({
|
||||
suppressed: entry !== null,
|
||||
entry,
|
||||
}),
|
||||
200
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CreateSuppressionSchema`, `SuppressionQuerySchema`,
|
||||
`SuppressionCheckQuerySchema`, `SuppressionIdParamSchema` — input
|
||||
validators.
|
||||
- `SuppressionResponseSchema`, `SuppressionListResponseSchema`,
|
||||
`SuppressionCheckResponseSchema` — response shapes.
|
||||
- `type CreateSuppressionBody`, `SuppressionQueryInput` — inferred TS
|
||||
types.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `channel` field defaults to `"email"` rather than enumerating
|
||||
known channels because the suppression store is intentionally
|
||||
channel-agnostic (a tenant may opt into SMS or push channels without
|
||||
schema changes here). The trade-off: the OpenAPI consumer sees a
|
||||
free-string rather than a closed enum. The enforcement boundary moves
|
||||
to the recipient delivery pipeline, which always knows its channel
|
||||
identifier before consulting suppression.
|
||||
|
||||
`metadata` is `z.record(z.string(), z.unknown())` so adapters can
|
||||
attach provider-specific context (bounce subcodes, complaint headers,
|
||||
…) without a schema migration. The schema is **strict-on-input** only
|
||||
for fields it owns; per-channel extensions live inside `metadata` and
|
||||
are validated at the integration boundary, not here.
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
CreateSuppressionSchema,
|
||||
SuppressionCheckQuerySchema,
|
||||
SuppressionCheckResponseSchema,
|
||||
SuppressionIdParamSchema,
|
||||
SuppressionListResponseSchema,
|
||||
SuppressionQuerySchema,
|
||||
SuppressionReasonSchema,
|
||||
SuppressionResponseSchema,
|
||||
} from "./schemas.js";
|
||||
|
||||
describe("suppression route schemas", () => {
|
||||
it("SuppressionReasonSchema accepts all 5 reasons", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const r of [
|
||||
"unsubscribe",
|
||||
"bounce",
|
||||
"complaint",
|
||||
"manual",
|
||||
"compliance",
|
||||
] as const) {
|
||||
expect(SuppressionReasonSchema.parse(r)).toBe(r);
|
||||
}
|
||||
expect(() => SuppressionReasonSchema.parse("bogus")).toThrow();
|
||||
});
|
||||
|
||||
it("CreateSuppressionSchema applies channel default", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = CreateSuppressionSchema.parse({
|
||||
channelValue: "a@b.c",
|
||||
reason: "bounce",
|
||||
});
|
||||
expect(out.channel).toBe("email");
|
||||
expect(out.reason).toBe("bounce");
|
||||
});
|
||||
|
||||
it("CreateSuppressionSchema accepts source and metadata", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = CreateSuppressionSchema.parse({
|
||||
channel: "sms",
|
||||
channelValue: "+1",
|
||||
reason: "compliance",
|
||||
source: "import",
|
||||
metadata: { x: 1 },
|
||||
});
|
||||
expect(out.source).toBe("import");
|
||||
expect(out.metadata).toEqual({ x: 1 });
|
||||
});
|
||||
|
||||
it("CreateSuppressionSchema rejects empty channelValue", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
CreateSuppressionSchema.parse({
|
||||
channelValue: "",
|
||||
reason: "bounce",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("SuppressionQuerySchema coerces numeric fields with defaults", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = SuppressionQuerySchema.parse({ limit: "20" });
|
||||
expect(out.limit).toBe(20);
|
||||
expect(out.offset).toBe(0);
|
||||
});
|
||||
|
||||
it("SuppressionQuerySchema rejects oversized limit", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => SuppressionQuerySchema.parse({ limit: 1000 })).toThrow();
|
||||
});
|
||||
|
||||
it("SuppressionCheckQuerySchema requires channel + channelValue", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
SuppressionCheckQuerySchema.parse({
|
||||
channel: "email",
|
||||
channelValue: "a@b.c",
|
||||
})
|
||||
).toEqual({ channel: "email", channelValue: "a@b.c" });
|
||||
expect(() =>
|
||||
SuppressionCheckQuerySchema.parse({
|
||||
channel: "email",
|
||||
channelValue: "",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("SuppressionIdParamSchema requires non-empty id", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => SuppressionIdParamSchema.parse({ id: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("SuppressionResponseSchema accepts a minimal entry", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ok = SuppressionResponseSchema.safeParse({
|
||||
id: "s1",
|
||||
tenantId: "t1",
|
||||
channel: "email",
|
||||
channelValue: "a@b.c",
|
||||
reason: "manual",
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
expect(ok.success).toBe(true);
|
||||
});
|
||||
|
||||
it("SuppressionListResponseSchema wraps the entry array", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
SuppressionListResponseSchema.safeParse({
|
||||
entries: [],
|
||||
total: 0,
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("SuppressionCheckResponseSchema accepts null entry", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
SuppressionCheckResponseSchema.safeParse({
|
||||
suppressed: false,
|
||||
entry: null,
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Zod schemas for the `/suppression` route tree. The suppression-store
|
||||
* interface is multi-channel (the `(tenantId, channel, channelValue)`
|
||||
* triple is the natural primary key), so the schemas surface
|
||||
* `channel` and `channelValue` rather than the email-only
|
||||
* `channelType` + `channelValue` shape from upstream.
|
||||
*/
|
||||
|
||||
import z from "zod";
|
||||
|
||||
/** Mirror of `CampaignSuppressionReason` from the store types. */
|
||||
export const SuppressionReasonSchema = z.enum([
|
||||
"unsubscribe",
|
||||
"bounce",
|
||||
"complaint",
|
||||
"manual",
|
||||
"compliance",
|
||||
]);
|
||||
|
||||
/** Body for `POST /suppression`. */
|
||||
export const CreateSuppressionSchema = z.object({
|
||||
channel: z.string().min(1).default("email"),
|
||||
channelValue: z.string().min(1),
|
||||
reason: SuppressionReasonSchema,
|
||||
source: z.string().optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
/** Query string for `GET /suppression`. */
|
||||
export const SuppressionQuerySchema = z.object({
|
||||
channel: z.string().min(1).optional(),
|
||||
reason: SuppressionReasonSchema.optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
/** Query string for `GET /suppression/check`. */
|
||||
export const SuppressionCheckQuerySchema = z.object({
|
||||
channel: z.string().min(1),
|
||||
channelValue: z.string().min(1),
|
||||
});
|
||||
|
||||
/** Path param `:id`. */
|
||||
export const SuppressionIdParamSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
/** Response shape for a single suppression entry. */
|
||||
export const SuppressionResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
tenantId: z.string(),
|
||||
channel: z.string(),
|
||||
channelValue: z.string(),
|
||||
reason: SuppressionReasonSchema,
|
||||
source: z.string().optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
createdAt: z.string(),
|
||||
});
|
||||
|
||||
/** Response shape for `GET /suppression`. */
|
||||
export const SuppressionListResponseSchema = z.object({
|
||||
entries: z.array(SuppressionResponseSchema),
|
||||
total: z.number(),
|
||||
});
|
||||
|
||||
/** Response shape for `GET /suppression/check`. */
|
||||
export const SuppressionCheckResponseSchema = z.object({
|
||||
suppressed: z.boolean(),
|
||||
entry: SuppressionResponseSchema.nullable(),
|
||||
});
|
||||
|
||||
export type CreateSuppressionBody = z.infer<typeof CreateSuppressionSchema>;
|
||||
export type SuppressionQueryInput = z.infer<typeof SuppressionQuerySchema>;
|
||||
@@ -0,0 +1,63 @@
|
||||
# `build-routes` (webhooks)
|
||||
|
||||
## Purpose
|
||||
|
||||
`buildWebhookRoutes` aggregates the two `/webhooks/*` route entries in
|
||||
canonical order — delivery ingest first, then one-click unsubscribe.
|
||||
Co-locating both endpoints in a single bundle keeps the OpenAPI spec
|
||||
deterministic, makes the unit-test surface trivially mountable on a
|
||||
bare `Hono` app, and gives the plugin's `onInit` a single hook point
|
||||
for the entire webhook tree.
|
||||
|
||||
The two endpoints differ markedly in shape (POST body vs. GET token
|
||||
param) but share the same tenant-guard and store-resolution
|
||||
pattern, so the aggregator is the right seam for any future
|
||||
cross-cutting middleware (rate limit, HMAC verifier, replay-detector).
|
||||
|
||||
## Features
|
||||
|
||||
- 2 entries in canonical order: `delivery`, `unsubscribe`.
|
||||
- Returned array is `readonly` and ordering is stable across calls.
|
||||
- Pure — no I/O, no captured state.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildWebhookRoutes } from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
for (const e of buildWebhookRoutes()) {
|
||||
app[e.route.method as "get" | "post"](e.route.path, (c) =>
|
||||
e.handler(c)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildWebhookRoutes,
|
||||
definePluginRoutes,
|
||||
} from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
const entries = buildWebhookRoutes().map((e) => ({
|
||||
...e,
|
||||
route: { ...e.route, path: `/v1${e.route.path}` },
|
||||
}));
|
||||
definePluginRoutes(entries, { app, registry, pluginName: "campaigns" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `buildWebhookRoutes(): readonly PluginRouteEntry[]` — the two
|
||||
canonical webhook entries.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The delivery endpoint comes first because that is the high-volume
|
||||
ingestion path, while the unsubscribe endpoint is rate-limited by the
|
||||
human throughput of clicking a link. Keeping them in this order makes
|
||||
the OpenAPI consumer's first impression align with the operational
|
||||
weight of each endpoint. The aggregator does not try to share any
|
||||
middleware between the two builders — each one is self-contained and
|
||||
already enforces its own tenant + service-resolution guards.
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { buildWebhookRoutes } from "./build-routes.js";
|
||||
|
||||
describe("buildWebhookRoutes", () => {
|
||||
it("returns 2 entries in canonical order", { timeout: 5000 }, () => {
|
||||
const entries = buildWebhookRoutes();
|
||||
expect(entries.map((e) => `${e.route.method} ${e.route.path}`)).toEqual(
|
||||
["post /webhooks/delivery", "get /webhooks/unsubscribe/:token"]
|
||||
);
|
||||
});
|
||||
|
||||
it("every entry tagged 'webhooks'", { timeout: 5000 }, () => {
|
||||
for (const e of buildWebhookRoutes()) {
|
||||
expect(e.route.tags).toEqual(["webhooks"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Aggregator returning every `/webhooks/*` `PluginRouteEntry` in
|
||||
* canonical order: delivery ingest, then one-click unsubscribe.
|
||||
*/
|
||||
|
||||
import type { PluginRouteEntry } from "@suaveplan/api-openapi";
|
||||
import { buildDeliveryWebhookRoute, buildUnsubscribeRoute } from "./routes.js";
|
||||
|
||||
/** Returns the 2 `/webhooks/*` plugin-route entries. */
|
||||
export function buildWebhookRoutes(): readonly PluginRouteEntry[] {
|
||||
return [buildDeliveryWebhookRoute(), buildUnsubscribeRoute()];
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export { buildWebhookRoutes } from "./build-routes.js";
|
||||
export {
|
||||
buildDeliveryWebhookRoute,
|
||||
buildUnsubscribeRoute,
|
||||
} from "./routes.js";
|
||||
export {
|
||||
DeliveryEventTypeSchema,
|
||||
type DeliveryWebhookBody,
|
||||
DeliveryWebhookResponseSchema,
|
||||
DeliveryWebhookSchema,
|
||||
decodeUnsubscribeToken,
|
||||
UnsubscribeResponseSchema,
|
||||
UnsubscribeTokenParamSchema,
|
||||
type UnsubscribeTokenPayload,
|
||||
} from "./schemas.js";
|
||||
@@ -0,0 +1,81 @@
|
||||
# Webhook routes
|
||||
|
||||
## Purpose
|
||||
|
||||
The webhook-route module owns the two webhook endpoints: a delivery
|
||||
event ingestion endpoint that normalises and persists provider events
|
||||
through `DeliveryEventStore`, and a one-click unsubscribe endpoint
|
||||
driven by a base64-JSON token. The delivery handler is the authoritative
|
||||
fan-out point: it persists the event and — for the three "negative"
|
||||
events (`unsubscribed`, `bounced`, `complained`) — additionally
|
||||
inserts a `CampaignSuppressionEntry` so the sender pipeline can
|
||||
short-circuit future deliveries to the same contact.
|
||||
|
||||
The unsubscribe endpoint hardcodes `channel: "email"` because the
|
||||
token format does not carry a channel field (the design assumes
|
||||
unsubscribe links are always embedded in email messages). Multi-channel
|
||||
unsubscribe is a future extension — when that ships, the token shape
|
||||
will gain a `channel` field and the handler will decode it.
|
||||
|
||||
## Features
|
||||
|
||||
- 2 builders: `buildDeliveryWebhookRoute`, `buildUnsubscribeRoute`.
|
||||
- Delivery handler fan-out: records the event, then optionally adds
|
||||
a suppression entry for `unsubscribed` / `bounced` / `complained`.
|
||||
- Idempotent suppression: a duplicate `add` is short-circuited by a
|
||||
pre-existing `check` hit, so retries from the provider do not raise
|
||||
`DUPLICATE_SUPPRESSION`.
|
||||
- Strict tenant guard: every handler enforces `requireTenant` so
|
||||
provider webhooks must traverse a tenant-aware auth layer before
|
||||
hitting the handler.
|
||||
- Token decode failures map to `INVALID_UNSUBSCRIBE_TOKEN` (HTTP 400);
|
||||
schema failures on the delivery body map to `WEBHOOK_REJECTED`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildDeliveryWebhookRoute,
|
||||
buildUnsubscribeRoute,
|
||||
} from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
app.post(buildDeliveryWebhookRoute().route.path, (c) =>
|
||||
buildDeliveryWebhookRoute().handler(c)
|
||||
);
|
||||
app.get(buildUnsubscribeRoute().route.path, (c) =>
|
||||
buildUnsubscribeRoute().handler(c)
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildDeliveryWebhookRoute,
|
||||
buildUnsubscribeRoute,
|
||||
definePluginRoutes,
|
||||
} from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
definePluginRoutes(
|
||||
[buildDeliveryWebhookRoute(), buildUnsubscribeRoute()],
|
||||
{ app, registry, pluginName: "campaigns" }
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `buildDeliveryWebhookRoute(): PluginRouteEntry`
|
||||
- `buildUnsubscribeRoute(): PluginRouteEntry`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The negative-event mapping (`unsubscribed → unsubscribe`,
|
||||
`bounced → bounce`, `complained → complaint`) is exhaustive but
|
||||
intentionally narrow — we do not auto-suppress on `dropped` or
|
||||
`deferred` because those are transient provider states that should
|
||||
trigger investigation rather than permanent suppression. A future
|
||||
change can expand the mapping if operators request a `dropped → manual`
|
||||
auto-suppression policy. The handler returns `{ eventId, suppressed }`
|
||||
so the calling provider can correlate retries against a stable id and
|
||||
operators can confirm via response inspection whether the fan-out
|
||||
fired.
|
||||
@@ -0,0 +1,324 @@
|
||||
import { InMemoryDeliveryEventStore } from "@suaveplan/delivery-tracking";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
CAMPAIGNS_API_CODES,
|
||||
CampaignsApiError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { InMemoryCampaignSuppressionStore } from "../../repositories/suppression/suppression-memory.js";
|
||||
import { buildDeliveryWebhookRoute, buildUnsubscribeRoute } from "./routes.js";
|
||||
import "../../context/context.js";
|
||||
|
||||
function makeFixtures(principal: object | null = { id: "u", tenantId: "t" }) {
|
||||
let nextId = 0;
|
||||
const store = new InMemoryCampaignSuppressionStore({
|
||||
clock: () => 1_700_000_000_000,
|
||||
idGenerator: () => `s${++nextId}`,
|
||||
});
|
||||
const eventStore = new InMemoryDeliveryEventStore();
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
if (principal !== null) c.set("user", principal as never);
|
||||
c.set("campaignSuppressionStore", store);
|
||||
c.set("deliveryEventStore", eventStore);
|
||||
c.set("eventBus", { emit: () => undefined } as never);
|
||||
await next();
|
||||
});
|
||||
app.onError((err, c) => {
|
||||
if (err instanceof CampaignsApiError) {
|
||||
const status = ((err.context as { statusCode?: number } | undefined)
|
||||
?.statusCode ?? 500) as 400 | 401 | 500;
|
||||
return c.json({ code: err.code, error: err.message }, status);
|
||||
}
|
||||
return c.json({ error: String(err) }, 500);
|
||||
});
|
||||
for (const e of [buildDeliveryWebhookRoute(), buildUnsubscribeRoute()]) {
|
||||
const method = e.route.method as "get" | "post";
|
||||
app[method](e.route.path, (c) => e.handler(c as never));
|
||||
}
|
||||
return { app, store, eventStore };
|
||||
}
|
||||
|
||||
describe("webhook delivery route", () => {
|
||||
it("records a delivered event without suppression", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app, store, eventStore } = makeFixtures();
|
||||
const res = await app.request("/webhooks/delivery", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
messageId: "m1",
|
||||
eventType: "delivered",
|
||||
contactId: "u1",
|
||||
campaignId: "cmp1",
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.suppressed).toBe(false);
|
||||
expect(await eventStore.getByCampaign("cmp1")).toHaveLength(1);
|
||||
expect(await store.count("t")).toBe(0);
|
||||
});
|
||||
|
||||
it("suppresses on unsubscribed event", { timeout: 5000 }, async () => {
|
||||
const { app, store } = makeFixtures();
|
||||
const res = await app.request("/webhooks/delivery", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
messageId: "m1",
|
||||
eventType: "unsubscribed",
|
||||
contactId: "u1",
|
||||
campaignId: "cmp1",
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.suppressed).toBe(true);
|
||||
expect(await store.count("t")).toBe(1);
|
||||
});
|
||||
|
||||
it("suppresses on bounced event", { timeout: 5000 }, async () => {
|
||||
const { app, store } = makeFixtures();
|
||||
await app.request("/webhooks/delivery", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
messageId: "m1",
|
||||
eventType: "bounced",
|
||||
contactId: "u1",
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect(await store.count("t")).toBe(1);
|
||||
});
|
||||
|
||||
it("suppresses on complained event", { timeout: 5000 }, async () => {
|
||||
const { app, store } = makeFixtures();
|
||||
await app.request("/webhooks/delivery", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
messageId: "m1",
|
||||
eventType: "complained",
|
||||
contactId: "u1",
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect(await store.count("t")).toBe(1);
|
||||
});
|
||||
|
||||
it("is idempotent — second unsubscribe does not raise", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app, store } = makeFixtures();
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const res = await app.request("/webhooks/delivery", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
messageId: `m${i}`,
|
||||
eventType: "unsubscribed",
|
||||
contactId: "u1",
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).data.suppressed).toBe(true);
|
||||
}
|
||||
expect(await store.count("t")).toBe(1);
|
||||
});
|
||||
|
||||
it("accepts a timestamp from the payload", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app, eventStore } = makeFixtures();
|
||||
await app.request("/webhooks/delivery", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
messageId: "m1",
|
||||
eventType: "delivered",
|
||||
contactId: "u1",
|
||||
campaignId: "cmp1",
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const events = await eventStore.getByCampaign("cmp1");
|
||||
expect(events[0]?.timestamp.toISOString()).toBe(
|
||||
"2026-01-01T00:00:00.000Z"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects malformed body with WEBHOOK_REJECTED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app } = makeFixtures();
|
||||
const res = await app.request("/webhooks/delivery", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ messageId: "", eventType: "delivered" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).code).toBe(
|
||||
CAMPAIGNS_API_CODES.WEBHOOK_REJECTED
|
||||
);
|
||||
});
|
||||
|
||||
it("401 without a principal", { timeout: 5000 }, async () => {
|
||||
const { app } = makeFixtures(null);
|
||||
const res = await app.request("/webhooks/delivery", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
messageId: "m1",
|
||||
eventType: "delivered",
|
||||
contactId: "u1",
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("POST /webhooks/delivery 400 on malformed JSON body", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app } = makeFixtures();
|
||||
const res = await app.request("/webhooks/delivery", {
|
||||
method: "POST",
|
||||
body: "not-json{",
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("500 when event store slot empty", { timeout: 5000 }, async () => {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", { id: "u", tenantId: "t" } as never);
|
||||
await next();
|
||||
});
|
||||
app.onError((err, c) =>
|
||||
c.json({ code: (err as CampaignsApiError).code }, 500)
|
||||
);
|
||||
const e = buildDeliveryWebhookRoute();
|
||||
app.post(e.route.path, (c) => e.handler(c as never));
|
||||
const res = await app.request("/webhooks/delivery", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
messageId: "m",
|
||||
eventType: "delivered",
|
||||
contactId: "u1",
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect((await res.json()).code).toBe(
|
||||
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE
|
||||
);
|
||||
});
|
||||
|
||||
it("500 when suppression store slot empty", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const eventStore = new InMemoryDeliveryEventStore();
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", { id: "u", tenantId: "t" } as never);
|
||||
c.set("deliveryEventStore", eventStore);
|
||||
await next();
|
||||
});
|
||||
app.onError((err, c) =>
|
||||
c.json({ code: (err as CampaignsApiError).code }, 500)
|
||||
);
|
||||
const e = buildDeliveryWebhookRoute();
|
||||
app.post(e.route.path, (c) => e.handler(c as never));
|
||||
const res = await app.request("/webhooks/delivery", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
messageId: "m",
|
||||
eventType: "delivered",
|
||||
contactId: "u1",
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect((await res.json()).code).toBe(
|
||||
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("webhook unsubscribe route", () => {
|
||||
it("accepts a valid token", { timeout: 5000 }, async () => {
|
||||
const { app, store } = makeFixtures();
|
||||
const token = btoa(JSON.stringify({ contactId: "u1" }));
|
||||
const res = await app.request(`/webhooks/unsubscribe/${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.contactId).toBe("u1");
|
||||
expect(body.data.campaignId).toBeNull();
|
||||
expect(await store.count("t")).toBe(1);
|
||||
});
|
||||
|
||||
it("includes campaignId metadata when supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app, store } = makeFixtures();
|
||||
const token = btoa(
|
||||
JSON.stringify({ contactId: "u1", campaignId: "cmp1" })
|
||||
);
|
||||
const res = await app.request(`/webhooks/unsubscribe/${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
const entries = await store.list({ tenantId: "t" });
|
||||
expect(entries[0]?.metadata).toEqual({ campaignId: "cmp1" });
|
||||
});
|
||||
|
||||
it("is idempotent — repeat call does not double-insert", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app, store } = makeFixtures();
|
||||
const token = btoa(JSON.stringify({ contactId: "u1" }));
|
||||
await app.request(`/webhooks/unsubscribe/${token}`);
|
||||
await app.request(`/webhooks/unsubscribe/${token}`);
|
||||
expect(await store.count("t")).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects an undecodable token", { timeout: 5000 }, async () => {
|
||||
const { app } = makeFixtures();
|
||||
const res = await app.request("/webhooks/unsubscribe/ÿÿÿÿ");
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).code).toBe(
|
||||
CAMPAIGNS_API_CODES.INVALID_UNSUBSCRIBE_TOKEN
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a token without contactId", { timeout: 5000 }, async () => {
|
||||
const { app } = makeFixtures();
|
||||
const token = btoa(JSON.stringify({ campaignId: "cmp1" }));
|
||||
const res = await app.request(`/webhooks/unsubscribe/${token}`);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("401 without a principal", { timeout: 5000 }, async () => {
|
||||
const { app } = makeFixtures(null);
|
||||
const token = btoa(JSON.stringify({ contactId: "u1" }));
|
||||
const res = await app.request(`/webhooks/unsubscribe/${token}`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("500 when suppression store slot empty", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", { id: "u", tenantId: "t" } as never);
|
||||
await next();
|
||||
});
|
||||
app.onError((err, c) =>
|
||||
c.json({ code: (err as CampaignsApiError).code }, 500)
|
||||
);
|
||||
const e = buildUnsubscribeRoute();
|
||||
app.get(e.route.path, (c) => e.handler(c as never));
|
||||
const token = btoa(JSON.stringify({ contactId: "u1" }));
|
||||
const res = await app.request(`/webhooks/unsubscribe/${token}`);
|
||||
expect((await res.json()).code).toBe(
|
||||
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* `/webhooks` route builders — provider delivery-event ingest and
|
||||
* one-click unsubscribe.
|
||||
*
|
||||
* The delivery webhook normalises the payload, persists it via
|
||||
* `deliveryEventStore.record`, and on the three "negative" event
|
||||
* types (`unsubscribed`, `bounced`, `complained`) it additionally
|
||||
* inserts a matching `CampaignSuppressionEntry` so the sender
|
||||
* pipeline can short-circuit future deliveries. Both store writes
|
||||
* use `add({ ... })`; the store deduplicates by `(tenant, channel,
|
||||
* channelValue)` so retries are safe.
|
||||
*/
|
||||
|
||||
import {
|
||||
GET,
|
||||
type PluginRouteEntry,
|
||||
POST,
|
||||
successResponse,
|
||||
} from "@suaveplan/api-openapi";
|
||||
import type { DeliveryEvent } from "@suaveplan/delivery-tracking";
|
||||
import type { Context } from "hono";
|
||||
import {
|
||||
CAMPAIGNS_API_CODES,
|
||||
CampaignsApiError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { getCampaignsApiInstruments } from "../../otel/instruments.js";
|
||||
import type {
|
||||
CampaignSuppressionReason,
|
||||
CampaignSuppressionStore,
|
||||
} from "../../repositories/suppression/suppression.types.js";
|
||||
import { requireTenant } from "../shared/require-tenant.js";
|
||||
import {
|
||||
DeliveryWebhookResponseSchema,
|
||||
DeliveryWebhookSchema,
|
||||
decodeUnsubscribeToken,
|
||||
UnsubscribeResponseSchema,
|
||||
UnsubscribeTokenParamSchema,
|
||||
} from "./schemas.js";
|
||||
|
||||
const SUPPRESSION_REASON_BY_EVENT: Record<
|
||||
"unsubscribed" | "bounced" | "complained",
|
||||
CampaignSuppressionReason
|
||||
> = {
|
||||
unsubscribed: "unsubscribe",
|
||||
bounced: "bounce",
|
||||
complained: "complaint",
|
||||
};
|
||||
|
||||
function requireEventStore(c: Context) {
|
||||
const store = c.get("deliveryEventStore");
|
||||
if (store === undefined) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE,
|
||||
"deliveryEventStore slot is empty"
|
||||
);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
function requireSuppressionStore(c: Context): CampaignSuppressionStore {
|
||||
const store = c.get("campaignSuppressionStore");
|
||||
if (store === undefined) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.SERVICE_NOT_AVAILABLE,
|
||||
"campaignSuppressionStore slot is empty"
|
||||
);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
async function maybeSuppress(
|
||||
store: CampaignSuppressionStore,
|
||||
tenantId: string,
|
||||
body: {
|
||||
eventType: string;
|
||||
channel: string;
|
||||
contactId: string;
|
||||
campaignId?: string;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
const reason =
|
||||
SUPPRESSION_REASON_BY_EVENT[
|
||||
body.eventType as keyof typeof SUPPRESSION_REASON_BY_EVENT
|
||||
];
|
||||
if (reason === undefined) return false;
|
||||
const existing = await store.check(tenantId, body.channel, body.contactId);
|
||||
if (existing !== null) return true;
|
||||
await store.add({
|
||||
tenantId,
|
||||
channel: body.channel,
|
||||
channelValue: body.contactId,
|
||||
reason,
|
||||
source: "webhook",
|
||||
...(body.campaignId !== undefined
|
||||
? { metadata: { campaignId: body.campaignId } }
|
||||
: {}),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `POST /webhooks/delivery` — ingest a normalised provider delivery event. */
|
||||
export function buildDeliveryWebhookRoute(): PluginRouteEntry {
|
||||
const route = POST("/webhooks/delivery")
|
||||
.tags("webhooks")
|
||||
.summary("Receive a delivery webhook event")
|
||||
.body(DeliveryWebhookSchema)
|
||||
.response(DeliveryWebhookResponseSchema)
|
||||
.build();
|
||||
async function handler(c: Context): Promise<Response> {
|
||||
const { tenantId } = requireTenant(c);
|
||||
const eventStore = requireEventStore(c);
|
||||
const suppressionStore = requireSuppressionStore(c);
|
||||
const raw = (await c.req.json().catch(() => ({}))) as unknown;
|
||||
const parsed = DeliveryWebhookSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
getCampaignsApiInstruments().requestsTotal.add(1, {
|
||||
route: "webhook-delivery",
|
||||
outcome: "error",
|
||||
});
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.WEBHOOK_REJECTED,
|
||||
"POST /webhooks/delivery body failed validation",
|
||||
{ cause: parsed.error, context: { statusCode: 400 } }
|
||||
);
|
||||
}
|
||||
const body = parsed.data;
|
||||
const event: DeliveryEvent = {
|
||||
id: crypto.randomUUID(),
|
||||
tenantId,
|
||||
...(body.campaignId !== undefined
|
||||
? { campaignId: body.campaignId }
|
||||
: {}),
|
||||
contactId: body.contactId,
|
||||
channel: body.channel,
|
||||
eventType: body.eventType,
|
||||
messageId: body.messageId,
|
||||
metadata: body.metadata,
|
||||
timestamp:
|
||||
body.timestamp !== undefined
|
||||
? new Date(body.timestamp)
|
||||
: new Date(),
|
||||
};
|
||||
await eventStore.record(event);
|
||||
const suppressed = await maybeSuppress(suppressionStore, tenantId, {
|
||||
eventType: body.eventType,
|
||||
channel: body.channel,
|
||||
contactId: body.contactId,
|
||||
...(body.campaignId !== undefined
|
||||
? { campaignId: body.campaignId }
|
||||
: {}),
|
||||
});
|
||||
getCampaignsApiInstruments().requestsTotal.add(1, {
|
||||
route: "webhook-delivery",
|
||||
outcome: "success",
|
||||
});
|
||||
return c.json(
|
||||
{
|
||||
success: true as const,
|
||||
data: { eventId: event.id, suppressed },
|
||||
},
|
||||
200
|
||||
);
|
||||
}
|
||||
return { route, handler };
|
||||
}
|
||||
|
||||
/** `GET /webhooks/unsubscribe/:token` — one-click unsubscribe. */
|
||||
export function buildUnsubscribeRoute(): PluginRouteEntry {
|
||||
const route = GET("/webhooks/unsubscribe/:token")
|
||||
.tags("webhooks")
|
||||
.summary("One-click unsubscribe via a base64-JSON token")
|
||||
.params(UnsubscribeTokenParamSchema)
|
||||
.response(successResponse(UnsubscribeResponseSchema))
|
||||
.build();
|
||||
async function handler(c: Context): Promise<Response> {
|
||||
const { tenantId } = requireTenant(c);
|
||||
const suppressionStore = requireSuppressionStore(c);
|
||||
const token = c.req.param("token");
|
||||
let payload: ReturnType<typeof decodeUnsubscribeToken>;
|
||||
try {
|
||||
payload = decodeUnsubscribeToken(token);
|
||||
} catch (cause) {
|
||||
getCampaignsApiInstruments().requestsTotal.add(1, {
|
||||
route: "webhook-unsubscribe",
|
||||
outcome: "error",
|
||||
});
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.INVALID_UNSUBSCRIBE_TOKEN,
|
||||
"unsubscribe token decode failed",
|
||||
{ cause, context: { statusCode: 400 } }
|
||||
);
|
||||
}
|
||||
const existing = await suppressionStore.check(
|
||||
tenantId,
|
||||
"email",
|
||||
payload.contactId
|
||||
);
|
||||
if (existing === null) {
|
||||
await suppressionStore.add({
|
||||
tenantId,
|
||||
channel: "email",
|
||||
channelValue: payload.contactId,
|
||||
reason: "unsubscribe",
|
||||
source: "one-click",
|
||||
...(payload.campaignId !== undefined
|
||||
? { metadata: { campaignId: payload.campaignId } }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
getCampaignsApiInstruments().requestsTotal.add(1, {
|
||||
route: "webhook-unsubscribe",
|
||||
outcome: "success",
|
||||
});
|
||||
return c.json(
|
||||
{
|
||||
success: true as const,
|
||||
data: {
|
||||
contactId: payload.contactId,
|
||||
campaignId: payload.campaignId ?? null,
|
||||
},
|
||||
},
|
||||
200
|
||||
);
|
||||
}
|
||||
return { route, handler };
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
# Webhook route schemas
|
||||
|
||||
## Purpose
|
||||
|
||||
The webhook-route schemas describe the OpenAPI contract for the
|
||||
`/webhooks` route tree, plus a `decodeUnsubscribeToken` helper. The
|
||||
delivery webhook accepts a normalised `DeliveryEvent` payload (the
|
||||
provider-specific parser lives in `@suaveplan/delivery-tracking`'s
|
||||
`WebhookEventParser`; this plugin only handles already-normalised
|
||||
events). The unsubscribe endpoint decodes a base64-JSON token carrying
|
||||
`{ contactId, campaignId? }` and falls through to the suppression
|
||||
store.
|
||||
|
||||
The token format is deliberately lightweight — base64-encoded JSON
|
||||
with no signature. Production deployments SHOULD wrap this endpoint in
|
||||
a per-tenant HMAC verification layer (e.g. via the rate-limit or
|
||||
auth-plugin middleware chain) before the token decode happens. This
|
||||
module is the parsing primitive; identity verification is layered on
|
||||
above.
|
||||
|
||||
## Features
|
||||
|
||||
- `DeliveryEventTypeSchema` — 10-member enum.
|
||||
- `DeliveryWebhookSchema` — body validator with `channel: "email"`
|
||||
default and ISO 8601 timestamp.
|
||||
- `UnsubscribeTokenParamSchema` — `:token` path validator.
|
||||
- `DeliveryWebhookResponseSchema`, `UnsubscribeResponseSchema` —
|
||||
response shapes.
|
||||
- `decodeUnsubscribeToken(token)` — base64 → JSON → Zod-validated
|
||||
payload, with three distinct error messages so the route handler
|
||||
can map all to `INVALID_UNSUBSCRIBE_TOKEN`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { DeliveryWebhookSchema } from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
const event = DeliveryWebhookSchema.parse(await c.req.json());
|
||||
await store.record({ ...event, tenantId, id: crypto.randomUUID() });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
CAMPAIGNS_API_CODES,
|
||||
CampaignsApiError,
|
||||
decodeUnsubscribeToken,
|
||||
} from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = decodeUnsubscribeToken(c.req.param("token"));
|
||||
} catch (cause) {
|
||||
throw new CampaignsApiError(
|
||||
CAMPAIGNS_API_CODES.INVALID_UNSUBSCRIBE_TOKEN,
|
||||
"could not decode unsubscribe token",
|
||||
{ cause, context: { statusCode: 400 } }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DeliveryEventTypeSchema: ZodEnum<10>`
|
||||
- `DeliveryWebhookSchema: ZodObject<...>`
|
||||
- `UnsubscribeTokenParamSchema: ZodObject<{ token: ZodString }>`
|
||||
- `DeliveryWebhookResponseSchema`, `UnsubscribeResponseSchema` —
|
||||
response shapes.
|
||||
- `decodeUnsubscribeToken(token: string): UnsubscribeTokenPayload` —
|
||||
throws `Error` on any failure mode.
|
||||
- `type DeliveryWebhookBody`, `type UnsubscribeTokenPayload`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`decodeUnsubscribeToken` throws plain `Error` (not
|
||||
`CampaignsApiError`) because the helper is consumed from a single
|
||||
route-handler site that wraps every throw in
|
||||
`CampaignsApiError(INVALID_UNSUBSCRIBE_TOKEN)`. Centralising the wrap
|
||||
at the call site keeps the helper portable for future consumers that
|
||||
may wish to map the failure mode differently (e.g. a CLI tool issuing
|
||||
test tokens). The three distinct error messages — base64,
|
||||
JSON, contactId — are preserved so server-side logs can distinguish
|
||||
between a tampered token (likely malicious), a malformed token (likely
|
||||
truncated by a mail-client URL-shortener), and a structurally-valid
|
||||
but content-invalid token (likely a tenant-config bug).
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DeliveryEventTypeSchema,
|
||||
DeliveryWebhookSchema,
|
||||
decodeUnsubscribeToken,
|
||||
UnsubscribeTokenParamSchema,
|
||||
} from "./schemas.js";
|
||||
|
||||
describe("webhook schemas", () => {
|
||||
it("DeliveryEventTypeSchema accepts the 10 event types", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const t of [
|
||||
"sent",
|
||||
"delivered",
|
||||
"opened",
|
||||
"clicked",
|
||||
"bounced",
|
||||
"unsubscribed",
|
||||
"complained",
|
||||
"deferred",
|
||||
"dropped",
|
||||
"read",
|
||||
] as const) {
|
||||
expect(DeliveryEventTypeSchema.parse(t)).toBe(t);
|
||||
}
|
||||
expect(() => DeliveryEventTypeSchema.parse("bogus")).toThrow();
|
||||
});
|
||||
|
||||
it("DeliveryWebhookSchema applies channel + metadata defaults", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = DeliveryWebhookSchema.parse({
|
||||
messageId: "m1",
|
||||
eventType: "delivered",
|
||||
contactId: "c1",
|
||||
});
|
||||
expect(out.channel).toBe("email");
|
||||
expect(out.metadata).toEqual({});
|
||||
});
|
||||
|
||||
it("DeliveryWebhookSchema rejects empty messageId", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
DeliveryWebhookSchema.parse({
|
||||
messageId: "",
|
||||
eventType: "delivered",
|
||||
contactId: "c1",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("DeliveryWebhookSchema accepts an ISO timestamp", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = DeliveryWebhookSchema.parse({
|
||||
messageId: "m1",
|
||||
eventType: "opened",
|
||||
contactId: "c1",
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
expect(out.timestamp).toBe("2026-01-01T00:00:00Z");
|
||||
});
|
||||
|
||||
it("UnsubscribeTokenParamSchema requires a non-empty token", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
UnsubscribeTokenParamSchema.parse({ token: "" })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeUnsubscribeToken", () => {
|
||||
it("decodes a valid token", { timeout: 5000 }, () => {
|
||||
const token = btoa(
|
||||
JSON.stringify({ contactId: "c1", campaignId: "cmp1" })
|
||||
);
|
||||
const out = decodeUnsubscribeToken(token);
|
||||
expect(out).toEqual({ contactId: "c1", campaignId: "cmp1" });
|
||||
});
|
||||
|
||||
it("decodes a token without campaignId", { timeout: 5000 }, () => {
|
||||
const token = btoa(JSON.stringify({ contactId: "c1" }));
|
||||
expect(decodeUnsubscribeToken(token)).toEqual({ contactId: "c1" });
|
||||
});
|
||||
|
||||
it("throws when token is not valid base64", { timeout: 5000 }, () => {
|
||||
// 'œ' is outside the base64 alphabet; atob rejects.
|
||||
expect(() => decodeUnsubscribeToken("ÿÿÿÿÿ")).toThrow();
|
||||
});
|
||||
|
||||
it("throws when decoded body is not JSON", { timeout: 5000 }, () => {
|
||||
const token = btoa("not json{");
|
||||
expect(() => decodeUnsubscribeToken(token)).toThrow(/JSON/);
|
||||
});
|
||||
|
||||
it("throws when payload lacks contactId", { timeout: 5000 }, () => {
|
||||
const token = btoa(JSON.stringify({ campaignId: "cmp1" }));
|
||||
expect(() => decodeUnsubscribeToken(token)).toThrow(/contactId/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Zod schemas for the `/webhooks` route tree. Webhooks ingest
|
||||
* provider-side delivery events (one shape per provider, normalised to
|
||||
* the genesis `DeliveryEvent` model) and a one-click unsubscribe URL
|
||||
* driven by a base64-encoded JSON token.
|
||||
*/
|
||||
|
||||
import z from "zod";
|
||||
|
||||
/** 10-member event-type enum mirroring `DeliveryEventType`. */
|
||||
export const DeliveryEventTypeSchema = z.enum([
|
||||
"sent",
|
||||
"delivered",
|
||||
"opened",
|
||||
"clicked",
|
||||
"bounced",
|
||||
"unsubscribed",
|
||||
"complained",
|
||||
"deferred",
|
||||
"dropped",
|
||||
"read",
|
||||
]);
|
||||
|
||||
/** Body for `POST /webhooks/delivery`. */
|
||||
export const DeliveryWebhookSchema = z.object({
|
||||
messageId: z.string().min(1),
|
||||
eventType: DeliveryEventTypeSchema,
|
||||
contactId: z.string().min(1),
|
||||
campaignId: z.string().min(1).optional(),
|
||||
channel: z.string().min(1).default("email"),
|
||||
metadata: z.record(z.string(), z.unknown()).default({}),
|
||||
timestamp: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
/** Path param for `GET /webhooks/unsubscribe/:token`. */
|
||||
export const UnsubscribeTokenParamSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
});
|
||||
|
||||
/** Response shape for `POST /webhooks/delivery`. */
|
||||
export const DeliveryWebhookResponseSchema = z.object({
|
||||
success: z.literal(true),
|
||||
data: z.object({
|
||||
eventId: z.string(),
|
||||
suppressed: z.boolean(),
|
||||
}),
|
||||
});
|
||||
|
||||
/** Response shape for `GET /webhooks/unsubscribe/:token`. */
|
||||
export const UnsubscribeResponseSchema = z.object({
|
||||
success: z.literal(true),
|
||||
data: z.object({
|
||||
contactId: z.string(),
|
||||
campaignId: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type DeliveryWebhookBody = z.infer<typeof DeliveryWebhookSchema>;
|
||||
export type UnsubscribeTokenPayload = {
|
||||
contactId: string;
|
||||
campaignId?: string;
|
||||
};
|
||||
|
||||
const UnsubscribeTokenPayloadSchema = z.object({
|
||||
contactId: z.string().min(1),
|
||||
campaignId: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Decode + validate an unsubscribe token. The token is a base64-encoded
|
||||
* JSON object carrying `{ contactId, campaignId? }`. Returns the parsed
|
||||
* payload or throws — callers wrap the throw in
|
||||
* `CAMPAIGNS_API.INVALID_UNSUBSCRIBE_TOKEN`.
|
||||
*/
|
||||
export function decodeUnsubscribeToken(token: string): UnsubscribeTokenPayload {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = atob(token);
|
||||
} catch (cause) {
|
||||
throw new Error("token is not valid base64", { cause });
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(decoded);
|
||||
} catch (cause) {
|
||||
throw new Error("token did not decode to JSON", { cause });
|
||||
}
|
||||
const validated = UnsubscribeTokenPayloadSchema.safeParse(parsed);
|
||||
if (!validated.success) {
|
||||
throw new Error("token payload missing contactId");
|
||||
}
|
||||
const payload: UnsubscribeTokenPayload = {
|
||||
contactId: validated.data.contactId,
|
||||
};
|
||||
if (validated.data.campaignId !== undefined) {
|
||||
payload.campaignId = validated.data.campaignId;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type CampaignsPluginOptionsInput,
|
||||
type CampaignsPluginOptionsParsed,
|
||||
CampaignsPluginOptionsSchema,
|
||||
} from "./options.js";
|
||||
@@ -0,0 +1,86 @@
|
||||
# `schemas/options`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `schemas/options` module owns the Zod validator that
|
||||
`createCampaignsPlugin` runs against its caller-supplied options at
|
||||
construction time. The schema doubles as the source of truth for default
|
||||
values: each field declares `.default(...)` so a single `parse` call
|
||||
produces a fully-resolved `CampaignsPluginConfig` regardless of which
|
||||
fields the caller actually passed.
|
||||
|
||||
Routing every option through one schema rather than scattering defaults
|
||||
across the plugin scaffold and route modules is what lets Chunks B and C
|
||||
inherit the validated config shape without redoing the parse. Any
|
||||
follow-on chunk that needs a new option adds the field here (with its
|
||||
default) and the rest of the package picks it up via type inference.
|
||||
|
||||
## Features
|
||||
|
||||
- `CampaignsPluginOptionsSchema` — Zod object schema with default-bearing
|
||||
fields for `routePrefix`, `maxCampaignsPerTenant`,
|
||||
`maxRecipientsPerCampaign`, `defaultThrottlePerSecond`, and
|
||||
`applySchemaOnInit`.
|
||||
- Optional `logger` slot validated against the structural shape of a
|
||||
`LoggerLike` (four function members).
|
||||
- `CampaignsPluginOptionsInput` (input type) and
|
||||
`CampaignsPluginOptionsParsed` (output type) inferred via `z.input` and
|
||||
`z.output` for use at the package boundary.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CampaignsPluginOptionsSchema } from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
const config = CampaignsPluginOptionsSchema.parse({});
|
||||
// config.routePrefix === "/"
|
||||
// config.maxCampaignsPerTenant === 10_000
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { CampaignsPluginOptionsSchema } from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
const parsed = CampaignsPluginOptionsSchema.safeParse({
|
||||
routePrefix: "/api/v1",
|
||||
maxCampaignsPerTenant: 50_000,
|
||||
applySchemaOnInit: false,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
throw parsed.error;
|
||||
}
|
||||
// `parsed.data` is fully resolved with defaults applied to omitted
|
||||
// fields and validations enforced on the supplied fields.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CampaignsPluginOptionsSchema: z.ZodObject<...>` — the validator.
|
||||
- `CampaignsPluginOptionsInput` — input shape (all fields optional).
|
||||
- `CampaignsPluginOptionsParsed` — output shape (all defaults applied).
|
||||
- Validation rules:
|
||||
- `routePrefix` — non-empty string starting with `/`.
|
||||
- `maxCampaignsPerTenant` — positive integer.
|
||||
- `maxRecipientsPerCampaign` — positive integer.
|
||||
- `defaultThrottlePerSecond` — positive integer.
|
||||
- `applySchemaOnInit` — boolean.
|
||||
- `logger` — optional structural `LoggerLike`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema applies defaults rather than expecting upstream code to fill
|
||||
them. That single-source-of-truth pattern matches the rest of the suaveplan
|
||||
plugin family (`@suaveplan/api-plugin-contacts` follows the same shape)
|
||||
and means the resolved config carried on the descriptor's `defaults`
|
||||
slot is always fully populated.
|
||||
|
||||
`z.function()` is the Zod v4 spelling used to validate the four
|
||||
`LoggerLike` members. Marking the field optional (rather than supplying
|
||||
a default `LoggerLike`) is deliberate — the plugin's `onInit` will fall
|
||||
back to `ctx.logger` when the option is omitted, so the schema only
|
||||
needs to attest "if you supplied one, it has the right shape".
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CampaignsPluginOptionsSchema } from "./options.js";
|
||||
|
||||
describe("CampaignsPluginOptionsSchema", () => {
|
||||
it("applies all defaults when given an empty object", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parsed = CampaignsPluginOptionsSchema.parse({});
|
||||
expect(parsed.routePrefix).toBe("/");
|
||||
expect(parsed.maxCampaignsPerTenant).toBe(10_000);
|
||||
expect(parsed.maxRecipientsPerCampaign).toBe(1_000_000);
|
||||
expect(parsed.defaultThrottlePerSecond).toBe(100);
|
||||
expect(parsed.applySchemaOnInit).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves a custom routePrefix", { timeout: 5000 }, () => {
|
||||
const parsed = CampaignsPluginOptionsSchema.parse({
|
||||
routePrefix: "/api/v1",
|
||||
});
|
||||
expect(parsed.routePrefix).toBe("/api/v1");
|
||||
});
|
||||
|
||||
it("rejects routePrefix that does not start with '/'", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = CampaignsPluginOptionsSchema.safeParse({
|
||||
routePrefix: "api",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty routePrefix", { timeout: 5000 }, () => {
|
||||
const result = CampaignsPluginOptionsSchema.safeParse({
|
||||
routePrefix: "",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-positive maxCampaignsPerTenant", { timeout: 5000 }, () => {
|
||||
const result = CampaignsPluginOptionsSchema.safeParse({
|
||||
maxCampaignsPerTenant: 0,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-integer maxRecipientsPerCampaign", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = CampaignsPluginOptionsSchema.safeParse({
|
||||
maxRecipientsPerCampaign: 1.5,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects negative defaultThrottlePerSecond", { timeout: 5000 }, () => {
|
||||
const result = CampaignsPluginOptionsSchema.safeParse({
|
||||
defaultThrottlePerSecond: -1,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts applySchemaOnInit: false", { timeout: 5000 }, () => {
|
||||
const parsed = CampaignsPluginOptionsSchema.parse({
|
||||
applySchemaOnInit: false,
|
||||
});
|
||||
expect(parsed.applySchemaOnInit).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a custom logger object", { timeout: 5000 }, () => {
|
||||
const logger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
const parsed = CampaignsPluginOptionsSchema.parse({ logger });
|
||||
// Zod's `z.function()` wraps the member functions, so identity
|
||||
// is not preserved — assert the structural shape instead.
|
||||
expect(parsed.logger).toBeDefined();
|
||||
const out = parsed.logger as NonNullable<typeof parsed.logger>;
|
||||
expect(typeof out.debug).toBe("function");
|
||||
expect(typeof out.info).toBe("function");
|
||||
expect(typeof out.warn).toBe("function");
|
||||
expect(typeof out.error).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Zod schema for `createCampaignsPlugin(options)`. All fields are
|
||||
* optional at the input boundary; defaults are applied at parse time
|
||||
* so downstream code reads a fully-resolved `CampaignsPluginConfig`
|
||||
* regardless of which fields the caller supplied.
|
||||
*/
|
||||
export const CampaignsPluginOptionsSchema = z.object({
|
||||
routePrefix: z
|
||||
.string()
|
||||
.min(1, "routePrefix must be non-empty")
|
||||
.startsWith("/", "routePrefix must start with '/'")
|
||||
.default("/"),
|
||||
maxCampaignsPerTenant: z
|
||||
.number()
|
||||
.int("maxCampaignsPerTenant must be an integer")
|
||||
.positive("maxCampaignsPerTenant must be > 0")
|
||||
.default(10_000),
|
||||
maxRecipientsPerCampaign: z
|
||||
.number()
|
||||
.int("maxRecipientsPerCampaign must be an integer")
|
||||
.positive("maxRecipientsPerCampaign must be > 0")
|
||||
.default(1_000_000),
|
||||
defaultThrottlePerSecond: z
|
||||
.number()
|
||||
.int("defaultThrottlePerSecond must be an integer")
|
||||
.positive("defaultThrottlePerSecond must be > 0")
|
||||
.default(100),
|
||||
applySchemaOnInit: z.boolean().default(true),
|
||||
logger: z
|
||||
.object({
|
||||
debug: z.function(),
|
||||
info: z.function(),
|
||||
warn: z.function(),
|
||||
error: z.function(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/** Inferred input shape with all fields optional. */
|
||||
export type CampaignsPluginOptionsInput = z.input<
|
||||
typeof CampaignsPluginOptionsSchema
|
||||
>;
|
||||
/** Inferred output shape with defaults applied. */
|
||||
export type CampaignsPluginOptionsParsed = z.output<
|
||||
typeof CampaignsPluginOptionsSchema
|
||||
>;
|
||||
@@ -0,0 +1 @@
|
||||
import "./registry-augmentation.js";
|
||||
@@ -0,0 +1,89 @@
|
||||
# `services/registry-augmentation`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `services/registry-augmentation` module owns the
|
||||
`ServiceRegistry` declaration-merge augmentation applied by
|
||||
`@suaveplan/api-plugin-campaigns`. Importing the module for its side
|
||||
effects (which the package root barrel does automatically) reserves the
|
||||
host-side service slots (`app`, `openAPIRegistry`, `logger`,
|
||||
`eventBus`, `clock`) so the plugin's lifecycle hooks can resolve them
|
||||
via `ctx.getService(...)` without a cast and without depending on
|
||||
which other plugin happens to register the slot first.
|
||||
|
||||
The slots declared here are the minimum baseline that Chunk A needs.
|
||||
Chunks B and C will extend the same interface with
|
||||
`campaignRepository`, `campaignSuppressionStore`, and
|
||||
`deliveryEventStore` slots once the repository classes exist; that
|
||||
extension lands in a new module so this file stays untouched across
|
||||
the chunked migration.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure declaration-merge — zero runtime cost, zero bundle size.
|
||||
- Re-exports an empty value (`export {}`) so the module is a true ESM
|
||||
module and the `declare module` block applies.
|
||||
- Type-only imports from `@suaveplan/api-openapi`,
|
||||
`@hono/zod-openapi`, and `@suaveplan/types`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import "@suaveplan/api-plugin-campaigns";
|
||||
import type { ServiceRegistry } from "@suaveplan/api-plugin/service-registry";
|
||||
|
||||
declare const reg: ServiceRegistry;
|
||||
reg.app; // OpenAPIHono
|
||||
reg.logger; // LoggerLike
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", /* OpenAPIHono instance */ undefined);
|
||||
services.set("openAPIRegistry", /* OpenAPIRegistry instance */ undefined);
|
||||
|
||||
const ctx = createPluginContext({
|
||||
logger: console as never,
|
||||
eventBus: { emit: () => undefined, on: () => () => undefined } as never,
|
||||
signal: new AbortController().signal,
|
||||
config: {} as never,
|
||||
services,
|
||||
});
|
||||
const app = ctx.getService("app"); // typed as OpenAPIHono | undefined
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
Adds the following slots to `ServiceRegistry`:
|
||||
|
||||
- `app: OpenAPIHono`
|
||||
- `openAPIRegistry: OpenAPIRegistry`
|
||||
- `logger: LoggerLike`
|
||||
- `eventBus: EventBusLike`
|
||||
- `clock: Clock`
|
||||
|
||||
No runtime exports.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The augmentation declares the slots as required, not optional. That is
|
||||
the right shape even for plugins that consume only a subset of slots:
|
||||
TypeScript declaration merging unions slots from every importer, so
|
||||
declaring a slot here does not force every other plugin in the host to
|
||||
register it — `ctx.getService(name)` always returns
|
||||
`ServiceRegistry[K] | undefined` and the caller decides how to handle a
|
||||
missing entry.
|
||||
|
||||
The empty `export {}` at the bottom is required for the file to count
|
||||
as a module rather than a script; without it, the `declare module`
|
||||
block silently no-ops. Chunks B and C augment the same registry from
|
||||
their own modules, leveraging declaration merging.
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* `ServiceRegistry` module augmentation for
|
||||
* `@suaveplan/api-plugin-campaigns`. Importing this module for side
|
||||
* effects (which the package root barrel does automatically) reserves
|
||||
* the host-side slots (`app`, `db`, `openAPIRegistry`,
|
||||
* `migrationRunner`, `logger`, `eventBus`, `clock`) so the plugin's
|
||||
* `onInit` can resolve them with full typing even when this plugin is
|
||||
* imported in isolation. Chunk C populates the repository slots
|
||||
* (`campaignRepository`, `campaignSuppressionStore`,
|
||||
* `deliveryEventStore`) at runtime via `ctx.registerService(...)`.
|
||||
*/
|
||||
|
||||
import type { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import type { MigrationRunner } from "@suaveplan/api-migrations";
|
||||
import type { OpenAPIRegistry } from "@suaveplan/api-openapi";
|
||||
import type { CampaignStore } from "@suaveplan/campaigns";
|
||||
import type { DeliveryEventStore } from "@suaveplan/delivery-tracking";
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import type { Clock, EventBusLike, LoggerLike } from "@suaveplan/types";
|
||||
import type { CampaignSuppressionStore } from "../repositories/suppression/suppression.types.js";
|
||||
|
||||
declare module "@suaveplan/api-plugin/service-registry" {
|
||||
interface ServiceRegistry {
|
||||
app: OpenAPIHono;
|
||||
openAPIRegistry: OpenAPIRegistry;
|
||||
logger: LoggerLike;
|
||||
eventBus: EventBusLike;
|
||||
clock: Clock;
|
||||
db?: SurrealAdapter;
|
||||
migrationRunner?: MigrationRunner;
|
||||
// Chunk C populates these at runtime via `ctx.registerService`.
|
||||
// They remain optional so consumers can import the plugin in
|
||||
// isolation (without an `onInit` having fired) and still type-check.
|
||||
campaignRepository?: CampaignStore;
|
||||
campaignSuppressionStore?: CampaignSuppressionStore;
|
||||
deliveryEventStore?: DeliveryEventStore;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type { CampaignsPluginConfig, CampaignsPluginOptions } from "./types.js";
|
||||
@@ -0,0 +1,77 @@
|
||||
# `types`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `types` module exports the public type bundle for
|
||||
`@suaveplan/api-plugin-campaigns` — the option-input shape consumers pass
|
||||
to `createCampaignsPlugin`, and the fully-resolved config shape stored on
|
||||
the plugin descriptor's `defaults` slot. Keeping the two shapes distinct
|
||||
matters because the input has every field optional (the Zod schema fills
|
||||
defaults at parse time) while the resolved config has every required
|
||||
field present, so the rest of the package can read it without nullish
|
||||
chaining.
|
||||
|
||||
Hono `ContextVariableMap` augmentations and `ServiceRegistry`
|
||||
augmentations live in their own `context/` and `services/` modules
|
||||
respectively, so this file stays declaration-only. That separation is
|
||||
important during the chunked migration: Chunk B and Chunk C will add
|
||||
new repository-typed slots to the registry without touching the
|
||||
public-API shapes exported here.
|
||||
|
||||
## Features
|
||||
|
||||
- `CampaignsPluginConfig` — fully-resolved, readonly config record.
|
||||
- `CampaignsPluginOptions` — raw, all-optional input shape.
|
||||
- Both interfaces are pure type declarations and ship zero runtime code.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type {
|
||||
CampaignsPluginConfig,
|
||||
CampaignsPluginOptions,
|
||||
} from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
const opts: CampaignsPluginOptions = { routePrefix: "/api" };
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { CampaignsPluginConfig } from "@suaveplan/api-plugin-campaigns";
|
||||
|
||||
function describeConfig(cfg: CampaignsPluginConfig): string {
|
||||
return `mount=${cfg.routePrefix} cap=${cfg.maxCampaignsPerTenant}`;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface CampaignsPluginConfig`
|
||||
- `routePrefix: string` — route mount prefix.
|
||||
- `maxCampaignsPerTenant: number` — soft cap on retained campaigns.
|
||||
- `maxRecipientsPerCampaign: number` — recipient ceiling per campaign.
|
||||
- `defaultThrottlePerSecond: number` — dispatch throttle ceiling.
|
||||
- `applySchemaOnInit: boolean` — toggle for the bundled DDL apply
|
||||
step (wired by Chunk B/C).
|
||||
- `logger?: LoggerLike` — optional override logger.
|
||||
- `interface CampaignsPluginOptions` — same fields, all optional.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Both interfaces are `readonly` because once Zod has parsed the input,
|
||||
the rest of the package treats the resulting config as immutable.
|
||||
Marking the fields `readonly` at the type level catches the common
|
||||
mistake of mutating the resolved config inside `onInit`. The logger
|
||||
field's `?` modifier is preserved across both shapes so consumers can
|
||||
spread one into the other without TypeScript inferring `undefined` into
|
||||
a required slot.
|
||||
|
||||
The `applySchemaOnInit` toggle is part of the Chunk-A surface even
|
||||
though no Chunk-A code path consumes it — exposing the field now means
|
||||
Chunk B can wire it without bumping the public type signature. The
|
||||
no-op `onInit` placeholder simply ignores the flag.
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Public type bundle for `@suaveplan/api-plugin-campaigns`. Only the
|
||||
* config + descriptor shapes that consumers depend on at compile time
|
||||
* live here. Hono `ContextVariableMap` augmentations live in
|
||||
* `../context/context.ts`; ServiceRegistry augmentations live in
|
||||
* `../services/registry-augmentation.ts`.
|
||||
*/
|
||||
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
|
||||
/** Validated config carried by the descriptor's `defaults` slot. */
|
||||
export interface CampaignsPluginConfig {
|
||||
/** Route mount prefix (default `"/"`). */
|
||||
readonly routePrefix: string;
|
||||
/** Maximum number of campaigns retained per tenant (soft cap). */
|
||||
readonly maxCampaignsPerTenant: number;
|
||||
/** Maximum number of recipients accepted in a single campaign. */
|
||||
readonly maxRecipientsPerCampaign: number;
|
||||
/** Per-second send throttle ceiling applied at dispatch time. */
|
||||
readonly defaultThrottlePerSecond: number;
|
||||
/** When `true`, plugin `onInit` applies the bundled SurrealQL schema (Chunk B/C wire this). */
|
||||
readonly applySchemaOnInit: boolean;
|
||||
/** Optional override logger; falls back to `ctx.logger`. */
|
||||
readonly logger?: LoggerLike;
|
||||
}
|
||||
|
||||
/** Raw options accepted by `createCampaignsPlugin`. All fields optional. */
|
||||
export interface CampaignsPluginOptions {
|
||||
readonly routePrefix?: string;
|
||||
readonly maxCampaignsPerTenant?: number;
|
||||
readonly maxRecipientsPerCampaign?: number;
|
||||
readonly defaultThrottlePerSecond?: number;
|
||||
readonly applySchemaOnInit?: boolean;
|
||||
readonly logger?: LoggerLike;
|
||||
}
|
||||
Reference in New Issue
Block a user