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
11 changes: 11 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 { createSmsConsentRouter } from './services/notification/smsConsentRouter';
import { smsConsentService } from './services/container';
import { createNotificationInboxRouter } from './services/notification/notificationInboxRouter';
import { notificationCenterService } from './services/notification/notificationCenterService';
import { createScheduledReportPoller } from './services/notification/jobs/scheduledReportJob';
Expand Down Expand Up @@ -530,6 +532,7 @@ feat/stellar-wave-issues
reportSnapshotFactory
);

const smsConsentRouter = createSmsConsentRouter(smsConsentService);
// ---------------------------------------------------------------------------
// In-app notification inbox — issue #1750
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -2111,6 +2114,14 @@ feat/stellar-wave-issues
return;
}

// -----------------------------------------------------------------
// SMS consent ledger /api/sms-consent (issue #1747)
// -----------------------------------------------------------------
if (pathname.startsWith('/api/sms-consent')) {
const handled = await smsConsentRouter(req, res, pathname);
if (handled) return;
}

// -----------------------------------------------------------------
// In-app notification inbox /api/notifications (issue #1750)
// -----------------------------------------------------------------
Expand Down
13 changes: 13 additions & 0 deletions backend/services/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,8 +387,21 @@ container.register('ITransactionalEmailRenderer', { renderTransactionalEmail });

// ── Two-Way SMS (#1256) ───────────────────────────────────────────────────────
import { smsInboundHandler } from './notification/smsInboundHandler';
import {
InMemorySmsConsentRepository,
SmsConsentService,
} from './notification/smsConsentService';
container.register('ISmsInboundHandler', smsInboundHandler);

// ── SMS consent ledger (#1747) ────────────────────────────────────────────────
// Mirrors consent changes into the shared provider opt-out registry so the
// low-level send path refuses opted-out numbers even if a caller skips us.
export const smsConsentService = new SmsConsentService({
repository: new InMemorySmsConsentRepository(),
optOutRegistry: optOutStore,
});
container.register('ISmsConsentService', smsConsentService);

// ── Voice Call Reminders (#1255) ──────────────────────────────────────────────
import { voiceReminderService } from './notification/voiceReminderService';
container.register('IVoiceReminderService', voiceReminderService);
Expand Down
189 changes: 189 additions & 0 deletions backend/services/notification/__tests__/smsConsentRouter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/**
* smsConsentRouter (#1747)
*
* Covers routing, JSON responses, body parsing, source validation, and the
* 404/400 error paths. The router is driven with lightweight request/response
* doubles rather than a live server.
*/

import { describe, expect, it, beforeEach } from '@jest/globals';
import type http from 'node:http';

import { createSmsConsentRouter } from '../smsConsentRouter';
import {
InMemorySmsConsentRepository,
SmsConsentService,
} from '../smsConsentService';

interface Captured {
status: number;
body: unknown;
}

/**
* Simpler driver that resolves the body synchronously: the router's
* `readJsonBody` attaches `data`/`end` listeners, which we trigger here.
*/
async function invoke(
router: ReturnType<typeof createSmsConsentRouter>,
method: string,
pathname: string,
body?: Record<string, unknown>
): Promise<Captured> {
const handlers: Record<string, Array<(arg?: unknown) => 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<typeof createSmsConsentRouter>;

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<string, Array<(arg?: unknown) => 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) });
});
});
Loading
Loading