first commit
This commit is contained in:
@@ -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,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,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,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.
|
||||
Reference in New Issue
Block a user