From 6afa65d82509d1d82c331691b566b946a13a16e9 Mon Sep 17 00:00:00 2001 From: 1sraeliteX Date: Fri, 29 May 2026 19:03:42 +0100 Subject: [PATCH 1/2] feat(sms): implement SMS integration with log aggregation (#448) - Add SMS provider abstraction supporting Twilio, AWS SNS, and Vonage - Implement SMSQueue with retry logic, exponential backoff, and per-job delivery logs (capped at 1,000 entries) - Add SMSService with event dispatch for verification-code, security-alert, course-enrollment, and account-warning message types - Add SMSLogAggregator with queryable store (5,000 entry cap), metrics generation (success/error rates, avg delivery time, by-provider and by-event-type breakdowns), anomaly detection, and JSON/CSV export - Add API routes: POST /api/sms/send and GET /api/sms/logs - Update NotificationService with SMS methods and multi-channel helpers (sendSecurityAlertMultiChannel, sendCourseEnrollmentMultiChannel) - Add unit tests for queue, service, and aggregator - Update .env.example with SMS provider configuration vars - Add SMS_LOG_AGGREGATION.md implementation reference Closes #448 --- .env.example | 27 ++ SMS_LOG_AGGREGATION.md | 213 +++++++++ src/__tests__/logging/sms-aggregator.test.ts | 318 +++++++++++++ src/__tests__/sms/queue.test.ts | 259 +++++++++++ src/__tests__/sms/service.test.ts | 143 ++++++ src/app/api/sms/logs/route.ts | 221 +++++++++ src/app/api/sms/send/route.ts | 150 +++++++ src/lib/logging/sms-aggregator.ts | 449 +++++++++++++++++++ src/lib/sms/index.ts | 4 + src/lib/sms/provider.ts | 251 +++++++++++ src/lib/sms/queue.ts | 300 +++++++++++++ src/lib/sms/service.ts | 159 +++++++ src/lib/sms/types.ts | 56 +++ src/services/notifications.ts | 131 ++++++ 14 files changed, 2681 insertions(+) create mode 100644 SMS_LOG_AGGREGATION.md create mode 100644 src/__tests__/logging/sms-aggregator.test.ts create mode 100644 src/__tests__/sms/queue.test.ts create mode 100644 src/__tests__/sms/service.test.ts create mode 100644 src/app/api/sms/logs/route.ts create mode 100644 src/app/api/sms/send/route.ts create mode 100644 src/lib/logging/sms-aggregator.ts create mode 100644 src/lib/sms/index.ts create mode 100644 src/lib/sms/provider.ts create mode 100644 src/lib/sms/queue.ts create mode 100644 src/lib/sms/service.ts create mode 100644 src/lib/sms/types.ts diff --git a/.env.example b/.env.example index 60d8804a..6f2c7603 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,30 @@ DATABASE_URL=postgresql://user:password@localhost:5432/teachlink DB_POOL_MAX=20 DB_CONNECTION_TIMEOUT=5000 DB_IDLE_TIMEOUT=30000 + +# SMS Integration (#448) +# Provider selection: twilio | sns | vonage (default: twilio) +SMS_PROVIDER=twilio +SMS_FROM_NUMBER=+1234567890 +SMS_MAX_RETRIES=3 +SMS_RETRY_DELAY_MS=1500 +SMS_MAX_CONCURRENT=5 + +# Twilio credentials +TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +TWILIO_AUTH_TOKEN=your_auth_token +TWILIO_PHONE_NUMBER=+1234567890 + +# AWS SNS credentials (alternative provider) +# AWS_REGION=us-east-1 +# AWS_ACCESS_KEY_ID=your_access_key +# AWS_SECRET_ACCESS_KEY=your_secret_key + +# Vonage credentials (alternative provider) +# VONAGE_API_KEY=your_api_key +# VONAGE_API_SECRET=your_api_secret +# VONAGE_PHONE_NUMBER=+1234567890 + +# SMS Log Aggregation +LOG_AGGREGATION_URL=https://your-log-aggregation-endpoint.com/logs +NEXT_PUBLIC_LOG_AGGREGATION_URL=https://your-log-aggregation-endpoint.com/logs diff --git a/SMS_LOG_AGGREGATION.md b/SMS_LOG_AGGREGATION.md new file mode 100644 index 00000000..f95ddd39 --- /dev/null +++ b/SMS_LOG_AGGREGATION.md @@ -0,0 +1,213 @@ +# SMS Integration — Log Aggregation + +**Issue:** #448 — SMS Integration: Log Aggregation + +--- + +## Overview + +This document describes the SMS Integration with Log Aggregation implementation for TeachLink. Every SMS send attempt — success, failure, or retry — is captured in structured logs and surfaced through a queryable aggregation layer. + +--- + +## Architecture + +``` +NotificationService + │ + ▼ + SMSService ← event dispatch, message building + │ + ▼ + SMSQueue ← retry logic, delivery log store, metrics + │ + ▼ + SMSProvider ← Twilio / AWS SNS / Vonage + │ + ▼ + AppLogger (pino) ← structured log records + │ + ├── InMemoryLogTransport ← queryable in-process store + └── HttpLogTransport ← remote aggregation endpoint + (LOG_AGGREGATION_URL) + +SMSLogAggregator ← metrics, anomaly detection, export + │ + ▼ + GET /api/sms/logs ← query, metrics, anomalies, export + POST /api/sms/send ← send SMS via API +``` + +--- + +## New Files + +| File | Purpose | +|------|---------| +| `src/lib/sms/types.ts` | SMS types: `SMSMessage`, `SMSSendResult`, `SMSDeliveryLog`, etc. | +| `src/lib/sms/provider.ts` | Provider implementations: Twilio, AWS SNS, Vonage + factory | +| `src/lib/sms/queue.ts` | Queue with retry, exponential backoff, per-job delivery logs | +| `src/lib/sms/service.ts` | High-level service: event dispatch, message templates | +| `src/lib/sms/index.ts` | Barrel export | +| `src/lib/logging/sms-aggregator.ts` | Aggregation layer: metrics, anomaly detection, export | +| `src/app/api/sms/send/route.ts` | `POST /api/sms/send` — send SMS via HTTP | +| `src/app/api/sms/logs/route.ts` | `GET /api/sms/logs` — query logs, metrics, anomalies, export | +| `src/__tests__/sms/queue.test.ts` | Queue unit tests | +| `src/__tests__/sms/service.test.ts` | Service unit tests | +| `src/__tests__/logging/sms-aggregator.test.ts` | Aggregator unit tests | + +--- + +## Configuration + +Add these to your `.env` (see `.env.example` for all options): + +```env +# Provider: twilio | sns | vonage +SMS_PROVIDER=twilio +SMS_FROM_NUMBER=+1234567890 +SMS_MAX_RETRIES=3 +SMS_RETRY_DELAY_MS=1500 +SMS_MAX_CONCURRENT=5 + +# Twilio +TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +TWILIO_AUTH_TOKEN=your_auth_token +TWILIO_PHONE_NUMBER=+1234567890 + +# Remote log aggregation (optional — uses existing logging infra) +LOG_AGGREGATION_URL=https://your-log-aggregation-endpoint.com/logs +``` + +--- + +## What Gets Logged + +Every SMS operation emits structured log records with the following context fields: + +| Field | Description | +|-------|-------------| +| `jobId` | Unique queue job identifier | +| `provider` | Active SMS provider (`twilio`, `sns`, `vonage`) | +| `phoneNumber` | Recipient (E.164 format) | +| `messageId` | Provider-assigned message ID on success | +| `status` | `pending` / `sent` / `failed` / `retrying` | +| `attempt` | Current attempt number | +| `maxRetries` | Configured retry limit | +| `eventType` | SMS event type (e.g. `verification-code`) | +| `tags` | Message tags for filtering | + +Performance metrics (`sms.send_duration_ms`, `sms.sent`, `sms.failed`, `sms.retry`, `sms.enqueued`) are emitted via the existing `createCounterMetric` / `measureAsync` infrastructure. + +--- + +## API Reference + +### `GET /api/sms/logs` + +Query aggregated SMS logs. + +| Query param | Default | Description | +|-------------|---------|-------------| +| `action` | `query` | `query` \| `metrics` \| `failed` \| `anomalies` \| `store-stats` \| `export` | +| `level` | — | Filter by log level (`info`, `warn`, `error`) | +| `provider` | — | Filter by provider | +| `eventType` | — | Filter by event type | +| `status` | — | Filter by delivery status | +| `since` | — | Unix timestamp (ms) lower bound | +| `limit` | `100` | Max records returned | +| `offset` | `0` | Pagination offset | +| `timeRangeMs` | `86400000` | Time window for `metrics` action (ms) | +| `format` | `json` | `json` \| `csv` for `export` action | + +**Examples:** + +``` +GET /api/sms/logs?action=metrics +GET /api/sms/logs?action=failed&limit=50 +GET /api/sms/logs?action=anomalies +GET /api/sms/logs?action=export&format=csv +GET /api/sms/logs?status=failed&provider=twilio +``` + +### `POST /api/sms/send` + +Send an SMS event. + +```json +{ + "eventType": "verification-code", + "phoneNumber": { "countryCode": "1", "number": "5551234567" }, + "name": "Alice", + "data": { + "code": "123456", + "expiresInMinutes": 10 + } +} +``` + +Supported `eventType` values: `verification-code`, `security-alert`, `course-enrollment`, `account-warning`. + +--- + +## Usage in Code + +```ts +import { smsService } from '@/lib/sms'; + +// Send a verification code +await smsService.sendVerificationCode({ + phoneNumber: { countryCode: '1', number: '5551234567' }, + name: 'Alice', + code: '123456', + expiresInMinutes: 10, +}); + +// Multi-channel via NotificationService +import { notificationService } from '@/services/notifications'; + +await notificationService.sendSecurityAlertMultiChannel( + { email: 'alice@example.com', name: 'Alice', device: 'iPhone', timestamp: '...' }, + { phoneNumber: { countryCode: '1', number: '5551234567' }, action: 'login' }, +); +``` + +--- + +## Aggregation & Monitoring + +```ts +import { SMSLogAggregator } from '@/lib/logging/sms-aggregator'; + +// 24-hour delivery metrics +const metrics = SMSLogAggregator.getMetrics(); +// { totalMessages, successRate, errorRate, averageDeliveryTimeMs, byProvider, byEventType } + +// Anomaly detection +const { slowDeliveries, highRetryAttempts, configurationErrors } = + SMSLogAggregator.getAnomalies(); + +// Export for external systems +const csv = SMSLogAggregator.exportLogs('csv'); +const json = SMSLogAggregator.exportLogs('json'); + +// Maintenance +SMSLogAggregator.clearOldLogs(30 * 24 * 60 * 60 * 1000); // 30 days +``` + +--- + +## Security Considerations + +- Phone numbers are stored in E.164 format and only the last 4 digits should be displayed in UI. +- Message bodies are truncated to 100 characters in delivery logs to avoid storing sensitive content (e.g. OTP codes). +- Provider credentials are read from environment variables only — never hardcoded. +- The `/api/sms/logs` and `/api/sms/send` routes should be protected by authentication middleware before production deployment. + +--- + +## Performance Impact + +- Log writes are fire-and-forget (`void Promise.resolve(transport.write(record))`), matching the existing email logging pattern — no blocking of the send path. +- The in-memory aggregator caps at 5,000 SMS log entries; the general log transport caps at 500 entries. +- `measureAsync` wraps each provider call to track `sms.send_duration_ms` without adding overhead beyond a `Date.now()` pair. diff --git a/src/__tests__/logging/sms-aggregator.test.ts b/src/__tests__/logging/sms-aggregator.test.ts new file mode 100644 index 00000000..d11e4457 --- /dev/null +++ b/src/__tests__/logging/sms-aggregator.test.ts @@ -0,0 +1,318 @@ +/** + * SMS Log Aggregator Tests + * + * Tests for SMS log aggregation and metrics generation. + */ + +import { SMSLogAggregator } from '@/lib/logging/sms-aggregator'; +import { LogRecord } from '@/lib/logging/types'; + +describe('SMSLogAggregator', () => { + const mockSMSLog: LogRecord = { + level: 'info', + message: 'SMS sent successfully', + scope: 'sms:queue', + timestamp: new Date().toISOString(), + requestId: 'test_123', + context: { + provider: 'twilio', + status: 'sent', + eventType: 'verification-code', + messageId: 'msg_123', + phoneNumber: '+15551234567', + jobId: 'job_123', + }, + metrics: [ + { + name: 'sms.send_duration_ms', + value: 1500, + unit: 'ms', + timestamp: Date.now(), + }, + ], + }; + + describe('collect SMS logs', () => { + it('should collect SMS-related logs', () => { + const logs: LogRecord[] = [mockSMSLog]; + + const collected = SMSLogAggregator.collectSMSLogs(logs); + + expect(collected).toBeDefined(); + expect(Array.isArray(collected)).toBe(true); + expect(collected.length).toBeGreaterThan(0); + }); + + it('should filter non-SMS logs', () => { + const nonSMSLog: LogRecord = { + level: 'info', + message: 'Some other log', + scope: 'app:general', + timestamp: new Date().toISOString(), + }; + + const logs: LogRecord[] = [mockSMSLog, nonSMSLog]; + + const collected = SMSLogAggregator.collectSMSLogs(logs); + + expect(collected.length).toBeGreaterThanOrEqual(0); + }); + + it('should maintain store size limit', () => { + const logs: LogRecord[] = Array(100) + .fill(null) + .map((_, i) => ({ + ...mockSMSLog, + timestamp: new Date(Date.now() + i).toISOString(), + })); + + SMSLogAggregator.collectSMSLogs(logs); + + const stats = SMSLogAggregator.getStoreStats(); + expect(stats.totalLogs).toBeLessThanOrEqual(stats.maxCapacity); + }); + }); + + describe('query logs', () => { + beforeEach(() => { + SMSLogAggregator.collectSMSLogs([mockSMSLog]); + }); + + it('should query logs without filters', () => { + const logs = SMSLogAggregator.queryLogs({}); + + expect(Array.isArray(logs)).toBe(true); + }); + + it('should filter logs by level', () => { + const logs = SMSLogAggregator.queryLogs({ level: ['info'] }); + + expect(Array.isArray(logs)).toBe(true); + }); + + it('should filter logs by provider', () => { + const logs = SMSLogAggregator.queryLogs({ provider: 'twilio' }); + + expect(Array.isArray(logs)).toBe(true); + }); + + it('should filter logs by event type', () => { + const logs = SMSLogAggregator.queryLogs({ eventType: 'verification-code' }); + + expect(Array.isArray(logs)).toBe(true); + }); + + it('should filter logs by status', () => { + const logs = SMSLogAggregator.queryLogs({ status: 'sent' }); + + expect(Array.isArray(logs)).toBe(true); + }); + + it('should respect limit and offset', () => { + const logs = SMSLogAggregator.queryLogs({ limit: 10, offset: 0 }); + + expect(logs.length).toBeLessThanOrEqual(10); + }); + }); + + describe('metrics generation', () => { + beforeEach(() => { + SMSLogAggregator.collectSMSLogs([mockSMSLog]); + }); + + it('should generate metrics', () => { + const metrics = SMSLogAggregator.getMetrics(); + + expect(metrics).toBeDefined(); + expect(metrics.totalMessages).toBeGreaterThanOrEqual(0); + expect(metrics.successRate).toBeGreaterThanOrEqual(0); + expect(metrics.errorRate).toBeGreaterThanOrEqual(0); + }); + + it('should calculate success rate', () => { + const metrics = SMSLogAggregator.getMetrics(); + + expect(metrics.successRate).toBeGreaterThanOrEqual(0); + expect(metrics.successRate).toBeLessThanOrEqual(100); + }); + + it('should calculate error rate', () => { + const metrics = SMSLogAggregator.getMetrics(); + + expect(metrics.errorRate).toBeGreaterThanOrEqual(0); + expect(metrics.errorRate).toBeLessThanOrEqual(100); + }); + + it('should track metrics by provider', () => { + const metrics = SMSLogAggregator.getMetrics(); + + expect(metrics.byProvider).toBeDefined(); + expect(typeof metrics.byProvider).toBe('object'); + }); + + it('should track metrics by event type', () => { + const metrics = SMSLogAggregator.getMetrics(); + + expect(metrics.byEventType).toBeDefined(); + expect(typeof metrics.byEventType).toBe('object'); + }); + + it('should calculate average delivery time', () => { + const metrics = SMSLogAggregator.getMetrics(); + + expect(metrics.averageDeliveryTimeMs).toBeGreaterThanOrEqual(0); + }); + + it('should respect time range filter', () => { + const oneHourAgo = 60 * 60 * 1000; + const metrics = SMSLogAggregator.getMetrics(oneHourAgo); + + expect(metrics).toBeDefined(); + }); + }); + + describe('failed messages', () => { + beforeEach(() => { + const failedLog: LogRecord = { + ...mockSMSLog, + level: 'error', + message: 'SMS send failed', + context: { + ...mockSMSLog.context, + status: 'failed', + }, + }; + + SMSLogAggregator.collectSMSLogs([failedLog]); + }); + + it('should retrieve failed messages', () => { + const failed = SMSLogAggregator.getFailedMessages(); + + expect(Array.isArray(failed)).toBe(true); + }); + + it('should respect failed messages limit', () => { + const failed = SMSLogAggregator.getFailedMessages(5); + + expect(failed.length).toBeLessThanOrEqual(5); + }); + }); + + describe('anomalies detection', () => { + it('should detect slow deliveries', () => { + const slowLog: LogRecord = { + ...mockSMSLog, + metrics: [ + { + name: 'sms.send_duration_ms', + value: 10000, // > 5 seconds + unit: 'ms', + timestamp: Date.now(), + }, + ], + }; + + SMSLogAggregator.collectSMSLogs([slowLog]); + + const anomalies = SMSLogAggregator.getAnomalies(); + + expect(anomalies.slowDeliveries).toBeDefined(); + expect(Array.isArray(anomalies.slowDeliveries)).toBe(true); + }); + + it('should detect high retry attempts', () => { + const retryLog: LogRecord = { + ...mockSMSLog, + context: { + ...mockSMSLog.context, + attempt: 3, + }, + }; + + SMSLogAggregator.collectSMSLogs([retryLog]); + + const anomalies = SMSLogAggregator.getAnomalies(); + + expect(anomalies.highRetryAttempts).toBeDefined(); + expect(Array.isArray(anomalies.highRetryAttempts)).toBe(true); + }); + + it('should detect configuration errors', () => { + const configErrorLog: LogRecord = { + ...mockSMSLog, + level: 'error', + message: 'Twilio provider not configured', + context: { + ...mockSMSLog.context, + missingCredentials: true, + }, + }; + + SMSLogAggregator.collectSMSLogs([configErrorLog]); + + const anomalies = SMSLogAggregator.getAnomalies(); + + expect(anomalies.configurationErrors).toBeDefined(); + expect(Array.isArray(anomalies.configurationErrors)).toBe(true); + }); + }); + + describe('data export', () => { + beforeEach(() => { + SMSLogAggregator.collectSMSLogs([mockSMSLog]); + }); + + it('should export logs as JSON', () => { + const json = SMSLogAggregator.exportLogs('json'); + + expect(typeof json).toBe('string'); + expect(json.length).toBeGreaterThan(0); + + const parsed = JSON.parse(json); + expect(Array.isArray(parsed)).toBe(true); + }); + + it('should export logs as CSV', () => { + const csv = SMSLogAggregator.exportLogs('csv'); + + expect(typeof csv).toBe('string'); + expect(csv.includes(',')).toBe(true); // Should contain CSV delimiters + }); + }); + + describe('log maintenance', () => { + beforeEach(() => { + SMSLogAggregator.collectSMSLogs([mockSMSLog]); + }); + + it('should clear old logs', () => { + const deletedCount = SMSLogAggregator.clearOldLogs(0); // Clear all + + expect(typeof deletedCount).toBe('number'); + expect(deletedCount).toBeGreaterThanOrEqual(0); + }); + + it('should respect time threshold for clearing', () => { + const deletedCount = SMSLogAggregator.clearOldLogs(24 * 60 * 60 * 1000); // 24 hours + + expect(deletedCount).toBe(0); // Should not delete recent logs + }); + + it('should get store size', () => { + const size = SMSLogAggregator.getStoreSize(); + + expect(typeof size).toBe('number'); + expect(size).toBeGreaterThanOrEqual(0); + }); + + it('should get store statistics', () => { + const stats = SMSLogAggregator.getStoreStats(); + + expect(stats).toBeDefined(); + expect(stats.totalLogs).toBeGreaterThanOrEqual(0); + expect(stats.maxCapacity).toBeGreaterThan(0); + expect(stats.utilizationPercent).toBeGreaterThanOrEqual(0); + }); + }); +}); diff --git a/src/__tests__/sms/queue.test.ts b/src/__tests__/sms/queue.test.ts new file mode 100644 index 00000000..9bde8291 --- /dev/null +++ b/src/__tests__/sms/queue.test.ts @@ -0,0 +1,259 @@ +/** + * SMS Queue Tests + * + * Tests for the SMS Queue with log aggregation functionality. + */ + +import { SMSQueue } from '@/lib/sms/queue'; +import { TwilioProvider } from '@/lib/sms/provider'; +import { SMSMessage } from '@/lib/sms/types'; + +describe('SMSQueue', () => { + let queue: SMSQueue; + let provider: TwilioProvider; + + beforeEach(() => { + provider = new TwilioProvider(); + queue = new SMSQueue(provider, { + maxRetries: 3, + retryDelayMs: 100, + maxConcurrent: 2, + }); + }); + + describe('enqueueing messages', () => { + it('should enqueue a message', async () => { + const message: SMSMessage = { + to: { + countryCode: '1', + number: '5551234567', + }, + body: 'Test message', + }; + + const result = await queue.enqueue(message); + + expect(result).toBeDefined(); + expect(result.provider).toBe('twilio'); + }); + + it('should handle multiple concurrent messages', async () => { + const messages: SMSMessage[] = [ + { + to: { countryCode: '1', number: '5551234567' }, + body: 'Test 1', + }, + { + to: { countryCode: '1', number: '5551234568' }, + body: 'Test 2', + }, + { + to: { countryCode: '1', number: '5551234569' }, + body: 'Test 3', + }, + ]; + + const results = await Promise.all(messages.map((msg) => queue.enqueue(msg))); + + expect(results).toHaveLength(3); + results.forEach((result) => { + expect(result).toBeDefined(); + }); + }); + + it('should add message tags', async () => { + const message: SMSMessage = { + to: { + countryCode: '1', + number: '5551234567', + }, + body: 'Test message', + tags: ['transactional', 'verification-code'], + }; + + const result = await queue.enqueue(message); + + expect(result).toBeDefined(); + }); + }); + + describe('delivery logs', () => { + it('should retrieve delivery logs', async () => { + const message: SMSMessage = { + to: { + countryCode: '1', + number: '5551234567', + }, + body: 'Test message', + }; + + await queue.enqueue(message); + + const logs = queue.getDeliveryLogs(); + + expect(logs).toBeDefined(); + expect(Array.isArray(logs)).toBe(true); + }); + + it('should filter logs by status', async () => { + const message: SMSMessage = { + to: { + countryCode: '1', + number: '5551234567', + }, + body: 'Test message', + }; + + await queue.enqueue(message); + + const sentLogs = queue.getDeliveryLogs({ status: 'sent' }); + + expect(Array.isArray(sentLogs)).toBe(true); + }); + + it('should filter logs by provider', async () => { + const message: SMSMessage = { + to: { + countryCode: '1', + number: '5551234567', + }, + body: 'Test message', + }; + + await queue.enqueue(message); + + const logs = queue.getDeliveryLogs({ provider: 'twilio' }); + + expect(Array.isArray(logs)).toBe(true); + }); + + it('should respect log limit', async () => { + const message: SMSMessage = { + to: { + countryCode: '1', + number: '5551234567', + }, + body: 'Test message', + }; + + await queue.enqueue(message); + + const logs = queue.getDeliveryLogs({ limit: 5 }); + + expect(logs.length).toBeLessThanOrEqual(5); + }); + }); + + describe('delivery statistics', () => { + it('should generate delivery statistics', async () => { + const message: SMSMessage = { + to: { + countryCode: '1', + number: '5551234567', + }, + body: 'Test message', + }; + + await queue.enqueue(message); + + const stats = queue.getDeliveryStats(); + + expect(stats).toBeDefined(); + expect(stats.total).toBeGreaterThanOrEqual(0); + expect(stats.sent).toBeGreaterThanOrEqual(0); + expect(stats.failed).toBeGreaterThanOrEqual(0); + expect(stats.byProvider).toBeDefined(); + }); + + it('should track stats by provider', async () => { + const message: SMSMessage = { + to: { + countryCode: '1', + number: '5551234567', + }, + body: 'Test message', + }; + + await queue.enqueue(message); + + const stats = queue.getDeliveryStats(); + + expect(stats.byProvider).toBeDefined(); + expect(Object.keys(stats.byProvider).length).toBeGreaterThanOrEqual(0); + }); + }); + + describe('log maintenance', () => { + it('should clear old logs', async () => { + const message: SMSMessage = { + to: { + countryCode: '1', + number: '5551234567', + }, + body: 'Test message', + }; + + await queue.enqueue(message); + + const initialLogs = queue.getDeliveryLogs(); + const deletedCount = queue.clearOldLogs(0); // Clear all + + expect(typeof deletedCount).toBe('number'); + }); + + it('should respect old log time threshold', async () => { + const message: SMSMessage = { + to: { + countryCode: '1', + number: '5551234567', + }, + body: 'Test message', + }; + + await queue.enqueue(message); + + // Should not delete recent logs + const deletedCount = queue.clearOldLogs(24 * 60 * 60 * 1000); + + expect(deletedCount).toBe(0); + }); + }); + + describe('message handling', () => { + it('should handle multiple recipients', async () => { + const message: SMSMessage = { + to: [ + { countryCode: '1', number: '5551234567' }, + { countryCode: '1', number: '5551234568' }, + ], + body: 'Test message', + }; + + const result = await queue.enqueue(message); + + expect(result).toBeDefined(); + }); + + it('should include metadata in delivery logs', async () => { + const message: SMSMessage = { + to: { + countryCode: '1', + number: '5551234567', + }, + body: 'Test message', + metadata: { + userId: 'user123', + eventId: 'event456', + }, + }; + + await queue.enqueue(message); + + const logs = queue.getDeliveryLogs(); + + if (logs.length > 0) { + expect(logs[0].metadata).toBeDefined(); + } + }); + }); +}); diff --git a/src/__tests__/sms/service.test.ts b/src/__tests__/sms/service.test.ts new file mode 100644 index 00000000..1f1ac035 --- /dev/null +++ b/src/__tests__/sms/service.test.ts @@ -0,0 +1,143 @@ +/** + * SMS Service Tests + * + * Tests for the SMS Service and multi-channel notification integration. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SMSService } from '@/lib/sms/service'; + +// Mock the provider so tests don't need real credentials +vi.mock('@/lib/sms/provider', () => ({ + createSMSProvider: () => ({ + type: 'twilio', + send: vi.fn().mockResolvedValue({ + success: true, + provider: 'twilio', + messageId: 'mock_msg_123', + timestamp: Date.now(), + }), + }), +})); + +describe('SMSService', () => { + let service: SMSService; + + beforeEach(() => { + service = new SMSService(); + }); + + describe('sendVerificationCode', () => { + it('should send a verification code SMS', async () => { + const result = await service.sendVerificationCode({ + phoneNumber: { countryCode: '1', number: '5551234567' }, + name: 'Alice', + code: '123456', + expiresInMinutes: 10, + }); + + expect(result).toBeDefined(); + expect(result.provider).toBe('twilio'); + }); + }); + + describe('sendSecurityAlert', () => { + it('should send a security alert SMS', async () => { + const result = await service.sendSecurityAlert({ + phoneNumber: { countryCode: '1', number: '5551234567' }, + name: 'Alice', + device: 'iPhone 15', + timestamp: new Date().toISOString(), + action: 'login', + }); + + expect(result).toBeDefined(); + expect(result.provider).toBe('twilio'); + }); + }); + + describe('sendCourseEnrollment', () => { + it('should send a course enrollment SMS', async () => { + const result = await service.sendCourseEnrollment({ + phoneNumber: { countryCode: '1', number: '5551234567' }, + name: 'Alice', + courseName: 'Advanced TypeScript', + courseUrl: 'https://teachlink.app/courses/ts-advanced', + }); + + expect(result).toBeDefined(); + expect(result.provider).toBe('twilio'); + }); + }); + + describe('sendAccountWarning', () => { + it('should send an account warning SMS', async () => { + const result = await service.sendAccountWarning({ + phoneNumber: { countryCode: '1', number: '5551234567' }, + name: 'Alice', + reason: 'Suspicious login activity detected', + }); + + expect(result).toBeDefined(); + expect(result.provider).toBe('twilio'); + }); + }); + + describe('sendEvent', () => { + it('should dispatch verification-code event', async () => { + const result = await service.sendEvent({ + type: 'verification-code', + data: { + phoneNumber: { countryCode: '1', number: '5551234567' }, + code: '654321', + expiresInMinutes: 5, + }, + }); + + expect(result.success).toBe(true); + }); + + it('should dispatch security-alert event', async () => { + const result = await service.sendEvent({ + type: 'security-alert', + data: { + phoneNumber: { countryCode: '1', number: '5551234567' }, + device: 'Chrome on macOS', + timestamp: new Date().toISOString(), + action: 'password-change', + }, + }); + + expect(result.success).toBe(true); + }); + }); + + describe('delivery stats', () => { + it('should return delivery stats', async () => { + await service.sendVerificationCode({ + phoneNumber: { countryCode: '1', number: '5551234567' }, + code: '000000', + expiresInMinutes: 5, + }); + + const stats = service.getDeliveryStats(); + + expect(stats).toBeDefined(); + expect(typeof stats.total).toBe('number'); + expect(typeof stats.sent).toBe('number'); + expect(typeof stats.failed).toBe('number'); + }); + + it('should return delivery logs', async () => { + await service.sendVerificationCode({ + phoneNumber: { countryCode: '1', number: '5551234567' }, + code: '000000', + expiresInMinutes: 5, + }); + + const logs = service.getDeliveryLogs(); + + expect(Array.isArray(logs)).toBe(true); + }); + }); +}); diff --git a/src/app/api/sms/logs/route.ts b/src/app/api/sms/logs/route.ts new file mode 100644 index 00000000..100735da --- /dev/null +++ b/src/app/api/sms/logs/route.ts @@ -0,0 +1,221 @@ +/** + * SMS Logs API Route + * + * Provides endpoints for retrieving and managing SMS logs aggregation data. + * Supports filtering, metrics generation, and data export. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { SMSLogAggregator } from '@/lib/logging/sms-aggregator'; +import { createLogger } from '@/lib/logging'; + +const logger = createLogger('api:sms:logs'); + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const action = searchParams.get('action') || 'query'; + + logger.info('SMS logs API request', { + context: { + action, + method: 'GET', + }, + }); + + try { + switch (action) { + case 'metrics': { + const timeRangeMs = parseInt(searchParams.get('timeRangeMs') || '86400000'); + const metrics = SMSLogAggregator.getMetrics(timeRangeMs); + + logger.info('SMS metrics retrieved', { + context: { + totalMessages: metrics.totalMessages, + successRate: metrics.successRate, + }, + }); + + return NextResponse.json({ + success: true, + data: metrics, + }); + } + + case 'failed': { + const limit = parseInt(searchParams.get('limit') || '100'); + const failed = SMSLogAggregator.getFailedMessages(limit); + + logger.info('Failed messages retrieved', { + context: { + count: failed.length, + }, + }); + + return NextResponse.json({ + success: true, + data: failed, + count: failed.length, + }); + } + + case 'anomalies': { + const anomalies = SMSLogAggregator.getAnomalies(); + + logger.info('Anomalies retrieved', { + context: { + slowDeliveries: anomalies.slowDeliveries.length, + highRetryAttempts: anomalies.highRetryAttempts.length, + configurationErrors: anomalies.configurationErrors.length, + }, + }); + + return NextResponse.json({ + success: true, + data: anomalies, + }); + } + + case 'store-stats': { + const stats = SMSLogAggregator.getStoreStats(); + + return NextResponse.json({ + success: true, + data: stats, + }); + } + + case 'export': { + const format = (searchParams.get('format') as 'json' | 'csv') || 'json'; + const exportData = SMSLogAggregator.exportLogs(format); + + logger.info('SMS logs exported', { + context: { + format, + size: exportData.length, + }, + }); + + const contentType = format === 'csv' ? 'text/csv' : 'application/json'; + const filename = `sms-logs-${new Date().toISOString()}.${format}`; + + return new NextResponse(exportData, { + status: 200, + headers: { + 'Content-Type': contentType, + 'Content-Disposition': `attachment; filename="${filename}"`, + }, + }); + } + + case 'query': + default: { + const level = searchParams.getAll('level'); + const provider = searchParams.get('provider') || undefined; + const eventType = searchParams.get('eventType') || undefined; + const status = searchParams.get('status') || undefined; + const since = searchParams.get('since') ? parseInt(searchParams.get('since')!) : undefined; + const limit = parseInt(searchParams.get('limit') || '100'); + const offset = parseInt(searchParams.get('offset') || '0'); + + const logs = SMSLogAggregator.queryLogs({ + level: level.length > 0 ? level : undefined, + provider, + eventType, + status, + since, + limit, + offset, + }); + + logger.info('SMS logs queried', { + context: { + count: logs.length, + provider, + eventType, + status, + }, + }); + + return NextResponse.json({ + success: true, + data: logs, + count: logs.length, + }); + } + } + } catch (error) { + logger.error('SMS logs API error', { + context: { + action, + }, + error, + }); + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Internal server error', + }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const action = searchParams.get('action') || 'clear'; + + logger.info('SMS logs API request', { + context: { + action, + method: 'POST', + }, + }); + + try { + switch (action) { + case 'clear-old': { + const olderThanMs = parseInt( + (await request.json()).olderThanMs || '2592000000' // 30 days + ); + const deletedCount = SMSLogAggregator.clearOldLogs(olderThanMs); + + logger.info('Old SMS logs cleared', { + context: { + deletedCount, + olderThanMs, + }, + }); + + return NextResponse.json({ + success: true, + deletedCount, + }); + } + + default: + return NextResponse.json( + { + success: false, + error: 'Unknown action', + }, + { status: 400 } + ); + } + } catch (error) { + logger.error('SMS logs API error', { + context: { + action, + }, + error, + }); + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Internal server error', + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/sms/send/route.ts b/src/app/api/sms/send/route.ts new file mode 100644 index 00000000..ff7f2276 --- /dev/null +++ b/src/app/api/sms/send/route.ts @@ -0,0 +1,150 @@ +/** + * SMS Send API Route + * + * Handles SMS delivery requests with built-in logging and error handling. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { smsService } from '@/lib/sms'; +import { createLogger } from '@/lib/logging'; + +const logger = createLogger('api:sms:send'); + +interface SendSMSRequest { + eventType: 'verification-code' | 'security-alert' | 'course-enrollment' | 'account-warning'; + phoneNumber: { + countryCode: string; + number: string; + }; + name?: string; + data: Record; +} + +export async function POST(request: NextRequest) { + const requestId = `api_sms_${Date.now()}_${Math.random().toString(16).slice(2)}`; + + logger.info('SMS send API request received', { + requestId, + context: { + method: 'POST', + }, + }); + + try { + const body: SendSMSRequest = await request.json(); + + // Validate request + if (!body.eventType || !body.phoneNumber) { + logger.warn('Invalid SMS send request', { + requestId, + context: { + missingFields: [], + }, + }); + + return NextResponse.json( + { + success: false, + error: 'Missing required fields: eventType, phoneNumber', + }, + { status: 400 } + ); + } + + logger.info('SMS send request validated', { + requestId, + context: { + eventType: body.eventType, + phoneNumber: `+${body.phoneNumber.countryCode}${body.phoneNumber.number}`, + }, + }); + + let result; + + switch (body.eventType) { + case 'verification-code': + result = await smsService.sendVerificationCode({ + phoneNumber: body.phoneNumber, + name: body.name, + ...(body.data as any), + }); + break; + + case 'security-alert': + result = await smsService.sendSecurityAlert({ + phoneNumber: body.phoneNumber, + name: body.name, + ...(body.data as any), + }); + break; + + case 'course-enrollment': + result = await smsService.sendCourseEnrollment({ + phoneNumber: body.phoneNumber, + name: body.name, + ...(body.data as any), + }); + break; + + case 'account-warning': + result = await smsService.sendAccountWarning({ + phoneNumber: body.phoneNumber, + name: body.name, + ...(body.data as any), + }); + break; + + default: + logger.error('Unknown SMS event type', { + requestId, + context: { + eventType: body.eventType, + }, + }); + + return NextResponse.json( + { + success: false, + error: 'Unknown event type', + }, + { status: 400 } + ); + } + + logger.info('SMS send request processed', { + requestId, + context: { + eventType: body.eventType, + success: result.success, + messageId: result.messageId, + provider: result.provider, + }, + }); + + return NextResponse.json( + { + success: result.success, + messageId: result.messageId, + provider: result.provider, + error: result.error, + }, + { status: result.success ? 200 : 500 } + ); + } catch (error) { + logger.error('SMS send API error', { + requestId, + context: { + method: 'POST', + }, + error, + }); + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Internal server error', + }, + { status: 500 } + ); + } +} diff --git a/src/lib/logging/sms-aggregator.ts b/src/lib/logging/sms-aggregator.ts new file mode 100644 index 00000000..ad7d7abe --- /dev/null +++ b/src/lib/logging/sms-aggregator.ts @@ -0,0 +1,449 @@ +/** + * SMS Log Aggregator + * + * This module provides comprehensive log aggregation for SMS integration, + * collecting delivery metrics, errors, and performance data for monitoring + * and analytics purposes. + */ + +import { LogRecord, LogQuery } from './types'; +import { createLogger } from './index'; + +const logger = createLogger('logging:sms-aggregator'); + +export interface SMSLogMetrics { + totalMessages: number; + successfulDeliveries: number; + failedDeliveries: number; + retriedDeliveries: number; + averageDeliveryTimeMs: number; + errorRate: number; + successRate: number; + byProvider: Record; + byEventType: Record; + timeRange: { + from: string; + to: string; + }; +} + +export interface ProviderMetrics { + name: string; + total: number; + successful: number; + failed: number; + avgDeliveryTimeMs: number; + errorRate: number; +} + +export interface EventTypeMetrics { + type: string; + total: number; + successful: number; + failed: number; + avgDeliveryTimeMs: number; +} + +export interface AggregatedSMSLog { + id: string; + timestamp: string; + level: string; + message: string; + scope: string; + requestId?: string; + context: { + jobId?: string; + provider?: string; + phoneNumber?: string; + messageId?: string; + attempt?: number; + status?: string; + eventType?: string; + recipientCount?: number; + queueLength?: number; + [key: string]: unknown; + }; + error?: { + name?: string; + message: string; + stack?: string; + }; + metrics?: Array<{ + name: string; + value: number; + unit: string; + }>; +} + +// In-memory store for SMS-specific logs +const smsLogStore: AggregatedSMSLog[] = []; +const MAX_SMS_LOGS = 5000; + +export class SMSLogAggregator { + /** + * Collect SMS-related logs from the general log stream + */ + static collectSMSLogs(records: LogRecord[]): AggregatedSMSLog[] { + const smsLogs = records + .filter((record) => record.scope.includes('sms') || record.context?.provider) + .map((record) => this.transformToAggregatedLog(record)); + + // Store in local aggregator + smsLogs.forEach((log) => this.addToStore(log)); + + logger.info('SMS logs collected', { + context: { + collectedCount: smsLogs.length, + storeSize: smsLogStore.length, + }, + }); + + return smsLogs; + } + + /** + * Query aggregated SMS logs with filtering and aggregation + */ + static queryLogs(query: { + level?: string[]; + provider?: string; + eventType?: string; + status?: string; + since?: number; + limit?: number; + offset?: number; + }): AggregatedSMSLog[] { + let filtered = [...smsLogStore]; + + if (query.level && query.level.length > 0) { + filtered = filtered.filter((log) => query.level!.includes(log.level)); + } + + if (query.provider) { + filtered = filtered.filter((log) => log.context.provider === query.provider); + } + + if (query.eventType) { + filtered = filtered.filter((log) => log.context.eventType === query.eventType); + } + + if (query.status) { + filtered = filtered.filter((log) => log.context.status === query.status); + } + + if (query.since) { + filtered = filtered.filter((log) => new Date(log.timestamp).getTime() >= query.since!); + } + + const offset = query.offset ?? 0; + const limit = query.limit ?? 100; + + return filtered.slice(offset, offset + limit); + } + + /** + * Generate comprehensive SMS delivery metrics + */ + static getMetrics(timeRangeMs: number = 24 * 60 * 60 * 1000): SMSLogMetrics { + const cutoffTime = Date.now() - timeRangeMs; + const recentLogs = smsLogStore.filter( + (log) => new Date(log.timestamp).getTime() >= cutoffTime + ); + + const metrics: SMSLogMetrics = { + totalMessages: 0, + successfulDeliveries: 0, + failedDeliveries: 0, + retriedDeliveries: 0, + averageDeliveryTimeMs: 0, + errorRate: 0, + successRate: 0, + byProvider: {}, + byEventType: {}, + timeRange: { + from: new Date(cutoffTime).toISOString(), + to: new Date().toISOString(), + }, + }; + + const deliveryTimes: number[] = []; + const providers = new Set(); + const eventTypes = new Set(); + + // Aggregate data + for (const log of recentLogs) { + if (log.context.status === 'sent') { + metrics.successfulDeliveries++; + } else if (log.context.status === 'failed') { + metrics.failedDeliveries++; + } else if (log.context.status === 'retrying') { + metrics.retriedDeliveries++; + } + + if (log.context.provider) { + providers.add(log.context.provider); + } + + if (log.context.eventType) { + eventTypes.add(log.context.eventType); + } + + // Collect delivery times from metrics + if (log.metrics) { + log.metrics.forEach((metric) => { + if (metric.name === 'sms.send_duration_ms') { + deliveryTimes.push(metric.value); + } + }); + } + } + + metrics.totalMessages = recentLogs.length; + metrics.averageDeliveryTimeMs = + deliveryTimes.length > 0 + ? deliveryTimes.reduce((a, b) => a + b, 0) / deliveryTimes.length + : 0; + + if (metrics.totalMessages > 0) { + metrics.successRate = (metrics.successfulDeliveries / metrics.totalMessages) * 100; + metrics.errorRate = 100 - metrics.successRate; + } + + // Provider metrics + for (const provider of providers) { + const providerLogs = recentLogs.filter((log) => log.context.provider === provider); + const successful = providerLogs.filter((log) => log.context.status === 'sent').length; + const failed = providerLogs.filter((log) => log.context.status === 'failed').length; + + const providerDeliveryTimes = providerLogs + .filter((log) => log.metrics) + .flatMap((log) => + log.metrics!.filter((m) => m.name === 'sms.send_duration_ms').map((m) => m.value) + ); + + metrics.byProvider[provider] = { + name: provider, + total: providerLogs.length, + successful, + failed, + avgDeliveryTimeMs: + providerDeliveryTimes.length > 0 + ? providerDeliveryTimes.reduce((a, b) => a + b, 0) / providerDeliveryTimes.length + : 0, + errorRate: providerLogs.length > 0 ? (failed / providerLogs.length) * 100 : 0, + }; + } + + // Event type metrics + for (const eventType of eventTypes) { + const eventLogs = recentLogs.filter((log) => log.context.eventType === eventType); + const successful = eventLogs.filter((log) => log.context.status === 'sent').length; + const failed = eventLogs.filter((log) => log.context.status === 'failed').length; + + const eventDeliveryTimes = eventLogs + .filter((log) => log.metrics) + .flatMap((log) => + log.metrics!.filter((m) => m.name === 'sms.send_duration_ms').map((m) => m.value) + ); + + metrics.byEventType[eventType] = { + type: eventType, + total: eventLogs.length, + successful, + failed, + avgDeliveryTimeMs: + eventDeliveryTimes.length > 0 + ? eventDeliveryTimes.reduce((a, b) => a + b, 0) / eventDeliveryTimes.length + : 0, + }; + } + + logger.info('SMS metrics generated', { + context: { + totalMessages: metrics.totalMessages, + successRate: metrics.successRate, + errorRate: metrics.errorRate, + avgDeliveryTimeMs: metrics.averageDeliveryTimeMs, + }, + }); + + return metrics; + } + + /** + * Get failed message logs for investigation and recovery + */ + static getFailedMessages(limit: number = 100) { + const failed = smsLogStore + .filter((log) => log.context.status === 'failed') + .slice(-limit); + + logger.info('Failed messages retrieved', { + context: { + count: failed.length, + }, + }); + + return failed; + } + + /** + * Get performance anomalies (slow deliveries, high retry rates) + */ + static getAnomalies(): { + slowDeliveries: AggregatedSMSLog[]; + highRetryAttempts: AggregatedSMSLog[]; + configurationErrors: AggregatedSMSLog[]; + } { + const anomalies = { + slowDeliveries: smsLogStore.filter((log) => { + const delivery = log.metrics?.find((m) => m.name === 'sms.send_duration_ms'); + return delivery && delivery.value > 5000; // > 5 seconds + }), + highRetryAttempts: smsLogStore.filter((log) => { + return log.context.attempt && log.context.attempt >= 2; + }), + configurationErrors: smsLogStore.filter((log) => { + return ( + log.context.missingCredentials || + log.error?.message.includes('not configured') + ); + }), + }; + + if (anomalies.slowDeliveries.length > 0) { + logger.warn('Slow SMS deliveries detected', { + context: { + count: anomalies.slowDeliveries.length, + }, + }); + } + + if (anomalies.configurationErrors.length > 0) { + logger.error('SMS configuration errors detected', { + context: { + count: anomalies.configurationErrors.length, + }, + }); + } + + return anomalies; + } + + /** + * Export logs for external aggregation service + */ + static exportLogs(format: 'json' | 'csv' = 'json'): string { + if (format === 'json') { + return JSON.stringify(smsLogStore, null, 2); + } + + // CSV format + const headers = [ + 'timestamp', + 'level', + 'message', + 'provider', + 'status', + 'eventType', + 'phoneNumber', + 'messageId', + 'error', + ]; + + const rows = smsLogStore.map((log) => [ + log.timestamp, + log.level, + log.message, + log.context.provider || '', + log.context.status || '', + log.context.eventType || '', + log.context.phoneNumber || '', + log.context.messageId || '', + log.error?.message || '', + ]); + + const csv = [ + headers.join(','), + ...rows.map((row) => row.map((cell) => `"${cell}"`).join(',')), + ].join('\n'); + + return csv; + } + + /** + * Clear old logs to manage storage + */ + static clearOldLogs(olderThanMs: number = 30 * 24 * 60 * 60 * 1000): number { + const cutoffTime = Date.now() - olderThanMs; + const initialSize = smsLogStore.length; + + const index = smsLogStore.findIndex((log) => new Date(log.timestamp).getTime() >= cutoffTime); + + if (index > 0) { + smsLogStore.splice(0, index); + } + + const deletedCount = initialSize - smsLogStore.length; + + if (deletedCount > 0) { + logger.info('Old SMS logs cleared', { + context: { + deletedCount, + olderThanMs, + remainingLogs: smsLogStore.length, + }, + }); + } + + return deletedCount; + } + + /** + * Transform a LogRecord into an AggregatedSMSLog + */ + private static transformToAggregatedLog(record: LogRecord): AggregatedSMSLog { + return { + id: `${record.scope}_${record.timestamp}`, + timestamp: record.timestamp, + level: record.level, + message: record.message, + scope: record.scope, + requestId: record.requestId, + context: (record.context || {}) as any, + error: record.error, + metrics: record.metrics, + }; + } + + /** + * Add log to store, maintaining size limit + */ + private static addToStore(log: AggregatedSMSLog): void { + smsLogStore.push(log); + + if (smsLogStore.length > MAX_SMS_LOGS) { + smsLogStore.splice(0, smsLogStore.length - MAX_SMS_LOGS); + } + } + + /** + * Get store size for monitoring + */ + static getStoreSize(): number { + return smsLogStore.length; + } + + /** + * Get store stats + */ + static getStoreStats() { + return { + totalLogs: smsLogStore.length, + maxCapacity: MAX_SMS_LOGS, + utilizationPercent: (smsLogStore.length / MAX_SMS_LOGS) * 100, + oldestLog: smsLogStore.length > 0 ? smsLogStore[0].timestamp : null, + newestLog: smsLogStore.length > 0 ? smsLogStore[smsLogStore.length - 1].timestamp : null, + }; + } +} diff --git a/src/lib/sms/index.ts b/src/lib/sms/index.ts new file mode 100644 index 00000000..f3f03974 --- /dev/null +++ b/src/lib/sms/index.ts @@ -0,0 +1,4 @@ +export * from './types'; +export * from './provider'; +export * from './queue'; +export { SMSService, smsService } from './service'; diff --git a/src/lib/sms/provider.ts b/src/lib/sms/provider.ts new file mode 100644 index 00000000..2f68b920 --- /dev/null +++ b/src/lib/sms/provider.ts @@ -0,0 +1,251 @@ +import { SMSMessage, SMSProvider, SMSSendResult, SMSProviderType } from './types'; +import { createLogger } from '@/lib/logging'; + +const logger = createLogger('sms:provider'); + +export class TwilioProvider implements SMSProvider { + readonly type: SMSProviderType = 'twilio'; + private readonly accountSid = process.env.TWILIO_ACCOUNT_SID; + private readonly authToken = process.env.TWILIO_AUTH_TOKEN; + private readonly fromNumber = process.env.TWILIO_PHONE_NUMBER; + + async send(message: SMSMessage): Promise { + const requestId = `twilio_${Date.now()}_${Math.random().toString(16).slice(2)}`; + + if (!this.accountSid || !this.authToken || !this.fromNumber) { + const error = 'Twilio credentials not configured'; + logger.error('Twilio provider not configured', { + requestId, + context: { provider: 'twilio', missingCredentials: true }, + error: new Error(error), + }); + return { + success: false, + provider: this.type, + error, + }; + } + + try { + const toNumbers = Array.isArray(message.to) ? message.to : [message.to]; + const formattedNumbers = toNumbers.map((num) => `+${num.countryCode}${num.number}`); + + // Simulate Twilio API call - replace with actual Twilio SDK + logger.info('Sending SMS via Twilio', { + requestId, + context: { + provider: 'twilio', + recipientCount: formattedNumbers.length, + tags: message.tags, + }, + }); + + // In production, call Twilio API: + // const response = await twilioClient.messages.create({ + // body: message.body, + // from: this.fromNumber, + // to: formattedNumbers[0], + // }); + + const messageId = `twilio_${Date.now()}`; + + logger.info('SMS sent successfully via Twilio', { + requestId, + context: { + provider: 'twilio', + messageId, + recipientCount: formattedNumbers.length, + }, + }); + + return { + success: true, + provider: this.type, + messageId, + timestamp: Date.now(), + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error('Twilio SMS send failed', { + requestId, + context: { provider: 'twilio' }, + error, + }); + + return { + success: false, + provider: this.type, + error: errorMessage, + }; + } + } +} + +export class SNSProvider implements SMSProvider { + readonly type: SMSProviderType = 'sns'; + private readonly region = process.env.AWS_REGION || 'us-east-1'; + private readonly accessKeyId = process.env.AWS_ACCESS_KEY_ID; + private readonly secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; + + async send(message: SMSMessage): Promise { + const requestId = `sns_${Date.now()}_${Math.random().toString(16).slice(2)}`; + + if (!this.accessKeyId || !this.secretAccessKey) { + const error = 'AWS credentials not configured'; + logger.error('SNS provider not configured', { + requestId, + context: { provider: 'sns', missingCredentials: true }, + error: new Error(error), + }); + return { + success: false, + provider: this.type, + error, + }; + } + + try { + const toNumbers = Array.isArray(message.to) ? message.to : [message.to]; + const formattedNumbers = toNumbers.map((num) => `+${num.countryCode}${num.number}`); + + logger.info('Sending SMS via AWS SNS', { + requestId, + context: { + provider: 'sns', + region: this.region, + recipientCount: formattedNumbers.length, + tags: message.tags, + }, + }); + + // In production, call AWS SNS API: + // const snsClient = new SNSClient({ region: this.region }); + // const response = await snsClient.send(new PublishCommand({ + // Message: message.body, + // PhoneNumber: formattedNumbers[0], + // })); + + const messageId = `sns_${Date.now()}`; + + logger.info('SMS sent successfully via AWS SNS', { + requestId, + context: { + provider: 'sns', + messageId, + recipientCount: formattedNumbers.length, + }, + }); + + return { + success: true, + provider: this.type, + messageId, + timestamp: Date.now(), + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error('SNS SMS send failed', { + requestId, + context: { provider: 'sns' }, + error, + }); + + return { + success: false, + provider: this.type, + error: errorMessage, + }; + } + } +} + +export class VonageProvider implements SMSProvider { + readonly type: SMSProviderType = 'vonage'; + private readonly apiKey = process.env.VONAGE_API_KEY; + private readonly apiSecret = process.env.VONAGE_API_SECRET; + private readonly fromNumber = process.env.VONAGE_PHONE_NUMBER; + + async send(message: SMSMessage): Promise { + const requestId = `vonage_${Date.now()}_${Math.random().toString(16).slice(2)}`; + + if (!this.apiKey || !this.apiSecret || !this.fromNumber) { + const error = 'Vonage credentials not configured'; + logger.error('Vonage provider not configured', { + requestId, + context: { provider: 'vonage', missingCredentials: true }, + error: new Error(error), + }); + return { + success: false, + provider: this.type, + error, + }; + } + + try { + const toNumbers = Array.isArray(message.to) ? message.to : [message.to]; + const formattedNumbers = toNumbers.map((num) => `+${num.countryCode}${num.number}`); + + logger.info('Sending SMS via Vonage', { + requestId, + context: { + provider: 'vonage', + recipientCount: formattedNumbers.length, + tags: message.tags, + }, + }); + + // In production, call Vonage API: + // const response = await vonageClient.message.sendSms( + // this.fromNumber, + // formattedNumbers[0], + // message.body, + // ); + + const messageId = `vonage_${Date.now()}`; + + logger.info('SMS sent successfully via Vonage', { + requestId, + context: { + provider: 'vonage', + messageId, + recipientCount: formattedNumbers.length, + }, + }); + + return { + success: true, + provider: this.type, + messageId, + timestamp: Date.now(), + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error('Vonage SMS send failed', { + requestId, + context: { provider: 'vonage' }, + error, + }); + + return { + success: false, + provider: this.type, + error: errorMessage, + }; + } + } +} + +export function createSMSProvider(): SMSProvider { + const provider = (process.env.SMS_PROVIDER || 'twilio') as SMSProviderType; + + switch (provider) { + case 'sns': + return new SNSProvider(); + case 'vonage': + return new VonageProvider(); + case 'twilio': + default: + return new TwilioProvider(); + } +} diff --git a/src/lib/sms/queue.ts b/src/lib/sms/queue.ts new file mode 100644 index 00000000..c4924ded --- /dev/null +++ b/src/lib/sms/queue.ts @@ -0,0 +1,300 @@ +import { SMSMessage, SMSProvider, SMSSendResult, QueueJob, QueueOptions, SMSProviderType, SMSDeliveryLog } from './types'; +import { createLogger } from '@/lib/logging'; +import { createCounterMetric, measureAsync } from '@/lib/logging/performance'; + +const logger = createLogger('sms:queue'); + +const DEFAULT_OPTIONS: QueueOptions = { + maxRetries: 3, + retryDelayMs: 1500, + maxConcurrent: 5, +}; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function createJobId(): string { + return `sms_${Date.now()}_${Math.random().toString(16).slice(2)}`; +} + +// In-memory delivery log store for aggregation +const deliveryLogs: Map = new Map(); + +export class SMSQueue { + private readonly provider: SMSProvider; + private readonly options: QueueOptions; + private readonly queue: QueueJob[] = []; + private processing = 0; + private requestId = ''; + + constructor(provider: SMSProvider, options?: Partial) { + this.provider = provider; + this.options = { + ...DEFAULT_OPTIONS, + ...options, + }; + + logger.info('SMS Queue initialized', { + context: { + provider: this.provider.type, + maxRetries: this.options.maxRetries, + maxConcurrent: this.options.maxConcurrent, + retryDelayMs: this.options.retryDelayMs, + }, + }); + } + + enqueue(message: SMSMessage): Promise { + return new Promise((resolve) => { + const jobId = createJobId(); + this.requestId = `${jobId}_parent`; + + const job: QueueJob = { + id: jobId, + message, + attempts: 0, + createdAt: Date.now(), + }; + + logger.info('SMS message enqueued', { + requestId: this.requestId, + context: { + jobId, + provider: this.provider.type, + queueLength: this.queue.length + 1, + tags: message.tags, + }, + }); + + this.queue.push(job); + createCounterMetric('sms.enqueued', 1, { + provider: this.provider.type, + }); + + this.process(resolve); + }); + } + + private process(resolve: (result: SMSSendResult) => void): void { + while (this.processing < this.options.maxConcurrent && this.queue.length > 0) { + const nextJob = this.queue.shift(); + if (!nextJob) { + return; + } + + this.processing += 1; + void this.runJob(nextJob) + .then((result) => resolve(result)) + .finally(() => { + this.processing -= 1; + this.process(resolve); + }); + } + } + + private async runJob(job: QueueJob): Promise { + let result: SMSSendResult = { + success: false, + provider: this.provider.type, + error: 'No attempt made', + }; + + const phoneNumbers = Array.isArray(job.message.to) ? job.message.to : [job.message.to]; + + while (job.attempts < this.options.maxRetries) { + job.attempts += 1; + + logger.info('SMS send attempt', { + requestId: this.requestId, + context: { + jobId: job.id, + attempt: job.attempts, + maxAttempts: this.options.maxRetries, + provider: this.provider.type, + recipientCount: phoneNumbers.length, + }, + }); + + // Measure send operation + const { result: measuredResult } = await measureAsync( + 'sms.send_duration_ms', + async () => { + result = await this.provider.send(job.message); + return result; + }, + { provider: this.provider.type } + ); + + // Log delivery attempt + for (const phoneNumber of phoneNumbers) { + this.logDeliveryAttempt(job, phoneNumber, result, job.attempts); + } + + if (result.success) { + logger.info('SMS sent successfully', { + requestId: this.requestId, + context: { + jobId: job.id, + messageId: result.messageId, + provider: this.provider.type, + attempts: job.attempts, + }, + }); + + createCounterMetric('sms.sent', 1, { + provider: this.provider.type, + }); + + return result; + } + + if (job.attempts < this.options.maxRetries) { + const backoffDelay = this.options.retryDelayMs * job.attempts; + logger.warn('SMS send failed, retrying', { + requestId: this.requestId, + context: { + jobId: job.id, + attempt: job.attempts, + nextRetryIn: backoffDelay, + error: result.error, + }, + error: new Error(result.error), + }); + + createCounterMetric('sms.retry', 1, { + provider: this.provider.type, + attempt: String(job.attempts), + }); + + await delay(backoffDelay); + } + } + + logger.error('SMS send failed after max retries', { + requestId: this.requestId, + context: { + jobId: job.id, + attempts: job.attempts, + maxAttempts: this.options.maxRetries, + provider: this.provider.type, + lastError: result.error, + }, + error: new Error(result.error), + }); + + createCounterMetric('sms.failed', 1, { + provider: this.provider.type, + }); + + return { + ...result, + error: `Queue failed after ${job.attempts} attempts: ${result.error ?? 'Unknown error'}`, + }; + } + + private logDeliveryAttempt(job: QueueJob, phoneNumber: any, result: SMSSendResult, attempt: number): void { + const formattedNumber = `+${phoneNumber.countryCode}${phoneNumber.number}`; + const logId = `${job.id}_${formattedNumber}`; + + const deliveryLog: SMSDeliveryLog = { + jobId: job.id, + provider: this.provider.type, + phoneNumber: formattedNumber, + messageBody: job.message.body.substring(0, 100), // Truncate for log + messageId: result.messageId, + status: result.success ? 'sent' : (attempt < this.options.maxRetries ? 'retrying' : 'failed'), + attempts: attempt, + maxRetries: this.options.maxRetries, + error: result.error, + tags: job.message.tags, + metadata: job.message.metadata, + createdAt: job.createdAt, + updatedAt: Date.now(), + }; + + deliveryLogs.set(logId, deliveryLog); + + // Keep only recent logs (last 1000) + if (deliveryLogs.size > 1000) { + const oldestKey = Array.from(deliveryLogs.keys())[0]; + deliveryLogs.delete(oldestKey); + } + } + + // Get delivery logs for aggregation and monitoring + getDeliveryLogs(filter?: { + status?: 'pending' | 'sent' | 'failed' | 'retrying'; + provider?: SMSProviderType; + limit?: number; + }): SMSDeliveryLog[] { + let logs = Array.from(deliveryLogs.values()); + + if (filter?.status) { + logs = logs.filter((log) => log.status === filter.status); + } + + if (filter?.provider) { + logs = logs.filter((log) => log.provider === filter.provider); + } + + const limit = filter?.limit ?? 100; + return logs.slice(-limit); + } + + // Get delivery statistics + getDeliveryStats(): { + total: number; + sent: number; + failed: number; + retrying: number; + byProvider: Record; + } { + const logs = Array.from(deliveryLogs.values()); + const stats = { + total: logs.length, + sent: logs.filter((l) => l.status === 'sent').length, + failed: logs.filter((l) => l.status === 'failed').length, + retrying: logs.filter((l) => l.status === 'retrying').length, + byProvider: {} as Record, + }; + + for (const log of logs) { + if (!stats.byProvider[log.provider]) { + stats.byProvider[log.provider] = { total: 0, sent: 0, failed: 0 }; + } + stats.byProvider[log.provider].total++; + if (log.status === 'sent') { + stats.byProvider[log.provider].sent++; + } else if (log.status === 'failed') { + stats.byProvider[log.provider].failed++; + } + } + + return stats; + } + + // Clear old logs (for maintenance) + clearOldLogs(olderThanMs: number = 24 * 60 * 60 * 1000): number { + const cutoffTime = Date.now() - olderThanMs; + let deletedCount = 0; + + for (const [key, log] of deliveryLogs.entries()) { + if (log.updatedAt < cutoffTime) { + deliveryLogs.delete(key); + deletedCount++; + } + } + + if (deletedCount > 0) { + logger.info('Cleared old SMS delivery logs', { + context: { + deletedCount, + olderThanMs, + }, + }); + } + + return deletedCount; + } +} diff --git a/src/lib/sms/service.ts b/src/lib/sms/service.ts new file mode 100644 index 00000000..37e8aa10 --- /dev/null +++ b/src/lib/sms/service.ts @@ -0,0 +1,159 @@ +import { SMSMessage, SMSSendResult, PhoneNumber } from './types'; +import { SMSQueue } from './queue'; +import { createSMSProvider } from './provider'; +import { createLogger } from '@/lib/logging'; + +const logger = createLogger('sms:service'); + +interface BaseSMSInput { + phoneNumber: PhoneNumber; + name?: string; +} + +interface VerificationCodeInput extends BaseSMSInput { + code: string; + expiresInMinutes: number; +} + +interface SecurityAlertInput extends BaseSMSInput { + device: string; + timestamp: string; + action: string; +} + +interface CourseEnrollmentInput extends BaseSMSInput { + courseName: string; + courseUrl: string; +} + +interface AccountWarningInput extends BaseSMSInput { + reason: string; +} + +export type SMSEvent = + | { type: 'verification-code'; data: VerificationCodeInput } + | { type: 'security-alert'; data: SecurityAlertInput } + | { type: 'course-enrollment'; data: CourseEnrollmentInput } + | { type: 'account-warning'; data: AccountWarningInput }; + +export class SMSService { + private readonly queue: SMSQueue; + private readonly fromNumber = process.env.SMS_FROM_NUMBER || '+1234567890'; + + constructor() { + const provider = createSMSProvider(); + this.queue = new SMSQueue(provider, { + maxRetries: Number(process.env.SMS_MAX_RETRIES ?? 3), + retryDelayMs: Number(process.env.SMS_RETRY_DELAY_MS ?? 1500), + maxConcurrent: Number(process.env.SMS_MAX_CONCURRENT ?? 5), + }); + + logger.info('SMS Service initialized', { + context: { + fromNumber: this.fromNumber, + maxRetries: Number(process.env.SMS_MAX_RETRIES ?? 3), + maxConcurrent: Number(process.env.SMS_MAX_CONCURRENT ?? 5), + }, + }); + } + + async sendEvent(event: SMSEvent): Promise { + const requestId = `sms_event_${Date.now()}_${Math.random().toString(16).slice(2)}`; + + logger.info('Processing SMS event', { + requestId, + context: { + eventType: event.type, + phoneNumber: `+${event.data.phoneNumber.countryCode}${event.data.phoneNumber.number}`, + }, + }); + + const body = this.buildMessageBody(event.type, event.data as any); + + const message: SMSMessage = { + to: event.data.phoneNumber, + body, + from: this.fromNumber, + tags: ['transactional', event.type], + metadata: { + eventType: event.type, + recipientName: event.data.name, + }, + }; + + try { + const result = await this.queue.enqueue(message); + + logger.info('SMS event processed', { + requestId, + context: { + eventType: event.type, + success: result.success, + messageId: result.messageId, + }, + }); + + return result; + } catch (error) { + logger.error('SMS event processing failed', { + requestId, + context: { + eventType: event.type, + }, + error, + }); + + throw error; + } + } + + async sendVerificationCode(data: VerificationCodeInput): Promise { + return this.sendEvent({ type: 'verification-code', data }); + } + + async sendSecurityAlert(data: SecurityAlertInput): Promise { + return this.sendEvent({ type: 'security-alert', data }); + } + + async sendCourseEnrollment(data: CourseEnrollmentInput): Promise { + return this.sendEvent({ type: 'course-enrollment', data }); + } + + async sendAccountWarning(data: AccountWarningInput): Promise { + return this.sendEvent({ type: 'account-warning', data }); + } + + private buildMessageBody(eventType: string, data: any): string { + switch (eventType) { + case 'verification-code': + return `Your TeachLink verification code is ${data.code}. It expires in ${data.expiresInMinutes} minutes. Do not share this code.`; + + case 'security-alert': + return `Security Alert: A ${data.action} was performed on your TeachLink account from ${data.device} at ${data.timestamp}. If this wasn't you, please change your password immediately.`; + + case 'course-enrollment': + return `Welcome! You've been enrolled in ${data.courseName}. View the course: ${data.courseUrl}`; + + case 'account-warning': + return `Important: ${data.reason}. Please take action immediately to secure your TeachLink account.`; + + default: + return 'You have a new message from TeachLink.'; + } + } + + // Aggregation and monitoring methods + getDeliveryLogs(filter?: any) { + return this.queue.getDeliveryLogs(filter); + } + + getDeliveryStats() { + return this.queue.getDeliveryStats(); + } + + clearOldLogs(olderThanMs?: number) { + return this.queue.clearOldLogs(olderThanMs); + } +} + +export const smsService = new SMSService(); diff --git a/src/lib/sms/types.ts b/src/lib/sms/types.ts new file mode 100644 index 00000000..b10458a9 --- /dev/null +++ b/src/lib/sms/types.ts @@ -0,0 +1,56 @@ +export type SMSProviderType = 'twilio' | 'sns' | 'vonage'; + +export interface PhoneNumber { + countryCode: string; + number: string; +} + +export interface SMSMessage { + to: PhoneNumber | PhoneNumber[]; + body: string; + from?: string; + tags?: string[]; + metadata?: Record; +} + +export interface SMSSendResult { + success: boolean; + provider: SMSProviderType; + messageId?: string; + error?: string; + timestamp?: number; +} + +export interface SMSProvider { + readonly type: SMSProviderType; + send(message: SMSMessage): Promise; +} + +export interface QueueOptions { + maxRetries: number; + retryDelayMs: number; + maxConcurrent: number; +} + +export interface QueueJob { + id: string; + message: SMSMessage; + attempts: number; + createdAt: number; +} + +export interface SMSDeliveryLog { + jobId: string; + provider: SMSProviderType; + phoneNumber: string; + messageBody: string; + messageId?: string; + status: 'pending' | 'sent' | 'failed' | 'retrying'; + attempts: number; + maxRetries: number; + error?: string; + tags?: string[]; + metadata?: Record; + createdAt: number; + updatedAt: number; +} diff --git a/src/services/notifications.ts b/src/services/notifications.ts index 495fcd81..191b763b 100644 --- a/src/services/notifications.ts +++ b/src/services/notifications.ts @@ -6,6 +6,10 @@ import { emailTemplateManager, TransactionalTemplateId, } from '@/lib/email'; +import { SMSService, SMSSendResult, PhoneNumber } from '@/lib/sms'; +import { createLogger } from '@/lib/logging'; + +const logger = createLogger('service:notifications'); interface BaseNotificationInput extends EmailTemplatePayload { email: string; @@ -33,8 +37,19 @@ export type NotificationEvent = | { type: 'security-alert'; data: SecurityAlertInput } | { type: 'course-enrollment'; data: CourseEnrollmentInput }; +export interface SMSNotificationInput { + phoneNumber: PhoneNumber; + name?: string; +} + +export interface MultiChannelResult { + email?: EmailSendResult; + sms?: SMSSendResult; +} + export class NotificationService { private readonly queue: EmailQueue; + private readonly smsService: SMSService; constructor() { const provider = createEmailProvider(); @@ -43,6 +58,7 @@ export class NotificationService { retryDelayMs: Number(process.env.EMAIL_RETRY_DELAY_MS ?? 1500), maxConcurrent: Number(process.env.EMAIL_MAX_CONCURRENT ?? 2), }); + this.smsService = new SMSService(); } async sendEvent(event: NotificationEvent): Promise { @@ -76,6 +92,121 @@ export class NotificationService { return this.sendEvent({ type: 'course-enrollment', data }); } + // ── SMS methods ────────────────────────────────────────────────────────── + + sendVerificationCodeSMS( + sms: SMSNotificationInput & { code: string; expiresInMinutes: number }, + ): Promise { + return this.smsService.sendVerificationCode(sms); + } + + sendSecurityAlertSMS( + sms: SMSNotificationInput & { device: string; timestamp: string; action: string }, + ): Promise { + return this.smsService.sendSecurityAlert(sms); + } + + sendCourseEnrollmentSMS( + sms: SMSNotificationInput & { courseName: string; courseUrl: string }, + ): Promise { + return this.smsService.sendCourseEnrollment(sms); + } + + // ── Multi-channel methods ───────────────────────────────────────────────── + + /** + * Send a security alert via both email and SMS simultaneously. + * Failures on one channel do not block the other. + */ + async sendSecurityAlertMultiChannel( + email: SecurityAlertInput, + sms?: SMSNotificationInput & { action: string }, + ): Promise { + const requestId = `multi_${Date.now()}_${Math.random().toString(16).slice(2)}`; + + logger.info('Sending multi-channel security alert', { + requestId, + context: { + hasEmail: true, + hasSMS: !!sms, + }, + }); + + const [emailResult, smsResult] = await Promise.allSettled([ + this.sendSecurityAlertEmail(email), + sms + ? this.smsService.sendSecurityAlert({ + phoneNumber: sms.phoneNumber, + name: sms.name, + device: email.device, + timestamp: email.timestamp, + action: sms.action, + }) + : Promise.resolve(undefined), + ]); + + const result: MultiChannelResult = { + email: emailResult.status === 'fulfilled' ? emailResult.value : undefined, + sms: smsResult.status === 'fulfilled' ? (smsResult.value ?? undefined) : undefined, + }; + + logger.info('Multi-channel security alert sent', { + requestId, + context: { + emailSuccess: result.email?.success, + smsSuccess: result.sms?.success, + }, + }); + + return result; + } + + /** + * Send a course enrollment notification via both email and SMS. + */ + async sendCourseEnrollmentMultiChannel( + email: CourseEnrollmentInput, + sms?: SMSNotificationInput, + ): Promise { + const requestId = `multi_${Date.now()}_${Math.random().toString(16).slice(2)}`; + + logger.info('Sending multi-channel course enrollment', { + requestId, + context: { + hasEmail: true, + hasSMS: !!sms, + courseName: email.courseName, + }, + }); + + const [emailResult, smsResult] = await Promise.allSettled([ + this.sendCourseEnrollmentEmail(email), + sms + ? this.smsService.sendCourseEnrollment({ + phoneNumber: sms.phoneNumber, + name: sms.name, + courseName: email.courseName, + courseUrl: email.courseUrl, + }) + : Promise.resolve(undefined), + ]); + + const result: MultiChannelResult = { + email: emailResult.status === 'fulfilled' ? emailResult.value : undefined, + sms: smsResult.status === 'fulfilled' ? (smsResult.value ?? undefined) : undefined, + }; + + logger.info('Multi-channel course enrollment sent', { + requestId, + context: { + emailSuccess: result.email?.success, + smsSuccess: result.sms?.success, + }, + }); + + return result; + } + private buildTemplate(templateId: TransactionalTemplateId, payload: EmailTemplatePayload) { return emailTemplateManager.getTemplate(templateId, payload); } From 28ea9a994bb06766a3b453d659afc43f0ace0ce9 Mon Sep 17 00:00:00 2001 From: 1sraeliteX Date: Fri, 29 May 2026 19:22:08 +0100 Subject: [PATCH 2/2] feat(profile): add Customer Support tab to Profile page (#467) - Add CustomerSupportPanel component with three sections: - Contact options (email, live chat, phone) as accessible links - FAQ accordion with 5 common questions (keyboard accessible) - Contact form with subject + message fields and success state - Extend ProfileTabId union type to include 'support' - Add supportFaqs and supportContactOptions data to profile-data.ts - Lazy-load CustomerSupportPanel via next/dynamic with skeleton fallback - Full ARIA compliance: role=tabpanel, aria-expanded, aria-controls, aria-labelledby, role=status, aria-live, role=alert - Follows existing panel patterns (SettingsPanel, AchievementsPanel) --- .../components/CustomerSupportPanel.tsx | 214 ++++++++++++++++++ src/app/profile/components/ProfileTabs.tsx | 5 + src/app/profile/profile-data.ts | 80 ++++++- 3 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 src/app/profile/components/CustomerSupportPanel.tsx diff --git a/src/app/profile/components/CustomerSupportPanel.tsx b/src/app/profile/components/CustomerSupportPanel.tsx new file mode 100644 index 00000000..a812d3cd --- /dev/null +++ b/src/app/profile/components/CustomerSupportPanel.tsx @@ -0,0 +1,214 @@ +'use client'; + +import { memo, useCallback, useState } from 'react'; +import { ChevronDown, ChevronUp, Mail, MessageCircle, Phone } from 'lucide-react'; +import { supportFaqs, supportContactOptions } from '../profile-data'; + +// ── FAQ Accordion Item ──────────────────────────────────────────────────────── + +interface FaqItemProps { + id: string; + question: string; + answer: string; +} + +function FaqItem({ id, question, answer }: FaqItemProps) { + const [isOpen, setIsOpen] = useState(false); + const headingId = `faq-heading-${id}`; + const panelId = `faq-panel-${id}`; + + const toggle = useCallback(() => setIsOpen((prev) => !prev), []); + + return ( +
+

+ +

+ + +
+ ); +} + +// ── Contact Form ────────────────────────────────────────────────────────────── + +type SubmitState = 'idle' | 'submitting' | 'success' | 'error'; + +function ContactForm() { + const [submitState, setSubmitState] = useState('idle'); + const [subject, setSubject] = useState(''); + const [message, setMessage] = useState(''); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitState('submitting'); + + // Simulated async submission — replace with real API call + await new Promise((resolve) => setTimeout(resolve, 1200)); + + setSubmitState('success'); + setSubject(''); + setMessage(''); + }, + [], + ); + + if (submitState === 'success') { + return ( +
+

+ ✅ Your message has been sent. We'll get back to you within 24 hours. +

+ +
+ ); + } + + return ( +
+
+ + setSubject(e.target.value)} + placeholder="Briefly describe your issue" + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-blue-500" + /> +
+ +
+ +