diff --git a/.env.example b/.env.example index 1b705ed..cb112c1 100644 --- a/.env.example +++ b/.env.example @@ -65,22 +65,19 @@ SENDGRID_FROM_EMAIL=noreply@scholarmancy.com SENDGRID_FROM_NAME=Scholaracle SENDGRID_REPLY_TO=rvegajr@yolovibecodebootcamp.com -# --- Twilio SMS (optional) --- -# Obtain from https://www.twilio.com/console -TWILIO_ACCOUNT_SID= -TWILIO_API_KEY_SID= -TWILIO_API_KEY_SECRET= -# Auth token — required in production for webhook signature validation -TWILIO_AUTH_TOKEN= -TWILIO_FROM_NUMBER= -# Messaging Service SID — enables status callbacks + opt-out handling at the service level -TWILIO_MESSAGING_SERVICE_SID= +# --- Scholarmancy SMS (Noctusoft relay) --- +# Outbound: POST https://api.twilio.noctusoft.com/sms/send (Bearer token below). +NOCTUSOFT_API_KEY= +# Inbound webhook HMAC: x-relay-signature = base64(HMAC-SHA256(secret, publicUrl + rawBody)) +RELAY_INBOUND_SECRET= +# Optional overrides (defaults to production api.scholarmancy.com webhook URLs) +# RELAY_TWILIO_SMS_WEBHOOK_URL=https://api.scholarmancy.com/api/webhooks/twilio/sms +# RELAY_TWILIO_STATUS_WEBHOOK_URL=https://api.scholarmancy.com/api/webhooks/twilio/status # --- Noctusoft API Relay (optional) --- # Route vendor API calls through the Noctusoft unified relay gateway instead of -# hitting SendGrid/Twilio directly. Leave unset for direct vendor access. +# hitting SendGrid directly. Leave unset for direct vendor access. # SENDGRID_BASE_URL=https://api.sendgrid.noctusoft.com -# TWILIO_API_BASE_URL=https://api.twilio.noctusoft.com # --- AI Personalization (optional) --- # Anthropic API key for LLM-powered notification personalization diff --git a/packages/agents/package.json b/packages/agents/package.json index 774141f..2fce216 100644 --- a/packages/agents/package.json +++ b/packages/agents/package.json @@ -23,7 +23,7 @@ "@scholaracle/studio-core": "workspace:*", "@scholaracle/logger": "workspace:*", "@sendgrid/mail": "^8.1.0", - "twilio": "^5.0.0", + "libphonenumber-js": "^1.12.9", "mongodb": "^6.3.0", "nodemailer": "^6.9.0" }, diff --git a/packages/agents/src/delivery/SMSDelivery/SMSDelivery.test.ts b/packages/agents/src/delivery/SMSDelivery/SMSDelivery.test.ts index 9bbe0b9..53bb1bf 100644 --- a/packages/agents/src/delivery/SMSDelivery/SMSDelivery.test.ts +++ b/packages/agents/src/delivery/SMSDelivery/SMSDelivery.test.ts @@ -5,246 +5,43 @@ import { NotificationPriority, AgentType, } from '@scholaracle/contracts'; -import { DeliveryError } from '@scholaracle/contracts'; -import type { Twilio } from 'twilio'; +import type { GuardedSmsSender } from '../../sms/GuardedSmsSender'; describe('SMSDelivery', () => { let smsDelivery: SMSDelivery; - let mockTwilio: { - messages: { - create: jest.Mock; - }; - }; - - const testConfig = { - accountSid: 'test-account-sid', - authToken: 'test-auth-token', - fromNumber: '+15551234567', - }; + let mockGuarded: jest.Mocked>; beforeEach(() => { - mockTwilio = { - messages: { - create: jest.fn(), - }, + mockGuarded = { + sendTransactional: jest.fn().mockResolvedValue({ messageId: 'SM123' }), }; - - smsDelivery = new SMSDelivery(testConfig, mockTwilio as unknown as Twilio); - }); - - afterEach(() => { - jest.clearAllMocks(); + smsDelivery = new SMSDelivery(mockGuarded as unknown as GuardedSmsSender); }); - describe('supports', () => { - it('should return true for SMS channel', () => { - // Act - const result = smsDelivery.supports(NotificationChannel.SMS); - - // Assert - expect(result).toBe(true); - }); - - it('should return false for non-SMS channels', () => { - // Act & Assert - expect(smsDelivery.supports(NotificationChannel.EMAIL)).toBe(false); - expect(smsDelivery.supports(NotificationChannel.PUSH)).toBe(false); - expect(smsDelivery.supports(NotificationChannel.IN_APP)).toBe(false); - }); + it('supports SMS channel only', () => { + expect(smsDelivery.supports(NotificationChannel.SMS)).toBe(true); + expect(smsDelivery.supports(NotificationChannel.EMAIL)).toBe(false); }); - describe('deliver', () => { - it('should deliver SMS notification successfully', async () => { - // Arrange - const notification = new Notification({ - agentType: AgentType.STUDENT, - studentId: 'student-123', - userId: '+15559876543', - subject: 'MISSING ASSIGNMENT', - body: 'Math: Homework 5\nDue: 2 days ago\nValue: 25 points\n\nSubmit immediately.', - priority: NotificationPriority.HIGH, - triggerType: 'missing_assignment', - }); - - const messageSid = 'SM1234567890abcdef'; - mockTwilio.messages.create.mockResolvedValue({ - sid: messageSid, - status: 'queued', - to: notification.userId, - from: testConfig.fromNumber, - body: notification.body, - } as unknown as Awaited>); - - // Act - const result = await smsDelivery.deliver(notification); - - // Assert - expect(result.success).toBe(true); - expect(result.channel).toBe(NotificationChannel.SMS); - expect(result.messageId).toBe(messageSid); - expect(mockTwilio.messages.create).toHaveBeenCalledTimes(1); - const callArgs = mockTwilio.messages.create.mock.calls[0]?.[0] as { - to?: string; - from?: string; - body?: string; - }; - expect(callArgs?.to).toBe(notification.userId); - expect(callArgs?.from).toBe(testConfig.fromNumber); - expect(callArgs?.body).toBeDefined(); - }); - - it('should format SMS body with subject prefix', async () => { - // Arrange - const notification = new Notification({ - agentType: AgentType.PARENT, - studentId: 'student-123', - userId: '+15559876543', - subject: 'John Doe - Grade Drop Alert', - body: 'Math grade dropped from 92% to 85%', - priority: NotificationPriority.HIGH, - triggerType: 'grade_drop', - }); - - mockTwilio.messages.create.mockResolvedValue({ - sid: 'SM123', - status: 'queued', - } as unknown as Awaited>); - - // Act - await smsDelivery.deliver(notification); - - // Assert - const callArgs = mockTwilio.messages.create.mock.calls[0]?.[0] as { - body?: string; - }; - if (callArgs?.body) { - expect(callArgs.body).toContain(notification.subject); - expect(callArgs.body).toContain(notification.body); - } - }); - - it('should truncate body if exceeds SMS length limit', async () => { - // Arrange - const longBody = 'A'.repeat(2000); - const notification = new Notification({ - agentType: AgentType.STUDENT, - studentId: 'student-123', - userId: '+15559876543', - subject: 'Test', - body: longBody, - priority: NotificationPriority.MEDIUM, - triggerType: 'test', - }); - - mockTwilio.messages.create.mockResolvedValue({ - sid: 'SM123', - status: 'queued', - } as unknown as Awaited>); - - // Act - await smsDelivery.deliver(notification); - - // Assert - const callArgs = mockTwilio.messages.create.mock.calls[0]?.[0] as { - body?: string; - }; - expect(callArgs?.body?.length).toBeLessThanOrEqual(1600); - }); - - it('should use userId as recipient phone number', async () => { - // Arrange - const notification = new Notification({ - agentType: AgentType.STUDENT, - studentId: 'student-123', - userId: '+15551234567', - subject: 'Test', - body: 'Test body', - priority: NotificationPriority.MEDIUM, - triggerType: 'test', - }); - - mockTwilio.messages.create.mockResolvedValue({ - sid: 'SM123', - status: 'queued', - } as unknown as Awaited>); - - // Act - await smsDelivery.deliver(notification); - - // Assert - const callArgs = mockTwilio.messages.create.mock.calls[0]?.[0] as { - to?: string; - }; - expect(callArgs?.to).toBe('+15551234567'); - }); - - it('should throw DeliveryError when Twilio API fails', async () => { - // Arrange - const notification = new Notification({ - agentType: AgentType.STUDENT, - studentId: 'student-123', - userId: '+15551234567', - subject: 'Test', - body: 'Test body', - priority: NotificationPriority.HIGH, - triggerType: 'test', - }); - - const twilioError = new Error('Invalid phone number'); - mockTwilio.messages.create.mockRejectedValue(twilioError); - - // Act & Assert - await expect(smsDelivery.deliver(notification)).rejects.toThrow(DeliveryError); - await expect(smsDelivery.deliver(notification)).rejects.toThrow( - expect.objectContaining({ - channel: NotificationChannel.SMS, - }) - ); - }); - - it('should handle Twilio error response format', async () => { - // Arrange - const notification = new Notification({ - agentType: AgentType.STUDENT, - studentId: 'student-123', - userId: '+15551234567', - subject: 'Test', - body: 'Test body', - priority: NotificationPriority.HIGH, - triggerType: 'test', - }); - - const twilioError = { - code: 21211, - message: "Invalid 'To' Phone Number", - status: 400, - }; - mockTwilio.messages.create.mockRejectedValue(twilioError); - - // Act & Assert - await expect(smsDelivery.deliver(notification)).rejects.toThrow(DeliveryError); - }); - - it('should handle error without message property', async () => { - // Arrange - const notification = new Notification({ - agentType: AgentType.STUDENT, - studentId: 'student-123', - userId: '+15551234567', - subject: 'Test', - body: 'Test body', - priority: NotificationPriority.HIGH, - triggerType: 'test', - }); - - const errorWithoutMessage = { code: 500 }; - mockTwilio.messages.create.mockRejectedValue(errorWithoutMessage); - - // Act & Assert - await expect(smsDelivery.deliver(notification)).rejects.toThrow(DeliveryError); - await expect(smsDelivery.deliver(notification)).rejects.toThrow( - 'Unknown error occurred during SMS delivery' - ); - }); + it('delivers via guarded sender with formatted body', async () => { + const notification = new Notification({ + id: 'n1', + agentType: AgentType.PARENT, + studentId: 'stu-1', + userId: '+15125550100', + subject: 'Due tomorrow', + body: 'Math homework', + priority: NotificationPriority.MEDIUM, + triggerType: 'deadline', + channels: [NotificationChannel.SMS], + }); + const result = await smsDelivery.deliver(notification); + expect(result.success).toBe(true); + expect(result.messageId).toBe('SM123'); + expect(mockGuarded.sendTransactional).toHaveBeenCalledWith( + '+15125550100', + 'Due tomorrow\n\nMath homework', + expect.objectContaining({ subject: 'Due tomorrow' }) + ); }); }); diff --git a/packages/agents/src/delivery/SMSDelivery/SMSDelivery.ts b/packages/agents/src/delivery/SMSDelivery/SMSDelivery.ts index 3104ace..7bf6878 100644 --- a/packages/agents/src/delivery/SMSDelivery/SMSDelivery.ts +++ b/packages/agents/src/delivery/SMSDelivery/SMSDelivery.ts @@ -5,141 +5,49 @@ import { NotificationChannel, DeliveryError, } from '@scholaracle/contracts'; -import type { Twilio } from 'twilio'; - -export interface ISMSDeliveryConfig { - readonly accountSid: string; - readonly authToken: string; - readonly fromNumber: string; - readonly messagingServiceSid?: string; -} - -const MAX_SMS_LENGTH = 1600; +import type { GuardedSmsSender } from '../../sms/GuardedSmsSender'; /** - * SMS delivery service using Twilio. - * Implements INotificationDelivery for SMS channel. + * SMS delivery via GuardedSmsSender (Noctusoft relay, consent-gated). */ export class SMSDelivery implements INotificationDelivery { - private readonly _config: ISMSDeliveryConfig; - private readonly _twilio: Twilio; + constructor(private readonly _guarded: GuardedSmsSender) {} - constructor(config: ISMSDeliveryConfig, twilio: Twilio) { - this._config = config; - this._twilio = twilio; - } - - /** - * Check if this delivery service supports the given channel. - * - * @param channel - The notification channel to check - * @returns True if this service can deliver via the channel - */ public supports(channel: NotificationChannel): boolean { return channel === NotificationChannel.SMS; } - /** - * Deliver a notification via SMS. - * - * @param notification - The notification to deliver - * @returns Delivery result with success status and message ID - * @throws {DeliveryError} If delivery fails - */ public async deliver(notification: Notification): Promise { try { const smsBody = this._formatSmsBody(notification.subject, notification.body); - - const message = await this._twilio.messages.create({ - to: notification.userId, - ...(this._config.messagingServiceSid - ? { messagingServiceSid: this._config.messagingServiceSid } - : { from: this._config.fromNumber }), - body: smsBody, + const result = await this._guarded.sendTransactional(notification.userId, smsBody, { + userId: notification.userId, + subject: notification.subject, + templateName: 'notification', + triggeredBy: 'system', }); - return { success: true, channel: NotificationChannel.SMS, - messageId: message.sid, + messageId: result.messageId, deliveredAt: new Date(), }; } catch (error) { - throw this._createDeliveryError(error, notification.id); - } - } - - /** - * Create DeliveryError from unknown error. - * - * @param error - Unknown error - * @param notificationId - Notification ID for error context - * @returns DeliveryError instance - */ - private _createDeliveryError(error: unknown, notificationId: string): DeliveryError { - const errorMessage = this._extractErrorMessage(error); - const errorCode = this._extractErrorCode(error); - - return new DeliveryError(`Failed to deliver SMS: ${errorMessage}`, NotificationChannel.SMS, { - notificationId, - errorCode, - errorMessage, - }); - } - - /** - * Extract error message from unknown error. - * - * @param error - Unknown error - * @returns Error message string - */ - private _extractErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - - if ( - error && - typeof error === 'object' && - 'message' in error && - typeof error.message === 'string' - ) { - return error.message; + if (error instanceof DeliveryError) { + throw error; + } + throw new DeliveryError( + error instanceof Error ? error.message : 'Unknown SMS error', + NotificationChannel.SMS, + { notificationId: notification.id } + ); } - - return 'Unknown error occurred during SMS delivery'; } - /** - * Extract error code from unknown error. - * - * @param error - Unknown error - * @returns Error code or undefined - */ - private _extractErrorCode(error: unknown): number | undefined { - if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'number') { - return error.code; - } - - return undefined; - } - - /** - * Format SMS body from notification subject and body. - * Truncates if exceeds SMS length limit. - * - * @param subject - Notification subject - * @param body - Notification body - * @returns Formatted SMS body - */ private _formatSmsBody(subject: string, body: string): string { - const fullBody = `${subject}\n\n${body}`; - - if (fullBody.length <= MAX_SMS_LENGTH) { - return fullBody; + if (!subject.trim()) { + return body; } - - const truncatedBody = body.substring(0, MAX_SMS_LENGTH - subject.length - 10); - return `${subject}\n\n${truncatedBody}...`; + return `${subject}\n\n${body}`; } } diff --git a/packages/agents/src/delivery/SMSDelivery/applyTwilioApiBaseUrl.test.ts b/packages/agents/src/delivery/SMSDelivery/applyTwilioApiBaseUrl.test.ts deleted file mode 100644 index 4bf3500..0000000 --- a/packages/agents/src/delivery/SMSDelivery/applyTwilioApiBaseUrl.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { applyTwilioApiBaseUrl } from './applyTwilioApiBaseUrl'; -import type { Twilio } from 'twilio'; - -function makeFakeClient(): Twilio { - return { api: { baseUrl: 'https://api.twilio.com' } } as unknown as Twilio; -} - -describe('applyTwilioApiBaseUrl', () => { - it('overrides the api domain base URL when provided', () => { - const client = makeFakeClient(); - - const result = applyTwilioApiBaseUrl(client, 'https://api.twilio.noctusoft.com'); - - expect((result as unknown as { api: { baseUrl: string } }).api.baseUrl).toBe( - 'https://api.twilio.noctusoft.com' - ); - }); - - it('strips a trailing slash from the override', () => { - const client = makeFakeClient(); - - applyTwilioApiBaseUrl(client, 'https://api.twilio.noctusoft.com/'); - - expect((client as unknown as { api: { baseUrl: string } }).api.baseUrl).toBe( - 'https://api.twilio.noctusoft.com' - ); - }); - - it('leaves the client untouched when no override is provided', () => { - const client = makeFakeClient(); - - applyTwilioApiBaseUrl(client, undefined); - - expect((client as unknown as { api: { baseUrl: string } }).api.baseUrl).toBe( - 'https://api.twilio.com' - ); - }); - - it('returns the same client instance for chaining', () => { - const client = makeFakeClient(); - - expect(applyTwilioApiBaseUrl(client, 'https://relay.example.com')).toBe(client); - }); -}); diff --git a/packages/agents/src/delivery/SMSDelivery/applyTwilioApiBaseUrl.ts b/packages/agents/src/delivery/SMSDelivery/applyTwilioApiBaseUrl.ts deleted file mode 100644 index 2cad911..0000000 --- a/packages/agents/src/delivery/SMSDelivery/applyTwilioApiBaseUrl.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Twilio } from 'twilio'; - -/** - * Point a Twilio client's REST API domain at an alternative base URL, e.g. a - * Twilio-compatible relay such as the Noctusoft gateway. No-op when baseUrl is - * unset, so callers can pass the raw env value. Returns the client for chaining. - */ -export function applyTwilioApiBaseUrl(client: Twilio, baseUrl?: string): Twilio { - if (baseUrl) { - (client as unknown as { api: { baseUrl: string } }).api.baseUrl = baseUrl.replace(/\/+$/, ''); - } - return client; -} diff --git a/packages/agents/src/delivery/SMSDelivery/index.ts b/packages/agents/src/delivery/SMSDelivery/index.ts index e055e5a..1584fc6 100644 --- a/packages/agents/src/delivery/SMSDelivery/index.ts +++ b/packages/agents/src/delivery/SMSDelivery/index.ts @@ -1,3 +1 @@ export { SMSDelivery } from './SMSDelivery'; -export type { ISMSDeliveryConfig } from './SMSDelivery'; -export { applyTwilioApiBaseUrl } from './applyTwilioApiBaseUrl'; diff --git a/packages/agents/src/delivery/index.ts b/packages/agents/src/delivery/index.ts index 86be4a0..4536488 100644 --- a/packages/agents/src/delivery/index.ts +++ b/packages/agents/src/delivery/index.ts @@ -27,7 +27,7 @@ export type { IExpoPushTokenStore, ExpoPushSender, } from './ExpoPushDelivery'; -export { SMSDelivery, applyTwilioApiBaseUrl } from './SMSDelivery'; -export type { ISMSDeliveryConfig } from './SMSDelivery'; +export { SMSDelivery } from './SMSDelivery'; +export * from '../sms'; export { InAppDelivery } from './InAppDelivery'; export { DeliveryRouter } from './DeliveryRouter'; diff --git a/packages/agents/src/guidance/ReminderNotificationSink.ts b/packages/agents/src/guidance/ReminderNotificationSink.ts index 3be5170..f265ea8 100644 --- a/packages/agents/src/guidance/ReminderNotificationSink.ts +++ b/packages/agents/src/guidance/ReminderNotificationSink.ts @@ -24,7 +24,7 @@ export class ReminderNotificationSink implements INotificationSink { }): Promise { const email = await this._deps.resolveEmail(input.audience, input.studentId); if (email === null || email === '') return; - const subject = input.audience === 'parent' ? 'Scholaracle' : 'A next step'; + const subject = input.audience === 'parent' ? 'Scholarmancy' : 'A next step'; await this._deps.notificationService.sendReminder( email, 'email', diff --git a/packages/agents/src/index.ts b/packages/agents/src/index.ts index 4fd44a2..a0fff34 100644 --- a/packages/agents/src/index.ts +++ b/packages/agents/src/index.ts @@ -10,4 +10,5 @@ export * from './scheduler'; export * from './queue'; export * from './worker'; export * from './recipient-resolver'; +export * from './sms'; export * from './ai'; diff --git a/packages/agents/src/integration/NotificationFlow.integration.test.ts b/packages/agents/src/integration/NotificationFlow.integration.test.ts index f44cf5d..4272bb5 100644 --- a/packages/agents/src/integration/NotificationFlow.integration.test.ts +++ b/packages/agents/src/integration/NotificationFlow.integration.test.ts @@ -19,7 +19,7 @@ import { } from '@scholaracle/contracts'; import type { Db } from 'mongodb'; import { MongoClient } from 'mongodb'; -import type { Twilio } from 'twilio'; +import type { GuardedSmsSender } from '../sms/GuardedSmsSender'; describe('NotificationFlow Integration', () => { jest.setTimeout(30_000); @@ -38,7 +38,7 @@ describe('NotificationFlow Integration', () => { let inAppDelivery: InAppDelivery; let pushDelivery: PushDelivery; let mockEmailTransport: jest.Mocked; - let mockTwilio: jest.Mocked; + let mockGuardedSms: jest.Mocked>; async function waitForCount(params: { readonly collection: string; @@ -84,25 +84,16 @@ describe('NotificationFlow Integration', () => { } as unknown as jest.Mocked; (mockEmailTransport.send as jest.Mock).mockResolvedValue({}); - mockTwilio = { - messages: { - create: jest.fn(), - }, - } as unknown as jest.Mocked; + mockGuardedSms = { + sendTransactional: jest.fn().mockResolvedValue({ messageId: 'sms-123' }), + }; emailDelivery = new EmailDelivery( { fromEmail: 'test@example.com', fromName: 'Test' }, mockEmailTransport ); - smsDelivery = new SMSDelivery( - { - accountSid: 'test-account-sid', - authToken: 'test-auth-token', - fromNumber: '+1234567890', - }, - mockTwilio - ); + smsDelivery = new SMSDelivery(mockGuardedSms as unknown as GuardedSmsSender); inAppDelivery = new InAppDelivery(); pushDelivery = new PushDelivery({ projectId: 'test' }); @@ -175,11 +166,6 @@ describe('NotificationFlow Integration', () => { // Mock SendGrid and Twilio responses (mockEmailTransport.send as jest.Mock).mockResolvedValue({ messageId: 'email-123' }); - (mockTwilio.messages.create as jest.Mock).mockResolvedValue({ - sid: 'sms-123', - status: 'queued', - }); - // Act Step 1: Generate notifications from alert const studentNotification = studentGenerator.generate(alert); const parentNotification = parentGenerator.generate(alert); diff --git a/packages/agents/src/sms/GuardedSmsSender.test.ts b/packages/agents/src/sms/GuardedSmsSender.test.ts new file mode 100644 index 0000000..b50dbfb --- /dev/null +++ b/packages/agents/src/sms/GuardedSmsSender.test.ts @@ -0,0 +1,33 @@ +import { TWILIO_OPT_OUT_ERROR_CODE, SCHOLARMANCY_SMS_PURPOSE } from '@scholaracle/contracts'; +import { GuardedSmsSender } from './GuardedSmsSender'; +import type { ISmsConsentRepository } from '@scholaracle/interfaces'; +import type { CommunicationLogRepository } from '@scholaracle/database'; +import type { NoctusoftSmsRelayClient } from './NoctusoftSmsRelayClient'; + +describe('GuardedSmsSender', () => { + it('records opt-out when relay returns 21610', async () => { + const consent: jest.Mocked = { + hasActiveConsent: jest.fn().mockResolvedValue(true), + findByPhoneAndPurpose: jest.fn(), + recordOptIn: jest.fn(), + recordOptOut: jest.fn().mockResolvedValue(undefined), + clearOptOut: jest.fn(), + markConfirmationSent: jest.fn(), + completeReplyYes: jest.fn(), + }; + const commLogs = { + create: jest.fn().mockResolvedValue({}), + } as unknown as CommunicationLogRepository; + const relay = { + send: jest + .fn() + .mockRejectedValue( + Object.assign(new Error('opt out'), { code: TWILIO_OPT_OUT_ERROR_CODE }) + ), + } as unknown as NoctusoftSmsRelayClient; + + const sender = new GuardedSmsSender(relay, consent, commLogs); + await expect(sender.sendTransactional('+15125550100', 'Hello')).rejects.toThrow(); + expect(consent.recordOptOut).toHaveBeenCalledWith('+15125550100', SCHOLARMANCY_SMS_PURPOSE); + }); +}); diff --git a/packages/agents/src/sms/GuardedSmsSender.ts b/packages/agents/src/sms/GuardedSmsSender.ts new file mode 100644 index 0000000..108ee74 --- /dev/null +++ b/packages/agents/src/sms/GuardedSmsSender.ts @@ -0,0 +1,127 @@ +import { + DeliveryError, + NotificationChannel, + SCHOLARMANCY_SMS_PURPOSE, + TWILIO_OPT_OUT_ERROR_CODE, +} from '@scholaracle/contracts'; +import type { ISmsConsentRepository } from '@scholaracle/interfaces'; +import type { CommunicationLogRepository, CommunicationTrigger } from '@scholaracle/database'; +import { createLogger } from '@scholaracle/logger'; + +const log = createLogger('guarded-sms'); +import { ensureBrandSmsBody } from './ensureBrandPrefix'; +import { normalizePhoneE164 } from './normalizePhoneE164'; +import { NoctusoftSmsRelayClient } from './NoctusoftSmsRelayClient'; + +export interface IGuardedSmsSendMeta { + readonly userId?: string; + readonly subject?: string; + readonly templateName?: string; + readonly triggeredBy?: CommunicationTrigger; +} + +/** + * Single outbound SMS gate: consent, brand prefix, relay send, comm log, opt-out on 21610. + */ +export class GuardedSmsSender { + constructor( + private readonly _relay: NoctusoftSmsRelayClient, + private readonly _consent: ISmsConsentRepository, + private readonly _commLogs: CommunicationLogRepository, + private readonly _purpose: string = SCHOLARMANCY_SMS_PURPOSE + ) {} + + public async sendTransactional( + to: string, + body: string, + meta: IGuardedSmsSendMeta = {} + ): Promise<{ messageId: string }> { + const phoneE164 = normalizePhoneE164(to); + if (!phoneE164) { + throw new DeliveryError('Invalid phone number', NotificationChannel.SMS, { + errorMessage: 'invalid_phone', + }); + } + const hasConsent = await this._consent.hasActiveConsent(phoneE164, this._purpose); + if (!hasConsent) { + throw new DeliveryError( + 'SMS refused: no active consent for this number', + NotificationChannel.SMS, + { + errorMessage: 'no_consent', + } + ); + } + return this._sendRelay(phoneE164, body, meta); + } + + public async sendDoubleOptInConfirmation( + to: string, + inviterName: string + ): Promise<{ messageId: string }> { + const phoneE164 = normalizePhoneE164(to); + if (!phoneE164) { + throw new DeliveryError('Invalid phone number', NotificationChannel.SMS, { + errorMessage: 'invalid_phone', + }); + } + const who = inviterName.trim() || 'Someone'; + const body = ensureBrandSmsBody( + `${who} added this number for ${this._purpose}. Reply YES to receive these texts. Reply STOP to opt out.` + ); + const result = await this._sendRelay(phoneE164, body, { + templateName: 'sms_double_opt_in', + triggeredBy: 'system', + }); + await this._consent.markConfirmationSent(phoneE164, this._purpose); + return result; + } + + private async _sendRelay( + phoneE164: string, + body: string, + meta: IGuardedSmsSendMeta + ): Promise<{ messageId: string }> { + const brandedBody = ensureBrandSmsBody(body); + try { + const relayResult = await this._relay.send(phoneE164, brandedBody); + await this._commLogs.create({ + userId: meta.userId ?? phoneE164, + channel: 'sms', + type: 'notification', + subject: meta.subject ?? 'SMS', + content: brandedBody, + recipientPhone: phoneE164, + status: 'sent', + sentAt: new Date(), + triggeredBy: meta.triggeredBy ?? 'system', + templateName: meta.templateName, + providerId: relayResult.messageSid, + }); + return { messageId: relayResult.messageSid }; + } catch (err: unknown) { + const code = + err && + typeof err === 'object' && + 'code' in err && + typeof (err as { code: unknown }).code === 'number' + ? (err as { code: number }).code + : undefined; + if (code === TWILIO_OPT_OUT_ERROR_CODE) { + await this._consent.recordOptOut(phoneE164, this._purpose); + log.info({ phoneE164 }, 'SMS opt-out recorded from relay 21610'); + } + const message = + err && + typeof err === 'object' && + 'message' in err && + typeof (err as { message: unknown }).message === 'string' + ? (err as { message: string }).message + : 'SMS relay error'; + throw new DeliveryError(`Failed to deliver SMS: ${message}`, NotificationChannel.SMS, { + errorCode: code, + errorMessage: message, + }); + } + } +} diff --git a/packages/agents/src/sms/NoctusoftSmsRelayClient.ts b/packages/agents/src/sms/NoctusoftSmsRelayClient.ts new file mode 100644 index 0000000..113f3ad --- /dev/null +++ b/packages/agents/src/sms/NoctusoftSmsRelayClient.ts @@ -0,0 +1,54 @@ +import { SMS_RELAY_SEND_URL, TWILIO_OPT_OUT_ERROR_CODE } from '@scholaracle/contracts'; + +export interface INoctusoftSmsRelayResult { + readonly messageSid: string; +} + +export interface INoctusoftSmsRelayError { + readonly code: number; + readonly message: string; +} + +/** + * POST /sms/send on the Noctusoft Twilio relay (no Twilio SDK). + */ +export class NoctusoftSmsRelayClient { + constructor( + private readonly _apiKey: string, + private readonly _fetchFn: typeof fetch = fetch + ) {} + + public async send(to: string, body: string): Promise { + const res = await this._fetchFn(SMS_RELAY_SEND_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${this._apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ to, body }), + }); + const text = await res.text(); + let payload: { error?: boolean; code?: number; message?: string; sid?: string } = {}; + try { + payload = JSON.parse(text) as typeof payload; + } catch { + payload = {}; + } + if (!res.ok) { + const code = typeof payload.code === 'number' ? payload.code : res.status; + const message = + typeof payload.message === 'string' ? payload.message : `SMS relay failed (${res.status})`; + throw Object.assign(new Error(message), { + code, + isOptOut: code === TWILIO_OPT_OUT_ERROR_CODE, + }); + } + const sid = + typeof payload.sid === 'string' + ? payload.sid + : typeof (payload as { messageSid?: string }).messageSid === 'string' + ? (payload as { messageSid: string }).messageSid + : ''; + return { messageSid: sid || 'unknown' }; + } +} diff --git a/packages/agents/src/sms/createSmsStack.ts b/packages/agents/src/sms/createSmsStack.ts new file mode 100644 index 0000000..2958049 --- /dev/null +++ b/packages/agents/src/sms/createSmsStack.ts @@ -0,0 +1,25 @@ +import type { Db } from 'mongodb'; +import { CommunicationLogRepository, SmsConsentRepository } from '@scholaracle/database'; +import { SMSDelivery } from '../delivery/SMSDelivery/SMSDelivery'; +import { GuardedSmsSender } from './GuardedSmsSender'; +import { NoctusoftSmsRelayClient } from './NoctusoftSmsRelayClient'; + +export interface ISmsStack { + readonly guardedSender: GuardedSmsSender; + readonly smsDelivery: SMSDelivery; + readonly consentRepository: SmsConsentRepository; +} + +/** Builds relay + consent + guarded SMS delivery when NOCTUSOFT_API_KEY is set. */ +export function createSmsStack(database: Db, apiKey?: string): ISmsStack | null { + const key = apiKey ?? process.env['NOCTUSOFT_API_KEY'] ?? ''; + if (!key) { + return null; + } + const consentRepository = new SmsConsentRepository(database); + const commLogs = new CommunicationLogRepository(database); + const relay = new NoctusoftSmsRelayClient(key); + const guardedSender = new GuardedSmsSender(relay, consentRepository, commLogs); + const smsDelivery = new SMSDelivery(guardedSender); + return { guardedSender, smsDelivery, consentRepository }; +} diff --git a/packages/agents/src/sms/ensureBrandPrefix.ts b/packages/agents/src/sms/ensureBrandPrefix.ts new file mode 100644 index 0000000..6883ec2 --- /dev/null +++ b/packages/agents/src/sms/ensureBrandPrefix.ts @@ -0,0 +1,14 @@ +import { SCHOLARMANCY_SMS_BRAND } from '@scholaracle/contracts'; + +const GSM_SINGLE_SEGMENT = 160; + +/** Prefix brand when missing; trim to one segment when possible. */ +export function ensureBrandSmsBody(body: string, brand = SCHOLARMANCY_SMS_BRAND): string { + const trimmed = body.trim(); + const prefix = `${brand}: `; + const withBrand = trimmed.startsWith(`${brand}:`) ? trimmed : `${prefix}${trimmed}`; + if (withBrand.length <= GSM_SINGLE_SEGMENT) { + return withBrand; + } + return `${withBrand.slice(0, GSM_SINGLE_SEGMENT - 3)}...`; +} diff --git a/packages/agents/src/sms/index.ts b/packages/agents/src/sms/index.ts new file mode 100644 index 0000000..c9c7d97 --- /dev/null +++ b/packages/agents/src/sms/index.ts @@ -0,0 +1,7 @@ +export { normalizePhoneE164 } from './normalizePhoneE164'; +export { ensureBrandSmsBody } from './ensureBrandPrefix'; +export { NoctusoftSmsRelayClient } from './NoctusoftSmsRelayClient'; +export { GuardedSmsSender } from './GuardedSmsSender'; +export type { IGuardedSmsSendMeta } from './GuardedSmsSender'; +export { createSmsStack } from './createSmsStack'; +export type { ISmsStack } from './createSmsStack'; diff --git a/packages/agents/src/sms/normalizePhoneE164.test.ts b/packages/agents/src/sms/normalizePhoneE164.test.ts new file mode 100644 index 0000000..451e83b --- /dev/null +++ b/packages/agents/src/sms/normalizePhoneE164.test.ts @@ -0,0 +1,24 @@ +import { normalizePhoneE164 } from './normalizePhoneE164'; + +describe('normalizePhoneE164', () => { + it('normalizes US 10-digit to E.164', () => { + expect(normalizePhoneE164('5125550100')).toBe('+15125550100'); + }); + + it('accepts already E.164', () => { + expect(normalizePhoneE164('+15125550100')).toBe('+15125550100'); + }); + + it('normalizes UK numbers when given international format', () => { + expect(normalizePhoneE164('+447911123456')).toBe('+447911123456'); + }); + + it('returns null for invalid numbers', () => { + expect(normalizePhoneE164('abc')).toBeNull(); + }); + + it('requires non-empty input', () => { + expect(() => normalizePhoneE164('')).toThrow(/required/); + expect(() => normalizePhoneE164(' ')).toThrow(/required/); + }); +}); diff --git a/packages/agents/src/sms/normalizePhoneE164.ts b/packages/agents/src/sms/normalizePhoneE164.ts new file mode 100644 index 0000000..7864cd9 --- /dev/null +++ b/packages/agents/src/sms/normalizePhoneE164.ts @@ -0,0 +1,16 @@ +import { parsePhoneNumberFromString } from 'libphonenumber-js'; + +/** + * Normalizes a phone string to E.164 using libphonenumber-js (default region US). + */ +export function normalizePhoneE164(input: string, defaultRegion: 'US' = 'US'): string | null { + const trimmed = input.trim(); + if (!trimmed) { + throw new Error('Phone number is required'); + } + const parsed = parsePhoneNumberFromString(trimmed, defaultRegion); + if (!parsed?.isValid()) { + return null; + } + return parsed.number; +} diff --git a/packages/agents/src/sms/smsSendSurface.test.ts b/packages/agents/src/sms/smsSendSurface.test.ts new file mode 100644 index 0000000..1104c34 --- /dev/null +++ b/packages/agents/src/sms/smsSendSurface.test.ts @@ -0,0 +1,37 @@ +import { readFileSync, readdirSync, statSync } from 'fs'; +import { join } from 'path'; + +const ROOT = join(__dirname, '..', '..', '..', '..'); +const SCAN_DIRS = ['packages/api/src', 'packages/agents/src', 'packages/workers/src'] as const; +const ALLOWED_RELAY_FILE = 'packages/agents/src/sms/NoctusoftSmsRelayClient.ts'; + +function listTsFiles(dir: string): string[] { + const abs = join(ROOT, dir); + const out: string[] = []; + for (const name of readdirSync(abs)) { + const p = join(abs, name); + const st = statSync(p); + if (st.isDirectory()) { + out.push(...listTsFiles(join(dir, name))); + } else if (name.endsWith('.ts') && !name.endsWith('.test.ts')) { + out.push(join(dir, name)); + } + } + return out; +} + +describe('SMS send surface', () => { + it('only NoctusoftSmsRelayClient calls /sms/send or messages.create', () => { + const violations: string[] = []; + for (const rel of SCAN_DIRS.flatMap((d) => listTsFiles(d))) { + if (rel === ALLOWED_RELAY_FILE) { + continue; + } + const src = readFileSync(join(ROOT, rel), 'utf8'); + if (src.includes('/sms/send') || src.includes('messages.create')) { + violations.push(rel); + } + } + expect(violations).toEqual([]); + }); +}); diff --git a/packages/api/package.json b/packages/api/package.json index 14ef666..302bfeb 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -46,7 +46,6 @@ "nodemailer": "^6.9.0", "playwright": "^1.58.2", "rrule": "^2.7.2", - "twilio": "^5.0.0", "ua-parser-js": "^2.0.9", "zod": "^4.1.13" }, diff --git a/packages/api/src/integration/ApiNotificationFlow.integration.test.ts b/packages/api/src/integration/ApiNotificationFlow.integration.test.ts index 0b7d96d..c5562c9 100644 --- a/packages/api/src/integration/ApiNotificationFlow.integration.test.ts +++ b/packages/api/src/integration/ApiNotificationFlow.integration.test.ts @@ -22,23 +22,6 @@ jest.mock('@sendgrid/mail', () => { }; }); -// Mock Twilio before importing server -jest.mock('twilio', () => { - const mockCreate = jest.fn().mockResolvedValue({ - sid: 'sms-123', - status: 'queued', - }); - - return { - __esModule: true, - default: jest.fn(() => ({ - messages: { - create: mockCreate, - }, - })), - }; -}); - // Mock Firebase Admin before importing server jest.mock('firebase-admin', () => { const mockSend = jest.fn().mockResolvedValue('fcm-message-id'); @@ -101,9 +84,6 @@ describe('API Notification Flow Integration', () => { sendGridApiKey: 'SG.test-key', sendGridFromEmail: 'test@example.com', sendGridFromName: 'Test', - twilioAccountSid: 'TEST_ACCOUNT_SID_PLACEHOLDER_NOT_REAL', - twilioAuthToken: 'test-token', - twilioFromNumber: '+1234567890', }, database ); diff --git a/packages/api/src/routes/agenda/agenda.ts b/packages/api/src/routes/agenda/agenda.ts index ba68b0a..a952008 100644 --- a/packages/api/src/routes/agenda/agenda.ts +++ b/packages/api/src/routes/agenda/agenda.ts @@ -463,7 +463,7 @@ export function agendaRouter(config: IAgendaRouterConfig): Router { const timeStr = timeAt ? new Date(timeAt).toLocaleString() : ''; if (timeStr) parts.push(`Due ${timeStr}`); const body = `Reminder: ${parts.join(' — ')}.`; - const subject = `Scholaracle: ${displayTitle}`; + const subject = `Scholarmancy: ${displayTitle}`; try { const result = await config.notificationService.sendReminder( diff --git a/packages/api/src/routes/auth/auth.ts b/packages/api/src/routes/auth/auth.ts index b5f16de..3abfd69 100644 --- a/packages/api/src/routes/auth/auth.ts +++ b/packages/api/src/routes/auth/auth.ts @@ -15,7 +15,9 @@ import type { IRefreshTokenStore, IOAuthAccountRepository, } from '@scholaracle/database'; -import { StudentRepository, UserRepository } from '@scholaracle/database'; +import { StudentRepository, UserRepository, SmsConsentRepository } from '@scholaracle/database'; +import { normalizePhoneE164 } from '@scholaracle/agents'; +import { recordSmsOptInFromRequest } from '../../services/sms/recordSmsOptIn'; import type { ISessionRepository } from '@scholaracle/database'; import { parseUserAgent } from '../../utils/parseUserAgent'; import { StudentMagicLink } from '../../services/provision/StudentMagicLink'; @@ -146,7 +148,8 @@ async function handleRegister( req: Request, res: Response, authService: AuthService, - sessionRepository?: ISessionRepository + sessionRepository?: ISessionRepository, + consentRepo?: SmsConsentRepository ): Promise { const { email, password, name, phone, smsConsent, rememberMe } = req.body as { email?: string; @@ -161,12 +164,29 @@ async function handleRegister( throw new ValidationError('Missing required fields: email, password, name'); } + let normalizedPhone: string | undefined; + if (phone?.trim()) { + const parsed = normalizePhoneE164(phone); + if (!parsed) { + throw new ValidationError('Invalid phone number'); + } + normalizedPhone = parsed; + } + const hasSmsConsent = smsConsent === true; + if (hasSmsConsent && !normalizedPhone) { + throw new ValidationError('Phone number is required when opting in to SMS'); + } + const result = await authService.register(email, password, name, { - phone: phone || undefined, - smsConsent: smsConsent === true, + phone: normalizedPhone, + smsConsent: hasSmsConsent, rememberMe: rememberMe !== false, }); + if (result.success && hasSmsConsent && normalizedPhone && consentRepo) { + await recordSmsOptInFromRequest(consentRepo, req, normalizedPhone, '/register'); + } + if (result.success && result.user?.id && result.familyId) { await upsertSession(sessionRepository, result.user.id, result.familyId, req); } @@ -562,6 +582,7 @@ async function handleLogout( */ export function authRouter(config: IAuthRouterConfig): Router { const router = Router(); + const consentRepo = new SmsConsentRepository(config.database); const authService = config.authService ?? new AuthService( @@ -584,7 +605,7 @@ export function authRouter(config: IAuthRouterConfig): Router { router.post( '/register', asyncHandler((req: Request, res: Response) => - handleRegister(req, res, authService, config.sessionRepository) + handleRegister(req, res, authService, config.sessionRepository, consentRepo) ) ); diff --git a/packages/api/src/routes/settings/settings.ts b/packages/api/src/routes/settings/settings.ts index 7aebed1..0413f75 100644 --- a/packages/api/src/routes/settings/settings.ts +++ b/packages/api/src/routes/settings/settings.ts @@ -1,7 +1,13 @@ import { Router, type Request, type Response } from 'express'; import type { Db } from 'mongodb'; import { AuthenticationError, NotFoundError, ValidationError } from '@scholaracle/contracts'; -import { UserRepository, CommunicationLogRepository } from '@scholaracle/database'; +import { + UserRepository, + CommunicationLogRepository, + SmsConsentRepository, +} from '@scholaracle/database'; +import { normalizePhoneE164 } from '@scholaracle/agents'; +import { recordSmsOptInFromRequest } from '../../services/sms/recordSmsOptIn'; import type { IAuthService } from '@scholaracle/auth'; import type { IAuthenticatedRequest } from '../../middleware/auth'; import { asyncHandler } from '../../middleware/asyncHandler'; @@ -340,6 +346,8 @@ async function handleGetSettings( profile: { name: user.name, email: user.email, + phone: user.phone ?? '', + smsConsent: user.smsConsent ?? false, oauthProviders: user.oauthProviders ?? [], }, }); @@ -355,7 +363,8 @@ async function handleGetSettings( async function handleUpdateSettings( req: Request, res: Response, - userRepository: UserRepository + userRepository: UserRepository, + consentRepo: SmsConsentRepository ): Promise { const authReq = req as IAuthenticatedRequest; const userId = authReq.userId; @@ -369,13 +378,28 @@ async function handleUpdateSettings( alerts, dashboard: dashboardBody, timezone, + profile, } = req.body as { notifications?: INotificationSettings; alerts?: IAlertThresholds; dashboard?: { gradeDisplay?: 'letter' | 'score' }; timezone?: string; + profile?: { phone?: string; smsConsent?: boolean }; }; + let normalizedPhone: string | undefined; + if (profile?.phone !== undefined && profile.phone.trim()) { + const parsed = normalizePhoneE164(profile.phone); + if (!parsed) { + throw new ValidationError('Invalid phone number'); + } + normalizedPhone = parsed; + } + const profileSmsConsent = profile?.smsConsent === true; + if (profileSmsConsent && !normalizedPhone && profile?.phone !== undefined) { + throw new ValidationError('Phone number is required when opting in to SMS'); + } + if (alerts) { const validationError = validateAlertThresholds(alerts); if (validationError) { @@ -504,11 +528,25 @@ async function handleUpdateSettings( }, }; - const userUpdate: { preferences: IUserPreferences; timezone?: string } = { + const userUpdate: { + preferences: IUserPreferences; + timezone?: string; + phone?: string; + smsConsent?: boolean; + } = { preferences: updatedPreferences, }; if (timezone !== undefined) userUpdate.timezone = timezone; + if (profile?.phone !== undefined) { + userUpdate.phone = normalizedPhone ?? undefined; + } + if (profile?.smsConsent !== undefined) { + userUpdate.smsConsent = profileSmsConsent; + } await userRepository.update(userId, userUpdate); + if (profileSmsConsent && normalizedPhone) { + await recordSmsOptInFromRequest(consentRepo, req, normalizedPhone, '/dashboard/settings'); + } const responseBody = buildSettingsResponse(updatedPreferences); res.status(200).json({ @@ -774,6 +812,7 @@ export function settingsRouter(config: ISettingsRouterConfig): Router { const router = Router(); const userRepository = new UserRepository(config.database); const commLogRepo = new CommunicationLogRepository(config.database); + const consentRepo = new SmsConsentRepository(config.database); /** * GET /api/settings @@ -810,7 +849,9 @@ export function settingsRouter(config: ISettingsRouterConfig): Router { */ router.put( '/', - asyncHandler((req: Request, res: Response) => handleUpdateSettings(req, res, userRepository)) + asyncHandler((req: Request, res: Response) => + handleUpdateSettings(req, res, userRepository, consentRepo) + ) ); /** diff --git a/packages/api/src/routes/students/students.ts b/packages/api/src/routes/students/students.ts index f71a191..a8b6394 100644 --- a/packages/api/src/routes/students/students.ts +++ b/packages/api/src/routes/students/students.ts @@ -56,6 +56,8 @@ import { StudentMagicLink } from '../../services/provision/StudentMagicLink'; import { MagicLoginLink } from '../../services/provision/MagicLoginLink'; import { registerStudentLoginRoutes } from './studentLogin'; import type { IMagicLinkSender } from '../../services/provision/MagicLinkSender'; +import type { GuardedSmsSender } from '@scholaracle/agents'; +import { normalizePhoneE164 } from '@scholaracle/agents'; import { noopSink, registerNudgeRoutes } from './nudge'; export interface IStudentsRouterConfig { @@ -71,6 +73,27 @@ export interface IStudentsRouterConfig { readonly nudgeSink?: import('@scholaracle/interfaces').INotificationSink; /** Optional sender for magic login links (email + SMS). */ readonly magicLinkSender?: IMagicLinkSender; + readonly guardedSmsSender?: GuardedSmsSender | null; +} + +async function sendContactSmsConfirmationIfNeeded( + config: IStudentsRouterConfig, + inviterName: string, + phone?: string, + alertChannels?: readonly ('email' | 'sms')[] +): Promise { + if (!phone?.trim() || !alertChannels?.includes('sms') || !config.guardedSmsSender) { + return; + } + const e164 = normalizePhoneE164(phone); + if (!e164) { + return; + } + try { + await config.guardedSmsSender.sendDoubleOptInConfirmation(e164, inviterName); + } catch { + // Relay or delivery failure — skip surfacing to client + } } // Action-board wire types live in @scholaracle/contracts (types/api/actionBoard.ts). @@ -516,6 +539,13 @@ export function studentsRouter(config: IStudentsRouterConfig): Router { }; const newShared: readonly ISharedParent[] = [...student.sharedWith, newContact]; await studentRepository.update(student._id!, { sharedWith: newShared }); + const inviter = await new UserRepository(config.database).findById(userId); + await sendContactSmsConfirmationIfNeeded( + config, + inviter?.name ?? 'A parent', + newContact.phone, + newContact.alertChannels + ); const baseUrl = config.baseUrl ?? process.env['BASE_URL'] ?? 'http://localhost:2800'; try { await config.sendInviteEmail?.sendInvite({ @@ -677,6 +707,21 @@ export function studentsRouter(config: IStudentsRouterConfig): Router { throw new ForbiddenError('You can only edit your own contact prefs'); } await studentRepository.update(student._id!, { sharedWith: updatedShared }); + const nextContact = updatedShared[idx]!; + const includesSms = nextContact.alertChannels?.includes('sms') ?? false; + if ( + isOwnerOrAdmin && + includesSms && + (body.phone !== undefined || body.alertChannels !== undefined) + ) { + const inviter = await new UserRepository(config.database).findById(userId); + await sendContactSmsConfirmationIfNeeded( + config, + inviter?.name ?? 'A parent', + nextContact.phone, + nextContact.alertChannels + ); + } res.status(200).json({ success: true }); }) ); diff --git a/packages/api/src/routes/webhooks/twilio/relay-signature.middleware.ts b/packages/api/src/routes/webhooks/twilio/relay-signature.middleware.ts new file mode 100644 index 0000000..e9eaf8e --- /dev/null +++ b/packages/api/src/routes/webhooks/twilio/relay-signature.middleware.ts @@ -0,0 +1,38 @@ +import type { Request, Response, NextFunction } from 'express'; +import { verifyRelayInboundSignature } from '../../../services/sms/verifyRelayInboundSignature'; + +export interface IRelaySignatureOptions { + readonly publicUrl: string; + readonly secret?: string; +} + +/** + * Validates x-relay-signature on Twilio webhook forwards from the Noctusoft relay. + */ +export function requireRelayInboundSignature(options: IRelaySignatureOptions) { + return (req: Request, res: Response, next: NextFunction): void => { + const nodeEnv = process.env['NODE_ENV'] ?? 'development'; + const secret = options.secret ?? process.env['RELAY_INBOUND_SECRET'] ?? ''; + if (!secret) { + if (nodeEnv === 'production') { + res.status(503).json({ error: 'Relay inbound secret not configured' }); + return; + } + next(); + return; + } + const signature = req.headers['x-relay-signature'] as string | undefined; + if (!signature) { + res.status(401).json({ error: 'Missing relay signature' }); + return; + } + const captured = (req as Request & { rawBody?: unknown }).rawBody; + const rawBody = typeof captured === 'string' ? captured : ''; + const isValid = verifyRelayInboundSignature(options.publicUrl, rawBody, signature, secret); + if (!isValid) { + res.status(401).json({ error: 'Invalid relay signature' }); + return; + } + next(); + }; +} diff --git a/packages/api/src/routes/webhooks/twilio/twilio-signature.middleware.ts b/packages/api/src/routes/webhooks/twilio/twilio-signature.middleware.ts deleted file mode 100644 index 44ffba2..0000000 --- a/packages/api/src/routes/webhooks/twilio/twilio-signature.middleware.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Request, Response, NextFunction } from 'express'; -import twilio from 'twilio'; - -/** - * Express middleware that validates Twilio request signatures. - * Rejects requests that were not signed by Twilio (prevents spoofed webhooks). - * Only enabled in production — skipped in dev/test so ngrok tunnels work without auth tokens. - */ -export function requireTwilioSignature(authToken: string) { - return (req: Request, res: Response, next: NextFunction): void => { - const signature = req.headers['x-twilio-signature'] as string | undefined; - if (!signature) { - res.status(403).json({ error: 'Missing Twilio signature' }); - return; - } - - const protocol = req.headers['x-forwarded-proto'] ?? req.protocol; - const host = req.headers['host'] ?? ''; - const url = `${protocol}://${host}${req.originalUrl}`; - - const isValid = twilio.validateRequest(authToken, signature, url, req.body); - if (!isValid) { - res.status(403).json({ error: 'Invalid Twilio signature' }); - return; - } - - next(); - }; -} diff --git a/packages/api/src/routes/webhooks/twilio/twilio-test.router.ts b/packages/api/src/routes/webhooks/twilio/twilio-test.router.ts index 1102bd1..56507aa 100644 --- a/packages/api/src/routes/webhooks/twilio/twilio-test.router.ts +++ b/packages/api/src/routes/webhooks/twilio/twilio-test.router.ts @@ -2,8 +2,7 @@ import { Router, type Request, type Response } from 'express'; import type { Db } from 'mongodb'; import { CommunicationLogRepository } from '@scholaracle/database'; import { InternalError, ValidationError } from '@scholaracle/contracts'; -import twilio from 'twilio'; -import { applyTwilioApiBaseUrl } from '@scholaracle/agents'; +import { createSmsStack } from '../../../services/sms/createSmsStack'; import { asyncHandler } from '../../../middleware/asyncHandler'; export interface ITwilioTestRouterConfig { @@ -11,15 +10,12 @@ export interface ITwilioTestRouterConfig { } /** - * Test endpoint for Twilio integration debugging. - * Simulates inbound SMS and status callbacks without Twilio signature validation. - * ONLY mount in development/staging environments. + * Test endpoints for SMS relay debugging (dev/staging only). */ export function twilioTestRouter(config: ITwilioTestRouterConfig): Router { const router = Router(); const commLogRepo = new CommunicationLogRepository(config.database); - // GET /test/simulate-inbound-sms?from=+1234567890&body=STOP router.get( '/simulate-inbound-sms', asyncHandler(async (req: Request, res: Response): Promise => { @@ -35,13 +31,11 @@ export function twilioTestRouter(config: ITwilioTestRouterConfig): Router { Body: body, MessageSid: `SM_TEST_${Date.now()}`, }, - note: 'Send this payload as POST to /api/webhooks/twilio/sms to test inbound handler', - curl: `curl -X POST https://api.scholarmancy.com/api/webhooks/twilio/sms -H "Content-Type: application/x-www-form-urlencoded" -d "From=${encodeURIComponent(from)}&To=${encodeURIComponent(to)}&Body=${encodeURIComponent(body)}&MessageSid=SM_TEST_${Date.now()}"`, + note: 'POST to /api/webhooks/twilio/sms with relay signature', }); }) ); - // GET /test/simulate-status-callback?messageSid=SM123&status=delivered router.get( '/simulate-status-callback', asyncHandler(async (req: Request, res: Response): Promise => { @@ -50,111 +44,49 @@ export function twilioTestRouter(config: ITwilioTestRouterConfig): Router { res.json({ success: true, - simulated: { - MessageSid: messageSid, - MessageStatus: status, - }, - note: 'Send this payload as POST to /api/webhooks/twilio/status to test status callback handler', - curl: `curl -X POST https://api.scholarmancy.com/api/webhooks/twilio/status -H "Content-Type: application/x-www-form-urlencoded" -d "MessageSid=${messageSid}&MessageStatus=${status}"`, + simulated: { MessageSid: messageSid, MessageStatus: status }, }); }) ); - // GET /test/comm-logs?limit=10 router.get( '/comm-logs', asyncHandler(async (req: Request, res: Response): Promise => { const limit = parseInt((req.query['limit'] as string) ?? '10', 10); const logs = await commLogRepo.filterByChannel('sms'); const recent = logs.slice(0, limit); - - res.json({ - success: true, - count: recent.length, - logs: recent.map((log) => ({ - _id: log._id?.toString(), - userId: log.userId, - status: log.status, - providerId: log.providerId, - recipientPhone: log.recipientPhone, - subject: log.subject, - sentAt: log.sentAt, - deliveredAt: log.deliveredAt, - failedAt: log.failedAt, - createdAt: log.createdAt, - })), - }); + res.json({ success: true, count: recent.length, logs: recent }); }) ); - // POST /test/send-sms (requires Twilio credentials in env) router.post( '/send-sms', asyncHandler(async (req: Request, res: Response): Promise => { const { to, body } = req.body as { to?: string; body?: string }; - if (!to || !body) { throw new ValidationError('to and body are required'); } - - const twilioAccountSid = process.env['TWILIO_ACCOUNT_SID']; - const twilioApiKeySid = process.env['TWILIO_API_KEY_SID']; - const twilioApiKeySecret = process.env['TWILIO_API_KEY_SECRET']; - const messagingServiceSid = process.env['TWILIO_MESSAGING_SERVICE_SID']; - - if (!twilioAccountSid || !twilioApiKeySid || !twilioApiKeySecret || !messagingServiceSid) { - throw new InternalError('Twilio credentials not configured'); + const stack = createSmsStack(config.database); + if (!stack) { + throw new InternalError('NOCTUSOFT_API_KEY not configured'); } - - const client = applyTwilioApiBaseUrl( - twilio(twilioApiKeySid, twilioApiKeySecret, { - accountSid: twilioAccountSid, - }), - process.env['TWILIO_API_BASE_URL'] - ); - - const message = await client.messages.create({ - messagingServiceSid, - to, - body, - }); - - res.json({ - success: true, - messageSid: message.sid, - status: message.status, - from: message.from, - to: message.to, + const result = await stack.guardedSender.sendTransactional(to, body, { + templateName: 'dev_test_send', + triggeredBy: 'system', }); + res.json({ success: true, messageSid: result.messageId }); }) ); - // GET /test/twilio-config router.get('/twilio-config', (_req: Request, res: Response): void => { - const hasAccountSid = Boolean(process.env['TWILIO_ACCOUNT_SID']); - const hasApiKey = Boolean(process.env['TWILIO_API_KEY_SID']); - const hasApiSecret = Boolean(process.env['TWILIO_API_KEY_SECRET']); - const hasAuthToken = Boolean(process.env['TWILIO_AUTH_TOKEN']); - const hasFromNumber = Boolean(process.env['TWILIO_FROM_NUMBER']); - const hasMessagingService = Boolean(process.env['TWILIO_MESSAGING_SERVICE_SID']); - + const hasKey = Boolean(process.env['NOCTUSOFT_API_KEY']); + const hasInbound = Boolean(process.env['RELAY_INBOUND_SECRET']); res.json({ configured: { - TWILIO_ACCOUNT_SID: hasAccountSid, - TWILIO_API_KEY_SID: hasApiKey, - TWILIO_API_KEY_SECRET: hasApiSecret, - TWILIO_AUTH_TOKEN: hasAuthToken, - TWILIO_FROM_NUMBER: hasFromNumber, - TWILIO_MESSAGING_SERVICE_SID: hasMessagingService, - }, - values: { - TWILIO_ACCOUNT_SID: hasAccountSid ? process.env['TWILIO_ACCOUNT_SID'] : null, - TWILIO_FROM_NUMBER: hasFromNumber ? process.env['TWILIO_FROM_NUMBER'] : null, - TWILIO_MESSAGING_SERVICE_SID: hasMessagingService - ? process.env['TWILIO_MESSAGING_SERVICE_SID'] - : null, + NOCTUSOFT_API_KEY: hasKey, + RELAY_INBOUND_SECRET: hasInbound, }, - ready: hasAccountSid && hasApiKey && hasApiSecret && hasMessagingService, + ready: hasKey, }); }); diff --git a/packages/api/src/routes/webhooks/twilio/twilio-webhook.handlers.ts b/packages/api/src/routes/webhooks/twilio/twilio-webhook.handlers.ts index 9e7a2c3..3b541ca 100644 --- a/packages/api/src/routes/webhooks/twilio/twilio-webhook.handlers.ts +++ b/packages/api/src/routes/webhooks/twilio/twilio-webhook.handlers.ts @@ -2,19 +2,35 @@ import type { Request, Response } from 'express'; import type { Db } from 'mongodb'; import { CommunicationLogRepository, - AuditLogRepository, + SmsConsentRepository, type CommunicationStatus, } from '@scholaracle/database'; +import { + SCHOLARMANCY_SMS_HELP_LINE, + SCHOLARMANCY_SMS_PURPOSE, + SCHOLARMANCY_SMS_SUPPORT_EMAIL, +} from '@scholaracle/contracts'; +import { normalizePhoneE164 } from '@scholaracle/agents'; -const OPT_OUT_KEYWORDS = new Set(['stop', 'stopall', 'unsubscribe', 'cancel', 'end', 'quit']); -const OPT_IN_KEYWORDS = new Set(['start', 'yes', 'unstop']); +const OPT_OUT_KEYWORDS = new Set([ + 'stop', + 'stopall', + 'unsubscribe', + 'cancel', + 'end', + 'quit', + 'revoke', + 'optout', +]); +const OPT_IN_KEYWORDS = new Set(['start', 'unstop']); +const HELP_KEYWORDS = new Set(['help', 'info']); interface ITwilioSmsBody { readonly MessageSid?: string; readonly From?: string; readonly To?: string; readonly Body?: string; - readonly NumMedia?: string; + readonly OptOutType?: string; } interface ITwilioStatusBody { @@ -34,77 +50,79 @@ const TWILIO_STATUS_MAP: Record = { failed: 'failed', }; +function normalizeFromPhone(from: string): string { + try { + const e164 = normalizePhoneE164(from); + return e164 ?? from.trim(); + } catch { + return from.trim(); + } +} + +function buildHelpTwiml(): string { + const text = + `${SCHOLARMANCY_SMS_HELP_LINE} Help: ${SCHOLARMANCY_SMS_SUPPORT_EMAIL}. ` + + 'Msg frequency varies. Msg & data rates may apply. Reply STOP to opt out.'; + const escaped = text.replace(/&/g, '&').replace(/${escaped}`; +} + /** - * Handle inbound SMS from Twilio. - * Processes opt-out/opt-in keywords (STOP/START) and logs inbound messages. + * Handle inbound SMS from the relay (STOP/START/HELP/YES). */ export function handleInboundSms(database: Db): (req: Request, res: Response) => Promise { - const auditRepo = new AuditLogRepository(database); + const consentRepo = new SmsConsentRepository(database); return async (req: Request, res: Response): Promise => { try { const body = req.body as ITwilioSmsBody; - const from = body.From ?? ''; + const fromRaw = body.From ?? ''; + const phoneE164 = normalizeFromPhone(fromRaw); const messageBody = (body.Body ?? '').trim(); const keyword = messageBody.toLowerCase(); + const optOutType = (body.OptOutType ?? '').toUpperCase(); + + const isStop = OPT_OUT_KEYWORDS.has(keyword) || optOutType === 'STOP'; + const isStart = OPT_IN_KEYWORDS.has(keyword) || optOutType === 'START'; + const isHelp = HELP_KEYWORDS.has(keyword) || optOutType === 'HELP'; + const isYes = keyword === 'yes'; + + if (isStop) { + await consentRepo.recordOptOut(phoneE164, SCHOLARMANCY_SMS_PURPOSE); + res.type('text/xml').send(''); + return; + } - if (OPT_OUT_KEYWORDS.has(keyword)) { - await auditRepo.create({ - adminUserId: 'system', - adminEmail: 'system@twilio-webhook', - action: 'system:config_change', - entityType: 'sms_opt_out', - entityId: from, - reason: `Opt-out received: "${messageBody}"`, - metadata: { phone: from, keyword, messageSid: body.MessageSid }, - ipAddress: req.ip ?? 'unknown', - userAgent: 'twilio-webhook', - }); - res - .type('text/xml') - .send( - 'You have been unsubscribed from Scholaracle notifications. Reply START to re-subscribe.' - ); + if (isStart) { + await consentRepo.clearOptOut(phoneE164, SCHOLARMANCY_SMS_PURPOSE); + res.type('text/xml').send(''); return; } - if (OPT_IN_KEYWORDS.has(keyword)) { - await auditRepo.create({ - adminUserId: 'system', - adminEmail: 'system@twilio-webhook', - action: 'system:config_change', - entityType: 'sms_opt_in', - entityId: from, - reason: `Opt-in received: "${messageBody}"`, - metadata: { phone: from, keyword, messageSid: body.MessageSid }, - ipAddress: req.ip ?? 'unknown', - userAgent: 'twilio-webhook', - }); - res - .type('text/xml') - .send( - 'You have been re-subscribed to Scholaracle notifications.' - ); + if (isHelp) { + res.type('text/xml').send(buildHelpTwiml()); + return; + } + + if (isYes) { + await consentRepo.completeReplyYes(phoneE164, SCHOLARMANCY_SMS_PURPOSE); + res.type('text/xml').send(''); return; } - // Non-keyword inbound SMS — acknowledge without reply res.type('text/xml').send(''); - } catch (error) { - // eslint-disable-next-line no-console - console.error('[TwilioWebhook] Inbound SMS error:', error); + } catch { res.type('text/xml').send(''); } }; } /** - * Handle delivery status callbacks from Twilio. - * Maps Twilio statuses (queued/sent/delivered/failed) to internal CommunicationStatus - * and updates the matching communication log entry. + * Handle delivery status callbacks; map status and record 21610 opt-outs. */ export function handleStatusCallback(database: Db): (req: Request, res: Response) => Promise { const commLogRepo = new CommunicationLogRepository(database); + const consentRepo = new SmsConsentRepository(database); return async (req: Request, res: Response): Promise => { try { @@ -122,10 +140,13 @@ export function handleStatusCallback(database: Db): (req: Request, res: Response await commLogRepo.updateDeliveryStatusByProviderId(messageSid, internalStatus); } + if (body.ErrorCode === '21610' && body.To) { + const phoneE164 = normalizeFromPhone(body.To); + await consentRepo.recordOptOut(phoneE164, SCHOLARMANCY_SMS_PURPOSE); + } + res.status(200).json({ success: true }); - } catch (error) { - // eslint-disable-next-line no-console - console.error('[TwilioWebhook] Status callback error:', error); + } catch { res.status(200).json({ success: true }); } }; diff --git a/packages/api/src/routes/webhooks/twilio/twilio-webhook.router.ts b/packages/api/src/routes/webhooks/twilio/twilio-webhook.router.ts index 90dda42..74f60d1 100644 --- a/packages/api/src/routes/webhooks/twilio/twilio-webhook.router.ts +++ b/packages/api/src/routes/webhooks/twilio/twilio-webhook.router.ts @@ -1,29 +1,47 @@ import { Router } from 'express'; +import express from 'express'; import type { Db } from 'mongodb'; -import { handleInboundSms } from './twilio-webhook.handlers'; -import { handleStatusCallback } from './twilio-webhook.handlers'; -import { requireTwilioSignature } from './twilio-signature.middleware'; +import { SMS_INBOUND_WEBHOOK_URL, SMS_STATUS_WEBHOOK_URL } from '@scholaracle/contracts'; +import { handleInboundSms, handleStatusCallback } from './twilio-webhook.handlers'; +import { requireRelayInboundSignature } from './relay-signature.middleware'; export interface ITwilioWebhookRouterConfig { readonly database: Db; - readonly twilioAuthToken?: string; + readonly relayInboundSecret?: string; + readonly smsWebhookPublicUrl?: string; + readonly statusWebhookPublicUrl?: string; +} + +function captureRawUrlencoded(): express.RequestHandler { + return express.urlencoded({ + extended: false, + verify: (req, _res, buf) => { + (req as unknown as { rawBody: string }).rawBody = buf.toString('utf8'); + }, + }); } /** - * Twilio webhook router. - * Mounts at /api/webhooks/twilio — receives inbound SMS and delivery status callbacks. + * Twilio webhook router (relay-forwarded inbound SMS + status). */ export function twilioWebhookRouter(config: ITwilioWebhookRouterConfig): Router { const router = Router(); - const authToken = config.twilioAuthToken ?? process.env['TWILIO_AUTH_TOKEN'] ?? ''; - const nodeEnv = process.env['NODE_ENV'] ?? 'development'; - - if (nodeEnv === 'production' && authToken) { - router.use(requireTwilioSignature(authToken)); - } + const secret = config.relayInboundSecret ?? process.env['RELAY_INBOUND_SECRET'] ?? ''; + const smsUrl = config.smsWebhookPublicUrl ?? SMS_INBOUND_WEBHOOK_URL; + const statusUrl = config.statusWebhookPublicUrl ?? SMS_STATUS_WEBHOOK_URL; - router.post('/sms', handleInboundSms(config.database)); - router.post('/status', handleStatusCallback(config.database)); + router.post( + '/sms', + captureRawUrlencoded(), + requireRelayInboundSignature({ publicUrl: smsUrl, secret }), + handleInboundSms(config.database) + ); + router.post( + '/status', + captureRawUrlencoded(), + requireRelayInboundSignature({ publicUrl: statusUrl, secret }), + handleStatusCallback(config.database) + ); return router; } diff --git a/packages/api/src/routes/webhooks/twilio/twilio-webhook.test.ts b/packages/api/src/routes/webhooks/twilio/twilio-webhook.test.ts index a89cddb..93c381c 100644 --- a/packages/api/src/routes/webhooks/twilio/twilio-webhook.test.ts +++ b/packages/api/src/routes/webhooks/twilio/twilio-webhook.test.ts @@ -1,157 +1,218 @@ import request from 'supertest'; import express, { type Express } from 'express'; import { MongoClient, type Db } from 'mongodb'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import querystring from 'node:querystring'; +import { + SCHOLARMANCY_SMS_PURPOSE, + SMS_INBOUND_WEBHOOK_URL, + SMS_STATUS_WEBHOOK_URL, +} from '@scholaracle/contracts'; import { twilioWebhookRouter } from './twilio-webhook.router'; - -describe('Twilio Webhooks', () => { +import { signRelayInboundBody } from '../../../services/sms/verifyRelayInboundSignature'; +import { SmsConsentRepository } from '@scholaracle/database'; + +const RELAY_SECRET = 'test-relay-inbound-secret'; + +function signedPost( + app: Express, + path: string, + publicUrl: string, + fields: Record +): request.Test { + const rawBody = querystring.stringify(fields); + const sig = signRelayInboundBody(publicUrl, rawBody, RELAY_SECRET); + return request(app) + .post(path) + .set('Content-Type', 'application/x-www-form-urlencoded') + .set('x-relay-signature', sig) + .send(rawBody); +} + +describe('Twilio Webhooks (relay)', () => { let app: Express; let client: MongoClient; let database: Db; + let mongoServer: MongoMemoryServer; beforeAll(async () => { - const uri = process.env['MONGODB_URI'] ?? 'mongodb://localhost:27017'; - client = new MongoClient(uri); + mongoServer = await MongoMemoryServer.create(); + client = new MongoClient(mongoServer.getUri()); await client.connect(); database = client.db('scholaracle_test'); + process.env['RELAY_INBOUND_SECRET'] = RELAY_SECRET; + app = express(); - app.use(express.json()); - app.use(express.urlencoded({ extended: true })); - app.use('/api/webhooks/twilio', twilioWebhookRouter({ database, twilioAuthToken: '' })); + app.use( + '/api/webhooks/twilio', + twilioWebhookRouter({ + database, + relayInboundSecret: RELAY_SECRET, + }) + ); }); afterAll(async () => { + delete process.env['RELAY_INBOUND_SECRET']; await client.close(); + await mongoServer.stop(); }); beforeEach(async () => { await database.collection('communication_logs').deleteMany({}); - await database.collection('audit_logs').deleteMany({}); + await database.collection('sms_consents').deleteMany({}); }); - describe('POST /status', () => { - it('returns 200 with valid status payload', async () => { - await database.collection('communication_logs').insertOne({ - userId: 'u1', - channel: 'sms', - type: 'notification', - subject: 'Test', - content: 'Hello', - recipientPhone: '+15005550001', - status: 'sent', - providerId: 'SM_TEST_123', - triggeredBy: 'system', - createdAt: new Date(), - }); - + describe('signature', () => { + it('rejects missing signature with 401', async () => { const res = await request(app) - .post('/api/webhooks/twilio/status') - .send({ MessageSid: 'SM_TEST_123', MessageStatus: 'delivered' }); - - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); + .post('/api/webhooks/twilio/sms') + .send({ Body: 'STOP', From: '+15005550006' }); + expect(res.status).toBe(401); }); - it('returns 400 when MessageSid is missing', async () => { + it('rejects invalid signature with 401', async () => { + const rawBody = querystring.stringify({ From: '+15005550006', Body: 'STOP' }); const res = await request(app) - .post('/api/webhooks/twilio/status') - .send({ MessageStatus: 'delivered' }); - - expect(res.status).toBe(400); - expect(res.body.error).toBe('MessageSid and MessageStatus are required'); + .post('/api/webhooks/twilio/sms') + .set('Content-Type', 'application/x-www-form-urlencoded') + .set('x-relay-signature', 'not-valid-base64-sig') + .send(rawBody); + expect(res.status).toBe(401); }); - it('returns 400 when MessageStatus is missing', async () => { + it('rejects tampered body with 401', async () => { + const rawBody = querystring.stringify({ From: '+15005550006', Body: 'STOP' }); + const sig = signRelayInboundBody(SMS_INBOUND_WEBHOOK_URL, rawBody, RELAY_SECRET); const res = await request(app) - .post('/api/webhooks/twilio/status') - .send({ MessageSid: 'SM_TEST_123' }); - - expect(res.status).toBe(400); - expect(res.body.error).toBe('MessageSid and MessageStatus are required'); - }); - - it('returns 200 with empty body (both fields missing)', async () => { - const res = await request(app).post('/api/webhooks/twilio/status').send({}); - - expect(res.status).toBe(400); - expect(res.body.error).toBe('MessageSid and MessageStatus are required'); + .post('/api/webhooks/twilio/sms') + .set('Content-Type', 'application/x-www-form-urlencoded') + .set('x-relay-signature', sig) + .send(`${rawBody}&tampered=1`); + expect(res.status).toBe(401); }); + }); - it('returns 200 for unknown Twilio status (no internal mapping)', async () => { - const res = await request(app) - .post('/api/webhooks/twilio/status') - .send({ MessageSid: 'SM_UNKNOWN_789', MessageStatus: 'accepted' }); - + describe('POST /sms', () => { + it('records opt-out for STOP with empty TwiML', async () => { + const res = await signedPost(app, '/api/webhooks/twilio/sms', SMS_INBOUND_WEBHOOK_URL, { + From: '+15005550006', + Body: 'STOP', + }); expect(res.status).toBe(200); - expect(res.body.success).toBe(true); + expect(res.text).toBe(''); + const repo = new SmsConsentRepository(database); + const row = await repo.findByPhoneAndPurpose('+15005550006', SCHOLARMANCY_SMS_PURPOSE); + expect(row?.revokedAt).toBeTruthy(); }); - }); - describe('POST /sms (inbound)', () => { - it('returns 200 with TwiML for a normal inbound message', async () => { - const res = await request(app).post('/api/webhooks/twilio/sms').send({ - MessageSid: 'SM_INBOUND_001', + it('clears opt-out on START', async () => { + const repo = new SmsConsentRepository(database); + await repo.recordOptOut('+15005550006', SCHOLARMANCY_SMS_PURPOSE); + const res = await signedPost(app, '/api/webhooks/twilio/sms', SMS_INBOUND_WEBHOOK_URL, { From: '+15005550006', - To: '+18449003903', - Body: 'Hello there', + Body: 'START', }); - expect(res.status).toBe(200); - expect(res.headers['content-type']).toMatch(/text\/xml/); expect(res.text).toBe(''); + const row = await repo.findByPhoneAndPurpose('+15005550006', SCHOLARMANCY_SMS_PURPOSE); + expect(row?.revokedAt).toBeUndefined(); }); - it('returns opt-out TwiML for STOP keyword', async () => { - const res = await request(app).post('/api/webhooks/twilio/sms').send({ - MessageSid: 'SM_INBOUND_STOP', + it('replies with HELP TwiML', async () => { + const res = await signedPost(app, '/api/webhooks/twilio/sms', SMS_INBOUND_WEBHOOK_URL, { From: '+15005550006', - To: '+18449003903', - Body: 'STOP', + Body: 'HELP', }); - expect(res.status).toBe(200); - expect(res.text).toContain('unsubscribed'); - - const auditLog = await database - .collection('audit_logs') - .findOne({ entityType: 'sms_opt_out' }); - expect(auditLog).toBeTruthy(); - expect(auditLog?.['entityId']).toBe('+15005550006'); + expect(res.text).toContain('Scholarmancy'); + expect(res.text).toContain('support@scholarmancy.com'); }); - it('returns opt-in TwiML for START keyword', async () => { - const res = await request(app).post('/api/webhooks/twilio/sms').send({ - MessageSid: 'SM_INBOUND_START', + it('records consent on YES', async () => { + const res = await signedPost(app, '/api/webhooks/twilio/sms', SMS_INBOUND_WEBHOOK_URL, { From: '+15005550006', - To: '+18449003903', - Body: 'start', + Body: 'YES', }); - expect(res.status).toBe(200); - expect(res.text).toContain('re-subscribed'); - - const auditLog = await database - .collection('audit_logs') - .findOne({ entityType: 'sms_opt_in' }); - expect(auditLog).toBeTruthy(); - expect(auditLog?.['entityId']).toBe('+15005550006'); + const repo = new SmsConsentRepository(database); + expect(await repo.hasActiveConsent('+15005550006', SCHOLARMANCY_SMS_PURPOSE)).toBe(true); }); - it('handles empty body gracefully', async () => { - const res = await request(app).post('/api/webhooks/twilio/sms').send({}); + it('handles OptOutType=STOP', async () => { + const res = await signedPost(app, '/api/webhooks/twilio/sms', SMS_INBOUND_WEBHOOK_URL, { + From: '+15005550007', + Body: '', + OptOutType: 'STOP', + }); + expect(res.status).toBe(200); + const repo = new SmsConsentRepository(database); + const row = await repo.findByPhoneAndPurpose('+15005550007', SCHOLARMANCY_SMS_PURPOSE); + expect(row?.revokedAt).toBeTruthy(); + }); + it('clears opt-out on OptOutType=START', async () => { + const repo = new SmsConsentRepository(database); + await repo.recordOptOut('+15005550009', SCHOLARMANCY_SMS_PURPOSE); + const res = await signedPost(app, '/api/webhooks/twilio/sms', SMS_INBOUND_WEBHOOK_URL, { + From: '+15005550009', + Body: '', + OptOutType: 'START', + }); expect(res.status).toBe(200); - expect(res.headers['content-type']).toMatch(/text\/xml/); expect(res.text).toBe(''); + const row = await repo.findByPhoneAndPurpose('+15005550009', SCHOLARMANCY_SMS_PURPOSE); + expect(row?.revokedAt).toBeUndefined(); }); - it('handles missing Body field gracefully', async () => { - const res = await request(app) - .post('/api/webhooks/twilio/sms') - .send({ MessageSid: 'SM_NO_BODY', From: '+15005550006' }); + it('replies with HELP TwiML for OptOutType=HELP', async () => { + const res = await signedPost(app, '/api/webhooks/twilio/sms', SMS_INBOUND_WEBHOOK_URL, { + From: '+15005550010', + Body: '', + OptOutType: 'HELP', + }); + expect(res.status).toBe(200); + expect(res.text).toContain('Scholarmancy'); + expect(res.text).toContain('support@scholarmancy.com'); + }); + }); + + describe('POST /status', () => { + it('returns 200 with valid status payload', async () => { + await database.collection('communication_logs').insertOne({ + userId: 'u1', + channel: 'sms', + type: 'notification', + subject: 'Test', + content: 'Hello', + recipientPhone: '+15005550001', + status: 'sent', + providerId: 'SM_TEST_123', + triggeredBy: 'system', + createdAt: new Date(), + }); + + const res = await signedPost(app, '/api/webhooks/twilio/status', SMS_STATUS_WEBHOOK_URL, { + MessageSid: 'SM_TEST_123', + MessageStatus: 'delivered', + }); expect(res.status).toBe(200); - expect(res.text).toBe(''); + expect(res.body.success).toBe(true); + }); + + it('records opt-out when ErrorCode is 21610', async () => { + const res = await signedPost(app, '/api/webhooks/twilio/status', SMS_STATUS_WEBHOOK_URL, { + MessageSid: 'SM_OPT', + MessageStatus: 'failed', + To: '+15005550008', + ErrorCode: '21610', + }); + expect(res.status).toBe(200); + const repo = new SmsConsentRepository(database); + const row = await repo.findByPhoneAndPurpose('+15005550008', SCHOLARMANCY_SMS_PURPOSE); + expect(row?.revokedAt).toBeTruthy(); }); }); }); diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index 364095c..9ce142c 100644 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -86,14 +86,12 @@ import { SmtpTransport, MongoQueue, } from '@scholaracle/agents'; -import { SMSDelivery, applyTwilioApiBaseUrl } from '@scholaracle/agents'; import type { INotificationDelivery } from '@scholaracle/interfaces'; import type { MailService } from '@sendgrid/mail'; -import type { Twilio } from 'twilio'; import type { IEmailTransport } from '@scholaracle/agents'; import sgMail from '@sendgrid/mail'; -import twilio from 'twilio'; import nodemailer from 'nodemailer'; +import { createSmsStack } from './services/sms/createSmsStack'; export interface IServerConfig { readonly port?: number; @@ -104,12 +102,6 @@ export interface IServerConfig { readonly sendGridApiKey?: string; readonly sendGridFromEmail?: string; readonly sendGridFromName?: string; - readonly twilioAccountSid?: string; - readonly twilioAuthToken?: string; - readonly twilioApiKeySid?: string; - readonly twilioApiKeySecret?: string; - readonly twilioFromNumber?: string; - readonly twilioMessagingServiceSid?: string; readonly relayUrl?: string; readonly relayApiKey?: string; readonly relayWebhookSecret?: string; @@ -138,48 +130,24 @@ function getSendGridConfig(config: IServerConfig): { }; } -/** - * Get Twilio configuration from config or environment. - * - * @param config - Server configuration - * @returns Twilio configuration - */ -function getTwilioConfig(config: IServerConfig): { - readonly accountSid: string; - readonly authToken: string; - readonly apiKeySid: string; - readonly apiKeySecret: string; - readonly fromNumber: string; - readonly messagingServiceSid: string; -} { - return { - accountSid: config.twilioAccountSid ?? process.env['TWILIO_ACCOUNT_SID'] ?? '', - authToken: config.twilioAuthToken ?? process.env['TWILIO_AUTH_TOKEN'] ?? '', - apiKeySid: config.twilioApiKeySid ?? process.env['TWILIO_API_KEY_SID'] ?? '', - apiKeySecret: config.twilioApiKeySecret ?? process.env['TWILIO_API_KEY_SECRET'] ?? '', - fromNumber: config.twilioFromNumber ?? process.env['TWILIO_FROM_NUMBER'] ?? '', - messagingServiceSid: - config.twilioMessagingServiceSid ?? process.env['TWILIO_MESSAGING_SERVICE_SID'] ?? '', - }; -} - /** * Initialize notification service with delivery services. * * @param config - Server configuration * @returns Notification service and email infrastructure */ -function initializeNotificationService(config: IServerConfig): { +function initializeNotificationService( + config: IServerConfig, + database?: Db +): { notificationService: NotificationService; emailTransport: IEmailTransport; fromEmail: string; fromName: string; - twilioClient: import('twilio').Twilio | null; - twilioFromNumber: string; - twilioMessagingServiceSid: string; + guardedSmsSender: import('@scholaracle/agents').GuardedSmsSender | null; } { const sendGridConfig = getSendGridConfig(config); - const twilioConfig = getTwilioConfig(config); + const smsStack = database ? createSmsStack(database) : null; const smtpHost = process.env['SMTP_HOST']; const transport: IEmailTransport = smtpHost @@ -211,36 +179,9 @@ function initializeNotificationService(config: IServerConfig): { }, transport ); - const hasApiKeyAuth = Boolean( - twilioConfig.accountSid && twilioConfig.apiKeySid && twilioConfig.apiKeySecret - ); - const hasAuthTokenAuth = Boolean(twilioConfig.accountSid && twilioConfig.authToken); - const twilioConfigured = - (hasApiKeyAuth || hasAuthTokenAuth) && - Boolean(twilioConfig.fromNumber || twilioConfig.messagingServiceSid); - const twilioClient = twilioConfigured - ? applyTwilioApiBaseUrl( - hasApiKeyAuth - ? twilio(twilioConfig.apiKeySid, twilioConfig.apiKeySecret, { - accountSid: twilioConfig.accountSid, - }) - : twilio(twilioConfig.accountSid, twilioConfig.authToken), - process.env['TWILIO_API_BASE_URL'] - ) - : ({} as unknown as Twilio); - const smsDelivery = new SMSDelivery( - { - accountSid: twilioConfig.accountSid, - authToken: twilioConfig.authToken, - fromNumber: twilioConfig.fromNumber, - messagingServiceSid: twilioConfig.messagingServiceSid, - }, - twilioClient - ); - const deliveryServices: readonly INotificationDelivery[] = [ emailDelivery, - ...(twilioConfigured ? [smsDelivery] : []), + ...(smsStack ? [smsStack.smsDelivery] : []), // Push and InApp are optional; omit when not configured to avoid delivery errors. ]; const deliveryRouter = new DeliveryRouter(deliveryServices); @@ -253,9 +194,7 @@ function initializeNotificationService(config: IServerConfig): { emailTransport: transport, fromEmail: sendGridConfig.fromEmail, fromName: sendGridConfig.fromName, - twilioClient: twilioConfigured ? twilioClient : null, - twilioFromNumber: twilioConfig.fromNumber, - twilioMessagingServiceSid: twilioConfig.messagingServiceSid, + guardedSmsSender: smsStack?.guardedSender ?? null, }; } @@ -331,7 +270,7 @@ export function createApp(config: IServerConfig = {}, database?: Db): Express { // express.raw() ever sees it (body-parser skips once req._body is set). const jsonParser = express.json({ limit: '10mb' }); app.use((req: Request, res: Response, next: NextFunction) => { - if (req.path === '/api/webhooks/noctusoft') { + if (req.path === '/api/webhooks/noctusoft' || req.path.startsWith('/api/webhooks/twilio')) { next(); return; } @@ -348,7 +287,7 @@ export function createApp(config: IServerConfig = {}, database?: Db): Express { throw new Error('JWT_SECRET environment variable is required in production'); } - const notificationInit = initializeNotificationService(config); + const notificationInit = initializeNotificationService(config, database); const { notificationService, emailTransport, fromEmail, fromName } = notificationInit; app.use('/api/health', healthRouter); @@ -437,15 +376,12 @@ export function createApp(config: IServerConfig = {}, database?: Db): Express { emailTransport: magicEmailTransport, fromEmail: magicFromEmail, fromName: magicFromName, - twilioClient: magicTwilioClient, - twilioFromNumber, - twilioMessagingServiceSid, + guardedSmsSender, } = notificationInit; const magicLinkSender = new MagicLinkSender( magicEmailTransport, { fromEmail: magicFromEmail, fromName: magicFromName }, - magicTwilioClient, - { fromNumber: twilioFromNumber, messagingServiceSid: twilioMessagingServiceSid } + guardedSmsSender ); app.use( @@ -459,6 +395,7 @@ export function createApp(config: IServerConfig = {}, database?: Db): Express { sendInviteEmail: inviteEmailSender, syncScheduler, magicLinkSender, + guardedSmsSender, }) ); app.use( @@ -699,8 +636,7 @@ export function createApp(config: IServerConfig = {}, database?: Db): Express { app.use('/api/webhooks/communications', communicationsWebhooksRouter({ database })); // Twilio webhooks (inbound SMS, delivery status callbacks) - const twilioAuthToken = config.twilioAuthToken ?? process.env['TWILIO_AUTH_TOKEN'] ?? ''; - app.use('/api/webhooks/twilio', twilioWebhookRouter({ database, twilioAuthToken })); + app.use('/api/webhooks/twilio', twilioWebhookRouter({ database })); if (storeClient && storeConfig) { app.use( diff --git a/packages/api/src/services/provision/MagicLinkSender.ts b/packages/api/src/services/provision/MagicLinkSender.ts index 5575599..380761a 100644 --- a/packages/api/src/services/provision/MagicLinkSender.ts +++ b/packages/api/src/services/provision/MagicLinkSender.ts @@ -1,16 +1,12 @@ import type { IEmailTransport } from '@scholaracle/agents'; -import type { Twilio } from 'twilio'; +import type { GuardedSmsSender } from '@scholaracle/agents'; +import { SCHOLARMANCY_SMS_BRAND } from '@scholaracle/contracts'; export interface IMagicLinkEmailConfig { readonly fromEmail: string; readonly fromName: string; } -export interface IMagicLinkSMSConfig { - readonly fromNumber?: string; - readonly messagingServiceSid?: string; -} - export interface ISendMagicLinkParams { readonly to: string; readonly loginUrl: string; @@ -23,16 +19,13 @@ export interface IMagicLinkSender { } /** - * Sends one-time magic login links via email or SMS. - * Email uses IEmailTransport (SendGrid or SMTP/Mailpit in dev). - * SMS uses the Twilio client directly. + * Sends one-time magic login links via email or consent-gated SMS. */ export class MagicLinkSender implements IMagicLinkSender { constructor( private readonly _transport: IEmailTransport, private readonly _emailConfig: IMagicLinkEmailConfig, - private readonly _twilioClient: Twilio | null, - private readonly _smsConfig: IMagicLinkSMSConfig + private readonly _guardedSms: GuardedSmsSender | null ) {} public async sendEmail(params: ISendMagicLinkParams): Promise { @@ -41,7 +34,7 @@ export class MagicLinkSender implements IMagicLinkSender { const text = [ greeting, '', - 'You have been sent a one-time sign-in link for Scholaracle.', + `You have been sent a one-time sign-in link for ${SCHOLARMANCY_SMS_BRAND}.`, 'Click the link below to log in (expires in 24 hours):', '', loginUrl, @@ -51,9 +44,9 @@ export class MagicLinkSender implements IMagicLinkSender { const html = [ `

${greeting}

`, - '

You have been sent a one-time sign-in link for Scholaracle.
', + `

You have been sent a one-time sign-in link for ${SCHOLARMANCY_SMS_BRAND}.
`, 'Click the button below to log in (expires in 24 hours):

', - `

Sign in to Scholaracle

`, + `

Sign in to ${SCHOLARMANCY_SMS_BRAND}

`, `

Or copy this link: ${loginUrl}

`, '

If you did not expect this email, you can ignore it.

', ].join(''); @@ -61,24 +54,21 @@ export class MagicLinkSender implements IMagicLinkSender { await this._transport.send({ to, from: { email: this._emailConfig.fromEmail, name: this._emailConfig.fromName }, - subject: 'Your Scholaracle sign-in link', + subject: `Your ${SCHOLARMANCY_SMS_BRAND} sign-in link`, text, html, }); } public async sendSms(params: ISendMagicLinkParams): Promise { - if (!this._twilioClient) { + if (!this._guardedSms) { throw new Error('SMS delivery is not configured'); } const { to, loginUrl } = params; - const body = `Your Scholaracle sign-in link (expires in 24h): ${loginUrl}`; - await this._twilioClient.messages.create({ - to, - ...(this._smsConfig.messagingServiceSid - ? { messagingServiceSid: this._smsConfig.messagingServiceSid } - : { from: this._smsConfig.fromNumber ?? '' }), - body, + const body = `Your sign-in link (expires in 24h): ${loginUrl}`; + await this._guardedSms.sendTransactional(to, body, { + templateName: 'magic_link', + triggeredBy: 'system', }); } } diff --git a/packages/api/src/services/sms/createSmsStack.ts b/packages/api/src/services/sms/createSmsStack.ts new file mode 100644 index 0000000..c83029f --- /dev/null +++ b/packages/api/src/services/sms/createSmsStack.ts @@ -0,0 +1 @@ +export { createSmsStack, type ISmsStack } from '@scholaracle/agents'; diff --git a/packages/api/src/services/sms/recordSmsOptIn.ts b/packages/api/src/services/sms/recordSmsOptIn.ts new file mode 100644 index 0000000..5303ad0 --- /dev/null +++ b/packages/api/src/services/sms/recordSmsOptIn.ts @@ -0,0 +1,26 @@ +import type { Request } from 'express'; +import { SMS_OPT_IN_TEXT_VERSION, SCHOLARMANCY_SMS_PURPOSE } from '@scholaracle/contracts'; +import { normalizePhoneE164 } from '@scholaracle/agents'; +import type { SmsConsentRepository } from '@scholaracle/database'; + +/** Persists checkbox opt-in when user supplied phone + explicit consent. */ +export async function recordSmsOptInFromRequest( + consentRepo: SmsConsentRepository, + req: Request, + phone: string, + source: string +): Promise { + const phoneE164 = normalizePhoneE164(phone); + if (!phoneE164) { + throw new Error('Invalid phone number'); + } + await consentRepo.recordOptIn({ + phoneE164, + purpose: SCHOLARMANCY_SMS_PURPOSE, + consentTextVersion: SMS_OPT_IN_TEXT_VERSION, + source, + ipAddress: req.ip ?? undefined, + userAgent: req.headers['user-agent'] ?? undefined, + }); + return phoneE164; +} diff --git a/packages/api/src/services/sms/verifyRelayInboundSignature.test.ts b/packages/api/src/services/sms/verifyRelayInboundSignature.test.ts new file mode 100644 index 0000000..32c8969 --- /dev/null +++ b/packages/api/src/services/sms/verifyRelayInboundSignature.test.ts @@ -0,0 +1,24 @@ +import { signRelayInboundBody, verifyRelayInboundSignature } from './verifyRelayInboundSignature'; + +const URL = 'https://api.scholarmancy.com/api/webhooks/twilio/sms'; +const SECRET = 'test-relay-secret'; + +describe('verifyRelayInboundSignature', () => { + it('accepts valid signature', () => { + const raw = 'From=%2B15125550100&Body=STOP'; + const sig = signRelayInboundBody(URL, raw, SECRET); + expect(verifyRelayInboundSignature(URL, raw, sig, SECRET)).toBe(true); + }); + + it('rejects tampered body', () => { + const raw = 'From=%2B15125550100&Body=STOP'; + const sig = signRelayInboundBody(URL, raw, SECRET); + expect(verifyRelayInboundSignature(URL, `${raw}x`, sig, SECRET)).toBe(false); + }); + + it('rejects wrong public URL', () => { + const raw = 'From=%2B15125550100&Body=STOP'; + const sig = signRelayInboundBody(URL, raw, SECRET); + expect(verifyRelayInboundSignature(`${URL}/extra`, raw, sig, SECRET)).toBe(false); + }); +}); diff --git a/packages/api/src/services/sms/verifyRelayInboundSignature.ts b/packages/api/src/services/sms/verifyRelayInboundSignature.ts new file mode 100644 index 0000000..dd8b939 --- /dev/null +++ b/packages/api/src/services/sms/verifyRelayInboundSignature.ts @@ -0,0 +1,31 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +/** Relay inbound: base64(HMAC-SHA256(secret, publicUrl + rawBody)). */ +export function verifyRelayInboundSignature( + publicUrl: string, + rawBody: string, + signatureHeader: string, + secret: string +): boolean { + const provided = signatureHeader.trim(); + if (!provided || !secret) { + return false; + } + const expected = createHmac('sha256', secret) + .update(publicUrl + rawBody, 'utf8') + .digest('base64'); + if (provided.length !== expected.length) { + return false; + } + try { + return timingSafeEqual(Buffer.from(provided, 'utf8'), Buffer.from(expected, 'utf8')); + } catch { + return false; + } +} + +export function signRelayInboundBody(publicUrl: string, rawBody: string, secret: string): string { + return createHmac('sha256', secret) + .update(publicUrl + rawBody, 'utf8') + .digest('base64'); +} diff --git a/packages/contracts/src/types/index.ts b/packages/contracts/src/types/index.ts index f1c7c94..20ee693 100644 --- a/packages/contracts/src/types/index.ts +++ b/packages/contracts/src/types/index.ts @@ -1,4 +1,5 @@ export type { SyncSchedule, NotificationTone, NotificationFrequency } from './ScheduleFrequency'; export type { IStudentAlertPreferences } from './StudentAlertPreferences'; export type { IStudentContact, IAlertRecipientResolved } from './StudentContact'; +export * from './smsCompliance'; export * from './api'; diff --git a/packages/contracts/src/types/smsCompliance.test.ts b/packages/contracts/src/types/smsCompliance.test.ts new file mode 100644 index 0000000..f111c35 --- /dev/null +++ b/packages/contracts/src/types/smsCompliance.test.ts @@ -0,0 +1,14 @@ +import { + SCHOLARMANCY_SMS_BRAND, + SCHOLARMANCY_SMS_PURPOSE, + buildSmsOptInLabelHtml, +} from './smsCompliance'; + +describe('smsCompliance constants', () => { + it('uses Scholarmancy brand and purpose in opt-in label', () => { + const label = buildSmsOptInLabelHtml(); + expect(label).toContain(SCHOLARMANCY_SMS_BRAND); + expect(label).toContain(SCHOLARMANCY_SMS_PURPOSE); + expect(label).toContain('Message frequency varies'); + }); +}); diff --git a/packages/contracts/src/types/smsCompliance.ts b/packages/contracts/src/types/smsCompliance.ts new file mode 100644 index 0000000..8879892 --- /dev/null +++ b/packages/contracts/src/types/smsCompliance.ts @@ -0,0 +1,43 @@ +/** Scholarmancy SMS program purpose (toll-free verification). */ +export const SCHOLARMANCY_SMS_BRAND = 'Scholarmancy'; + +export const SCHOLARMANCY_SMS_PURPOSE = 'grade and assignment alerts and sign-in links'; + +export const SCHOLARMANCY_SMS_HELP_LINE = 'Scholarmancy: grade and assignment alerts for parents.'; + +export const SCHOLARMANCY_SMS_SUPPORT_EMAIL = 'support@scholarmancy.com'; + +/** Bumped when opt-in checkbox copy changes. */ +export const SMS_OPT_IN_TEXT_VERSION = '2026-10-01'; + +export const SMS_RELAY_SEND_URL = 'https://api.twilio.noctusoft.com/sms/send'; + +export const SMS_INBOUND_WEBHOOK_URL = 'https://api.scholarmancy.com/api/webhooks/twilio/sms'; + +export const SMS_STATUS_WEBHOOK_URL = 'https://api.scholarmancy.com/api/webhooks/twilio/status'; + +export const TWILIO_OPT_OUT_ERROR_CODE = 21610; + +/** Builds the standard web opt-in checkbox label (markdown links for UI). */ +export function buildSmsOptInLabelHtml(): string { + return ( + `Text me ${SCHOLARMANCY_SMS_PURPOSE} from ${SCHOLARMANCY_SMS_BRAND}. ` + + 'Message frequency varies. Message and data rates may apply. ' + + 'Reply STOP to opt out, HELP for help. Consent is not a condition of purchase. ' + + 'See our SMS Terms and Privacy Policy.' + ); +} + +export interface ISmsConsentRecord { + readonly phoneE164: string; + readonly purpose: string; + readonly consentTextVersion: string; + readonly source: string; + readonly ipAddress?: string; + readonly userAgent?: string; + readonly consentedAt?: Date; + readonly revokedAt?: Date; + readonly confirmationSentAt?: Date; + readonly createdAt?: Date; + readonly updatedAt?: Date; +} diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index 152cf23..4a4167d 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -40,6 +40,7 @@ export * from './repositories/SmsDigestPendingRepository'; export * from './repositories/EmailDigestPendingRepository'; export * from './repositories/AiUsageRepository'; export * from './repositories/WebhookEventRepository'; +export * from './repositories/SmsConsentRepository'; // Connector / ingestion repositories export * from './repositories/IngestDeviceAuthRepository'; diff --git a/packages/database/src/indexes.ts b/packages/database/src/indexes.ts index 154357b..4124cfb 100644 --- a/packages/database/src/indexes.ts +++ b/packages/database/src/indexes.ts @@ -68,6 +68,10 @@ export async function createIndexes(database: Db): Promise { const communicationLogsCollection = database.collection('communication_logs'); await communicationLogsCollection.createIndex({ userId: 1, createdAt: -1 }); await communicationLogsCollection.createIndex({ channel: 1, status: 1 }); + await communicationLogsCollection.createIndex({ providerId: 1 }); + + const smsConsents = database.collection('sms_consents'); + await smsConsents.createIndex({ phoneE164: 1, purpose: 1 }, { unique: true }); // Alerts collection indexes (if not already created) const alertsCollection = database.collection('alerts'); diff --git a/packages/database/src/models/SmsConsent/SmsConsent.ts b/packages/database/src/models/SmsConsent/SmsConsent.ts new file mode 100644 index 0000000..8e4845a --- /dev/null +++ b/packages/database/src/models/SmsConsent/SmsConsent.ts @@ -0,0 +1,3 @@ +import type { ISmsConsentRecord } from '@scholaracle/contracts'; + +export type { ISmsConsentRecord }; diff --git a/packages/database/src/repositories/SmsConsentRepository/SmsConsentRepository.test.ts b/packages/database/src/repositories/SmsConsentRepository/SmsConsentRepository.test.ts new file mode 100644 index 0000000..e174eec --- /dev/null +++ b/packages/database/src/repositories/SmsConsentRepository/SmsConsentRepository.test.ts @@ -0,0 +1,61 @@ +import { MongoClient, type Db } from 'mongodb'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { SCHOLARMANCY_SMS_PURPOSE, SMS_OPT_IN_TEXT_VERSION } from '@scholaracle/contracts'; +import { SmsConsentRepository } from './SmsConsentRepository'; + +describe('SmsConsentRepository', () => { + let mongoServer: MongoMemoryServer; + let client: MongoClient; + let database: Db; + let repo: SmsConsentRepository; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + client = new MongoClient(mongoServer.getUri()); + await client.connect(); + database = client.db('sms_consent_test'); + repo = new SmsConsentRepository(database); + await repo.ensureIndexes(); + }); + + afterAll(async () => { + await client.close(); + await mongoServer.stop(); + }); + + beforeEach(async () => { + await database.collection('sms_consents').deleteMany({}); + }); + + it('records opt-in and reports active consent', async () => { + await repo.recordOptIn({ + phoneE164: '+15125550100', + purpose: SCHOLARMANCY_SMS_PURPOSE, + consentTextVersion: SMS_OPT_IN_TEXT_VERSION, + source: '/register', + ipAddress: '127.0.0.1', + userAgent: 'jest', + }); + const isActive = await repo.hasActiveConsent('+15125550100', SCHOLARMANCY_SMS_PURPOSE); + expect(isActive).toBe(true); + }); + + it('records opt-out and clears active consent', async () => { + await repo.recordOptIn({ + phoneE164: '+15125550100', + purpose: SCHOLARMANCY_SMS_PURPOSE, + consentTextVersion: SMS_OPT_IN_TEXT_VERSION, + source: '/register', + }); + await repo.recordOptOut('+15125550100', SCHOLARMANCY_SMS_PURPOSE); + expect(await repo.hasActiveConsent('+15125550100', SCHOLARMANCY_SMS_PURPOSE)).toBe(false); + }); + + it('completeReplyYes grants consent for pending numbers', async () => { + await repo.markConfirmationSent('+15125550101', SCHOLARMANCY_SMS_PURPOSE); + await repo.completeReplyYes('+15125550101', SCHOLARMANCY_SMS_PURPOSE); + expect(await repo.hasActiveConsent('+15125550101', SCHOLARMANCY_SMS_PURPOSE)).toBe(true); + const row = await repo.findByPhoneAndPurpose('+15125550101', SCHOLARMANCY_SMS_PURPOSE); + expect(row?.source).toBe('reply-yes'); + }); +}); diff --git a/packages/database/src/repositories/SmsConsentRepository/SmsConsentRepository.ts b/packages/database/src/repositories/SmsConsentRepository/SmsConsentRepository.ts new file mode 100644 index 0000000..7f22278 --- /dev/null +++ b/packages/database/src/repositories/SmsConsentRepository/SmsConsentRepository.ts @@ -0,0 +1,143 @@ +import type { Collection, Db } from 'mongodb'; +import type { ISmsConsentRecord } from '@scholaracle/contracts'; +import type { ISmsConsentReader, ISmsConsentWriter } from '@scholaracle/interfaces'; + +const COLLECTION = 'sms_consents'; + +type ISmsConsentDoc = ISmsConsentRecord & { _id?: unknown }; + +/** + * Persists SMS opt-in/opt-out per phone and purpose (toll-free compliance). + */ +export class SmsConsentRepository implements ISmsConsentReader, ISmsConsentWriter { + private readonly _collection: Collection; + + constructor(database: Db) { + this._collection = database.collection(COLLECTION); + } + + public async ensureIndexes(): Promise { + await this._collection.createIndex({ phoneE164: 1, purpose: 1 }, { unique: true }); + } + + public async findByPhoneAndPurpose( + phoneE164: string, + purpose: string + ): Promise { + const doc = await this._collection.findOne({ phoneE164, purpose }); + return doc ? this._toRecord(doc) : null; + } + + public async hasActiveConsent(phoneE164: string, purpose: string): Promise { + const doc = await this._collection.findOne({ phoneE164, purpose }); + if (!doc?.consentedAt) { + return false; + } + if (doc.revokedAt) { + return false; + } + return true; + } + + public async recordOptIn(params: { + phoneE164: string; + purpose: string; + consentTextVersion: string; + source: string; + ipAddress?: string; + userAgent?: string; + }): Promise { + const now = new Date(); + await this._collection.updateOne( + { phoneE164: params.phoneE164, purpose: params.purpose }, + { + $set: { + phoneE164: params.phoneE164, + purpose: params.purpose, + consentTextVersion: params.consentTextVersion, + source: params.source, + ipAddress: params.ipAddress, + userAgent: params.userAgent, + consentedAt: now, + updatedAt: now, + }, + $unset: { revokedAt: '' }, + $setOnInsert: { createdAt: now }, + }, + { upsert: true } + ); + const doc = await this._collection.findOne({ + phoneE164: params.phoneE164, + purpose: params.purpose, + }); + if (!doc) { + throw new Error('Failed to persist SMS consent'); + } + return this._toRecord(doc); + } + + public async recordOptOut(phoneE164: string, purpose: string): Promise { + const now = new Date(); + await this._collection.updateOne( + { phoneE164, purpose }, + { + $set: { revokedAt: now, updatedAt: now }, + $setOnInsert: { phoneE164, purpose, createdAt: now }, + }, + { upsert: true } + ); + } + + public async clearOptOut(phoneE164: string, purpose: string): Promise { + const now = new Date(); + await this._collection.updateOne( + { phoneE164, purpose }, + { $unset: { revokedAt: '' }, $set: { updatedAt: now } } + ); + } + + public async markConfirmationSent(phoneE164: string, purpose: string): Promise { + const now = new Date(); + await this._collection.updateOne( + { phoneE164, purpose }, + { + $set: { confirmationSentAt: now, updatedAt: now }, + $setOnInsert: { phoneE164, purpose, createdAt: now }, + }, + { upsert: true } + ); + } + + public async completeReplyYes(phoneE164: string, purpose: string): Promise { + const now = new Date(); + await this._collection.updateOne( + { phoneE164, purpose }, + { + $set: { + consentedAt: now, + source: 'reply-yes', + updatedAt: now, + }, + $unset: { revokedAt: '' }, + $setOnInsert: { phoneE164, purpose, createdAt: now }, + }, + { upsert: true } + ); + } + + private _toRecord(doc: ISmsConsentDoc): ISmsConsentRecord { + return { + phoneE164: doc.phoneE164, + purpose: doc.purpose, + consentTextVersion: doc.consentTextVersion ?? '', + source: doc.source ?? '', + ipAddress: doc.ipAddress, + userAgent: doc.userAgent, + consentedAt: doc.consentedAt, + revokedAt: doc.revokedAt, + confirmationSentAt: doc.confirmationSentAt, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + }; + } +} diff --git a/packages/database/src/repositories/SmsConsentRepository/index.ts b/packages/database/src/repositories/SmsConsentRepository/index.ts new file mode 100644 index 0000000..1361e15 --- /dev/null +++ b/packages/database/src/repositories/SmsConsentRepository/index.ts @@ -0,0 +1 @@ +export { SmsConsentRepository } from './SmsConsentRepository'; diff --git a/packages/interfaces/src/index.ts b/packages/interfaces/src/index.ts index 0ce370c..534920b 100644 --- a/packages/interfaces/src/index.ts +++ b/packages/interfaces/src/index.ts @@ -4,3 +4,4 @@ export * from './IAlertService'; export * from './parent'; export * from './studio'; export * from './guidance'; +export * from './sms'; diff --git a/packages/interfaces/src/sms/ISmsConsentRepository.ts b/packages/interfaces/src/sms/ISmsConsentRepository.ts new file mode 100644 index 0000000..62d3f9a --- /dev/null +++ b/packages/interfaces/src/sms/ISmsConsentRepository.ts @@ -0,0 +1,23 @@ +import type { ISmsConsentRecord } from '@scholaracle/contracts'; + +export interface ISmsConsentReader { + findByPhoneAndPurpose(phoneE164: string, purpose: string): Promise; + hasActiveConsent(phoneE164: string, purpose: string): Promise; +} + +export interface ISmsConsentWriter { + recordOptIn(params: { + phoneE164: string; + purpose: string; + consentTextVersion: string; + source: string; + ipAddress?: string; + userAgent?: string; + }): Promise; + recordOptOut(phoneE164: string, purpose: string): Promise; + clearOptOut(phoneE164: string, purpose: string): Promise; + markConfirmationSent(phoneE164: string, purpose: string): Promise; + completeReplyYes(phoneE164: string, purpose: string): Promise; +} + +export interface ISmsConsentRepository extends ISmsConsentReader, ISmsConsentWriter {} diff --git a/packages/interfaces/src/sms/index.ts b/packages/interfaces/src/sms/index.ts new file mode 100644 index 0000000..23186a3 --- /dev/null +++ b/packages/interfaces/src/sms/index.ts @@ -0,0 +1,5 @@ +export type { + ISmsConsentReader, + ISmsConsentWriter, + ISmsConsentRepository, +} from './ISmsConsentRepository'; diff --git a/packages/web/app/dashboard/settings/page.tsx b/packages/web/app/dashboard/settings/page.tsx index 8aa5468..a6dfb6e 100644 --- a/packages/web/app/dashboard/settings/page.tsx +++ b/packages/web/app/dashboard/settings/page.tsx @@ -11,6 +11,7 @@ import { Switch } from '@/components/ui/switch'; import { settingsApi, type IUserSettingsResponse, type INotificationHistoryItem, type IDigestSlotApi } from '@/lib/api/settings'; import { EditDigestSlotDialog } from '@/components/settings/EditDigestSlotDialog'; import { StudentLoginsSection } from '@/components/settings/StudentLoginsSection'; +import { SmsOptInCheckbox } from '@/components/legal/SmsOptInCheckbox'; const ALERT_TYPE_KEYS = [ 'missing_assignment', @@ -26,6 +27,7 @@ export default function SettingsPage() { const [name, setName] = useState(''); const [email, setEmail] = useState(''); const [phone, setPhone] = useState(''); + const [smsConsent, setSmsConsent] = useState(false); const [pushNotifications, setPushNotifications] = useState(true); const [emailNotifications, setEmailNotifications] = useState(true); @@ -84,6 +86,8 @@ export default function SettingsPage() { if (s.profile) { setName(s.profile.name ?? ''); setEmail(s.profile.email ?? ''); + setPhone(s.profile.phone ?? ''); + setSmsConsent(s.profile.smsConsent ?? false); setOauthProviders([...(s.profile.oauthProviders ?? [])]); } setPushNotifications(s.notifications.push); @@ -143,6 +147,10 @@ export default function SettingsPage() { const ok = await settingsApi.update({ timezone, + profile: { + phone, + smsConsent, + }, notifications: { push: pushNotifications, email: emailNotifications, @@ -312,6 +320,12 @@ export default function SettingsPage() { disabled={isSaving || !isLoaded} /> + diff --git a/packages/web/app/privacy/page.test.ts b/packages/web/app/privacy/page.test.ts index 1edcc59..4a1c83c 100644 --- a/packages/web/app/privacy/page.test.ts +++ b/packages/web/app/privacy/page.test.ts @@ -5,6 +5,13 @@ describe('App Store legal pages', () => { const read = (relative: string): string => readFileSync(join(__dirname, '..', relative), 'utf8'); + it('privacy policy includes required SMS no-sharing sentence', () => { + const src = read('privacy/page.tsx'); + expect(src).toMatch( + /We do not share, sell, or provide your mobile phone number or SMS opt-in data to third parties or affiliates for marketing or promotional purposes/ + ); + }); + it('privacy policy covers Sign in with Apple, OAuth, and in-app deletion', () => { const src = read('privacy/page.tsx'); expect(src).toMatch(/Sign in with Apple/); @@ -18,6 +25,8 @@ describe('App Store legal pages', () => { const src = read('terms/page.tsx'); expect(src).toMatch(/iOS app/); expect(src).toMatch(/delete-account/); + expect(src).toMatch(/id="sms"/); + expect(src).toMatch(/support@scholarmancy\.com/); }); it('support page exposes a reachable support email', () => { diff --git a/packages/web/app/privacy/page.tsx b/packages/web/app/privacy/page.tsx index efdf667..3f591c0 100644 --- a/packages/web/app/privacy/page.tsx +++ b/packages/web/app/privacy/page.tsx @@ -78,12 +78,15 @@ export default function PrivacyPage() {
-

3. SMS Text Messaging

+

Text messages

- By opting in to SMS notifications, you consent to receive text messages from Scholarmancy at the - phone number you provide. Message frequency varies based on your alert preferences. Standard - message and data rates may apply. You can opt out at any time by replying STOP to any message, - or by updating your notification preferences in your account settings. Reply HELP for support. + If you opt in, Scholarmancy sends text messages about grade and assignment alerts and sign-in + links to the mobile number you provide. Message frequency varies. Message and data rates may + apply. Reply STOP to opt out or HELP for help. You can also update notification preferences in + your account settings. +

+

+ We do not share, sell, or provide your mobile phone number or SMS opt-in data to third parties or affiliates for marketing or promotional purposes.

diff --git a/packages/web/app/register/page.tsx b/packages/web/app/register/page.tsx index 7728609..a44d0ac 100644 --- a/packages/web/app/register/page.tsx +++ b/packages/web/app/register/page.tsx @@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { OAuthButtons } from '@/components/auth/OAuthButtons'; import { authApi } from '@/lib/api/auth'; +import { SmsOptInCheckbox } from '@/components/legal/SmsOptInCheckbox'; export default function RegisterPage() { const router = useRouter(); @@ -161,22 +162,11 @@ export default function RegisterPage() { -
- setSmsConsent(e.target.checked)} - disabled={isLoading} - className="mt-1 h-4 w-4 rounded border-gray-300" - data-testid="sms-consent-checkbox" - /> - -
+ diff --git a/packages/web/app/terms/page.tsx b/packages/web/app/terms/page.tsx index 784edd4..70db1ab 100644 --- a/packages/web/app/terms/page.tsx +++ b/packages/web/app/terms/page.tsx @@ -48,14 +48,20 @@ export default function TermsPage() {

-
-

4. SMS Consent and Opt-In

+
+

4. Scholarmancy SMS Program

- If you opt in to receive SMS notifications, you consent to receive automated text messages from - Scholarmancy at the phone number you provide. Message frequency depends on your alert settings. - Standard message and data rates may apply. You may opt out at any time by replying STOP, - UNSTOP to resubscribe, or HELP for assistance. Your carrier is not liable for delayed or - undelivered messages. + The Scholarmancy SMS program sends grade and assignment alerts and sign-in links to parents who + opt in on our registration or account settings pages by checking the SMS consent box and providing + a mobile number. Message frequency varies. Message and data rates may apply. Reply STOP to opt + out; reply HELP for help. For support, email{' '} + + support@scholarmancy.com + + . Carriers are not liable for delayed or undelivered messages.

diff --git a/packages/web/components/legal/SmsOptInCheckbox.tsx b/packages/web/components/legal/SmsOptInCheckbox.tsx new file mode 100644 index 0000000..ad2647e --- /dev/null +++ b/packages/web/components/legal/SmsOptInCheckbox.tsx @@ -0,0 +1,51 @@ +'use client'; + +import Link from 'next/link'; +import { Label } from '@/components/ui/label'; +import { + SCHOLARMANCY_SMS_BRAND, + SCHOLARMANCY_SMS_PURPOSE, +} from '@scholaracle/contracts'; + +export interface ISmsOptInCheckboxProps { + readonly checked: boolean; + readonly onChange: (checked: boolean) => void; + readonly disabled?: boolean; + readonly id?: string; +} + +/** Toll-free compliant SMS opt-in (unchecked by default; separate from terms). */ +export function SmsOptInCheckbox({ + checked, + onChange, + disabled, + id = 'smsConsent', +}: ISmsOptInCheckboxProps) { + return ( +
+ onChange(e.target.checked)} + disabled={disabled} + className="mt-1 h-4 w-4 rounded border-gray-300" + data-testid="sms-consent-checkbox" + /> + +
+ ); +} diff --git a/packages/web/lib/api/settings.ts b/packages/web/lib/api/settings.ts index 6e89d5e..0761b1a 100644 --- a/packages/web/lib/api/settings.ts +++ b/packages/web/lib/api/settings.ts @@ -101,6 +101,10 @@ export interface IUserSettings { export interface IUpdateSettingsRequest { readonly dashboard?: IDashboardSettings; + readonly profile?: { + readonly phone?: string; + readonly smsConsent?: boolean; + }; readonly notifications?: INotificationSettings; readonly alerts?: IAlertThresholds; readonly timezone?: string; @@ -131,6 +135,8 @@ export interface IUserSettingsResponse extends IUserSettings { readonly profile?: { readonly name: string; readonly email: string; + readonly phone?: string; + readonly smsConsent?: boolean; readonly oauthProviders: readonly string[]; }; } diff --git a/packages/workers/package.json b/packages/workers/package.json index a8c658c..f85d4cd 100644 --- a/packages/workers/package.json +++ b/packages/workers/package.json @@ -30,8 +30,7 @@ "@sentry/node": "^10.0.0", "firebase-admin": "^12.0.0", "mongodb": "^6.3.0", - "nodemailer": "^6.9.0", - "twilio": "^5.0.0" + "nodemailer": "^6.9.0" }, "devDependencies": { "@types/jest": "^29.5.11", diff --git a/packages/workers/src/worker.test.ts b/packages/workers/src/worker.test.ts index 877a009..514b1e1 100644 --- a/packages/workers/src/worker.test.ts +++ b/packages/workers/src/worker.test.ts @@ -27,21 +27,6 @@ jest.mock('@sendgrid/mail', () => { }; }); -// Mock Twilio -jest.mock('twilio', () => { - return { - __esModule: true, - default: jest.fn(() => ({ - messages: { - create: jest.fn().mockResolvedValue({ - sid: 'sms-123', - status: 'queued', - }), - }, - })), - }; -}); - import { startWorker, type IWorkerConfig } from './worker'; import { MongoClient, type Db, type Collection } from 'mongodb'; @@ -94,9 +79,7 @@ describe('Worker', () => { 'SENDGRID_API_KEY', 'SENDGRID_FROM_EMAIL', 'SENDGRID_FROM_NAME', - 'TWILIO_ACCOUNT_SID', - 'TWILIO_AUTH_TOKEN', - 'TWILIO_FROM_NUMBER', + 'NOCTUSOFT_API_KEY', ]) { savedEnv[key] = process.env[key]; } @@ -210,21 +193,6 @@ describe('Worker', () => { await triggerShutdown(); }); - it('should accept custom Twilio config values', async () => { - const config: IWorkerConfig = { - mongodbUri: 'mongodb://localhost:27017', - twilioAccountSid: 'AC-test-sid', - twilioAuthToken: 'test-auth-token', - twilioFromNumber: '+15551234567', - }; - - await startWorker(config); - await new Promise((resolve) => setTimeout(resolve, 100)); - expect(mockMongoClient.connect).toHaveBeenCalled(); - - await triggerShutdown(); - }); - it('should fall back to SendGrid env vars when config omitted', async () => { process.env['SENDGRID_API_KEY'] = 'SG.env-key'; process.env['SENDGRID_FROM_EMAIL'] = 'env@example.com'; @@ -237,10 +205,8 @@ describe('Worker', () => { await triggerShutdown(); }); - it('should fall back to Twilio env vars when config omitted', async () => { - process.env['TWILIO_ACCOUNT_SID'] = 'AC-env-sid'; - process.env['TWILIO_AUTH_TOKEN'] = 'env-auth-token'; - process.env['TWILIO_FROM_NUMBER'] = '+15559999999'; + it('should start when NOCTUSOFT_API_KEY is set for SMS stack', async () => { + process.env['NOCTUSOFT_API_KEY'] = 'nsk_test_sms_key'; await startWorker({ mongodbUri: 'mongodb://localhost:27017' }); await new Promise((resolve) => setTimeout(resolve, 100)); diff --git a/packages/workers/src/worker.ts b/packages/workers/src/worker.ts index bce528f..b10a182 100644 --- a/packages/workers/src/worker.ts +++ b/packages/workers/src/worker.ts @@ -29,14 +29,13 @@ import { SyncWorker, SyncScheduler } from '@scholaracle/agents'; import type { AdapterRunnerFn } from '@scholaracle/agents'; import { EmailDelivery, SendGridTransport, SmtpTransport } from '@scholaracle/agents'; import type { IEmailTransport } from '@scholaracle/agents'; -import { SMSDelivery, applyTwilioApiBaseUrl } from '@scholaracle/agents'; +import { createSmsStack } from '@scholaracle/agents'; import { PushDelivery, ExpoPushDelivery } from '@scholaracle/agents'; import { InAppDelivery } from '@scholaracle/agents'; import sgMail from '@sendgrid/mail'; -import twilio from 'twilio'; import nodemailer from 'nodemailer'; import type { MailService } from '@sendgrid/mail'; -import type { Twilio } from 'twilio'; +import type { GuardedSmsSender } from '@scholaracle/agents'; import { ConnectorTokenService } from '@scholaracle/auth'; import { randomUUID } from 'crypto'; import { createAdapterRunner } from './adapter-runner'; @@ -51,12 +50,6 @@ export interface IWorkerConfig { readonly sendGridApiKey?: string; readonly sendGridFromEmail?: string; readonly sendGridFromName?: string; - readonly twilioAccountSid?: string; - readonly twilioAuthToken?: string; - readonly twilioApiKeySid?: string; - readonly twilioApiKeySecret?: string; - readonly twilioFromNumber?: string; - readonly twilioMessagingServiceSid?: string; readonly firebaseProjectId?: string; readonly pollIntervalMs?: number; readonly concurrency?: number; @@ -79,32 +72,7 @@ function getSendGridConfig(config: IWorkerConfig): { config.sendGridFromEmail ?? process.env['SENDGRID_FROM_EMAIL'] ?? 'notifications@scholarmancy.com', - fromName: config.sendGridFromName ?? process.env['SENDGRID_FROM_NAME'] ?? 'Scholaracle', - }; -} - -/** - * Get Twilio configuration from config or environment. - * - * @param config - Worker configuration - * @returns Twilio configuration - */ -function getTwilioConfig(config: IWorkerConfig): { - readonly accountSid: string; - readonly authToken: string; - readonly apiKeySid: string; - readonly apiKeySecret: string; - readonly fromNumber: string; - readonly messagingServiceSid: string; -} { - return { - accountSid: config.twilioAccountSid ?? process.env['TWILIO_ACCOUNT_SID'] ?? '', - authToken: config.twilioAuthToken ?? process.env['TWILIO_AUTH_TOKEN'] ?? '', - apiKeySid: config.twilioApiKeySid ?? process.env['TWILIO_API_KEY_SID'] ?? '', - apiKeySecret: config.twilioApiKeySecret ?? process.env['TWILIO_API_KEY_SECRET'] ?? '', - fromNumber: config.twilioFromNumber ?? process.env['TWILIO_FROM_NUMBER'] ?? '', - messagingServiceSid: - config.twilioMessagingServiceSid ?? process.env['TWILIO_MESSAGING_SERVICE_SID'] ?? '', + fromName: config.sendGridFromName ?? process.env['SENDGRID_FROM_NAME'] ?? 'Scholarmancy', }; } @@ -117,12 +85,7 @@ const MAX_SMS_LENGTH = 1600; * Flush pending SMS digest: send one combined SMS per user with digest enabled, then clear pending. * Intended to run once per day at SMS_DIGEST_UTC_HOUR. */ -async function flushSmsDigests( - database: Db, - twilioClient: Twilio, - fromNumber: string, - messagingServiceSid?: string -): Promise { +async function flushSmsDigests(database: Db, guardedSender: GuardedSmsSender): Promise { const repo = new SmsDigestPendingRepository(database); const userIds = await repo.getDistinctUserIds(); if (userIds.length === 0) return; @@ -132,29 +95,17 @@ async function flushSmsDigests( if (items.length === 0) continue; const phone = items[0]!.phone; const parts = items.map((i) => `${i.subject}\n${i.body}`); - let body = `Scholaracle daily digest (${items.length} alert${items.length === 1 ? '' : 's'}):\n\n${parts.join('\n\n')}`; + let body = `Scholarmancy daily digest (${items.length} alert${items.length === 1 ? '' : 's'}):\n\n${parts.join('\n\n')}`; if (body.length > MAX_SMS_LENGTH) { body = `${body.substring(0, MAX_SMS_LENGTH - 3)}...`; } const commLogRepo = new CommunicationLogRepository(database); try { - const msg = await twilioClient.messages.create({ - to: phone, - ...(messagingServiceSid ? { messagingServiceSid } : { from: fromNumber }), - body, - }); - await commLogRepo.create({ + await guardedSender.sendTransactional(phone, body, { userId, - channel: 'sms', - type: 'notification', subject: `SMS Digest (${items.length} alerts)`, - content: body, - recipientPhone: phone, - status: 'sent', - sentAt: new Date(), - triggeredBy: 'scheduled', templateName: 'sms_digest', - providerId: msg.sid, + triggeredBy: 'scheduled', }); await repo.deleteByUserId(userId); } catch (err) { @@ -220,23 +171,7 @@ function initializeNotificationService( emailTransport?: IEmailTransport ): NotificationService { const sendGridConfig = getSendGridConfig(config); - const twilioConfig = getTwilioConfig(config); - - const hasApiKeyAuth = Boolean( - twilioConfig.accountSid && twilioConfig.apiKeySid && twilioConfig.apiKeySecret - ); - const hasAuthTokenAuth = Boolean(twilioConfig.accountSid && twilioConfig.authToken); - const twilioClient = - hasApiKeyAuth || hasAuthTokenAuth - ? applyTwilioApiBaseUrl( - hasApiKeyAuth - ? twilio(twilioConfig.apiKeySid, twilioConfig.apiKeySecret, { - accountSid: twilioConfig.accountSid, - }) - : twilio(twilioConfig.accountSid, twilioConfig.authToken), - process.env['TWILIO_API_BASE_URL'] - ) - : ({} as unknown as Twilio); + const smsStack = database ? createSmsStack(database) : null; const transport: IEmailTransport = emailTransport ?? getEmailTransport(config); @@ -250,16 +185,6 @@ function initializeNotificationService( }, transport ); - const smsDelivery = new SMSDelivery( - { - accountSid: twilioConfig.accountSid, - authToken: twilioConfig.authToken, - fromNumber: twilioConfig.fromNumber, - messagingServiceSid: twilioConfig.messagingServiceSid, - }, - twilioClient - ); - const firebaseProjectId = config.firebaseProjectId ?? 'default'; const pushDelivery = new PushDelivery({ projectId: firebaseProjectId }); @@ -289,7 +214,7 @@ function initializeNotificationService( const deliveryRouter = new DeliveryRouter([ emailDelivery, - smsDelivery, + ...(smsStack ? [smsStack.smsDelivery] : []), ...(expoPushDelivery ? [expoPushDelivery] : []), pushDelivery, inAppDelivery, @@ -552,35 +477,14 @@ export async function startWorker(config: IWorkerConfig = {}): Promise { }); syncScheduler.start(); - const twilioConfig = getTwilioConfig(config); - const digestHasApiKey = Boolean( - twilioConfig.accountSid && twilioConfig.apiKeySid && twilioConfig.apiKeySecret - ); - const digestHasAuthToken = Boolean(twilioConfig.accountSid && twilioConfig.authToken); - const twilioClientForDigest = - digestHasApiKey || digestHasAuthToken - ? applyTwilioApiBaseUrl( - digestHasApiKey - ? twilio(twilioConfig.apiKeySid, twilioConfig.apiKeySecret, { - accountSid: twilioConfig.accountSid, - }) - : twilio(twilioConfig.accountSid, twilioConfig.authToken), - process.env['TWILIO_API_BASE_URL'] - ) - : null; - const hasSender = Boolean(twilioConfig.fromNumber || twilioConfig.messagingServiceSid); - if (twilioClientForDigest && hasSender) { + const digestSmsStack = createSmsStack(database); + if (digestSmsStack) { safeInterval( 'sms-digest', async () => { const now = new Date(); if (now.getUTCHours() === DIGEST_UTC_HOUR) { - await flushSmsDigests( - database, - twilioClientForDigest, - twilioConfig.fromNumber, - twilioConfig.messagingServiceSid || undefined - ); + await flushSmsDigests(database, digestSmsStack.guardedSender); } }, 60_000 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df80883..25e6f29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,15 +68,15 @@ importers: '@sendgrid/mail': specifier: ^8.1.0 version: 8.1.6 + libphonenumber-js: + specifier: ^1.12.9 + version: 1.13.14 mongodb: specifier: ^6.3.0 version: 6.21.0 nodemailer: specifier: ^6.9.0 version: 6.10.1 - twilio: - specifier: ^5.0.0 - version: 5.13.1 devDependencies: '@types/jest': specifier: ^29.5.11 @@ -180,9 +180,6 @@ importers: rrule: specifier: ^2.7.2 version: 2.8.1 - twilio: - specifier: ^5.0.0 - version: 5.13.1 ua-parser-js: specifier: ^2.0.9 version: 2.0.10 @@ -553,10 +550,10 @@ importers: version: 1.20.1 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)) + version: 29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3)) ts-jest: specifier: ^29.1.1 - version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@30.4.1)(jest@29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)))(typescript@6.0.3) + version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@30.4.1)(jest@29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3)))(typescript@6.0.3) typescript: specifier: ~6.0.3 version: 6.0.3 @@ -786,9 +783,6 @@ importers: nodemailer: specifier: ^6.9.0 version: 6.10.1 - twilio: - specifier: ^5.0.0 - version: 5.13.1 devDependencies: '@types/jest': specifier: ^29.5.11 @@ -830,49 +824,6 @@ packages: zod: optional: true - '@apimatic/authentication-adapters@0.5.14': - resolution: {integrity: sha512-V7nhHShPrU8LfjKKHoVJNS50SveSL77CexVuS4aeQyXx99HwdQVJwl2MK0KAYM6/b2ufQbJ7Eee2fzQT0TVXSQ==} - engines: {node: '>=14.15.0 || >=16.0.0'} - - '@apimatic/axios-client-adapter@0.3.21': - resolution: {integrity: sha512-pr/XvAvH9FjbpwM+B7vHQxM7alocOX1kLNtSpXKW3yxTYxksF3ydnUuQ85rRbCoNpyfMOIjnRBCNUBzX5p2Hnw==} - engines: {node: '>=14.15.0 || >=16.0.0'} - - '@apimatic/convert-to-stream@0.1.9': - resolution: {integrity: sha512-C9NEKnDZoTRBRVeUGXVyAEmy6P5o+8oLwEckTKj0iBlExJLEXNt14nf4wxfzRO1KR8j5Bw8S6yStKCrQzcVERA==} - engines: {node: '>=14.15.0 || >=16.0.0'} - - '@apimatic/core-interfaces@0.2.14': - resolution: {integrity: sha512-PQmSU32ndxtDddMCjbkNY/sVvDwQAsHUGKrdG5aGVE7iw/qvB2Tm2zyCarOB5TlDr4OB+/tuLCVhji0icx6MHg==} - engines: {node: '>=14.15.0 || >=16.0.0'} - - '@apimatic/core@0.10.30': - resolution: {integrity: sha512-MqODm1YwuW5yK7gkVtqiRQBgoAfjsTSNYTYJP4cg/JDaF8RokpiupSEDuUW6Xdo3fl/4tGCzphtAlKSeLUWKVA==} - engines: {node: '>=14.15.0 || >=16.0.0'} - - '@apimatic/file-wrapper@0.3.9': - resolution: {integrity: sha512-Fh3UE7UPs2v4wkJdsD+uJFF147+7X0qkQfKBdeLZx6mZ5RmBJOBbS6ApvstQTV279YsHiiedKUZGJ6XLoVU+pQ==} - engines: {node: '>=14.15.0 || >=16.0.0'} - - '@apimatic/http-headers@0.3.8': - resolution: {integrity: sha512-ShvCuT39hYfBTI+H1I16m5i6XZCyUy2kQJ6Jhfj78TwsW5r6AyCbzW7DEro8GN2nNYRU1+E/hrgH6J85YmriOA==} - engines: {node: '>=14.15.0 || >=16.0.0'} - - '@apimatic/http-query@0.3.9': - resolution: {integrity: sha512-D6nqXcCR3P6iWbJ9uFXyyF2z1PEhTbGFbHNNuwF1NQ4tnThQk67DW9ou7/XcWi21zLh9MUchDWw9I0iE+5F2xA==} - engines: {node: '>=14.15.0 || >=16.0.0'} - - '@apimatic/json-bigint@1.2.0': - resolution: {integrity: sha512-+bmVzYMdZu0Ya5L+my4FXFUih54OvQA/qlZsFOYdOoostyUuB27UDrVWQs/WVCmS0ADdo5vTU0eeTrrBkHoySw==} - - '@apimatic/proxy@0.1.4': - resolution: {integrity: sha512-Vzgfu7wcA5aEJyj2SjQ00Tb06fhBof8gDo1kSsF6sZBm4QjdFywN5AMbQwhfFOKjHqcsNmJspdeqcdymUQ77jA==} - engines: {node: '>=14.15.0 || >=16.0.0'} - - '@apimatic/schema@0.7.21': - resolution: {integrity: sha512-RCke4toXjA7fBRxQVa1GR+Lj9utVOEJ3voDI26dhk+bZuAac4UXPzkTEaIO3AIe/o8pcKCOkpNIzhzm57Cv2Qg==} - engines: {node: '>=14.15.0 || >=16.0.0'} - '@apm-js-collab/code-transformer-bundler-plugins@0.7.4': resolution: {integrity: sha512-nAfOeZPSUAQvJa1iFT/5oCrTm5YQhMMrfCNthNnaXHZiOQhu1KGuLoIx7HtbAi3wfwaBYLaICPIeenIaEwcXIg==} engines: {node: '>=18.0.0'} @@ -3567,9 +3518,6 @@ packages: '@types/multer@1.4.13': resolution: {integrity: sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==} - '@types/node@14.18.63': - resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} - '@types/node@20.19.43': resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} @@ -4730,9 +4678,6 @@ packages: dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} - dayjs@1.11.21: - resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} - debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -4810,9 +4755,6 @@ packages: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - detect-browser@5.3.0: - resolution: {integrity: sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w==} - detect-europe-js@0.1.2: resolution: {integrity: sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==} @@ -4827,9 +4769,6 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} - detect-node@2.1.0: - resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} - dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} @@ -5574,10 +5513,6 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} - form-data-encoder@4.1.0: - resolution: {integrity: sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==} - engines: {node: '>= 18'} - form-data@2.5.6: resolution: {integrity: sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==} engines: {node: '>= 0.12'} @@ -5586,10 +5521,6 @@ packages: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} - formdata-node@6.0.3: - resolution: {integrity: sha512-8e1++BCiTzUno9v5IZ2J6bv4RU+3UKDmqWUQD0MIMVCd9AdhWkO1gw57oo1mNEX1dMq2EGI+FbWz4B92pscSQg==} - engines: {node: '>= 18'} - formidable@2.1.5: resolution: {integrity: sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==} @@ -6447,6 +6378,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + libphonenumber-js@1.13.14: + resolution: {integrity: sha512-llihgCcx0BFLksecLP+x1J+6JDE1GsXS1RN/LoPF6qcwpeQcnjj0lcvZxY8AzbEpYwyZWPZW/nDuqkqzm3amiw==} + lighthouse-logger@1.4.2: resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==} @@ -6635,12 +6569,6 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} - lodash.defaultsdeep@4.6.1: - resolution: {integrity: sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA==} - - lodash.flatmap@4.5.0: - resolution: {integrity: sha512-/OcpcAGWlrZyoHGeHh3cAoa6nGdX6QYtmzNP84Jqol6UEQQ2gIaU3H+0eICcjcKGl0/XF8LWOujNn9lffsnaOg==} - lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -7846,10 +7774,6 @@ packages: resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} engines: {node: '>= 10.13.0'} - scmp@2.1.0: - resolution: {integrity: sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==} - deprecated: Just use Node.js's crypto.timingSafeEqual() - section-matter@1.0.0: resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} engines: {node: '>=4'} @@ -7995,14 +7919,6 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - square@39.1.1: - resolution: {integrity: sha512-75b/UWbXl6xk1cG0jEWeWTRHAbDseF78kdPa3N4Cm57KMkDwOS2qGSLfooyqMDmFEKhQAfTA0WSiMkw40hQ/2A==} - engines: {node: '>=14.17.0'} - - square@44.2.1: - resolution: {integrity: sha512-mGqFhxdGMKornyxtbyZbLYaPHbqnef/297kx6lGlNIxxvtcgAekuAOM/fHOs4V949oFV6F0Lu4GMVjutVH15NA==} - engines: {node: '>=18.0.0'} - stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -8246,9 +8162,6 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} - tiny-warning@1.0.3: - resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} - tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -8372,10 +8285,6 @@ packages: tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} - twilio@5.13.1: - resolution: {integrity: sha512-sT+PkhptF4Mf7t8eXFFvPQx4w5VHnBIPXbltGPMFRe+R2GxfRdMuFbuNA/cEm0aQR6LFQOn33+fhClg+TjRVqQ==} - engines: {node: '>=14.0'} - type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -8722,10 +8631,6 @@ packages: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} engines: {node: '>=4.0'} - xmlbuilder@13.0.2: - resolution: {integrity: sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==} - engines: {node: '>=6.0'} - xmlbuilder@15.1.1: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} @@ -8811,87 +8716,6 @@ snapshots: optionalDependencies: zod: 4.4.3 - '@apimatic/authentication-adapters@0.5.14': - dependencies: - '@apimatic/core-interfaces': 0.2.14 - '@apimatic/http-headers': 0.3.8 - '@apimatic/http-query': 0.3.9 - tslib: 2.8.1 - - '@apimatic/axios-client-adapter@0.3.21': - dependencies: - '@apimatic/convert-to-stream': 0.1.9 - '@apimatic/core-interfaces': 0.2.14 - '@apimatic/file-wrapper': 0.3.9 - '@apimatic/http-headers': 0.3.8 - '@apimatic/http-query': 0.3.9 - '@apimatic/json-bigint': 1.2.0 - '@apimatic/proxy': 0.1.4 - axios: 1.19.0 - detect-browser: 5.3.0 - detect-node: 2.1.0 - form-data: 4.0.6 - lodash.flatmap: 4.5.0 - tiny-warning: 1.0.3 - tslib: 2.8.1 - transitivePeerDependencies: - - debug - - supports-color - - '@apimatic/convert-to-stream@0.1.9': - dependencies: - tslib: 2.8.1 - - '@apimatic/core-interfaces@0.2.14': - dependencies: - '@apimatic/file-wrapper': 0.3.9 - '@apimatic/json-bigint': 1.2.0 - tslib: 2.8.1 - - '@apimatic/core@0.10.30': - dependencies: - '@apimatic/convert-to-stream': 0.1.9 - '@apimatic/core-interfaces': 0.2.14 - '@apimatic/file-wrapper': 0.3.9 - '@apimatic/http-headers': 0.3.8 - '@apimatic/http-query': 0.3.9 - '@apimatic/json-bigint': 1.2.0 - '@apimatic/schema': 0.7.21 - detect-browser: 5.3.0 - detect-node: 2.1.0 - form-data: 4.0.6 - lodash.defaultsdeep: 4.6.1 - lodash.flatmap: 4.5.0 - tiny-warning: 1.0.3 - tslib: 2.8.1 - - '@apimatic/file-wrapper@0.3.9': - dependencies: - tslib: 2.8.1 - - '@apimatic/http-headers@0.3.8': - dependencies: - tslib: 2.8.1 - - '@apimatic/http-query@0.3.9': - dependencies: - '@apimatic/core-interfaces': 0.2.14 - '@apimatic/file-wrapper': 0.3.9 - tslib: 2.8.1 - - '@apimatic/json-bigint@1.2.0': {} - - '@apimatic/proxy@0.1.4': - dependencies: - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - transitivePeerDependencies: - - supports-color - - '@apimatic/schema@0.7.21': - dependencies: - tslib: 2.8.1 - '@apm-js-collab/code-transformer-bundler-plugins@0.7.4': dependencies: '@apm-js-collab/code-transformer': 0.18.1 @@ -10498,7 +10322,7 @@ snapshots: - supports-color - ts-node - '@jest/core@29.7.0(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3))': + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3))': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 @@ -10512,7 +10336,7 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)) + jest-config: 29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -12021,8 +11845,6 @@ snapshots: dependencies: '@types/express': 4.17.25 - '@types/node@14.18.63': {} - '@types/node@20.19.43': dependencies: undici-types: 6.21.0 @@ -13299,13 +13121,13 @@ snapshots: - supports-color - ts-node - create-jest@29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)): + create-jest@29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)) + jest-config: 29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -13396,8 +13218,6 @@ snapshots: dateformat@4.6.3: {} - dayjs@1.11.21: {} - debug@2.6.9: dependencies: ms: 2.0.0 @@ -13446,8 +13266,6 @@ snapshots: destroy@1.2.0: {} - detect-browser@5.3.0: {} - detect-europe-js@0.1.2: {} detect-libc@2.1.2: {} @@ -13456,8 +13274,6 @@ snapshots: detect-node-es@1.1.0: {} - detect-node@2.1.0: {} - dezalgo@1.0.4: dependencies: asap: 2.0.6 @@ -14523,8 +14339,6 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - form-data-encoder@4.1.0: {} - form-data@2.5.6: dependencies: asynckit: 0.4.0 @@ -14543,8 +14357,6 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 - formdata-node@6.0.3: {} - formidable@2.1.5: dependencies: '@paralleldrive/cuid2': 2.3.1 @@ -15219,16 +15031,16 @@ snapshots: - supports-color - ts-node - jest-cli@29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)): + jest-cli@29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)) + create-jest: 29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3)) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)) + jest-config: 29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.3 @@ -15269,7 +15081,7 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)): + jest-config@29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3)): dependencies: '@babel/core': 7.29.7 '@jest/test-sequencer': 29.7.0 @@ -15295,7 +15107,38 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 20.19.43 - ts-node: 10.9.2(@types/node@20.19.43)(typescript@6.0.3) + ts-node: 10.9.2(@types/node@22.20.1)(typescript@6.0.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-config@29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3)): + dependencies: + '@babel/core': 7.29.7 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.7) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 22.20.1 + ts-node: 10.9.2(@types/node@22.20.1)(typescript@6.0.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -15573,12 +15416,12 @@ snapshots: - supports-color - ts-node - jest@29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)): + jest@29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)) + jest-cli: 29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -15731,6 +15574,8 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libphonenumber-js@1.13.14: {} + lighthouse-logger@1.4.2: dependencies: debug: 2.6.9 @@ -15897,10 +15742,6 @@ snapshots: lodash.debounce@4.0.8: {} - lodash.defaultsdeep@4.6.1: {} - - lodash.flatmap@4.5.0: {} - lodash.includes@4.3.0: {} lodash.isboolean@3.0.3: {} @@ -17225,8 +17066,6 @@ snapshots: ajv-formats: 2.1.1(ajv@8.20.0) ajv-keywords: 5.1.0(ajv@8.20.0) - scmp@2.1.0: {} - section-matter@1.0.0: dependencies: extend-shallow: 2.0.1 @@ -17421,31 +17260,6 @@ snapshots: sprintf-js@1.0.3: {} - square@39.1.1: - dependencies: - '@apimatic/authentication-adapters': 0.5.14 - '@apimatic/axios-client-adapter': 0.3.21 - '@apimatic/core': 0.10.30 - '@apimatic/json-bigint': 1.2.0 - '@apimatic/schema': 0.7.21 - '@types/node': 14.18.63 - transitivePeerDependencies: - - debug - - supports-color - - square@44.2.1: - dependencies: - form-data: 4.0.6 - form-data-encoder: 4.1.0 - formdata-node: 6.0.3 - node-fetch: 2.7.0 - readable-stream: 4.7.0 - square-legacy: square@39.1.1 - transitivePeerDependencies: - - debug - - encoding - - supports-color - stable-hash@0.0.5: {} stack-utils@2.0.6: @@ -17736,8 +17550,6 @@ snapshots: tiny-invariant@1.3.3: {} - tiny-warning@1.0.3: {} - tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.5) @@ -17811,12 +17623,12 @@ snapshots: esbuild: 0.24.2 jest-util: 30.4.1 - ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@30.4.1)(jest@29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)))(typescript@6.0.3): + ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@30.4.1)(jest@29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3)))(typescript@6.0.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 handlebars: 4.7.9 - jest: 29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)) + jest: 29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3)) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 @@ -17867,14 +17679,14 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3): + ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 20.19.43 + '@types/node': 22.20.1 acorn: 8.18.0 acorn-walk: 8.3.5 arg: 4.1.3 @@ -17904,19 +17716,6 @@ snapshots: tw-animate-css@1.4.0: {} - twilio@5.13.1: - dependencies: - axios: 1.19.0 - dayjs: 1.11.21 - https-proxy-agent: 5.0.1 - jsonwebtoken: 9.0.3 - qs: 6.15.3 - scmp: 2.1.0 - xmlbuilder: 13.0.2 - transitivePeerDependencies: - - debug - - supports-color - type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -18305,8 +18104,6 @@ snapshots: xmlbuilder@11.0.1: {} - xmlbuilder@13.0.2: {} - xmlbuilder@15.1.1: {} xmlchars@2.2.0: {}