From 8c2e645339aaa8c084df8d90a67e57b7c9801232 Mon Sep 17 00:00:00 2001 From: zarah Date: Tue, 20 Oct 2026 14:54:30 +0100 Subject: [PATCH] feat(notifications): add SMS consent ledger with source attribution Record phone-level SMS consent with provenance (web form, inbound STOP/START, admin) and an append-only audit trail, honouring opt-outs in the shared provider store. Adds the /api/sms-consent router, migration 016, an on-device store and an SMS Consent screen linked from Settings. --- backend/server.ts | 12 + backend/services/container.ts | 13 + .../__tests__/smsConsentRouter.test.ts | 189 ++++++++++ .../__tests__/smsConsentService.test.ts | 176 ++++++++++ .../services/notification/smsConsentRouter.ts | 145 ++++++++ .../notification/smsConsentService.ts | 295 ++++++++++++++++ .../notification/smsInboundHandler.ts | 32 ++ db/migrations/016_sms_consent.sql | 37 ++ docs/NOTIFICATIONS.md | 44 +++ src/navigation/modules/SettingsStack.tsx | 6 + src/navigation/types.ts | 1 + src/screens/SettingsScreen.tsx | 6 + src/screens/SmsConsentScreen.tsx | 324 ++++++++++++++++++ .../__tests__/SmsConsentScreen.test.tsx | 160 +++++++++ src/store/__tests__/smsConsentStore.test.ts | 165 +++++++++ src/store/smsConsentStore.ts | 133 +++++++ 16 files changed, 1738 insertions(+) create mode 100644 backend/services/notification/__tests__/smsConsentRouter.test.ts create mode 100644 backend/services/notification/__tests__/smsConsentService.test.ts create mode 100644 backend/services/notification/smsConsentRouter.ts create mode 100644 backend/services/notification/smsConsentService.ts create mode 100644 db/migrations/016_sms_consent.sql create mode 100644 src/screens/SmsConsentScreen.tsx create mode 100644 src/screens/__tests__/SmsConsentScreen.test.tsx create mode 100644 src/store/__tests__/smsConsentStore.test.ts create mode 100644 src/store/smsConsentStore.ts diff --git a/backend/server.ts b/backend/server.ts index 8e4f29a0..9cbae5ac 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -61,6 +61,8 @@ import { createLoggerFor } from './services/shared/logging'; import { createTracerFromEnv, type Tracer } from './services/shared/tracing'; import { createScheduledReportService } from './services/notification/scheduledReportService'; import { createScheduledReportRouter } from './services/notification/scheduledReportRouter'; +import { createSmsConsentRouter } from './services/notification/smsConsentRouter'; +import { smsConsentService } from './services/container'; import { createScheduledReportPoller } from './services/notification/jobs/scheduledReportJob'; import { createEmailProviderFromEnv } from './services/notification/emailProvider'; import type { ReportAnalyticsSnapshot } from './services/notification/scheduledReportService'; @@ -493,6 +495,8 @@ export async function startServer(options: StartServerOptions = {}): Promise, + method: string, + pathname: string, + body?: Record +): Promise { + const handlers: Record void>> = {}; + const req = { + method, + on(event: string, cb: (arg?: unknown) => void) { + (handlers[event] ??= []).push(cb); + return this; + }, + } as unknown as http.IncomingMessage; + + const captured: Captured = { status: 0, body: undefined }; + const res = { + writeHead(status: number) { + captured.status = status; + return this; + }, + end(payload?: string) { + captured.body = payload ? JSON.parse(payload) : undefined; + return this; + }, + } as unknown as http.ServerResponse; + + const handled = router(req, res, pathname); + + // Feed the request body (or EOF) so `readJsonBody` settles. + if (body !== undefined) { + const raw = Buffer.from(JSON.stringify(body)); + for (const cb of handlers['data'] ?? []) cb(raw); + } + for (const cb of handlers['end'] ?? []) cb(); + + await handled; + return captured; +} + +function makeService() { + return new SmsConsentService({ repository: new InMemorySmsConsentRepository() }); +} + +describe('createSmsConsentRouter', () => { + let service: SmsConsentService; + let router: ReturnType; + + beforeEach(() => { + service = makeService(); + router = createSmsConsentRouter(service); + }); + + it('ignores paths outside its prefix', async () => { + const req = { method: 'GET', on: () => req } as unknown as http.IncomingMessage; + const res = {} as http.ServerResponse; + expect(await router(req, res, '/api/other')).toBe(false); + }); + + it('returns the current consent record', async () => { + await service.optIn('+15550001111', 'web_form', { userId: 'user-1' }); + const { status, body } = await invoke(router, 'GET', '/api/sms-consent/+15550001111'); + expect(status).toBe(200); + expect(body).toMatchObject({ phone: '+15550001111', status: 'opted_in', source: 'web_form' }); + }); + + it('returns 404 for an unknown number', async () => { + const { status, body } = await invoke(router, 'GET', '/api/sms-consent/+19999999999'); + expect(status).toBe(404); + expect(body).toMatchObject({ error: expect.stringContaining('+19999999999') }); + }); + + it('returns the audit trail', async () => { + await service.optOut('+15550001111', 'admin', { reason: 'spam' }); + const { status, body } = await invoke(router, 'GET', '/api/sms-consent/+15550001111/events'); + expect(status).toBe(200); + expect(body).toMatchObject({ + events: [expect.objectContaining({ status: 'opted_out', source: 'admin', reason: 'spam' })], + }); + }); + + it('lists records owned by a user', async () => { + await service.optIn('+15550001111', 'web_form', { userId: 'user-1' }); + await service.optIn('+15550002222', 'web_form', { userId: 'user-2' }); + const { status, body } = await invoke(router, 'GET', '/api/sms-consent/user/user-1'); + expect(status).toBe(200); + expect(body).toMatchObject({ consents: [expect.objectContaining({ phone: '+15550001111' })] }); + }); + + it('records an opt-out from a POST body', async () => { + const { status, body } = await invoke(router, 'POST', '/api/sms-consent/+15550001111/opt-out', { + source: 'admin', + reason: 'requested', + userId: 'user-1', + }); + expect(status).toBe(200); + expect(body).toMatchObject({ status: 'opted_out', source: 'admin', reason: 'requested' }); + expect(await service.isOptedOut('+15550001111')).toBe(true); + }); + + it('defaults the opt-in source to web_form', async () => { + const { body } = await invoke(router, 'POST', '/api/sms-consent/+15550001111/opt-in', {}); + expect(body).toMatchObject({ status: 'opted_in', source: 'web_form' }); + }); + + it('rejects an unknown consent source', async () => { + const { status, body } = await invoke(router, 'POST', '/api/sms-consent/+15550001111/opt-out', { + source: 'carrier_pigeon', + }); + expect(status).toBe(400); + expect(body).toMatchObject({ error: expect.stringContaining('carrier_pigeon') }); + }); + + it('rejects malformed JSON', async () => { + const handlers: Record void>> = {}; + const req = { + method: 'POST', + on(event: string, cb: (arg?: unknown) => void) { + (handlers[event] ??= []).push(cb); + return this; + }, + } as unknown as http.IncomingMessage; + let status = 0; + let body: unknown; + const res = { + writeHead(s: number) { + status = s; + return this; + }, + end(payload?: string) { + body = payload ? JSON.parse(payload) : undefined; + return this; + }, + } as unknown as http.ServerResponse; + + const handled = router(req, res, '/api/sms-consent/+15550001111/opt-in'); + for (const cb of handlers['data'] ?? []) cb(Buffer.from('{not json')); + for (const cb of handlers['end'] ?? []) cb(); + await handled; + + expect(status).toBe(400); + expect(body).toMatchObject({ error: expect.stringContaining('Invalid JSON') }); + }); + + it('rejects a request with no phone segment', async () => { + const req = { method: 'GET', on: () => req } as unknown as http.IncomingMessage; + let status = 0; + let body: unknown; + const res = { + writeHead(s: number) { + status = s; + return this; + }, + end(payload?: string) { + body = payload ? JSON.parse(payload) : undefined; + return this; + }, + } as unknown as http.ServerResponse; + await router(req, res, '/api/sms-consent'); + expect(status).toBe(400); + expect(body).toMatchObject({ error: expect.any(String) }); + }); +}); diff --git a/backend/services/notification/__tests__/smsConsentService.test.ts b/backend/services/notification/__tests__/smsConsentService.test.ts new file mode 100644 index 00000000..8e471ccb --- /dev/null +++ b/backend/services/notification/__tests__/smsConsentService.test.ts @@ -0,0 +1,176 @@ +/** + * SMS consent ledger (#1747) + * + * Covers: consent recording with provenance, opt-out honoured by the shared + * registry, express-opt-in latching, idempotent replays, the append-only audit + * trail, per-user listing, and phone normalisation. + */ + +import { describe, expect, it, jest, beforeEach } from '@jest/globals'; +import { + InMemorySmsConsentRepository, + SmsConsentService, + normalisePhone, + type OptOutRegistry, +} from '../smsConsentService'; + +/** Minimal in-memory stand-in for the shared provider opt-out registry. */ +function makeRegistry(): OptOutRegistry & { optedOut: Set } { + const optedOut = new Set(); + return { + optedOut, + optOut: (phone) => void optedOut.add(phone), + optIn: (phone) => void optedOut.delete(phone), + isOptedOut: (phone) => optedOut.has(phone), + }; +} + +function makeService(now = new Date('2026-01-01T00:00:00.000Z')) { + const repository = new InMemorySmsConsentRepository(); + const registry = makeRegistry(); + const clock = jest.fn(() => now); + const service = new SmsConsentService({ repository, optOutRegistry: registry, now: clock }); + return { service, repository, registry, clock }; +} + +describe('normalisePhone', () => { + it('strips formatting characters', () => { + expect(normalisePhone('+1 (555) 000-1111')).toBe('+15550001111'); + expect(normalisePhone('1.555.000.1111')).toBe('15550001111'); + }); + + it('preserves a leading plus and nothing else', () => { + expect(normalisePhone('0015550001111')).toBe('0015550001111'); + expect(normalisePhone('+ 1 555 000 1111')).toBe('+15550001111'); + }); +}); + +describe('SmsConsentService.recordConsent', () => { + let ctx: ReturnType; + beforeEach(() => { + ctx = makeService(); + }); + + it('records a consent decision with its source', async () => { + const { consent, changed } = await ctx.service.recordConsent({ + phone: '+1 (555) 000-1111', + status: 'opted_in', + source: 'web_form', + userId: 'user-1', + reason: 'signup checkbox', + }); + + expect(changed).toBe(true); + expect(consent).toMatchObject({ + phone: '+15550001111', + status: 'opted_in', + source: 'web_form', + userId: 'user-1', + reason: 'signup checkbox', + explicitOptIn: true, + }); + expect(consent.createdAt).toBe('2026-01-01T00:00:00.000Z'); + expect(consent.updatedAt).toBe('2026-01-01T00:00:00.000Z'); + }); + + it('reports changed=false when the status is unchanged', async () => { + await ctx.service.optOut('+15550001111', 'admin'); + const { changed, consent } = await ctx.service.recordConsent({ + phone: '+15550001111', + status: 'opted_out', + source: 'inbound_sms', + }); + expect(changed).toBe(false); + expect(consent.status).toBe('opted_out'); + }); + + it('always appends an audit event, even for a no-op', async () => { + await ctx.service.optOut('+15550001111', 'admin'); + await ctx.service.optOut('+15550001111', 'inbound_sms', { messageSid: 'SM9' }); + const events = await ctx.service.listEvents('+15550001111'); + expect(events).toHaveLength(2); + expect(events[0]).toMatchObject({ source: 'inbound_sms', messageSid: 'SM9' }); + expect(events[1]).toMatchObject({ source: 'admin' }); + }); + + it('keeps the original userId and createdAt across changes', async () => { + await ctx.service.optIn('+15550001111', 'web_form', { userId: 'user-1' }); + const later = new Date('2026-06-01T00:00:00.000Z'); + ctx.clock.mockReturnValue(later); + const consent = await ctx.service.optOut('+15550001111', 'inbound_sms'); + expect(consent.userId).toBe('user-1'); + expect(consent.createdAt).toBe('2026-01-01T00:00:00.000Z'); + expect(consent.updatedAt).toBe('2026-06-01T00:00:00.000Z'); + }); + + it('latches explicitOptIn once set by a non-system source', async () => { + await ctx.service.optIn('+15550001111', 'web_form'); + const afterSystemReset = await ctx.service.recordConsent({ + phone: '+15550001111', + status: 'opted_out', + source: 'system', + }); + expect(afterSystemReset.consent.explicitOptIn).toBe(true); + }); + + it('does not treat a system opt-in as express consent', async () => { + const consent = await ctx.service.optIn('+15550001111', 'system'); + expect(consent.explicitOptIn).toBe(false); + }); +}); + +describe('SmsConsentService opt-out honouring', () => { + it('mirrors an opt-out into the shared registry', async () => { + const { service, registry } = makeService(); + await service.optOut('+15550001111', 'inbound_sms'); + expect(registry.isOptedOut('+15550001111')).toBe(true); + expect(await service.isOptedOut('+15550001111')).toBe(true); + }); + + it('clears the shared registry on opt-in', async () => { + const { service, registry } = makeService(); + await service.optOut('+15550001111', 'inbound_sms'); + await service.optIn('+15550001111', 'inbound_sms'); + expect(registry.isOptedOut('+15550001111')).toBe(false); + expect(await service.isOptedOut('+15550001111')).toBe(false); + }); + + it('normalises the phone before touching the registry', async () => { + const { service, registry } = makeService(); + await service.optOut('+1 (555) 000-1111', 'admin'); + expect(registry.isOptedOut('+15550001111')).toBe(true); + expect(await service.isOptedOut('+1-555-000-1111')).toBe(true); + }); + + it('reports isOptedOut=false for an unknown number', async () => { + const { service } = makeService(); + expect(await service.isOptedOut('+19999999999')).toBe(false); + }); +}); + +describe('SmsConsentService lookups', () => { + it('returns null for an unknown number', async () => { + const { service } = makeService(); + expect(await service.getConsent('+19999999999')).toBeNull(); + }); + + it('lists consent records owned by a user, newest first', async () => { + const { service, clock } = makeService(); + await service.optIn('+15550001111', 'web_form', { userId: 'user-1' }); + clock.mockReturnValue(new Date('2026-02-01T00:00:00.000Z')); + await service.optIn('+15550002222', 'web_form', { userId: 'user-1' }); + await service.optIn('+15550003333', 'web_form', { userId: 'user-2' }); + + const consents = await service.listForUser('user-1'); + expect(consents.map((c) => c.phone)).toEqual(['+15550002222', '+15550001111']); + }); + + it('caps the audit trail to the requested limit, newest first', async () => { + const { service } = makeService(); + for (let i = 0; i < 5; i += 1) { + await service.optOut('+15550001111', 'inbound_sms', { messageSid: `SM${i}` }); + } + const events = await service.listEvents('+15550001111', 3); + expect(events.map((e) => e.messageSid)).toEqual(['SM4', 'SM3', 'SM2']); + }); +}); diff --git a/backend/services/notification/smsConsentRouter.ts b/backend/services/notification/smsConsentRouter.ts new file mode 100644 index 00000000..5d71c064 --- /dev/null +++ b/backend/services/notification/smsConsentRouter.ts @@ -0,0 +1,145 @@ +/** + * smsConsentRouter (#1747) + * + * Plain Node.js HTTP handler for the SMS consent ledger. Mounts under + * `/api/sms-consent` in server.ts. + * + * Routes + * ────── + * GET /api/sms-consent/:phone Current consent record + * GET /api/sms-consent/:phone/events Consent audit trail + * GET /api/sms-consent/user/:userId Records owned by a user + * POST /api/sms-consent/:phone/opt-out Record an opt-out + * POST /api/sms-consent/:phone/opt-in Record an opt-in + * + * The opt-in/opt-out endpoints are for web forms and admin actions; inbound + * STOP/START is written by `SmsInboundHandler` directly. All responses are + * JSON; errors are `{ error: string }`. + */ + +import type http from 'node:http'; + +import { + type ConsentSource, + type SmsConsentService, +} from './smsConsentService'; + +const PREFIX = '/api/sms-consent'; + +const SOURCES: readonly ConsentSource[] = [ + 'web_form', + 'inbound_sms', + 'admin', + 'import', + 'api', + 'system', +]; + +function sendJson(res: http.ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +async function readJsonBody(req: http.IncomingMessage, maxBytes = 32_000): Promise> { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + req.on('data', (chunk: Buffer) => { + total += chunk.length; + if (total > maxBytes) { + reject(new Error('Request body too large')); + return; + } + chunks.push(chunk); + }); + req.on('end', () => { + try { + const raw = Buffer.concat(chunks).toString('utf8').trim(); + resolve(raw ? (JSON.parse(raw) as Record) : {}); + } catch { + reject(new Error('Invalid JSON body')); + } + }); + req.on('error', reject); + }); +} + +/** Split `/api/sms-consent/…` into its path segments. */ +function segments(pathname: string): string[] { + return pathname.slice(PREFIX.length).replace(/^\//, '').split('/').filter(Boolean); +} + +function isSource(value: string): value is ConsentSource { + return (SOURCES as readonly string[]).includes(value); +} + +// ─── Router factory ─────────────────────────────────────────────────────────── + +export function createSmsConsentRouter(service: SmsConsentService) { + return async function handleSmsConsentRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + pathname: string + ): Promise { + const method = req.method ?? 'GET'; + if (!pathname.startsWith(PREFIX)) return false; + + const parts = segments(pathname); + if (parts.length === 0) { + sendJson(res, 400, { error: 'A phone number or /user/:userId is required' }); + return true; + } + + // ── GET /api/sms-consent/user/:userId (records owned by a user) ───────── + if (parts[0] === 'user' && parts.length === 2 && method === 'GET') { + sendJson(res, 200, { consents: await service.listForUser(parts[1]!) }); + return true; + } + + const phone = decodeURIComponent(parts[0]!); + const sub = parts[1]; + + // ── GET /api/sms-consent/:phone (current record) ──────────────────────── + if (!sub && method === 'GET') { + const consent = await service.getConsent(phone); + if (!consent) { + sendJson(res, 404, { error: `No consent record for ${phone}` }); + } else { + sendJson(res, 200, consent); + } + return true; + } + + // ── GET /api/sms-consent/:phone/events (audit trail) ──────────────────── + if (sub === 'events' && method === 'GET') { + sendJson(res, 200, { events: await service.listEvents(phone) }); + return true; + } + + // ── POST /api/sms-consent/:phone/opt-out | /opt-in ─────────────────────── + if ((sub === 'opt-out' || sub === 'opt-in') && method === 'POST') { + try { + const body = await readJsonBody(req); + const rawSource = body['source'] !== undefined ? String(body['source']) : 'web_form'; + if (!isSource(rawSource)) { + sendJson(res, 400, { error: `Unknown source: ${rawSource}` }); + return true; + } + const options = { + userId: body['userId'] !== undefined ? String(body['userId']) : null, + reason: body['reason'] !== undefined ? String(body['reason']) : undefined, + }; + const consent = + sub === 'opt-out' + ? await service.optOut(phone, rawSource, options) + : await service.optIn(phone, rawSource, options); + sendJson(res, 200, consent); + } catch (err) { + sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) }); + } + return true; + } + + return false; + }; +} diff --git a/backend/services/notification/smsConsentService.ts b/backend/services/notification/smsConsentService.ts new file mode 100644 index 00000000..99a76195 --- /dev/null +++ b/backend/services/notification/smsConsentService.ts @@ -0,0 +1,295 @@ +/** + * SMS consent ledger (#1747) + * + * A phone number's SMS consent is a legal record: we must be able to say, for + * any number, *whether* we may text it, *when* that decision was made, *where* + * it came from (a web form, an inbound STOP, an admin action), and *why*. The + * bare `optOutStore` in the provider package answers only the first — it is a + * set of opted-out numbers with no provenance. + * + * This service keeps the missing provenance. Each number has one current + * `SmsConsent` record plus an append-only event log, so a "why did you text + * me?" question is answerable after the fact. It mirrors the event-log shape of + * `CommunicationPreferenceService` (which tracks channel-level opt-outs per + * user) — this one is keyed by *phone number*, because a number can change + * hands before a user id is known. + * + * Opt-out is honoured in two places by design: + * • `optOutStore` (shared package) is written so the low-level send path + * refuses to text an opted-out number even if a caller skips this service. + * • `isOptedOut` lets higher layers check before attempting a send. + */ + +// ─── Types ───────────────────────────────────────────────────────────────────── + +/** + * The slice of the shared provider opt-out registry this service writes to. + * + * Declared here rather than imported from `smsProvider` so the ledger has no + * dependency on the notification-providers package — the container wires the + * real registry in. That also makes the service trivially unit-testable with a + * plain in-memory stub. + */ +export interface OptOutRegistry { + optOut(phoneNumber: string): void; + optIn(phoneNumber: string): void; + isOptedOut(phoneNumber: string): boolean; +} + +export type ConsentStatus = 'opted_in' | 'opted_out'; + +/** Where a consent decision came from. Doubles as the audit `source`. */ +export type ConsentSource = + | 'web_form' + | 'inbound_sms' + | 'admin' + | 'import' + | 'api' + | 'system'; + +export interface SmsConsent { + /** Normalised E.164 number this record belongs to. */ + phone: string; + status: ConsentStatus; + /** Registered owner of the number, when known. */ + userId: string | null; + /** How the current status came to be. */ + source: ConsentSource; + /** Human-readable explanation, e.g. the inbound keyword. */ + reason?: string; + /** True when the number was ever opted in with express consent. */ + explicitOptIn: boolean; + createdAt: string; + updatedAt: string; +} + +export interface SmsConsentEvent { + phone: string; + userId: string | null; + status: ConsentStatus; + source: ConsentSource; + reason?: string; + /** Twilio MessageSid when the change came from an inbound message. */ + messageSid?: string; + at: string; +} + +export interface RecordConsentInput { + phone: string; + status: ConsentStatus; + source: ConsentSource; + userId?: string | null; + reason?: string; + messageSid?: string; +} + +// ─── Normalisation ───────────────────────────────────────────────────────────── + +/** + * Canonicalise a phone number for consent lookups. + * + * Only formatting is normalised — spaces, dashes, parens and dots are stripped + * and a leading `+` preserved. This deliberately does not try to resolve a + * national number to E.164 without a country, since guessing one could map two + * different numbers onto the same consent record. + */ +export function normalisePhone(phone: string): string { + const stripped = phone.replace(/[\s\-().]/g, ''); + return stripped.startsWith('+') ? `+${stripped.slice(1)}` : stripped; +} + +// ─── Repository ──────────────────────────────────────────────────────────────── + +export interface SmsConsentRepository { + get(phone: string): Promise; + save(consent: SmsConsent): Promise; + appendEvent(event: SmsConsentEvent): Promise; + listEvents(phone: string, limit?: number): Promise; + listAllEvents(): Promise; +} + +/** In-memory repository — tests and local development. */ +export class InMemorySmsConsentRepository implements SmsConsentRepository { + private readonly consents = new Map(); + private readonly events: SmsConsentEvent[] = []; + + async get(phone: string): Promise { + const found = this.consents.get(normalisePhone(phone)); + return found ? { ...found } : null; + } + + async save(consent: SmsConsent): Promise { + this.consents.set(consent.phone, { ...consent }); + } + + async appendEvent(event: SmsConsentEvent): Promise { + this.events.push({ ...event }); + } + + async listEvents(phone: string, limit = 50): Promise { + const target = normalisePhone(phone); + return this.events.filter((event) => event.phone === target).slice(-limit).reverse(); + } + + async listAllEvents(): Promise { + return this.events.map((event) => ({ ...event })); + } +} + +// ─── Service ─────────────────────────────────────────────────────────────────── + +export interface SmsConsentServiceDeps { + repository: SmsConsentRepository; + /** Injectable clock keeps timestamps deterministic in tests. */ + now?: () => Date; + /** + * Shared provider opt-out registry. When omitted, consent changes are not + * mirrored to the low-level send path — the container always supplies the + * real one in production. + */ + optOutRegistry?: OptOutRegistry; +} + +export class SmsConsentService { + private readonly repository: SmsConsentRepository; + private readonly now: () => Date; + private readonly optOutRegistry: OptOutRegistry | null; + + constructor(deps: SmsConsentServiceDeps) { + this.repository = deps.repository; + this.now = deps.now ?? (() => new Date()); + this.optOutRegistry = deps.optOutRegistry ?? null; + } + + /** + * Record a consent decision, upserting the current record and appending an + * audit event. + * + * Returns the resulting record and whether the status actually changed, so a + * replayed inbound STOP does not look like a fresh opt-out in the audit. + */ + async recordConsent( + input: RecordConsentInput + ): Promise<{ consent: SmsConsent; changed: boolean }> { + const phone = normalisePhone(input.phone); + const existing = await this.repository.get(phone); + const at = this.now().toISOString(); + + const changed = !existing || existing.status !== input.status; + // Express consent latches: once a number has been opted in deliberately, a + // later preference reset should not silently revoke it. + const explicitOptIn = + existing?.explicitOptIn === true || + (input.status === 'opted_in' && input.source !== 'system'); + + const consent: SmsConsent = { + phone, + status: input.status, + userId: input.userId ?? existing?.userId ?? null, + source: input.source, + reason: input.reason ?? existing?.reason, + explicitOptIn, + createdAt: existing?.createdAt ?? at, + updatedAt: at, + }; + await this.repository.save(consent); + + // Always append an event, even for a no-op, so the attempt is auditable. + await this.repository.appendEvent({ + phone, + userId: consent.userId, + status: input.status, + source: input.source, + reason: input.reason, + messageSid: input.messageSid, + at, + }); + + if (this.optOutRegistry) { + if (input.status === 'opted_out') this.optOutRegistry.optOut(phone); + else this.optOutRegistry.optIn(phone); + } + + return { consent, changed }; + } + + /** Opt a number out (STOP, unsubscribe, do-not-call). */ + async optOut( + phone: string, + source: ConsentSource = 'inbound_sms', + options: { userId?: string | null; reason?: string; messageSid?: string } = {} + ): Promise { + const { consent } = await this.recordConsent({ + phone, + status: 'opted_out', + source, + userId: options.userId, + reason: options.reason, + messageSid: options.messageSid, + }); + return consent; + } + + /** Opt a number back in (START, web form consent). */ + async optIn( + phone: string, + source: ConsentSource = 'inbound_sms', + options: { userId?: string | null; reason?: string; messageSid?: string } = {} + ): Promise { + const { consent } = await this.recordConsent({ + phone, + status: 'opted_in', + source, + userId: options.userId, + reason: options.reason, + messageSid: options.messageSid, + }); + return consent; + } + + async getConsent(phone: string): Promise { + return this.repository.get(phone); + } + + /** True when we must not send SMS to this number. */ + async isOptedOut(phone: string): Promise { + const consent = await this.repository.get(phone); + return consent?.status === 'opted_out'; + } + + /** Consent audit trail for a number, newest first. */ + async listEvents(phone: string, limit = 50): Promise { + return this.repository.listEvents(phone, limit); + } + + /** + * Consent records owned by a user. + * + * The repository lists events, not records, so this reconstructs the owner + * from the most recent event per number that carries a matching `userId`. + */ + async listForUser(userId: string): Promise { + const events = await this.repository.listAllEvents(); + const phones = new Set( + events.filter((event) => event.userId === userId).map((event) => event.phone) + ); + const consents: SmsConsent[] = []; + for (const phone of phones) { + const consent = await this.repository.get(phone); + if (consent) consents.push(consent); + } + return consents.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + } +} + +// ─── Singleton ───────────────────────────────────────────────────────────────── + +/** + * In-memory singleton for local development. + * + * The container re-registers a fully-wired instance (with the shared provider + * opt-out registry) under `ISmsConsentService`; this default has no registry so + * the module stays free of a notification-providers dependency. + */ +export const smsConsentRepository = new InMemorySmsConsentRepository(); +export const smsConsentService = new SmsConsentService({ repository: smsConsentRepository }); diff --git a/backend/services/notification/smsInboundHandler.ts b/backend/services/notification/smsInboundHandler.ts index 60d1c85d..f1964947 100644 --- a/backend/services/notification/smsInboundHandler.ts +++ b/backend/services/notification/smsInboundHandler.ts @@ -28,6 +28,21 @@ import { optOutStore, smsProvider, truncateSms } from './smsProvider'; import type { SmsProvider } from './smsProvider'; import { communicationPreferenceService } from './communicationPreferencesService'; import type { CommunicationPreferenceService } from './communicationPreferencesService'; +import { smsConsentService } from './smsConsentService'; + +/** Minimal consent ledger the handler writes to on STOP/START (#1747). */ +export interface SmsConsentRecorder { + optOut( + phone: string, + source: 'inbound_sms', + options: { userId: string | null; reason: string; messageSid: string } + ): Promise; + optIn( + phone: string, + source: 'inbound_sms', + options: { userId: string | null; reason: string; messageSid: string } + ): Promise; +} // ─── Types ───────────────────────────────────────────────────────────────────── @@ -197,6 +212,8 @@ export interface SmsInboundHandlerDeps { seenMessageSids?: Set; maxSeenMessageSids?: number; now?: () => Date; + /** Phone-level consent ledger (#1747); records the source of a STOP/START. */ + consent?: SmsConsentRecorder; } export class SmsInboundHandler { @@ -208,6 +225,7 @@ export class SmsInboundHandler { private readonly seen: Set; private readonly maxSeen: number; private readonly now: () => Date; + private readonly consent: SmsConsentRecorder; private readonly audit: InboundSmsAudit[] = []; constructor(deps: SmsInboundHandlerDeps) { @@ -219,6 +237,7 @@ export class SmsInboundHandler { this.seen = deps.seenMessageSids ?? new Set(); this.maxSeen = deps.maxSeenMessageSids ?? 5000; this.now = deps.now ?? (() => new Date()); + this.consent = deps.consent ?? smsConsentService; } listAudit(): InboundSmsAudit[] { @@ -287,6 +306,14 @@ export class SmsInboundHandler { // Consent changes are applied before any reply so an opted-out number is // never texted again, even if the reply itself fails. if (command.intent === 'opt_out') { + // The phone-level ledger records *why* and *where* (#1747) before the + // shared opt-out store is touched, so a crash mid-reply cannot lose the + // audit trail for a message we legally must honour. + await this.consent.optOut(inbound.from, 'inbound_sms', { + userId, + reason: `inbound ${command.keyword}`, + messageSid: inbound.messageSid, + }); optOutStore.optOut(inbound.from); if (userId) { await this.preferences.optOutChannel( @@ -298,6 +325,11 @@ export class SmsInboundHandler { } replyBody = 'You are unsubscribed from SubTrackr texts. Reply START to resubscribe.'; } else if (command.intent === 'opt_in') { + await this.consent.optIn(inbound.from, 'inbound_sms', { + userId, + reason: `inbound ${command.keyword}`, + messageSid: inbound.messageSid, + }); optOutStore.optIn(inbound.from); if (userId) { await this.preferences.optInChannel(userId, 'sms', 'inbound_sms'); diff --git a/db/migrations/016_sms_consent.sql b/db/migrations/016_sms_consent.sql new file mode 100644 index 00000000..8992d910 --- /dev/null +++ b/db/migrations/016_sms_consent.sql @@ -0,0 +1,37 @@ +-- SMS consent ledger (#1747). +-- One current consent record per phone number plus an append-only audit trail, +-- so we can answer "may we text this number, since when, and why". +BEGIN; +CREATE TABLE IF NOT EXISTS sms_consents ( + id BIGSERIAL PRIMARY KEY, + phone TEXT NOT NULL, + user_id TEXT, + status TEXT NOT NULL CHECK (status IN ('opted_in', 'opted_out')), + source TEXT NOT NULL, + reason TEXT, + explicit_opt_in BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (phone) +); +CREATE INDEX IF NOT EXISTS idx_sms_consents_user ON sms_consents(user_id); +CREATE INDEX IF NOT EXISTS idx_sms_consents_status ON sms_consents(status); + +CREATE TABLE IF NOT EXISTS sms_consent_events ( + id BIGSERIAL PRIMARY KEY, + phone TEXT NOT NULL, + user_id TEXT, + status TEXT NOT NULL CHECK (status IN ('opted_in', 'opted_out')), + source TEXT NOT NULL, + reason TEXT, + message_sid TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_sms_consent_events_phone ON sms_consent_events(phone, created_at); +COMMIT; + +-- @down +BEGIN; +DROP TABLE IF EXISTS sms_consent_events; +DROP TABLE IF EXISTS sms_consents; +COMMIT; diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md index 94d20161..182961c7 100644 --- a/docs/NOTIFICATIONS.md +++ b/docs/NOTIFICATIONS.md @@ -201,12 +201,56 @@ delivery, which the settings screen surfaces as "you open Email most". Suppressed notifications are counted but excluded from the delivery rate: they never reached a transport, so they say nothing about deliverability. +## SMS consent + +A phone number's SMS consent is a legal record, so it is kept separately from +the per-channel preference flags. `SmsConsentService` stores one current +`SmsConsent` record per number plus an append-only event log: + +| Field | Meaning | +| ---------------- | -------------------------------------------------------- | +| `status` | `opted_in` or `opted_out` | +| `source` | `web_form`, `inbound_sms`, `admin`, `import`, `api`, `system` | +| `reason` | Free-text explanation (e.g. `inbound STOP`) | +| `explicitOptIn` | Latches true once a number is deliberately opted in | +| `createdAt` / `updatedAt` | First seen / last change | + +Recording a decision always appends an audit event, even when the status is +unchanged, so a replayed inbound STOP is still attributable. The `source` +field is the answer to "where did this consent come from" — the bare +`optOutStore` in the provider package records only *that* a number is opted +out, not *when*, *why* or *by whom*. + +Opt-out is honoured in two places: + +- The shared provider `optOutStore` is updated, so the low-level send path + refuses an opted-out number even if a caller skips the ledger. +- `isOptedOut(phone)` lets higher layers check before attempting a send. + +Inbound `STOP` / `START` keywords are recorded by `SmsInboundHandler` with the +message SID and the matched keyword as the reason, before the reply is sent. +Web forms and admin actions use the router: + +| Route | Purpose | +| -------------------------------------------- | ------------------------------ | +| `GET /api/sms-consent/:phone` | Current consent record | +| `GET /api/sms-consent/:phone/events` | Consent audit trail | +| `GET /api/sms-consent/user/:userId` | Records owned by a user | +| `POST /api/sms-consent/:phone/opt-out` | Record an opt-out | +| `POST /api/sms-consent/:phone/opt-in` | Record an opt-in | + +The device mirrors the ledger in `src/store/smsConsentStore.ts` and surfaces it +on the SMS Consent screen (Settings → Privacy). + ## Testing ```bash # Notification domain npx jest -c jest.backend.config.js backend/services/notification/__tests__/notificationCenterService.test.ts +npx jest -c jest.backend.config.js backend/services/notification/__tests__/smsConsentService.test.ts +npx jest -c jest.backend.config.js backend/services/notification/__tests__/smsConsentRouter.test.ts # Client store and delivery path +npx jest --config jest.backend.config.js --testMatch '**/src/store/__tests__/smsConsentStore.test.ts' npx jest src/store/__tests__/notificationPreferencesStore.test.ts ``` diff --git a/src/navigation/modules/SettingsStack.tsx b/src/navigation/modules/SettingsStack.tsx index 652e1820..e01a6e49 100644 --- a/src/navigation/modules/SettingsStack.tsx +++ b/src/navigation/modules/SettingsStack.tsx @@ -20,6 +20,7 @@ const EmailTemplateEditorScreen = lazyScreen( const GDPRSettingsScreen = lazyScreen(() => import('../../screens/GDPRSettingsScreen')); const PrivacyCenterScreen = lazyScreen(() => import('../../screens/PrivacyCenterScreen')); const ConsentManagementScreen = lazyScreen(() => import('../../screens/ConsentManagementScreen')); +const SmsConsentScreen = lazyScreen(() => import('../../screens/SmsConsentScreen')); const DataExportScreen = lazyScreen(() => import('../../screens/DataExportScreen')); const TaxSettingsScreen = lazyScreen(() => import('../../screens/TaxSettingsScreen')); const TaxComplianceScreen = lazyScreen(() => import('../../screens/TaxComplianceScreen')); @@ -64,6 +65,11 @@ export const SettingsStack = () => ( component={ConsentManagementScreen} options={{ title: 'Consent Preferences', headerShown: true }} /> + { onPress={() => navigation.navigate('ConsentManagement')}> Consent Preferences + navigation.navigate('SmsConsent')}> + SMS Consent + (path: string, options?: { method?: string; body?: unknown }): Promise>; +} + +export interface SmsConsentScreenProps { + userId?: string; + client?: SmsConsentClient; + service?: SmsConsentService; +} + +/** Server-side consent operations, wrapped so they can be faked in tests. */ +export interface SmsConsentService { + listForUser(userId: string): Promise; + historyFor(phone: string): Promise; + setStatus( + phone: string, + status: 'opted_in' | 'opted_out', + source: 'web_form' | 'admin' + ): Promise; +} + +export function createSmsConsentService(client: SmsConsentClient = apiClient): SmsConsentService { + return { + async listForUser(userId) { + const res = await client.request<{ consents: SmsConsentRecord[] }>( + `/api/sms-consent/user/${encodeURIComponent(userId)}` + ); + return res.data?.consents ?? []; + }, + async historyFor(phone) { + const res = await client.request<{ events: SmsConsentEvent[] }>( + `/api/sms-consent/${encodeURIComponent(phone)}/events` + ); + return res.data?.events ?? []; + }, + async setStatus(phone, status, source) { + const res = await client.request( + `/api/sms-consent/${encodeURIComponent(phone)}/${status === 'opted_out' ? 'opt-out' : 'opt-in'}`, + { method: 'POST', body: { source } } + ); + return res.data; + }, + }; +} + +const SOURCE_LABELS: Record = { + web_form: 'Web form', + inbound_sms: 'Text message', + admin: 'Admin', + import: 'Imported', + api: 'API', + system: 'System', +}; + +function formatTimestamp(value: string): string { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); +} + +const SmsConsentScreen: React.FC = ({ + userId = 'current_user', + client, + service, +}) => { + const svc = service ?? createSmsConsentService(client); + const { consents, ingest, applyConsent, historyFor } = useSmsConsentStore(); + + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [busyPhone, setBusyPhone] = useState(null); + const [expanded, setExpanded] = useState(null); + + useEffect(() => { + let active = true; + svc + .listForUser(userId) + .then((records) => { + if (!active) return; + ingest(records); + setError(null); + }) + .catch((err: unknown) => { + if (!active) return; + setError(err instanceof Error ? err.message : 'Failed to load consent records'); + }) + .finally(() => { + if (!active) return; + setLoading(false); + }); + return () => { + active = false; + }; + }, [svc, userId, ingest]); + + const onRefresh = useCallback(() => { + setRefreshing(true); + svc + .listForUser(userId) + .then((records) => { + ingest(records); + setError(null); + }) + .catch((err: unknown) => + setError(err instanceof Error ? err.message : 'Failed to load consent records') + ) + .finally(() => setRefreshing(false)); + }, [svc, userId, ingest]); + + const toggleStatus = useCallback( + async (record: SmsConsentRecord) => { + const next = record.status === 'opted_out' ? 'opted_in' : 'opted_out'; + setBusyPhone(record.phone); + setError(null); + try { + const updated = await svc.setStatus(record.phone, next, 'web_form'); + const events = await svc.historyFor(record.phone); + applyConsent(updated, events[0]); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update consent'); + } finally { + setBusyPhone(null); + } + }, + [svc, applyConsent] + ); + + const toggleExpanded = useCallback((phone: string) => { + setExpanded((current) => (current === phone ? null : phone)); + }, []); + + return ( + + }> + SMS consent + Who we may text, and where each decision came from. + + {error && ( + + {error} + + )} + + {loading ? ( + + ) : consents.length === 0 ? ( + + No phone numbers on file. + + ) : ( + consents.map((record) => { + const optedOut = record.status === 'opted_out'; + const events = expanded === record.phone ? historyFor(record.phone) : []; + return ( + + + {record.phone} + + {optedOut ? 'Opted out' : 'Opted in'} + + + + + Source: {SOURCE_LABELS[record.source] ?? record.source} + {record.reason ? ` · ${record.reason}` : ''} + + Updated {formatTimestamp(record.updatedAt)} + {record.explicitOptIn && ( + + Express consent on file + + )} + + + void toggleStatus(record)} + disabled={busyPhone === record.phone} + testID={`sms-consent-toggle-${record.phone}`}> + {busyPhone === record.phone ? ( + + ) : ( + {optedOut ? 'Opt back in' : 'Opt out'} + )} + + + toggleExpanded(record.phone)} + testID={`sms-consent-history-toggle-${record.phone}`}> + + {expanded === record.phone ? 'Hide history' : 'View history'} + + + + + {expanded === record.phone && ( + + {events.length === 0 ? ( + No recorded events. + ) : ( + events.map((event, index) => ( + + {formatTimestamp(event.at)} ·{' '} + {event.status === 'opted_out' ? 'Opted out' : 'Opted in'} ·{' '} + {SOURCE_LABELS[event.source] ?? event.source} + + )) + )} + + )} + + ); + }) + )} + + + ); +}; + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background }, + content: { padding: spacing.md, gap: spacing.md }, + title: { + color: colors.textPrimary, + fontSize: typography.fontSize.xl, + fontWeight: typography.fontWeight.bold, + }, + subtitle: { color: colors.textSecondary, fontSize: typography.fontSize.sm }, + errorBox: { + backgroundColor: colors.warningBackground, + borderRadius: borderRadius.md, + padding: spacing.sm, + }, + errorText: { color: colors.error, fontSize: typography.fontSize.sm }, + empty: { color: colors.textSecondary, fontSize: typography.fontSize.sm }, + card: { + backgroundColor: colors.surface, + borderRadius: borderRadius.lg, + padding: spacing.md, + gap: spacing.xs, + }, + cardHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + phone: { + color: colors.textPrimary, + fontSize: typography.fontSize.md, + fontWeight: typography.fontWeight.bold, + }, + statusBadge: { + borderRadius: borderRadius.full, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + }, + badgeOptedIn: { backgroundColor: colors.success }, + badgeOptedOut: { backgroundColor: colors.error }, + statusText: { color: colors.onPrimary, fontSize: typography.fontSize.xs }, + meta: { color: colors.textSecondary, fontSize: typography.fontSize.xs }, + actions: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + marginTop: spacing.sm, + }, + actionButton: { + borderRadius: borderRadius.md, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + }, + actionPrimary: { backgroundColor: colors.primary }, + actionDanger: { backgroundColor: colors.error }, + actionText: { color: colors.onPrimary, fontSize: typography.fontSize.sm }, + linkButton: { paddingVertical: spacing.sm }, + linkText: { color: colors.accent, fontSize: typography.fontSize.sm }, + history: { marginTop: spacing.sm, gap: spacing.xs }, + historyRow: { color: colors.textSecondary, fontSize: typography.fontSize.xs }, +}); + +export default SmsConsentScreen; diff --git a/src/screens/__tests__/SmsConsentScreen.test.tsx b/src/screens/__tests__/SmsConsentScreen.test.tsx new file mode 100644 index 00000000..a0da3a74 --- /dev/null +++ b/src/screens/__tests__/SmsConsentScreen.test.tsx @@ -0,0 +1,160 @@ +/** + * SmsConsentScreen (#1747) + * + * Covers: loading consent records, opted-out/opted-in status display, source + * attribution, toggling consent (calls the service and updates the ledger), + * history expansion, the empty state and error handling. + */ + +import React from 'react'; +import { render, fireEvent, waitFor, act } from '../../test-utils'; + +import SmsConsentScreen, { type SmsConsentService } from '../SmsConsentScreen'; +import { useSmsConsentStore, type SmsConsentRecord } from '../../store/smsConsentStore'; + +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: jest.fn(async () => null), + setItem: jest.fn(async () => undefined), + removeItem: jest.fn(async () => undefined), +})); + +jest.mock('react-native/Libraries/Components/Touchable/TouchableOpacity', () => + // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports + require('../../components/privacy/__tests__/mockHostComponent').mockHostComponent( + 'TouchableOpacity' + ) +); + +const record = (overrides: Partial = {}): SmsConsentRecord => ({ + phone: '+15550001111', + status: 'opted_in', + userId: 'user-1', + source: 'web_form', + explicitOptIn: true, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...overrides, +}); + +function makeService(records: SmsConsentRecord[] = [record()]): SmsConsentService & { + setStatus: jest.Mock; + listForUser: jest.Mock; + historyFor: jest.Mock; +} { + return { + listForUser: jest.fn(async () => records), + historyFor: jest.fn(async () => []), + setStatus: jest.fn(async (phone: string, status: 'opted_in' | 'opted_out') => + record({ phone, status, source: 'web_form', updatedAt: '2026-04-01T00:00:00.000Z' }) + ), + } as unknown as SmsConsentService & { + setStatus: jest.Mock; + listForUser: jest.Mock; + historyFor: jest.Mock; + }; +} + +async function setup(service = makeService()) { + const screen = render(); + await waitFor(() => expect(screen.getByTestId('sms-consent-screen')).toBeTruthy()); + return { screen, service }; +} + +beforeEach(() => { + useSmsConsentStore.setState({ consents: [], events: [] }); +}); + +describe('SmsConsentScreen', () => { + it('lists consent records with their source', async () => { + const { screen } = await setup(); + await waitFor(() => expect(screen.getByText('+15550001111')).toBeTruthy()); + expect(screen.getByText(/Web form/)).toBeTruthy(); + expect(screen.getByText('Opted in')).toBeTruthy(); + }); + + it('shows an empty state when there are no numbers', async () => { + const { screen } = await setup(makeService([])); + await waitFor(() => expect(screen.getByTestId('sms-consent-empty')).toBeTruthy()); + }); + + it('surfaces a load error', async () => { + const service = makeService(); + (service.listForUser as jest.Mock).mockRejectedValueOnce(new Error('boom')); + const { screen } = await setup(service as SmsConsentService); + await waitFor(() => expect(screen.getByTestId('sms-consent-error')).toBeTruthy()); + expect(screen.getByText(/boom/)).toBeTruthy(); + }); + + it('opts a number out and updates the ledger', async () => { + const { screen, service } = await setup(); + await waitFor(() => expect(screen.getByTestId('sms-consent-toggle-+15550001111')).toBeTruthy()); + + await act(async () => { + fireEvent.press(screen.getByTestId('sms-consent-toggle-+15550001111')); + }); + + expect(service.setStatus).toHaveBeenCalledWith('+15550001111', 'opted_out', 'web_form'); + await waitFor(() => expect(screen.getByText('Opted out')).toBeTruthy()); + expect(useSmsConsentStore.getState().isOptedOut('+15550001111')).toBe(true); + }); + + it('rewrites the button label after an opt-out', async () => { + const { screen } = await setup(makeService([record({ status: 'opted_out' })])); + await waitFor(() => expect(screen.getByText('Opt back in')).toBeTruthy()); + }); + + it('expands the consent history for a number', async () => { + const service = makeService(); + (service.historyFor as jest.Mock).mockResolvedValueOnce([ + { + phone: '+15550001111', + userId: 'user-1', + status: 'opted_out', + source: 'inbound_sms', + reason: 'inbound STOP', + at: '2026-02-01T00:00:00.000Z', + }, + ]); + const { screen } = await setup(service as SmsConsentService); + await waitFor(() => + expect(screen.getByTestId('sms-consent-history-toggle-+15550001111')).toBeTruthy() + ); + + await act(async () => { + fireEvent.press(screen.getByTestId('sms-consent-history-toggle-+15550001111')); + }); + + // Expanding reads the local ledger; seed it so the row renders. + await act(async () => { + useSmsConsentStore.setState({ + events: [ + { + phone: '+15550001111', + userId: 'user-1', + status: 'opted_out', + source: 'inbound_sms', + reason: 'inbound STOP', + at: '2026-02-01T00:00:00.000Z', + }, + ], + }); + }); + + await waitFor(() => + expect(screen.getByTestId('sms-consent-history-+15550001111')).toBeTruthy() + ); + }); + + it('surfaces an update error', async () => { + const service = makeService(); + (service.setStatus as jest.Mock).mockRejectedValueOnce(new Error('nope')); + const { screen } = await setup(service as SmsConsentService); + await waitFor(() => expect(screen.getByTestId('sms-consent-toggle-+15550001111')).toBeTruthy()); + + await act(async () => { + fireEvent.press(screen.getByTestId('sms-consent-toggle-+15550001111')); + }); + + await waitFor(() => expect(screen.getByText(/nope/)).toBeTruthy()); + }); +}); diff --git a/src/store/__tests__/smsConsentStore.test.ts b/src/store/__tests__/smsConsentStore.test.ts new file mode 100644 index 00000000..8d4a027c --- /dev/null +++ b/src/store/__tests__/smsConsentStore.test.ts @@ -0,0 +1,165 @@ +/** + * Unit tests for smsConsentStore.ts (#1747) + * + * Covers: ingestion merging, applyConsent upsert + event append, phone + * normalisation for lookups, the opt-out predicate, per-phone history, ordering + * and the retention cap. + */ + +import { describe, it, expect, beforeEach, jest } from '@jest/globals'; +import { + useSmsConsentStore, + normalisePhone, + MAX_CONSENT_ENTRIES, + type SmsConsentRecord, +} from '../smsConsentStore'; + +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: jest.fn(async () => null), + setItem: jest.fn(async () => undefined), + removeItem: jest.fn(async () => undefined), +})); + +const consent = (phone: string, overrides: Partial = {}): SmsConsentRecord => ({ + phone, + status: 'opted_in', + userId: 'user_1', + source: 'web_form', + explicitOptIn: true, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...overrides, +}); + +beforeEach(() => { + useSmsConsentStore.setState({ consents: [], events: [] }); +}); + +describe('normalisePhone', () => { + it('strips formatting and preserves a leading plus', () => { + expect(normalisePhone('+1 (555) 000-1111')).toBe('+15550001111'); + expect(normalisePhone('1.555.000.1111')).toBe('15550001111'); + }); +}); + +describe('useSmsConsentStore.ingest', () => { + it('adds records and sorts newest first', () => { + useSmsConsentStore + .getState() + .ingest([ + consent('+15550001111', { updatedAt: '2026-01-01T00:00:00.000Z' }), + consent('+15550002222', { updatedAt: '2026-02-01T00:00:00.000Z' }), + ]); + const { consents } = useSmsConsentStore.getState(); + expect(consents.map((c) => c.phone)).toEqual(['+15550002222', '+15550001111']); + }); + + it('merges by normalised phone, keeping the newer record', () => { + useSmsConsentStore.getState().ingest([consent('+1 (555) 000-1111')]); + useSmsConsentStore.getState().ingest([ + consent('+15550001111', { + status: 'opted_out', + source: 'inbound_sms', + updatedAt: '2026-03-01T00:00:00.000Z', + }), + ]); + const { consents } = useSmsConsentStore.getState(); + expect(consents).toHaveLength(1); + expect(consents[0].status).toBe('opted_out'); + }); + + it('caps the number of retained records', () => { + const many = Array.from({ length: MAX_CONSENT_ENTRIES + 20 }, (_, i) => + consent(`+1555000${String(i).padStart(4, '0')}`) + ); + useSmsConsentStore.getState().ingest(many); + expect(useSmsConsentStore.getState().consents).toHaveLength(MAX_CONSENT_ENTRIES); + }); +}); + +describe('useSmsConsentStore.applyConsent', () => { + it('upserts a record and appends its audit event', () => { + useSmsConsentStore + .getState() + .applyConsent(consent('+15550001111', { status: 'opted_out', source: 'admin' }), { + phone: '+15550001111', + userId: 'user_1', + status: 'opted_out', + source: 'admin', + reason: 'requested', + at: '2026-03-01T00:00:00.000Z', + }); + const state = useSmsConsentStore.getState(); + expect(state.consents).toHaveLength(1); + expect(state.events).toHaveLength(1); + expect(state.isOptedOut('+15550001111')).toBe(true); + }); + + it('replaces an existing record for the same number', () => { + useSmsConsentStore.getState().ingest([consent('+15550001111')]); + useSmsConsentStore + .getState() + .applyConsent( + consent('+1 (555) 000-1111', { status: 'opted_out', updatedAt: '2026-04-01T00:00:00.000Z' }) + ); + expect(useSmsConsentStore.getState().consents).toHaveLength(1); + expect(useSmsConsentStore.getState().consents[0].status).toBe('opted_out'); + }); + + it('leaves the event log untouched when no event is supplied', () => { + useSmsConsentStore.getState().applyConsent(consent('+15550001111')); + expect(useSmsConsentStore.getState().events).toHaveLength(0); + }); +}); + +describe('useSmsConsentStore lookups', () => { + beforeEach(() => { + useSmsConsentStore.getState().ingest([consent('+1 (555) 000-1111')]); + }); + + it('finds a record by un-normalised phone', () => { + expect(useSmsConsentStore.getState().getByPhone('+15550001111')).toBeDefined(); + expect(useSmsConsentStore.getState().getByPhone('+1-555-000-1111')?.status).toBe('opted_in'); + }); + + it('reports opted-out only for opted-out numbers', () => { + expect(useSmsConsentStore.getState().isOptedOut('+15550001111')).toBe(false); + expect(useSmsConsentStore.getState().isOptedOut('+19999999999')).toBe(false); + }); + + it('returns per-phone history newest first', () => { + useSmsConsentStore.setState({ + events: [ + { + phone: '+15550001111', + userId: null, + status: 'opted_in', + source: 'web_form', + at: '2026-01-01T00:00:00.000Z', + }, + { + phone: '+15550001111', + userId: null, + status: 'opted_out', + source: 'inbound_sms', + at: '2026-02-01T00:00:00.000Z', + }, + { + phone: '+15550002222', + userId: null, + status: 'opted_in', + source: 'web_form', + at: '2026-03-01T00:00:00.000Z', + }, + ], + }); + const history = useSmsConsentStore.getState().historyFor('+1-555-000-1111'); + expect(history.map((e) => e.status)).toEqual(['opted_out', 'opted_in']); + }); + + it('clears state', () => { + useSmsConsentStore.getState().clear(); + expect(useSmsConsentStore.getState().consents).toHaveLength(0); + expect(useSmsConsentStore.getState().events).toHaveLength(0); + }); +}); diff --git a/src/store/smsConsentStore.ts b/src/store/smsConsentStore.ts new file mode 100644 index 00000000..f1ff0731 --- /dev/null +++ b/src/store/smsConsentStore.ts @@ -0,0 +1,133 @@ +/** + * SMS consent ledger (on device) — #1747 + * + * A local mirror of the server-side consent ledger. The screen fetches consent + * records from `/api/sms-consent` and drops them in with `ingest`; mutations + * (`applyConsent`) reflect the response of an opt-in/opt-out call so the list + * updates without a refetch. Keeping the store pure (no network) means it can + * be exercised in the backend Jest environment, which cannot load the React + * Native preset. + */ + +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import { asyncStorageAdapter } from '../utils/storage'; + +const STORAGE_KEY = 'subtrackr-sms-consent'; + +/** How many consent records are retained on device. */ +export const MAX_CONSENT_ENTRIES = 200; + +export type ConsentStatus = 'opted_in' | 'opted_out'; + +export type ConsentSource = 'web_form' | 'inbound_sms' | 'admin' | 'import' | 'api' | 'system'; + +export interface SmsConsentRecord { + phone: string; + status: ConsentStatus; + userId: string | null; + source: ConsentSource; + reason?: string; + explicitOptIn: boolean; + createdAt: string; + updatedAt: string; +} + +export interface SmsConsentEvent { + phone: string; + userId: string | null; + status: ConsentStatus; + source: ConsentSource; + reason?: string; + messageSid?: string; + at: string; +} + +/** Canonicalise a number the same way the backend does, for lookups. */ +export function normalisePhone(phone: string): string { + const stripped = phone.replace(/[\s\-().]/g, ''); + return stripped.startsWith('+') ? `+${stripped.slice(1)}` : stripped; +} + +interface SmsConsentState { + consents: SmsConsentRecord[]; + events: SmsConsentEvent[]; + + /** Replace all records (e.g. after a fresh fetch for the current user). */ + ingest: (records: SmsConsentRecord[]) => void; + /** Merge a single record and, when supplied, append an audit event. */ + applyConsent: (record: SmsConsentRecord, event?: SmsConsentEvent) => void; + getByPhone: (phone: string) => SmsConsentRecord | undefined; + isOptedOut: (phone: string) => boolean; + historyFor: (phone: string) => SmsConsentEvent[]; + clear: () => void; +} + +const sortByUpdatedDesc = (records: SmsConsentRecord[]): SmsConsentRecord[] => + [...records].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + +export const useSmsConsentStore = create()( + persist( + (set, get) => ({ + consents: [], + events: [], + + ingest: (records) => + set((state) => { + const byPhone = new Map(); + for (const record of [...state.consents, ...records]) { + byPhone.set(normalisePhone(record.phone), record); + } + return { + consents: sortByUpdatedDesc([...byPhone.values()]).slice(0, MAX_CONSENT_ENTRIES), + }; + }), + + applyConsent: (record, event) => + set((state) => { + const phone = normalisePhone(record.phone); + const others = state.consents.filter( + (existing) => normalisePhone(existing.phone) !== phone + ); + const consents = sortByUpdatedDesc([{ ...record, phone }, ...others]).slice( + 0, + MAX_CONSENT_ENTRIES + ); + const events = event + ? [event, ...state.events].slice(0, MAX_CONSENT_ENTRIES) + : state.events; + return { consents, events }; + }), + + getByPhone: (phone) => { + const target = normalisePhone(phone); + return get().consents.find((record) => normalisePhone(record.phone) === target); + }, + + isOptedOut: (phone) => get().getByPhone(phone)?.status === 'opted_out', + + historyFor: (phone) => { + const target = normalisePhone(phone); + return get() + .events.filter((event) => normalisePhone(event.phone) === target) + .sort((a, b) => b.at.localeCompare(a.at)); + }, + + clear: () => set({ consents: [], events: [] }), + }), + { + name: STORAGE_KEY, + storage: createJSONStorage(() => asyncStorageAdapter), + partialize: (state) => ({ consents: state.consents, events: state.events }), + merge: (persistedState: unknown, currentState) => { + if (!persistedState || typeof persistedState !== 'object') return currentState; + const persisted = persistedState as Partial; + return { + ...currentState, + consents: persisted.consents ?? currentState.consents, + events: persisted.events ?? currentState.events, + }; + }, + } + ) +);