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/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" + /> +
+ +
+ +