first commit
This commit is contained in:
@@ -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