# `repositories/suppression` _(no submodules)_ ## 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` 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.