Initial Commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { OptOutRegistry } from "./opt-out.js";
|
||||
87
packages/communication/messaging/sms/src/opt-out/opt-out.md
Normal file
87
packages/communication/messaging/sms/src/opt-out/opt-out.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
62
packages/communication/messaging/sms/src/opt-out/opt-out.ts
Normal file
62
packages/communication/messaging/sms/src/opt-out/opt-out.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user