Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1 @@
export { OptOutRegistry } from "./opt-out.js";

View File

@@ -0,0 +1,87 @@
# `opt-out`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`opt-out` is the in-memory opt-out registry that `SmsService`
consults before every outbound message. Recipients on the
registry receive no further messages — `service.send` throws
`SmsRecipientOptedOutError` (code `SMS.RECIPIENT_OPTED_OUT`).
Entries can be scoped to a channel so transactional messages
remain deliverable when only marketing has been opted out by the
host application.
The registry is intentionally pluggable but ships as an in-process
`Map` so unit tests and small deployments need no external state;
production hosts that share opt-out across processes can wrap
`OptOutRegistry` in their own persistence layer.
## Features
- `add(phoneNumber, channel?)` — records the opt-out with a
timestamp from an injectable `Clock`.
- `remove(phoneNumber)` — opt-in helper.
- `has(phoneNumber)` and `get(phoneNumber)` — predicate +
detail accessors.
- `list()` snapshot, `size()`, and `clear()` for bulk operations.
- `importEntries(entries)` — bulk import with overwrite-by-id
semantics for syncing from an external source.
## Usage: Basic
```ts
import { OptOutRegistry } from "@suaveplan/sms/opt-out";
const reg = new OptOutRegistry();
reg.add("+15551234567", "marketing");
console.log(reg.has("+15551234567")); // true
reg.remove("+15551234567");
```
## Usage: Advanced
```ts
import { OptOutRegistry } from "@suaveplan/sms/opt-out";
// Inject a deterministic clock for tests; importEntries
// reseeds the registry from a downstream cache.
const reg = new OptOutRegistry({ clock: () => 1_700_000_000_000 });
reg.importEntries([
{
phoneNumber: "+15550000001",
optedOutAt: new Date(1_700_000_000_000),
channel: "marketing",
},
{
phoneNumber: "+15550000002",
optedOutAt: new Date(1_700_000_000_000),
},
]);
console.log(reg.size()); // 2
```
## API Reference
- `class OptOutRegistry`
- `constructor(options?: { clock?: Clock })`
- `add(phoneNumber: string, channel?: string): OptOutEntry`
- `remove(phoneNumber: string): boolean`
- `has(phoneNumber: string): boolean`
- `get(phoneNumber: string): OptOutEntry | undefined`
- `list(): ReadonlyArray<OptOutEntry>`
- `importEntries(entries: ReadonlyArray<OptOutEntry>): void`
- `clear(): void`
- `size(): number`
## Implementation Notes
The registry stores `OptOutEntry` records keyed by the verbatim
phone number — no normalisation. Callers SHOULD pass E.164
strings; mismatched formats will fail to match. `add` overwrites
existing entries by `phoneNumber`, so a caller switching the
`channel` on an existing opt-out simply re-adds. The `clock`
defaults to `Date.now`, deterministic only when explicitly
injected.

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { OptOutRegistry } from "./opt-out.js";
describe("OptOutRegistry", () => {
it("add/remove/has/list/clear", { timeout: 5000 }, () => {
const reg = new OptOutRegistry({ clock: () => 1000 });
const entry = reg.add("+15551234567", "marketing");
expect(entry.phoneNumber).toBe("+15551234567");
expect(entry.channel).toBe("marketing");
expect(entry.optedOutAt.getTime()).toBe(1000);
expect(reg.has("+15551234567")).toBe(true);
expect(reg.size()).toBe(1);
expect(reg.list()).toHaveLength(1);
expect(reg.get("+15551234567")?.channel).toBe("marketing");
expect(reg.remove("+15551234567")).toBe(true);
expect(reg.has("+15551234567")).toBe(false);
expect(reg.remove("+15551234567")).toBe(false);
});
it("omits channel when undefined", { timeout: 5000 }, () => {
const reg = new OptOutRegistry();
const entry = reg.add("+15551234567");
expect(entry.channel).toBeUndefined();
});
it("uses Date.now when no clock injected", { timeout: 5000 }, () => {
const reg = new OptOutRegistry();
const before = Date.now();
const entry = reg.add("+15551234567");
const after = Date.now();
expect(entry.optedOutAt.getTime()).toBeGreaterThanOrEqual(before);
expect(entry.optedOutAt.getTime()).toBeLessThanOrEqual(after);
});
it("importEntries overwrites existing", { timeout: 5000 }, () => {
const reg = new OptOutRegistry();
reg.add("+15551234567", "old");
reg.importEntries([
{
phoneNumber: "+15551234567",
optedOutAt: new Date(2000),
channel: "new",
},
{ phoneNumber: "+15559999999", optedOutAt: new Date(3000) },
]);
expect(reg.get("+15551234567")?.channel).toBe("new");
expect(reg.has("+15559999999")).toBe(true);
expect(reg.size()).toBe(2);
});
it("clear drops every entry", { timeout: 5000 }, () => {
const reg = new OptOutRegistry();
reg.add("+15551234567");
reg.add("+15559999999");
reg.clear();
expect(reg.size()).toBe(0);
});
});

View File

@@ -0,0 +1,62 @@
import type { Clock } from "@suaveplan/types";
import type { OptOutEntry } from "../types/sms.types.js";
const defaultClock: Clock = () => Date.now();
/** In-memory opt-out registry used by `SmsService`. */
export class OptOutRegistry {
private readonly entries = new Map<string, OptOutEntry>();
private readonly clock: Clock;
public constructor(options: { clock?: Clock } = {}) {
this.clock = options.clock ?? defaultClock;
}
/** Record an opt-out; overwrites the previous entry for `phoneNumber`. */
public add(phoneNumber: string, channel?: string): OptOutEntry {
const entry: OptOutEntry = {
phoneNumber,
optedOutAt: new Date(this.clock()),
...(channel !== undefined ? { channel } : {}),
};
this.entries.set(phoneNumber, entry);
return entry;
}
/** Remove an opt-out entry; returns `true` iff a row was removed. */
public remove(phoneNumber: string): boolean {
return this.entries.delete(phoneNumber);
}
/** Return `true` iff the recipient is opted out. */
public has(phoneNumber: string): boolean {
return this.entries.has(phoneNumber);
}
/** Look up the opt-out entry; returns `undefined` if not opted out. */
public get(phoneNumber: string): OptOutEntry | undefined {
return this.entries.get(phoneNumber);
}
/** Snapshot every opt-out entry. */
public list(): ReadonlyArray<OptOutEntry> {
return [...this.entries.values()];
}
/** Bulk-import entries; existing entries are overwritten by `phoneNumber`. */
public importEntries(entries: ReadonlyArray<OptOutEntry>): void {
for (const entry of entries) {
this.entries.set(entry.phoneNumber, entry);
}
}
/** Drop every entry. */
public clear(): void {
this.entries.clear();
}
/** Total entry count. */
public size(): number {
return this.entries.size;
}
}