Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions backend/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -534,6 +536,9 @@ feat/stellar-wave-issues
reportSnapshotFactory
);

const deliveryStatusRouter = createDeliveryStatusRouter(deliveryReceiptService, {
accountSid: process.env.TWILIO_ACCOUNT_SID || undefined,
});
const notificationTemplateRouter = createNotificationTemplateRouter(
notificationTemplateVersionService
);
Expand Down Expand Up @@ -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')) {
Expand Down
12 changes: 10 additions & 2 deletions backend/services/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────
Expand Down
267 changes: 267 additions & 0 deletions backend/services/notification/__tests__/deliveryReceiptService.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> }) => {
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');
});
});
Loading
Loading