Files
skills/docs/api-stack/api/api-plugin-campaigns/src/repositories/suppression/suppression.md
2026-05-29 08:20:56 +01:00

4.4 KiB

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

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

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.