first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.