diff --git a/backend/server.ts b/backend/server.ts index 574e53ef..edfb308a 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -65,6 +65,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 { createDeliveryStatusRouter } from './services/notification/deliveryStatusRouter'; +import { deliveryReceiptService } from './services/notification/deliveryReceiptService'; import { createNotificationTemplateRouter } from './services/notification/notificationTemplateRouter'; import { notificationTemplateVersionService } from './services/notification/notificationTemplateVersionService'; import { createSmsConsentRouter } from './services/notification/smsConsentRouter'; @@ -534,6 +536,9 @@ feat/stellar-wave-issues reportSnapshotFactory ); + const deliveryStatusRouter = createDeliveryStatusRouter(deliveryReceiptService, { + accountSid: process.env.TWILIO_ACCOUNT_SID || undefined, + }); const notificationTemplateRouter = createNotificationTemplateRouter( notificationTemplateVersionService ); @@ -2120,6 +2125,13 @@ feat/stellar-wave-issues } // ----------------------------------------------------------------- + // Notification delivery receipts /api/delivery-receipts (issue #1749) + // ----------------------------------------------------------------- + if (pathname.startsWith('/api/delivery-receipts')) { + const handled = await deliveryStatusRouter(req, res, pathname); + if (handled) return; + } + // Notification template versions /api/notification-templates (issue #1748) // ----------------------------------------------------------------- if (pathname.startsWith('/api/notification-templates')) { diff --git a/backend/services/container.ts b/backend/services/container.ts index 52e50600..0bbce736 100644 --- a/backend/services/container.ts +++ b/backend/services/container.ts @@ -470,20 +470,28 @@ container.bind('IDlqReplayWorker', (c) => // ── Notification Transport Wiring ───────────────────────────────────────────── // Wire email and SMS transports into the NotificationCenterService singleton. import { notificationCenterService } from './notification/notificationCenterService'; +import { deliveryReceiptService } from './notification/deliveryReceiptService'; +import { withDeliveryReceipts } from './notification/deliveryReceiptTransport'; import { notificationTemplateVersionService } from './notification/notificationTemplateVersionService'; container.register('INotificationCenterService', notificationCenterService); +container.register('IDeliveryReceiptService', deliveryReceiptService); container.register('INotificationTemplateVersionService', notificationTemplateVersionService); // Register email transport — transport builder is lazy so it runs after env is loaded. +// Each transport is wrapped so the attempt is recorded as a delivery receipt (#1749). notificationCenterService.registerTransport( 'email', - buildEmailTransport(emailProvider, { from: getEmailFromAddress() }) + withDeliveryReceipts( + 'email', + buildEmailTransport(emailProvider, { from: getEmailFromAddress() }), + deliveryReceiptService + ) ); // Register SMS transport. notificationCenterService.registerTransport( 'sms', - buildSmsTransport(smsProvider) + withDeliveryReceipts('sms', buildSmsTransport(smsProvider), deliveryReceiptService) ); // ── Subscription Notifier ───────────────────────────────────────────────────── diff --git a/backend/services/notification/__tests__/deliveryReceiptService.test.ts b/backend/services/notification/__tests__/deliveryReceiptService.test.ts new file mode 100644 index 00000000..46e57364 --- /dev/null +++ b/backend/services/notification/__tests__/deliveryReceiptService.test.ts @@ -0,0 +1,267 @@ +/** + * Delivery receipts (#1749) — service + transport decorator. + */ + +import { + DeliveryReceiptService, + InMemoryDeliveryReceiptRepository, + canTransition, + normalizeProviderStatus, +} from '../deliveryReceiptService'; +import { withDeliveryReceipts, PROVIDER_MESSAGE_ID_KEY } from '../deliveryReceiptTransport'; + +function makeService(): DeliveryReceiptService { + let tick = 0; + const base = Date.UTC(2026, 2, 1, 12, 0, 0); + return new DeliveryReceiptService( + new InMemoryDeliveryReceiptRepository(), + () => new Date(base + tick++ * 1000) + ); +} + +describe('normalizeProviderStatus', () => { + it.each([ + ['queued', 'accepted'], + ['accepted', 'accepted'], + ['sending', 'sent'], + ['sent', 'sent'], + ['delivered', 'delivered'], + ['failed', 'failed'], + ['undelivered', 'undelivered'], + ['expired', 'expired'], + ['DELIVERED', 'delivered'], + [' sent ', 'sent'], + ])('maps %s to %s', (raw, expected) => { + expect(normalizeProviderStatus(raw)).toBe(expected); + }); + + it('returns null for unknown statuses', () => { + expect(normalizeProviderStatus('read')).toBeNull(); + expect(normalizeProviderStatus('')).toBeNull(); + }); +}); + +describe('canTransition', () => { + it('allows forward progress', () => { + expect(canTransition('accepted', 'sent')).toBe(true); + expect(canTransition('accepted', 'delivered')).toBe(true); + expect(canTransition('sent', 'delivered')).toBe(true); + expect(canTransition('sent', 'failed')).toBe(true); + }); + + it('rejects out-of-order and duplicate transitions', () => { + expect(canTransition('delivered', 'sent')).toBe(false); + expect(canTransition('delivered', 'delivered')).toBe(false); + expect(canTransition('failed', 'sent')).toBe(false); + expect(canTransition('sent', 'accepted')).toBe(false); + }); +}); + +describe('DeliveryReceiptService.recordAttempt', () => { + it('records an accepted SMS as sent', async () => { + const service = makeService(); + const receipt = await service.recordAttempt({ + userId: 'u1', + type: 'renewal_reminder', + channel: 'sms', + providerMessageId: 'SM123', + accepted: true, + }); + + expect(receipt.status).toBe('sent'); + expect(receipt.providerMessageId).toBe('SM123'); + expect(receipt.events).toHaveLength(1); + expect(receipt.events[0]!.status).toBe('sent'); + }); + + it('records an in-app attempt as delivered immediately', async () => { + const service = makeService(); + const receipt = await service.recordAttempt({ + userId: 'u1', + type: 'product_update', + channel: 'in_app', + accepted: true, + }); + expect(receipt.status).toBe('delivered'); + }); + + it('records a refused attempt as failed with the error', async () => { + const service = makeService(); + const receipt = await service.recordAttempt({ + userId: 'u1', + type: 'charge_failed', + channel: 'email', + accepted: false, + error: 'mailbox unavailable', + }); + + expect(receipt.status).toBe('failed'); + expect(receipt.events[0]!.detail).toBe('mailbox unavailable'); + }); +}); + +describe('DeliveryReceiptService.applyStatusUpdate', () => { + it('advances a receipt via provider message id', async () => { + const service = makeService(); + await service.recordAttempt({ + userId: 'u1', + type: 'renewal_reminder', + channel: 'sms', + providerMessageId: 'SM123', + accepted: true, + }); + + const updated = await service.applyStatusUpdate({ + providerMessageId: 'SM123', + status: 'delivered', + }); + + expect(updated?.status).toBe('delivered'); + expect(updated?.events.map((e) => e.status)).toEqual(['sent', 'delivered']); + }); + + it('ignores a stale update that would regress the receipt', async () => { + const service = makeService(); + await service.recordAttempt({ + userId: 'u1', + type: 'renewal_reminder', + channel: 'sms', + providerMessageId: 'SM123', + accepted: true, + }); + await service.applyStatusUpdate({ providerMessageId: 'SM123', status: 'delivered' }); + + const after = await service.applyStatusUpdate({ + providerMessageId: 'SM123', + status: 'sent', + }); + + expect(after?.status).toBe('delivered'); + expect(after?.events).toHaveLength(2); + }); + + it('returns null for an unknown message id', async () => { + const service = makeService(); + expect(await service.applyStatusUpdate({ providerMessageId: 'nope', status: 'delivered' })).toBeNull(); + }); + + it('attaches the provider error code on failure', async () => { + const service = makeService(); + await service.recordAttempt({ + userId: 'u1', + type: 'renewal_reminder', + channel: 'sms', + providerMessageId: 'SM123', + accepted: true, + }); + const updated = await service.applyStatusUpdate({ + providerMessageId: 'SM123', + status: 'undelivered', + errorCode: '30003', + detail: 'Provider error code 30003', + }); + expect(updated?.errorCode).toBe('30003'); + expect(updated?.status).toBe('undelivered'); + }); +}); + +describe('DeliveryReceiptService queries', () => { + it('lists a user\u2019s receipts newest first and filters by channel', async () => { + const service = makeService(); + await service.recordAttempt({ userId: 'u1', type: 'renewal_reminder', channel: 'email', accepted: true }); + await service.recordAttempt({ userId: 'u1', type: 'renewal_reminder', channel: 'sms', accepted: true }); + await service.recordAttempt({ userId: 'u2', type: 'renewal_reminder', channel: 'sms', accepted: true }); + + const all = await service.listForUser('u1'); + expect(all).toHaveLength(2); + expect(all[0]!.channel).toBe('sms'); + + const smsOnly = await service.listForUser('u1', { channel: 'sms' }); + expect(smsOnly).toHaveLength(1); + }); + + it('summarises latest status per channel with counts', async () => { + const service = makeService(); + await service.recordAttempt({ + userId: 'u1', + type: 'renewal_reminder', + channel: 'sms', + providerMessageId: 'SM1', + accepted: true, + }); + await service.applyStatusUpdate({ providerMessageId: 'SM1', status: 'delivered' }); + await service.recordAttempt({ userId: 'u1', type: 'charge_failed', channel: 'email', accepted: false, error: 'x' }); + + const summary = await service.summarize('u1'); + expect(summary.total).toBe(2); + expect(summary.byChannel.sms.status).toBe('delivered'); + expect(summary.byChannel.email.status).toBe('failed'); + expect(summary.byChannel.push.status).toBeNull(); + expect(summary.counts.delivered).toBe(1); + expect(summary.counts.failed).toBe(1); + }); + + it('trims a user to the retention cap', async () => { + const service = makeService(); + for (let i = 0; i < 505; i++) { + await service.recordAttempt({ userId: 'u1', type: 'product_update', channel: 'email', accepted: true }); + } + const receipts = await service.listForUser('u1'); + expect(receipts.length).toBeLessThanOrEqual(500); + }); +}); + +describe('withDeliveryReceipts', () => { + it('records a receipt and passes through the transport result', async () => { + const service = makeService(); + const transport = jest.fn().mockResolvedValue(true); + const wrapped = withDeliveryReceipts('email', transport, service); + + const accepted = await wrapped({ + userId: 'u1', + channel: 'email', + subject: 's', + body: 'b', + data: { notificationType: 'renewal_reminder', email: 'a@b.c' }, + }); + + expect(accepted).toBe(true); + const [receipt] = await service.listForUser('u1'); + expect(receipt!.type).toBe('renewal_reminder'); + expect(receipt!.status).toBe('sent'); + }); + + it('captures a provider message id stashed in the data bag', async () => { + const service = makeService(); + const transport = jest.fn(async (input: { data?: Record }) => { + if (input.data) input.data[PROVIDER_MESSAGE_ID_KEY] = 'SM999'; + return true; + }); + const wrapped = withDeliveryReceipts('sms', transport as never, service); + + await wrapped({ + userId: 'u1', + channel: 'sms', + subject: 's', + body: 'b', + data: { notificationType: 'renewal_reminder', phone: '+1555' }, + }); + + const [receipt] = await service.listForUser('u1'); + expect(receipt!.providerMessageId).toBe('SM999'); + }); + + it('records a failed receipt and rethrows the transport error', async () => { + const service = makeService(); + const transport = jest.fn().mockRejectedValue(new Error('twilio down')); + const wrapped = withDeliveryReceipts('sms', transport, service); + + await expect( + wrapped({ userId: 'u1', channel: 'sms', subject: 's', body: 'b', data: { notificationType: 'dunning' } }) + ).rejects.toThrow('twilio down'); + + const [receipt] = await service.listForUser('u1'); + expect(receipt!.status).toBe('failed'); + expect(receipt!.events[0]!.detail).toBe('twilio down'); + }); +}); diff --git a/backend/services/notification/__tests__/deliveryStatusRouter.test.ts b/backend/services/notification/__tests__/deliveryStatusRouter.test.ts new file mode 100644 index 00000000..733a4e05 --- /dev/null +++ b/backend/services/notification/__tests__/deliveryStatusRouter.test.ts @@ -0,0 +1,252 @@ +/** + * deliveryStatusRouter (#1749) — real HTTP server over the router. + */ + +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { + DeliveryReceiptService, + InMemoryDeliveryReceiptRepository, +} from '../deliveryReceiptService'; +import { + createDeliveryStatusRouter, + parseStatusCallback, + isAuthorisedCallback, +} from '../deliveryStatusRouter'; + +const PREFIX = '/api/delivery-receipts'; + +function makeService(): DeliveryReceiptService { + return new DeliveryReceiptService(new InMemoryDeliveryReceiptRepository()); +} + +async function startServer( + service: DeliveryReceiptService, + accountSid?: string +): Promise<{ base: string; close: () => Promise }> { + const router = createDeliveryStatusRouter(service, { accountSid }); + const server = http.createServer(async (req, res) => { + const pathname = new URL(req.url ?? '/', 'http://localhost').pathname; + const handled = await router(req, res, pathname); + if (!handled) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not found' })); + } + }); + await new Promise((resolve) => server.listen(0, resolve)); + const { port } = server.address() as AddressInfo; + return { + base: `http://127.0.0.1:${port}`, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +describe('parseStatusCallback', () => { + it('parses a form-encoded Twilio body', () => { + const parsed = parseStatusCallback( + 'MessageSid=SM123&MessageStatus=delivered&ErrorCode=0', + 'application/x-www-form-urlencoded' + ); + expect(parsed).toEqual({ MessageSid: 'SM123', MessageStatus: 'delivered', ErrorCode: '0' }); + }); + + it('parses a JSON body', () => { + const parsed = parseStatusCallback('{"MessageSid":"SM1","MessageStatus":"sent"}', 'application/json'); + expect(parsed.MessageSid).toBe('SM1'); + }); + + it('treats a JSON content-type on a form body as form data', () => { + const parsed = parseStatusCallback('MessageSid=SM1&MessageStatus=sent', 'application/json'); + expect(parsed.MessageSid).toBe('SM1'); + }); + + it('returns an empty object for a blank body', () => { + expect(parseStatusCallback('', undefined)).toEqual({}); + }); +}); + +describe('isAuthorisedCallback', () => { + it('accepts a matching account SID as HTTP Basic username', () => { + const header = `Basic ${Buffer.from('AC123:').toString('base64')}`; + expect(isAuthorisedCallback(header, 'AC123')).toBe(true); + }); + + it('rejects a mismatched or missing credential', () => { + expect(isAuthorisedCallback(undefined, 'AC123')).toBe(false); + expect(isAuthorisedCallback('Basic ' + Buffer.from('AC999:x').toString('base64'), 'AC123')).toBe(false); + expect(isAuthorisedCallback('Bearer token', 'AC123')).toBe(false); + }); +}); + +describe('deliveryStatusRouter over HTTP', () => { + let service: DeliveryReceiptService; + let ctx: { base: string; close: () => Promise }; + + beforeEach(async () => { + service = makeService(); + ctx = await startServer(service); + }); + + afterEach(async () => { + await ctx.close(); + }); + + async function seedSms(): Promise { + const receipt = await service.recordAttempt({ + userId: 'u1', + type: 'renewal_reminder', + channel: 'sms', + providerMessageId: 'SM123', + accepted: true, + }); + return receipt.id; + } + + it('lists receipts for a user', async () => { + await seedSms(); + const res = await fetch(`${ctx.base}${PREFIX}?userId=u1`); + expect(res.status).toBe(200); + const body = (await res.json()) as unknown[]; + expect(body).toHaveLength(1); + }); + + it('requires a userId on the list route', async () => { + const res = await fetch(`${ctx.base}${PREFIX}`); + expect(res.status).toBe(400); + }); + + it('rejects an unknown channel filter', async () => { + const res = await fetch(`${ctx.base}${PREFIX}?userId=u1&channel=carrier-pigeon`); + expect(res.status).toBe(400); + }); + + it('returns the summary for a user', async () => { + await seedSms(); + const res = await fetch(`${ctx.base}${PREFIX}/summary?userId=u1`); + expect(res.status).toBe(200); + const body = (await res.json()) as { byChannel: { sms: { status: string } } }; + expect(body.byChannel.sms.status).toBe('sent'); + }); + + it('returns a single receipt by id', async () => { + const id = await seedSms(); + const res = await fetch(`${ctx.base}${PREFIX}/${id}`); + expect(res.status).toBe(200); + const body = (await res.json()) as { id: string }; + expect(body.id).toBe(id); + }); + + it('404s an unknown receipt id', async () => { + const res = await fetch(`${ctx.base}${PREFIX}/missing`); + expect(res.status).toBe(404); + }); + + it('applies a Twilio status callback and advances the receipt', async () => { + await seedSms(); + const res = await fetch(`${ctx.base}${PREFIX}/status`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ MessageSid: 'SM123', MessageStatus: 'delivered' }).toString(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { status: string }; + expect(body.status).toBe('delivered'); + expect((await service.getByProviderMessageId('SM123'))?.status).toBe('delivered'); + }); + + it('accepts a JSON status callback', async () => { + await seedSms(); + const res = await fetch(`${ctx.base}${PREFIX}/status`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ MessageSid: 'SM123', MessageStatus: 'failed', ErrorCode: '30003' }), + }); + expect(res.status).toBe(200); + expect((await service.getByProviderMessageId('SM123'))?.errorCode).toBe('30003'); + }); + + it('rejects a callback missing required fields', async () => { + const res = await fetch(`${ctx.base}${PREFIX}/status`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'MessageSid=SM123', + }); + expect(res.status).toBe(400); + }); + + it('acks an unknown message id without advancing anything', async () => { + const res = await fetch(`${ctx.base}${PREFIX}/status`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'MessageSid=SM_nope&MessageStatus=delivered', + }); + expect(res.status).toBe(202); + const body = (await res.json()) as { accepted: boolean }; + expect(body.accepted).toBe(false); + }); + + it('acks an unmodelled provider status', async () => { + const res = await fetch(`${ctx.base}${PREFIX}/status`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'MessageSid=SM123&MessageStatus=read', + }); + expect(res.status).toBe(202); + }); + + it('returns 404 for an unrelated path', async () => { + const res = await fetch(`${ctx.base}/nope`); + expect(res.status).toBe(404); + }); +}); + +describe('deliveryStatusRouter callback auth', () => { + it('rejects an unauthorised callback when an account SID is configured', async () => { + const service = makeService(); + await service.recordAttempt({ + userId: 'u1', + type: 'renewal_reminder', + channel: 'sms', + providerMessageId: 'SM123', + accepted: true, + }); + const ctx = await startServer(service, 'AC123'); + try { + const res = await fetch(`${ctx.base}${PREFIX}/status`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'MessageSid=SM123&MessageStatus=delivered', + }); + expect(res.status).toBe(401); + } finally { + await ctx.close(); + } + }); + + it('accepts an authorised callback', async () => { + const service = makeService(); + await service.recordAttempt({ + userId: 'u1', + type: 'renewal_reminder', + channel: 'sms', + providerMessageId: 'SM123', + accepted: true, + }); + const ctx = await startServer(service, 'AC123'); + try { + const res = await fetch(`${ctx.base}${PREFIX}/status`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${Buffer.from('AC123:').toString('base64')}`, + }, + body: 'MessageSid=SM123&MessageStatus=delivered', + }); + expect(res.status).toBe(200); + } finally { + await ctx.close(); + } + }); +}); diff --git a/backend/services/notification/deliveryReceiptService.ts b/backend/services/notification/deliveryReceiptService.ts new file mode 100644 index 00000000..d52d7493 --- /dev/null +++ b/backend/services/notification/deliveryReceiptService.ts @@ -0,0 +1,362 @@ +/** + * Notification delivery receipts (#1749) + * + * A `NotificationRecord` says a transport *accepted* a message; a receipt + * tracks what happened afterwards — the provider's own confirmation that the + * SMS was delivered, the email bounced, the push ticket expired. Each receipt + * carries a per-channel status timeline so support can answer "did the customer + * actually receive it?" without reading provider dashboards. + * + * Model + * ───── + * accepted — the provider took the message (handover confirmed) + * sent — the provider dispatched it on the wire + * delivered — the recipient's carrier/device confirmed receipt + * failed — the provider refused or the send errored + * undelivered — the provider gave up (bad number, full mailbox, …) + * expired — e.g. an Expo push ticket that never resolved + * + * Transitions are forward-only and idempotent: a late "sent" arriving after + * "delivered" is ignored, so out-of-order webhooks cannot regress a receipt. + * + * `in_app` receipts are marked `delivered` on acceptance — the store-and-show + * hop is the delivery. Other channels start at `sent` and wait for the + * provider's status callback (e.g. Twilio's `StatusCallback` webhook, handled + * by `deliveryStatusRouter`). + */ + +import type { NotificationChannel, NotificationType } from '../../../src/types/notification'; + +// ─── Types ───────────────────────────────────────────────────────────────────── + +export type DeliveryReceiptStatus = + | 'accepted' + | 'sent' + | 'delivered' + | 'failed' + | 'undelivered' + | 'expired'; + +export interface DeliveryReceiptEvent { + status: DeliveryReceiptStatus; + at: string; + /** Provider-supplied detail, e.g. an error message. */ + detail?: string; +} + +export interface DeliveryReceipt { + id: string; + userId: string; + type: NotificationType; + channel: NotificationChannel; + /** Provider message identifier (Twilio SM…, Expo ticket id, …). */ + providerMessageId?: string; + status: DeliveryReceiptStatus; + /** Forward-only timeline, oldest first. */ + events: DeliveryReceiptEvent[]; + errorCode?: string; + createdAt: string; + updatedAt: string; +} + +export interface RecordAttemptInput { + userId: string; + type: NotificationType; + channel: NotificationChannel; + providerMessageId?: string; + /** True when the channel transport accepted the message. */ + accepted: boolean; + error?: string; + errorCode?: string; +} + +export interface StatusUpdateInput { + /** Update by provider message id … */ + providerMessageId?: string; + /** … or by receipt id. */ + receiptId?: string; + status: DeliveryReceiptStatus; + detail?: string; + errorCode?: string; + /** Overrides "now" for the event timestamp. */ + at?: Date; +} + +export interface ChannelReceiptSummary { + channel: NotificationChannel; + receiptId: string | null; + status: DeliveryReceiptStatus | null; + updatedAt: string | null; +} + +/** Latest receipt per channel plus status counts, for a dashboard. */ +export interface DeliverySummary { + userId: string; + byChannel: Record; + counts: Record; + total: number; +} + +// ─── Status vocabulary ───────────────────────────────────────────────────────── + +const TERMINAL: ReadonlySet = new Set([ + 'delivered', + 'failed', + 'undelivered', + 'expired', +]); + +/** Progress rank — higher wins; terminal statuses accept nothing further. */ +const RANK: Record = { + accepted: 0, + sent: 1, + delivered: 2, + failed: 3, + undelivered: 3, + expired: 3, +}; + +/** Channels whose acceptance *is* delivery — no wire to wait on. */ +const INSTANTLY_DELIVERED: ReadonlySet = new Set(['in_app']); + +/** + * Map a provider status string onto our vocabulary. + * + * Handles Twilio's message statuses (the only provider with status callbacks + * today) and is deliberately lenient: unknown values resolve to null so the + * caller can ignore them rather than invent a transition. + */ +export function normalizeProviderStatus(raw: string): DeliveryReceiptStatus | null { + switch (raw.trim().toLowerCase()) { + case 'queued': + case 'accepted': + case 'received': + case 'receiving': + return 'accepted'; + case 'sending': + case 'sent': + return 'sent'; + case 'delivered': + return 'delivered'; + case 'failed': + return 'failed'; + case 'undelivered': + return 'undelivered'; + case 'expired': + return 'expired'; + default: + return null; + } +} + +/** True when a receipt may move from `from` to `to`. */ +export function canTransition(from: DeliveryReceiptStatus, to: DeliveryReceiptStatus): boolean { + if (from === to) return false; + if (TERMINAL.has(from)) return false; + return RANK[to] >= RANK[from]; +} + +// ─── Repository ──────────────────────────────────────────────────────────────── + +export interface DeliveryReceiptRepository { + save(receipt: DeliveryReceipt): Promise; + remove(receiptId: string): Promise; + get(id: string): Promise; + findByProviderMessageId(providerMessageId: string): Promise; + listForUser(userId: string): Promise; +} + +/** In-memory repository — tests and local development. */ +export class InMemoryDeliveryReceiptRepository implements DeliveryReceiptRepository { + private readonly byId = new Map(); + private readonly byProviderId = new Map(); + + async save(receipt: DeliveryReceipt): Promise { + this.byId.set(receipt.id, { ...receipt, events: [...receipt.events] }); + if (receipt.providerMessageId) { + this.byProviderId.set(receipt.providerMessageId, receipt.id); + } + } + + async remove(receiptId: string): Promise { + const existing = this.byId.get(receiptId); + if (existing?.providerMessageId) this.byProviderId.delete(existing.providerMessageId); + this.byId.delete(receiptId); + } + + async get(id: string): Promise { + const found = this.byId.get(id); + return found ? { ...found, events: [...found.events] } : null; + } + + async findByProviderMessageId(providerMessageId: string): Promise { + const id = this.byProviderId.get(providerMessageId); + return id ? this.get(id) : null; + } + + async listForUser(userId: string): Promise { + return [...this.byId.values()] + .filter((receipt) => receipt.userId === userId) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .map((receipt) => ({ ...receipt, events: [...receipt.events] })); + } +} + +// ─── Service ─────────────────────────────────────────────────────────────────── + +/** Retention cap per subscriber, mirroring the history cap. */ +export const MAX_RECEIPTS_PER_USER = 500; + +export class DeliveryReceiptService { + private sequence = 0; + + constructor( + private readonly repo: DeliveryReceiptRepository, + private readonly now: () => Date = () => new Date() + ) {} + + /** + * Record the outcome of one delivery attempt. + * + * Accepted attempts become `sent` (or `delivered` for in-app); refused + * attempts become `failed` with the transport's error attached. + */ + async recordAttempt(input: RecordAttemptInput): Promise { + const timestamp = this.now().toISOString(); + const initial: DeliveryReceiptStatus = !input.accepted + ? 'failed' + : INSTANTLY_DELIVERED.has(input.channel) + ? 'delivered' + : 'sent'; + + this.sequence += 1; + const receipt: DeliveryReceipt = { + id: `rcpt_${this.sequence.toString(36)}_${input.channel}`, + userId: input.userId, + type: input.type, + channel: input.channel, + providerMessageId: input.providerMessageId, + status: initial, + events: [{ status: initial, at: timestamp, detail: input.error }], + errorCode: input.errorCode, + createdAt: timestamp, + updatedAt: timestamp, + }; + + await this.repo.save(receipt); + await this.trim(input.userId); + return receipt; + } + + /** + * Apply a provider status update. + * + * Returns the updated receipt, or null when no receipt matches or the + * transition is stale/out-of-order (in which case nothing is written). + */ + async applyStatusUpdate(input: StatusUpdateInput): Promise { + const receipt = input.receiptId + ? await this.repo.get(input.receiptId) + : input.providerMessageId + ? await this.repo.findByProviderMessageId(input.providerMessageId) + : null; + if (!receipt) return null; + if (!canTransition(receipt.status, input.status)) return receipt; + + const at = (input.at ?? this.now()).toISOString(); + const updated: DeliveryReceipt = { + ...receipt, + status: input.status, + events: [...receipt.events, { status: input.status, at, detail: input.detail }], + errorCode: input.errorCode ?? receipt.errorCode, + updatedAt: at, + }; + await this.repo.save(updated); + return updated; + } + + async get(receiptId: string): Promise { + return this.repo.get(receiptId); + } + + async getByProviderMessageId(providerMessageId: string): Promise { + return this.repo.findByProviderMessageId(providerMessageId); + } + + /** Newest first, optionally narrowed to one channel. */ + async listForUser( + userId: string, + options: { channel?: NotificationChannel; limit?: number } = {} + ): Promise { + const all = await this.repo.listForUser(userId); + const filtered = options.channel + ? all.filter((receipt) => receipt.channel === options.channel) + : all; + return options.limit ? filtered.slice(0, options.limit) : filtered; + } + + /** The newest receipt on every channel — what the status dashboard shows. */ + async latestPerChannel(userId: string): Promise> { + const empty = (channel: NotificationChannel): ChannelReceiptSummary => ({ + channel, + receiptId: null, + status: null, + updatedAt: null, + }); + const byChannel = { + email: empty('email'), + push: empty('push'), + sms: empty('sms'), + in_app: empty('in_app'), + } as Record; + + for (const receipt of await this.repo.listForUser(userId)) { + const current = byChannel[receipt.channel]; + // listForUser is newest first, so the first sighting per channel wins. + if (!current.receiptId) { + byChannel[receipt.channel] = { + channel: receipt.channel, + receiptId: receipt.id, + status: receipt.status, + updatedAt: receipt.updatedAt, + }; + } + } + return byChannel; + } + + /** Per-channel latest status plus per-status totals. */ + async summarize(userId: string): Promise { + const counts: Record = { + accepted: 0, + sent: 0, + delivered: 0, + failed: 0, + undelivered: 0, + expired: 0, + }; + const all = await this.repo.listForUser(userId); + for (const receipt of all) counts[receipt.status] += 1; + + return { + userId, + byChannel: await this.latestPerChannel(userId), + counts, + total: all.length, + }; + } + + private async trim(userId: string): Promise { + const all = await this.repo.listForUser(userId); + if (all.length <= MAX_RECEIPTS_PER_USER) return; + // listForUser is newest first; the overflow is the oldest tail. + for (const receipt of all.slice(MAX_RECEIPTS_PER_USER)) { + await this.repo.remove(receipt.id); + } + } +} + +// ─── Singleton ───────────────────────────────────────────────────────────────── + +export const deliveryReceiptRepository = new InMemoryDeliveryReceiptRepository(); +export const deliveryReceiptService = new DeliveryReceiptService(deliveryReceiptRepository); diff --git a/backend/services/notification/deliveryReceiptTransport.ts b/backend/services/notification/deliveryReceiptTransport.ts new file mode 100644 index 00000000..d39b073a --- /dev/null +++ b/backend/services/notification/deliveryReceiptTransport.ts @@ -0,0 +1,83 @@ +/** + * withDeliveryReceipts — ChannelTransport decorator for #1749 + * + * `ChannelTransport` returns only a boolean ("did the provider accept it?"), + * which is all `NotificationCenterService` needs. Delivery receipts need two + * facts the boolean cannot carry: which notification type the attempt belonged + * to, and the provider's message id (for later status callbacks). + * + * This decorator wraps an existing transport, records one receipt per attempt, + * and folds the message id back out of the transport where the provider exposes + * it. The id is read from the `data` bag after the send — `buildSmsTransport` + * does not currently surface `SmsResult.messageId`, so the SMS provider is + * given the chance to stash it under `data.providerMessageId`. When it is + * absent the receipt still records the attempt status, just without an id to + * correlate a callback against. + * + * Correlation by hand is deliberate: changing `ChannelTransport`'s return type + * would ripple through every transport (email, push, in-app, voice, …) and the + * microservice boundary, for a field only the receipt layer consumes. + */ + +import type { ChannelTransport } from '@subtrackr/notification-providers'; +import type { + DeliveryReceiptService, + DeliveryReceiptStatus, +} from './deliveryReceiptService'; +import type { NotificationChannel, NotificationType } from '../../../src/types/notification'; + +/** Type hint the notification center places in `data` for templated sends. */ +const TYPE_HINT_KEY = 'notificationType'; +/** Key a transport may populate so the decorator can capture the provider id. */ +export const PROVIDER_MESSAGE_ID_KEY = 'providerMessageId'; + +const DEFAULT_TYPE: NotificationType = 'product_update'; + +function resolveType(data: Record | undefined): NotificationType { + const hinted = data?.[TYPE_HINT_KEY]; + // The type is a display-only classification on the receipt; an unknown hint + // must not fail the send, so fall back to a neutral type. + return (hinted as NotificationType | undefined) ?? DEFAULT_TYPE; +} + +export interface DeliveryReceiptTransportHooks { + /** Test seam; production reads from the transport's `data` mutation. */ + onAttempt?: (receiptId: string, status: DeliveryReceiptStatus) => void; +} + +/** Wrap a channel transport so every attempt produces a delivery receipt. */ +export function withDeliveryReceipts( + channel: NotificationChannel, + transport: ChannelTransport, + service: DeliveryReceiptService, + hooks: DeliveryReceiptTransportHooks = {} +): ChannelTransport { + return async (input) => { + const type = resolveType(input.data); + let accepted = false; + let error: string | undefined; + + try { + accepted = await transport(input); + } catch (err) { + error = err instanceof Error ? err.message : String(err); + // The center records the failure too; the receipt exists so support can + // see the provider-level outcome independently of the notification status. + } + + const receipt = await service.recordAttempt({ + userId: input.userId, + type, + channel, + providerMessageId: input.data?.[PROVIDER_MESSAGE_ID_KEY] || undefined, + accepted, + error, + }); + hooks.onAttempt?.(receipt.id, receipt.status); + + // Preserve the transport's contract: a throw must still propagate so the + // notification center can attach the reason to the delivery record. + if (error && !accepted) throw new Error(error); + return accepted; + }; +} diff --git a/backend/services/notification/deliveryStatusRouter.ts b/backend/services/notification/deliveryStatusRouter.ts new file mode 100644 index 00000000..fd729758 --- /dev/null +++ b/backend/services/notification/deliveryStatusRouter.ts @@ -0,0 +1,238 @@ +/** + * deliveryStatusRouter + * + * Plain Node.js HTTP handler exposing delivery receipts (#1749) and receiving + * provider status callbacks. Mounts under `/api/delivery-receipts` in server.ts. + * + * Routes + * ────── + * GET /api/delivery-receipts?userId=…[&channel=…&limit=…] Receipt list + * GET /api/delivery-receipts/summary?userId=… Latest per channel + * GET /api/delivery-receipts/:id Single receipt + * POST /api/delivery-receipts/status Provider callback + * + * The status callback accepts Twilio's `application/x-www-form-urlencoded` + * payload (`MessageSid`, `MessageStatus`, `ErrorCode`) as well as JSON. Twilio + * authenticates with HTTP Basic (account SID as username); set + * `TWILIO_ACCOUNT_SID` to enforce that check — when unset (local dev, tests) + * the callback is accepted without credentials. + * + * All responses are JSON; errors are `{ error: string }`. The callback + * acknowledges unknown message SIDs with 202 so providers do not retry forever. + */ + +import type http from 'node:http'; +import { URL } from 'node:url'; + +import { + normalizeProviderStatus, + type DeliveryReceiptService, + type DeliveryReceiptStatus, +} from '../notification/deliveryReceiptService'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const CHANNELS = ['email', 'push', 'sms', 'in_app'] as const; + +function sendJson(res: http.ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +async function readRawBody(req: http.IncomingMessage, maxBytes = 64_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', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} + +/** + * Parse a status callback body. + * + * Twilio posts `application/x-www-form-urlencoded`; other integrations may use + * JSON. A JSON body is detected by its leading `{`; anything else is treated as + * form data so a stray content-type header cannot desync the parser. + */ +export function parseStatusCallback( + raw: string, + contentType: string | undefined +): Record { + const trimmed = raw.trim(); + if (!trimmed) return {}; + + const looksJson = trimmed.startsWith('{') || (contentType ?? '').includes('application/json'); + if (looksJson) { + try { + const parsed = JSON.parse(trimmed) as Record; + return Object.fromEntries( + Object.entries(parsed).map(([key, value]) => [key, value == null ? '' : String(value)]) + ); + } catch { + // Fall through to form parsing — some senders set a JSON content-type on + // a form body. + } + } + + return Object.fromEntries(new URLSearchParams(trimmed).entries()); +} + +/** + * Twilio posts the account SID as HTTP Basic username with an empty password. + * Mirrors `isAuthorisedTwilioRequest` in the preferences controller. + */ +export function isAuthorisedCallback( + authorizationHeader: string | undefined, + expectedAccountSid: string +): boolean { + if (!authorizationHeader) return false; + const [scheme, encoded] = authorizationHeader.split(' '); + if (!scheme || scheme.toLowerCase() !== 'basic' || !encoded) return false; + const decoded = Buffer.from(encoded, 'base64').toString('utf8'); + const separator = decoded.indexOf(':'); + return (separator === -1 ? decoded : decoded.slice(0, separator)) === expectedAccountSid; +} + +function extractId(pathname: string, prefix: string): string | null { + const after = pathname.slice(prefix.length); + if (!after || after === '/') return null; + const segment = after.replace(/^\//, '').split('/')[0]; + return segment || null; +} + +// ─── Router factory ─────────────────────────────────────────────────────────── + +export interface DeliveryStatusRouterOptions { + /** Expected Twilio Account SID; when omitted the callback skips auth. */ + accountSid?: string; +} + +export function createDeliveryStatusRouter( + service: DeliveryReceiptService, + options: DeliveryStatusRouterOptions = {} +) { + const PREFIX = '/api/delivery-receipts'; + + return async function handleDeliveryStatusRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + pathname: string + ): Promise { + const method = req.method ?? 'GET'; + if (!pathname.startsWith(PREFIX)) return false; + + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); + + // ── POST /api/delivery-receipts/status (provider callback) ─────────────── + if (pathname === `${PREFIX}/status` && method === 'POST') { + if (options.accountSid) { + if (!isAuthorisedCallback(req.headers.authorization, options.accountSid)) { + sendJson(res, 401, { error: 'Unauthorized' }); + return true; + } + } + + let parsed: Record; + try { + parsed = parseStatusCallback( + await readRawBody(req), + req.headers['content-type'] + ); + } catch (err) { + sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) }); + return true; + } + + const providerMessageId = parsed['MessageSid'] ?? parsed['messageSid'] ?? parsed['providerMessageId']; + const rawStatus = parsed['MessageStatus'] ?? parsed['messageStatus'] ?? parsed['status']; + if (!providerMessageId || !rawStatus) { + sendJson(res, 400, { error: 'MessageSid and MessageStatus are required' }); + return true; + } + + const status = normalizeProviderStatus(rawStatus); + if (!status) { + // Recognised shape but a status we do not model — ack and move on. + sendJson(res, 202, { accepted: false, reason: `Unhandled status: ${rawStatus}` }); + return true; + } + + const errorCode = parsed['ErrorCode'] ?? parsed['errorCode']; + const updated = await service.applyStatusUpdate({ + providerMessageId, + status, + detail: errorCode ? `Provider error code ${errorCode}` : undefined, + errorCode: errorCode || undefined, + }); + if (!updated) { + sendJson(res, 202, { accepted: false, reason: 'Unknown message id' }); + return true; + } + sendJson(res, 200, updated); + return true; + } + + // ── GET /api/delivery-receipts/summary?userId=… ────────────────────────── + if (pathname === `${PREFIX}/summary` && method === 'GET') { + const userId = url.searchParams.get('userId'); + if (!userId) { + sendJson(res, 400, { error: 'userId query parameter is required' }); + return true; + } + sendJson(res, 200, await service.summarize(userId)); + return true; + } + + // ── GET /api/delivery-receipts?userId=… (list) ─────────────────────────── + if (pathname === PREFIX && method === 'GET') { + const userId = url.searchParams.get('userId'); + if (!userId) { + sendJson(res, 400, { error: 'userId query parameter is required' }); + return true; + } + const rawChannel = url.searchParams.get('channel'); + if (rawChannel && !CHANNELS.includes(rawChannel as (typeof CHANNELS)[number])) { + sendJson(res, 400, { error: `Unknown channel: ${rawChannel}` }); + return true; + } + const rawLimit = url.searchParams.get('limit'); + const limit = rawLimit ? Math.min(Math.max(parseInt(rawLimit, 10) || 0, 0), 200) : undefined; + + sendJson( + res, + 200, + await service.listForUser(userId, { + channel: (rawChannel as (typeof CHANNELS)[number]) || undefined, + limit, + }) + ); + return true; + } + + const id = extractId(pathname, PREFIX); + + // ── GET /api/delivery-receipts/:id (single) ────────────────────────────── + if (id && method === 'GET') { + const receipt = await service.get(id); + if (!receipt) { + sendJson(res, 404, { error: `Receipt not found: ${id}` }); + } else { + sendJson(res, 200, receipt); + } + return true; + } + + return false; + }; +} + +export type { DeliveryReceiptStatus }; diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md index 2509c94a..bffe6697 100644 --- a/docs/NOTIFICATIONS.md +++ b/docs/NOTIFICATIONS.md @@ -211,6 +211,38 @@ A click implies a read, so engagement is never reported as "clicked but never opened". Marking read twice keeps the first timestamp, so time-to-read stays accurate. +## Delivery receipts + +A history record says a transport *accepted* a message; a **delivery receipt** +tracks what happened afterwards — the carrier's confirmation the SMS landed, the +email bounce, the push ticket that never resolved. Each receipt carries a +forward-only status timeline (`accepted → sent → delivered`, or `failed` / +`undelivered` / `expired`) so support can answer "did the customer actually get +it?" without a provider dashboard. + +Receipts are created by wrapping each channel transport +(`withDeliveryReceipts`, wired in `services/container.ts`), so an attempt that +never reaches a transport leaves no receipt. `in_app` is marked `delivered` on +acceptance; other channels wait for a provider status callback. + +Providers post status to the router (`deliveryStatusRouter`): + +``` +GET /api/delivery-receipts?userId=…[&channel=…&limit=…] Recent receipts +GET /api/delivery-receipts/summary?userId=… Latest per channel +GET /api/delivery-receipts/:id One receipt timeline +POST /api/delivery-receipts/status Provider callback +``` + +The callback accepts Twilio's `application/x-www-form-urlencoded` +(`MessageSid`, `MessageStatus`, `ErrorCode`) or JSON. Transitions are +forward-only and idempotent, so an out-of-order webhook cannot regress a +receipt; an unknown message id is acknowledged with `202` so providers stop +retrying. Set `TWILIO_ACCOUNT_SID` to require Twilio's HTTP Basic credential on +the callback. + +`DeliveryStatusScreen` reads this through `deliveryReceiptsStore`. + ## In-app inbox The inbox is the feed behind the bell icon (`NotificationInboxScreen`, backed by diff --git a/src/navigation/modules/SettingsStack.tsx b/src/navigation/modules/SettingsStack.tsx index 732d4239..21375e58 100644 --- a/src/navigation/modules/SettingsStack.tsx +++ b/src/navigation/modules/SettingsStack.tsx @@ -14,6 +14,7 @@ const LanguageSettingsScreen = lazyScreen(() => import('../../screens/LanguageSe const NotificationPreferencesScreen = lazyScreen( () => import('../../screens/NotificationPreferencesScreen') ); +const DeliveryStatusScreen = lazyScreen(() => import('../../screens/DeliveryStatusScreen')); const NotificationInboxScreen = lazyScreen(() => import('../../screens/NotificationInboxScreen')); const EmailTemplateEditorScreen = lazyScreen( () => import('../../screens/EmailTemplateEditorScreen') @@ -50,6 +51,9 @@ export const SettingsStack = () => ( options={{ title: 'Notification Preferences', headerShown: true }} /> = { + email: 'Email', + push: 'Push', + sms: 'SMS', + in_app: 'In-app', +}; + +interface Props { + /** Defaults to the signed-in user; overridable for support tooling. */ + userId?: string; +} + +const toneColor = ( + tone: 'positive' | 'pending' | 'negative' | 'neutral', + colors: ReturnType +): string => { + switch (tone) { + case 'positive': + return colors.status.success; + case 'pending': + return colors.status.warning; + case 'negative': + return colors.status.error; + default: + return colors.text.secondary; + } +}; + +const formatTimestamp = (iso: string): string => + new Date(iso).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + +const DeliveryStatusScreen = ({ userId: userIdProp }: Props) => { + const colors = useThemeColors(); + const styles = React.useMemo(() => createStyles(colors), [colors]); + + const user = useUserStore((s) => s.user); + const userId = userIdProp ?? user?.id ?? user?.email ?? 'local-user'; + + const refresh = useDeliveryReceiptsStore((s) => s.refresh); + const receipts = useDeliveryReceiptsStore((s) => s.receipts); + const loading = useDeliveryReceiptsStore((s) => s.loading); + const error = useDeliveryReceiptsStore((s) => s.error); + const summaries = useDeliveryReceiptsStore(selectChannelSummaries); + + React.useEffect(() => { + refresh(userId); + }, [refresh, userId]); + + return ( + + + Delivery status + refresh(userId)} + accessibilityRole="button" + accessibilityLabel="Refresh delivery status" + testID="delivery-refresh"> + Refresh + + + + What happened after we handed each notification to its provider — including delivery + confirmations reported back by SMS and email carriers. + + + {loading && receipts.length === 0 && ( + + )} + + {error && ( + + {error} + + )} + + {/* Per-channel summary */} + + {summaries.map((summary) => ( + + {CHANNEL_LABELS[summary.channel]} + {summary.status ? ( + + + {STATUS_PRESENTATION[summary.status].label} + + {summary.updatedAt && ( + {formatTimestamp(summary.updatedAt)} + )} + + ) : ( + No activity + )} + + ))} + + + {/* Recent attempts */} + {receipts.length === 0 && !loading ? ( + + No delivery records + + Once we send you a notification, its delivery status will appear here. + + + ) : ( + receipts.map((receipt) => ( + + )) + )} + + ); +}; + +const ReceiptRow = ({ + receipt, + colors, + styles, +}: { + receipt: DeliveryReceipt; + colors: ReturnType; + styles: ReturnType; +}) => { + const presentation = STATUS_PRESENTATION[receipt.status]; + return ( + + + {CHANNEL_LABELS[receipt.channel]} + + {presentation.label} + + + {presentation.description} + + + {receipt.events.map((event, index) => ( + + + + {STATUS_PRESENTATION[event.status].label} + {' · '} + {formatTimestamp(event.at)} + {event.detail ? ` · ${event.detail}` : ''} + + + ))} + + + {receipt.errorCode && ( + Provider error {receipt.errorCode} + )} + + ); +}; + +/** Map a status to its presentation, exposed for tests and callers. */ +export const presentStatus = (status: DeliveryReceiptStatus) => STATUS_PRESENTATION[status]; + +function createStyles(colors: ReturnType) { + return StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background.primary }, + content: { padding: 16, paddingBottom: 40 }, + headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, + title: { fontSize: 24, fontWeight: '700', color: colors.text.primary }, + refreshText: { fontSize: 14, fontWeight: '600', color: colors.accent }, + subtitle: { fontSize: 13, color: colors.text.secondary, marginTop: 6, marginBottom: 12 }, + loading: { marginVertical: 16 }, + errorBox: { + backgroundColor: colors.status.error + '22', + padding: 12, + borderRadius: 8, + marginBottom: 12, + }, + errorText: { color: colors.status.error, fontSize: 13 }, + card: { + backgroundColor: colors.background.secondary, + borderRadius: 12, + padding: 14, + marginBottom: 12, + }, + summaryRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 6, + }, + summaryChannel: { fontSize: 15, fontWeight: '600', color: colors.text.primary }, + summaryStatus: { alignItems: 'flex-end' }, + summaryStatusText: { fontSize: 14, fontWeight: '700' }, + summaryUpdated: { fontSize: 11, color: colors.text.secondary }, + summaryNone: { fontSize: 13, color: colors.text.secondary }, + empty: { alignItems: 'center', paddingVertical: 32 }, + emptyTitle: { fontSize: 16, fontWeight: '600', color: colors.text.primary }, + emptyDesc: { fontSize: 13, color: colors.text.secondary, marginTop: 4, textAlign: 'center' }, + receiptHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, + receiptChannel: { fontSize: 15, fontWeight: '700', color: colors.text.primary }, + receiptStatus: { fontSize: 14, fontWeight: '700' }, + receiptDesc: { fontSize: 12, color: colors.text.secondary, marginTop: 4 }, + timeline: { marginTop: 10 }, + timelineRow: { flexDirection: 'row', alignItems: 'center', marginTop: 4 }, + timelineDot: { width: 8, height: 8, borderRadius: 4, marginRight: 8 }, + timelineText: { fontSize: 12, color: colors.text.secondary, flex: 1 }, + receiptError: { fontSize: 12, color: colors.status.error, marginTop: 6 }, + }); +} + +export default DeliveryStatusScreen; diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 6178ded5..d129a5e2 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -90,6 +90,24 @@ export const SettingsScreen = () => { + + Delivery Status + navigation.navigate('DeliveryStatus')}> + + Delivery Status + + Whether each notification was delivered, and any errors + + + › + + + Delivery Channels diff --git a/src/screens/__tests__/DeliveryStatusScreen.test.tsx b/src/screens/__tests__/DeliveryStatusScreen.test.tsx new file mode 100644 index 00000000..a5e19f99 --- /dev/null +++ b/src/screens/__tests__/DeliveryStatusScreen.test.tsx @@ -0,0 +1,103 @@ +/** + * Tests for DeliveryStatusScreen (#1749) + * + * Drives the real deliveryReceiptsStore (with a mocked apiClient) and asserts + * the per-channel summary, the receipt timeline, the empty state and the + * refresh action. + */ + +import React from 'react'; + +import { render, fireEvent, waitFor } from '../../test-utils'; +import DeliveryStatusScreen from '../DeliveryStatusScreen'; +import { apiClient } from '../../services/network/apiClient'; +import { useDeliveryReceiptsStore, emptySummary } from '../../store/deliveryReceiptsStore'; +import type { ApiResponse } from '../../services/network/apiClient'; + +jest.mock('../../services/network/apiClient', () => ({ + apiClient: { request: jest.fn() }, +})); + +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: jest.fn(async () => null), + setItem: jest.fn(async () => undefined), + removeItem: jest.fn(async () => undefined), +})); + +const mockRequest = apiClient.request as jest.MockedFunction; +const ok = (data: T): ApiResponse => ({ status: 200, ok: true, data, traceId: 't' }); + +const summary = (userId: string) => ({ + ...emptySummary(userId), + total: 1, + counts: { ...emptySummary(userId).counts, delivered: 1 }, + byChannel: { + ...emptySummary(userId).byChannel, + sms: { + channel: 'sms' as const, + receiptId: 'r1', + status: 'delivered' as const, + updatedAt: '2026-03-15T10:05:00Z', + }, + }, +}); + +const receipt = { + id: 'r1', + userId: 'u1', + type: 'renewal_reminder', + channel: 'sms' as const, + providerMessageId: 'SM123', + status: 'delivered' as const, + events: [ + { status: 'sent' as const, at: '2026-03-15T10:00:00Z' }, + { status: 'delivered' as const, at: '2026-03-15T10:05:00Z' }, + ], + createdAt: '2026-03-15T10:00:00Z', + updatedAt: '2026-03-15T10:05:00Z', +}; + +beforeEach(() => { + mockRequest.mockReset(); + useDeliveryReceiptsStore.getState().reset(); +}); + +describe('DeliveryStatusScreen', () => { + it('renders the per-channel summary and the receipt timeline', async () => { + mockRequest.mockResolvedValueOnce(ok(summary('u1'))).mockResolvedValueOnce(ok([receipt])); + + const screen = render(); + + await waitFor(() => expect(screen.getByTestId('delivery-summary')).toBeTruthy()); + expect(screen.getByTestId('delivery-channel-status-sms')).toBeTruthy(); + expect(screen.getByTestId('delivery-channel-status-email')).toBeFalsy(); + expect(screen.getByTestId('delivery-status-r1')).toBeTruthy(); + expect(screen.getAllByText('Delivered').length).toBeGreaterThan(0); + }); + + it('shows the empty state when there are no receipts', async () => { + mockRequest.mockResolvedValueOnce(ok(emptySummary('u1'))).mockResolvedValueOnce(ok([])); + + const screen = render(); + + await waitFor(() => expect(screen.getByTestId('delivery-empty')).toBeTruthy()); + }); + + it('refreshes on demand', async () => { + mockRequest.mockResolvedValueOnce(ok(emptySummary('u1'))).mockResolvedValueOnce(ok([])); + const screen = render(); + await waitFor(() => expect(mockRequest).toHaveBeenCalledTimes(2)); + + mockRequest.mockResolvedValueOnce(ok(summary('u1'))).mockResolvedValueOnce(ok([receipt])); + fireEvent.press(screen.getByTestId('delivery-refresh')); + + await waitFor(() => expect(screen.getByTestId('delivery-status-r1')).toBeTruthy()); + }); + + it('shows an error banner when the API fails', async () => { + mockRequest.mockRejectedValue(new Error('offline')); + const screen = render(); + await waitFor(() => expect(screen.getByTestId('delivery-error')).toBeTruthy()); + expect(screen.getByText('offline')).toBeTruthy(); + }); +}); diff --git a/src/store/__tests__/deliveryReceiptsStore.test.ts b/src/store/__tests__/deliveryReceiptsStore.test.ts new file mode 100644 index 00000000..fb5482a9 --- /dev/null +++ b/src/store/__tests__/deliveryReceiptsStore.test.ts @@ -0,0 +1,126 @@ +/** + * Tests for deliveryReceiptsStore (#1749) + * + * The store is a thin read-through cache over the delivery-receipts API; these + * tests drive the real store with a mocked `apiClient` and assert the fetch, + * caching, error and reset behaviour. + */ + +import { apiClient } from '../../services/network/apiClient'; +import { + useDeliveryReceiptsStore, + emptySummary, + STATUS_PRESENTATION, +} from '../deliveryReceiptsStore'; +import type { ApiResponse } from '../../services/network/apiClient'; + +jest.mock('../../services/network/apiClient', () => ({ + apiClient: { request: jest.fn() }, +})); + +const mockRequest = apiClient.request as jest.MockedFunction; + +const ok = (data: T): ApiResponse => ({ status: 200, ok: true, data, traceId: 't' }); + +const summary = (userId: string) => ({ + ...emptySummary(userId), + total: 1, + counts: { ...emptySummary(userId).counts, sent: 1 }, + byChannel: { + ...emptySummary(userId).byChannel, + sms: { + channel: 'sms' as const, + receiptId: 'r1', + status: 'sent' as const, + updatedAt: '2026-03-15T10:00:00Z', + }, + }, +}); + +const receipt = (id: string) => ({ + id, + userId: 'u1', + type: 'renewal_reminder', + channel: 'sms' as const, + status: 'sent' as const, + events: [{ status: 'sent' as const, at: '2026-03-15T10:00:00Z' }], + createdAt: '2026-03-15T10:00:00Z', + updatedAt: '2026-03-15T10:00:00Z', +}); + +const store = () => useDeliveryReceiptsStore.getState(); + +beforeEach(() => { + mockRequest.mockReset(); + store().reset(); +}); + +describe('deliveryReceiptsStore.refresh', () => { + it('loads the summary and receipts for a user', async () => { + mockRequest.mockResolvedValueOnce(ok(summary('u1'))).mockResolvedValueOnce(ok([receipt('r1')])); + + await store().refresh('u1'); + + expect(store().userId).toBe('u1'); + expect(store().summary.total).toBe(1); + expect(store().summary.byChannel.sms.status).toBe('sent'); + expect(store().receipts).toHaveLength(1); + expect(store().loading).toBe(false); + expect(store().error).toBeNull(); + }); + + it('records an error when the API responds non-ok', async () => { + mockRequest + .mockResolvedValueOnce({ status: 500, ok: false, data: null as never, traceId: 't' }) + .mockResolvedValueOnce(ok([])); + + await store().refresh('u1'); + + expect(store().loading).toBe(false); + expect(store().error).toContain('500'); + }); + + it('records an error when the request throws', async () => { + mockRequest.mockRejectedValueOnce(new Error('offline')); + await store().refresh('u1'); + expect(store().error).toBe('offline'); + }); +}); + +describe('deliveryReceiptsStore.loadReceipt', () => { + it('serves a cached receipt without hitting the API', async () => { + mockRequest.mockResolvedValueOnce(ok(receipt('r1'))).mockResolvedValueOnce(ok([receipt('r1')])); + await store().refresh('u1'); + mockRequest.mockClear(); + + const found = await store().loadReceipt('r1'); + + expect(found?.id).toBe('r1'); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it('fetches an unseen receipt and caches it', async () => { + mockRequest.mockResolvedValueOnce(ok(receipt('r2'))); + const found = await store().loadReceipt('r2'); + expect(found?.id).toBe('r2'); + expect(store().receipts[0]?.id).toBe('r2'); + }); + + it('returns null when the receipt is unknown', async () => { + mockRequest.mockResolvedValueOnce({ + status: 404, + ok: false, + data: null as never, + traceId: 't', + }); + expect(await store().loadReceipt('missing')).toBeNull(); + }); +}); + +describe('status presentation', () => { + it('labels every status with a tone', () => { + expect(STATUS_PRESENTATION.delivered).toMatchObject({ label: 'Delivered', tone: 'positive' }); + expect(STATUS_PRESENTATION.failed.tone).toBe('negative'); + expect(STATUS_PRESENTATION.sent.tone).toBe('pending'); + }); +}); diff --git a/src/store/deliveryReceiptsStore.ts b/src/store/deliveryReceiptsStore.ts new file mode 100644 index 00000000..c4463421 --- /dev/null +++ b/src/store/deliveryReceiptsStore.ts @@ -0,0 +1,199 @@ +/** + * deliveryReceiptsStore — client cache of per-channel delivery status (#1749) + * + * The backend owns the receipt timeline (providers post status callbacks to + * `/api/delivery-receipts/status`); this store is a read-through cache the UI + * uses to show "Delivered / Failed / Undelivered" without a spinner per render. + * + * Persistence is deliberately off: a status is only meaningful while it is + * fresh, and a stale cached "sent" would mislead more than an empty panel. The + * store hydrates on mount and refreshes on demand. + */ + +import { create } from 'zustand'; + +import { apiClient } from '../services/network/apiClient'; +import type { NotificationChannel } from '../types/notification'; + +// ─── Types ───────────────────────────────────────────────────────────────────── + +export type DeliveryReceiptStatus = + 'accepted' | 'sent' | 'delivered' | 'failed' | 'undelivered' | 'expired'; + +export interface DeliveryReceiptEvent { + status: DeliveryReceiptStatus; + at: string; + detail?: string; +} + +export interface DeliveryReceipt { + id: string; + userId: string; + type: string; + channel: NotificationChannel; + providerMessageId?: string; + status: DeliveryReceiptStatus; + events: DeliveryReceiptEvent[]; + errorCode?: string; + createdAt: string; + updatedAt: string; +} + +export interface ChannelReceiptSummary { + channel: NotificationChannel; + receiptId: string | null; + status: DeliveryReceiptStatus | null; + updatedAt: string | null; +} + +export interface DeliverySummary { + userId: string; + byChannel: Record; + counts: Record; + total: number; +} + +const EMPTY_COUNTS: Record = { + accepted: 0, + sent: 0, + delivered: 0, + failed: 0, + undelivered: 0, + expired: 0, +}; + +export const emptySummary = (userId: string): DeliverySummary => ({ + userId, + byChannel: (['email', 'push', 'sms', 'in_app'] as NotificationChannel[]).reduce( + (acc, channel) => { + acc[channel] = { channel, receiptId: null, status: null, updatedAt: null }; + return acc; + }, + {} as Record + ), + counts: { ...EMPTY_COUNTS }, + total: 0, +}); + +// ─── Display metadata ────────────────────────────────────────────────────────── + +export interface StatusPresentation { + label: string; + tone: 'positive' | 'pending' | 'negative' | 'neutral'; + description: string; +} + +export const STATUS_PRESENTATION: Record = { + accepted: { + label: 'Accepted', + tone: 'pending', + description: 'The provider has taken the message and will send it shortly.', + }, + sent: { + label: 'Sent', + tone: 'pending', + description: 'Handed to the provider, awaiting confirmation of delivery.', + }, + delivered: { + label: 'Delivered', + tone: 'positive', + description: 'The recipient’s device or inbox confirmed receipt.', + }, + failed: { + label: 'Failed', + tone: 'negative', + description: 'The provider refused the message.', + }, + undelivered: { + label: 'Undelivered', + tone: 'negative', + description: 'The provider gave up — the number or address may be unreachable.', + }, + expired: { + label: 'Expired', + tone: 'neutral', + description: 'No delivery confirmation arrived before the message expired.', + }, +}; + +// ─── Store ───────────────────────────────────────────────────────────────────── + +export interface DeliveryReceiptsState { + userId: string | null; + summary: DeliverySummary; + receipts: DeliveryReceipt[]; + loading: boolean; + error: string | null; +} + +export interface DeliveryReceiptsActions { + /** Fetch the summary and recent receipts for a user. */ + refresh: (userId: string) => Promise; + /** Load a single receipt's full timeline. */ + loadReceipt: (receiptId: string) => Promise; + reset: () => void; +} + +export type DeliveryReceiptsStore = DeliveryReceiptsState & DeliveryReceiptsActions; + +export const useDeliveryReceiptsStore = create((set, get) => ({ + userId: null, + summary: emptySummary(''), + receipts: [], + loading: false, + error: null, + + refresh: async (userId) => { + set({ userId, loading: true, error: null }); + try { + const [summaryRes, listRes] = await Promise.all([ + apiClient.request( + `/api/delivery-receipts/summary?userId=${encodeURIComponent(userId)}` + ), + apiClient.request( + `/api/delivery-receipts?userId=${encodeURIComponent(userId)}&limit=50` + ), + ]); + if (!summaryRes.ok || !listRes.ok) { + set({ loading: false, error: `Failed to load delivery status (${summaryRes.status})` }); + return; + } + set({ + summary: summaryRes.data, + receipts: listRes.data ?? [], + loading: false, + error: null, + }); + } catch (err) { + set({ loading: false, error: err instanceof Error ? err.message : String(err) }); + } + }, + + loadReceipt: async (receiptId) => { + // Prefer the cached copy; a callback may not have arrived yet, so hit the + // API only when we have never seen the id. + const cached = get().receipts.find((receipt) => receipt.id === receiptId); + if (cached) return cached; + try { + const response = await apiClient.request( + `/api/delivery-receipts/${encodeURIComponent(receiptId)}` + ); + if (!response.ok) return null; + set((state) => ({ + receipts: [response.data, ...state.receipts.filter((r) => r.id !== response.data.id)], + })); + return response.data; + } catch { + return null; + } + }, + + reset: () => + set({ userId: null, summary: emptySummary(''), receipts: [], loading: false, error: null }), +})); + +/** Convenience selector — the latest per-channel summary for the active user. */ +export const selectChannelSummaries = (state: DeliveryReceiptsStore): ChannelReceiptSummary[] => + (['email', 'push', 'sms', 'in_app'] as NotificationChannel[]).map( + (channel) => state.summary.byChannel[channel] + );