diff --git a/packages/call-service/src/constants.ts b/packages/call-service/src/constants.ts new file mode 100644 index 0000000..0737ac6 --- /dev/null +++ b/packages/call-service/src/constants.ts @@ -0,0 +1,29 @@ +// Constants for CallService +export const VALID_QUEUES = ['medical_spanish', 'medical_english', 'legal_spanish', 'legal_english'] as const; +export type ValidQueue = typeof VALID_QUEUES[number]; + +export const CALL_STATUS_WAITING = 'waiting'; +export const CALL_STATUS_ACTIVE = 'active'; +export const CALL_STATUS_ON_HOLD = 'on_hold'; +export const CALL_STATUS_ENDED = 'ended'; + +export const SLA_WAIT_TIME_THRESHOLD = 30; // seconds +export const MAX_HOLD_TIME_THRESHOLD = 60; // seconds +export const SHORT_CALL_THRESHOLD = 10; // seconds + +// Database table names +export const TABLE_CALLS = 'calls'; +export const TABLE_CALL_EVENTS = 'call_events'; + +// Event types +export const EVENT_CALL_INITIATED = 'call_initiated'; +export const EVENT_CALL_ROUTED = 'call_routed'; +export const EVENT_CALL_ANSWERED = 'call_answered'; +export const EVENT_CALL_HOLD = 'call_hold'; +export const EVENT_CALL_ENDED = 'call_ended'; + +// Error messages +export const ERROR_CALL_ALREADY_EXISTS = 'Call with id {callId} already exists'; +export const ERROR_CALL_NOT_FOUND = 'Call with id {callId} not found'; +export const ERROR_INVALID_QUEUE_ID = 'Invalid queueId: {queueId}'; +export const ERROR_UNSUPPORTED_EVENT_TYPE = 'Unsupported event type: {eventType}'; \ No newline at end of file diff --git a/packages/call-service/src/db/callRepository.ts b/packages/call-service/src/db/callRepository.ts new file mode 100644 index 0000000..cb27982 --- /dev/null +++ b/packages/call-service/src/db/callRepository.ts @@ -0,0 +1,94 @@ +import { Call } from '../domain/call'; +import { mapCallRow, mapCallEventRow } from '../db/mappers'; +import { db } from '../db/client'; +import { TABLE_CALLS, TABLE_CALL_EVENTS } from '../constants'; + +// Repository interface for call data access +export class CallRepository { + // Create a new call + async createCall(id: string, type: string, queueId: string): Promise { + await db.query( + `INSERT INTO ${TABLE_CALLS} (id, type, status, queue_id, start_time) VALUES ($1, $2, $3, $4, $5)`, + [id, type, 'waiting', queueId, new Date()] + ); + } + + // Get call by ID + async getCallById(id: string): Promise { + const result = await db.query( + `SELECT id, type, status, queue_id, start_time, end_time FROM ${TABLE_CALLS} WHERE id = $1`, + [id] + ); + return result.rows[0]; + } + + // Update call status + async updateCallStatus(id: string, status: string, additionalFields: Record = {}): Promise { + const fields = Object.keys(additionalFields); + let query = `UPDATE ${TABLE_CALLS} SET status = $1`; + const values: any[] = [status]; + + // Add additional fields + fields.forEach((field, index) => { + query += `, ${field} = $${index + 2}`; + values.push(additionalFields[field]); + }); + + query += ` WHERE id = $${fields.length + 2}`; + values.push(id); + + await db.query(query, values); + } + + // Create call event + async createCallEvent(id: string, callId: string, type: string, metadata: Record = {}): Promise { + await db.query( + `INSERT INTO ${TABLE_CALL_EVENTS} (id, call_id, type, timestamp, metadata) VALUES ($1, $2, $3, $4, $5)`, + [id, callId, type, new Date(), JSON.stringify(metadata)] + ); + } + + // Get calls with filters + async getCalls(filters: any): Promise { + let query = `SELECT id, type, status, queue_id, start_time, end_time FROM ${TABLE_CALLS}`; + const params: any[] = []; + const whereConditions: string[] = []; + + if (filters.status && filters.status !== 'all') { + whereConditions.push(`status = $${params.length + 1}`); + params.push(filters.status); + } + + if (filters.queueId) { + whereConditions.push(`queue_id = $${params.length + 1}`); + params.push(filters.queueId); + } + + if (whereConditions.length > 0) { + query += ' WHERE ' + whereConditions.join(' AND '); + } + + query += ' ORDER BY start_time DESC'; + + const result = await db.query(query, params); + return result.rows.map(mapCallRow); + } + + // Get call events by call ID + async getCallEvents(callId: string): Promise { + // First verify the call exists + const callResult = await db.query( + `SELECT id FROM ${TABLE_CALLS} WHERE id = $1`, + [callId] + ); + if (callResult.rowCount === 0) { + throw new Error(`Call with id ${callId} not found`); + } + + const result = await db.query( + `SELECT id, call_id, type, timestamp, metadata FROM ${TABLE_CALL_EVENTS} WHERE call_id = $1 ORDER BY timestamp ASC`, + [callId] + ); + return result.rows.map(mapCallEventRow); + } +} \ No newline at end of file diff --git a/packages/call-service/src/domain/call.ts b/packages/call-service/src/domain/call.ts index 99016df..d823973 100644 --- a/packages/call-service/src/domain/call.ts +++ b/packages/call-service/src/domain/call.ts @@ -21,6 +21,7 @@ export class Call { public readonly queueId: QueueId, public readonly startTime: Date, public endTime?: Date, + public holdStartTime?: Date, ) {} } diff --git a/packages/call-service/src/errors.ts b/packages/call-service/src/errors.ts new file mode 100644 index 0000000..d6efbf1 --- /dev/null +++ b/packages/call-service/src/errors.ts @@ -0,0 +1,42 @@ +// Custom error classes for CallService +import { + ERROR_CALL_ALREADY_EXISTS, + ERROR_CALL_NOT_FOUND, + ERROR_INVALID_QUEUE_ID, + ERROR_UNSUPPORTED_EVENT_TYPE, +} from './constants'; + +export class CallServiceError extends Error { + constructor(message: string, public readonly code?: string) { + super(message); + this.name = 'CallServiceError'; + } +} + +export class CallAlreadyExistsError extends CallServiceError { + constructor(callId: string) { + super(ERROR_CALL_ALREADY_EXISTS.replace('{callId}', callId), 'CALL_ALREADY_EXISTS'); + this.name = 'CallAlreadyExistsError'; + } +} + +export class CallNotFoundError extends CallServiceError { + constructor(callId: string) { + super(ERROR_CALL_NOT_FOUND.replace('{callId}', callId), 'CALL_NOT_FOUND'); + this.name = 'CallNotFoundError'; + } +} + +export class InvalidQueueIdError extends CallServiceError { + constructor(queueId: string) { + super(ERROR_INVALID_QUEUE_ID.replace('{queueId}', queueId), 'INVALID_QUEUE_ID'); + this.name = 'InvalidQueueIdError'; + } +} + +export class UnsupportedEventTypeError extends CallServiceError { + constructor(eventType: string) { + super(ERROR_UNSUPPORTED_EVENT_TYPE.replace('{eventType}', eventType), 'UNSUPPORTED_EVENT_TYPE'); + this.name = 'UnsupportedEventTypeError'; + } +} \ No newline at end of file diff --git a/packages/call-service/src/index.ts b/packages/call-service/src/index.ts index 1786181..efa2566 100644 --- a/packages/call-service/src/index.ts +++ b/packages/call-service/src/index.ts @@ -14,6 +14,10 @@ app.use('/api/calls', callsRouter); app.get('/health', (_req, res) => res.json({ status: 'ok' })); -app.listen(config.port, () => { - console.log(`call-service → http://localhost:${config.port}`); -}); +export const createServer = () => app; + +if (process.env.NODE_ENV !== 'test') { + app.listen(config.port, () => { + console.log(`call-service → http://localhost:${config.port}`); + }); +} diff --git a/packages/call-service/src/routes/events.test.ts b/packages/call-service/src/routes/events.test.ts index 79c8f7e..16cf935 100644 --- a/packages/call-service/src/routes/events.test.ts +++ b/packages/call-service/src/routes/events.test.ts @@ -1,7 +1,83 @@ -import { describe, it } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import type { CallInitiatedPayload } from '@voycelink/contracts'; +import {db} from "../db/client"; +import {createServer} from "../index"; + +// Mock the database module +vi.mock('../db/client', () => ({ + db: { + query: vi.fn() + } +})); + +// Mock the publisher +vi.mock('../bus/publisher', () => ({ + publishStatusUpdate: vi.fn() +})); describe('POST /api/events', () => { - it.todo('returns 201 and persists the event for a valid call_initiated payload'); - it.todo('returns 400 for an invalid payload'); - it.todo('returns 401 when the API key is missing'); + let app: ReturnType; + + beforeEach(() => { + app = createServer(); + vi.clearAllMocks(); + }); + + it.todo('returns 201 and persists the event for a valid call_initiated payload', async () => { + const payload: CallInitiatedPayload = { + event: 'call_initiated', + callId: 'test-call-id', + type: 'voice', + queueId: 'medical_spanish' + }; + + // Mock database responses + // 1. Check if call exists (rowCount: 0 means it doesn't exist) + // 2. Insert call (resolve with empty object) + // 3. Insert event (resolve with empty object) + (db.query as any) + .mockResolvedValueOnce({ rowCount: 0 }) // No existing call + .mockResolvedValueOnce({}) // Insert call + .mockResolvedValueOnce({}); // Insert event + + const response = await request(app) + .post('/api/events') + .set('x-api-key', 'test-api-key') + .send(payload) + .expect(201); + + expect(response.body).toHaveProperty('id'); + expect(response.body.callId).toBe('test-call-id'); + expect(response.body.type).toBe('call_initiated'); + }); + + it.todo('returns 400 for an invalid payload', async () => { + const invalidPayload = { + event: 'call_initiated', + callId: 'test-call-id', + // Missing required fields + }; + + await request(app) + .post('/api/events') + .set('x-api-key', 'test-api-key') + .send(invalidPayload) + .expect(400); + }); + + it.todo('returns 401 when the API key is missing', async () => { + const payload: CallInitiatedPayload = { + event: 'call_initiated', + callId: 'test-call-id', + type: 'voice', + queueId: 'medical_spanish' + }; + + await request(app) + .post('/api/events') + // No API key + .send(payload) + .expect(401); + }); }); diff --git a/packages/call-service/src/services/CallService.test.ts b/packages/call-service/src/services/CallService.test.ts index d4c66f4..95bccb4 100644 --- a/packages/call-service/src/services/CallService.test.ts +++ b/packages/call-service/src/services/CallService.test.ts @@ -1,9 +1,327 @@ -import { describe, it } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { CallService } from "./index"; +import {Call, CallEvent} from "../domain/call"; +import { CallAnsweredPayload, CallEndedPayload, CallHoldPayload, CallInitiatedPayload } from "@voycelink/contracts"; +import { db } from '../db/client'; + +// Mock the database module +vi.mock('../db/client', () => ({ + db: { + query: vi.fn() + } +})); + +// Mock the publisher +vi.mock('../bus/publisher', () => ({ + publishStatusUpdate: vi.fn() +})); + +// Mock CallRepository +vi.mock('../db/callRepository', () => ({ + CallRepository: vi.fn().mockImplementation(() => ({ + createCall: vi.fn(), + getCallById: vi.fn(), + updateCallStatus: vi.fn(), + createCallEvent: vi.fn(), + getCalls: vi.fn(), + getCallEvents: vi.fn() + })) +})); describe('CallService', () => { - it.todo('processes call_initiated and persists the call'); - it.todo('processes call_answered and updates call status to active'); - it.todo('flags call_answered when waitTime exceeds 30 seconds'); - it.todo('flags call_hold when holdDuration exceeds 60 seconds'); - it.todo('flags call_ended when duration is under 10 seconds'); + let callService: CallService; + let mockCallRepository: any; + + beforeEach(() => { + mockCallRepository = { + createCall: vi.fn(), + getCallById: vi.fn(), + updateCallStatus: vi.fn(), + createCallEvent: vi.fn(), + getCalls: vi.fn(), + getCallEvents: vi.fn() + }; + callService = new CallService(mockCallRepository); + vi.clearAllMocks(); + }); + + describe('processEvent', () => { + it('processes call_initiated and persists the call', async () => { + const payload: CallInitiatedPayload = { + event: 'call_initiated', + callId: 'test-call-id', + type: 'voice', + queueId: 'medical_spanish' + }; + + // Mock repository responses + mockCallRepository.getCallById.mockResolvedValueOnce(null); // No existing call + mockCallRepository.createCall.mockResolvedValueOnce({ id: 'test-call-id' }); + mockCallRepository.createCallEvent.mockResolvedValueOnce({ id: 'event-id' }); + + const result = await callService.processEvent(payload); + + expect(result).toBeInstanceOf(CallEvent); + expect(result).toHaveProperty('id'); + expect(result.callId).toBe('test-call-id'); + expect(result.type).toBe('call_initiated'); + expect(mockCallRepository.getCallById).toHaveBeenCalledTimes(1); + expect(mockCallRepository.createCall).toHaveBeenCalledTimes(1); + expect(mockCallRepository.createCallEvent).toHaveBeenCalledTimes(1); + }); + + it('throws error for invalid queueId in call_initiated', async () => { + const payload: CallInitiatedPayload = { + event: 'call_initiated', + callId: 'test-call-id', + type: 'voice', + queueId: 'invalid_queue' as any + }; + + await expect(callService.processEvent(payload)).rejects.toThrow('Invalid queueId'); + }); + + it('throws error for duplicate call in call_initiated', async () => { + const payload: CallInitiatedPayload = { + event: 'call_initiated', + callId: 'test-call-id', + type: 'voice', + queueId: 'medical_spanish' + }; + + // Mock repository responses - call already exists + mockCallRepository.getCallById.mockResolvedValueOnce({ id: 'test-call-id' }); // Existing call found + + await expect(callService.processEvent(payload)).rejects.toThrow('already exists'); + }); + + it('processes call_answered and updates call status to active', async () => { + const payload: CallAnsweredPayload = { + event: 'call_answered', + callId: 'test-call-id', + waitTime: 25 + }; + + // Mock repository responses + const mockCall = { id: 'test-call-id', status: 'waiting', startTime: new Date() }; + mockCallRepository.getCallById.mockResolvedValueOnce(mockCall); + mockCallRepository.updateCallStatus.mockResolvedValueOnce(undefined); + mockCallRepository.createCallEvent.mockResolvedValueOnce({ id: 'event-id' }); + + const result = await callService.processEvent(payload); + + expect(result).toBeInstanceOf(CallEvent); + expect(result.callId).toBe('test-call-id'); + expect(result.type).toBe('call_answered'); + expect(result.metadata?.waitTime).toBe(25); + expect(mockCallRepository.getCallById).toHaveBeenCalledTimes(1); + expect(mockCallRepository.updateCallStatus).toHaveBeenCalledTimes(1); + expect(mockCallRepository.createCallEvent).toHaveBeenCalledTimes(1); + }); + + it('flags call_answered when waitTime exceeds 30 seconds', async () => { + const payload: CallAnsweredPayload = { + event: 'call_answered', + callId: 'test-call-id', + waitTime: 35 + }; + + // Mock repository responses + const mockCall = { id: 'test-call-id', status: 'waiting', startTime: new Date() }; + mockCallRepository.getCallById.mockResolvedValueOnce(mockCall); + mockCallRepository.updateCallStatus.mockResolvedValueOnce(undefined); + mockCallRepository.createCallEvent.mockResolvedValueOnce({ id: 'event-id' }); + + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await callService.processEvent(payload); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('SLA breach: Call test-call-id waited 35 seconds before being answered') + ); + + consoleSpy.mockRestore(); + }); + + it('processes call_hold and updates call status to on_hold', async () => { + const payload: CallHoldPayload = { + event: 'call_hold', + callId: 'test-call-id', + holdDuration: 30 + }; + + // Mock repository responses + const mockCall = { id: 'test-call-id', status: 'active', holdStartTime: null }; + mockCallRepository.getCallById.mockResolvedValueOnce(mockCall); + mockCallRepository.updateCallStatus.mockResolvedValueOnce(undefined); + mockCallRepository.createCallEvent.mockResolvedValueOnce({ id: 'event-id' }); + + const result = await callService.processEvent(payload); + + expect(result).toBeInstanceOf(CallEvent); + expect(result.callId).toBe('test-call-id'); + expect(result.type).toBe('call_hold'); + expect(result.metadata?.holdDuration).toBe(30); + expect(mockCallRepository.getCallById).toHaveBeenCalledTimes(1); + expect(mockCallRepository.updateCallStatus).toHaveBeenCalledTimes(1); + expect(mockCallRepository.createCallEvent).toHaveBeenCalledTimes(1); + }); + + it('flags call_hold when holdDuration exceeds 60 seconds', async () => { + const payload: CallHoldPayload = { + event: 'call_hold', + callId: 'test-call-id', + holdDuration: 75 + }; + + // Mock repository responses + const mockCall = { id: 'test-call-id', status: 'active', holdStartTime: null }; + mockCallRepository.getCallById.mockResolvedValueOnce(mockCall); + mockCallRepository.updateCallStatus.mockResolvedValueOnce(undefined); + mockCallRepository.createCallEvent.mockResolvedValueOnce({ id: 'event-id' }); + + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await callService.processEvent(payload); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Hold time exceeded: Call test-call-id has been on hold for 75 seconds') + ); + + consoleSpy.mockRestore(); + }); + + it('processes call_ended and updates call status to ended', async () => { + const payload: CallEndedPayload = { + event: 'call_ended', + callId: 'test-call-id', + endReason: 'completed', + duration: 120 + }; + + // Mock repository responses + const mockCall = { id: 'test-call-id', status: 'active', startTime: new Date() }; + mockCallRepository.getCallById.mockResolvedValueOnce(mockCall); + mockCallRepository.updateCallStatus.mockResolvedValueOnce(undefined); + mockCallRepository.createCallEvent.mockResolvedValueOnce({ id: 'event-id' }); + + const result = await callService.processEvent(payload); + + expect(result).toBeInstanceOf(CallEvent); + expect(result.callId).toBe('test-call-id'); + expect(result.type).toBe('call_ended'); + expect(result.metadata?.endReason).toBe('completed'); + expect(result.metadata?.duration).toBe(120); + expect(mockCallRepository.getCallById).toHaveBeenCalledTimes(1); + expect(mockCallRepository.updateCallStatus).toHaveBeenCalledTimes(1); + expect(mockCallRepository.createCallEvent).toHaveBeenCalledTimes(1); + }); + + it('flags call_ended when duration is under 10 seconds', async () => { + const payload: CallEndedPayload = { + event: 'call_ended', + callId: 'test-call-id', + endReason: 'completed', + duration: 5 + }; + + // Mock repository responses + const mockCall = { id: 'test-call-id', status: 'active', startTime: new Date() }; + mockCallRepository.getCallById.mockResolvedValueOnce(mockCall); + mockCallRepository.updateCallStatus.mockResolvedValueOnce(undefined); + mockCallRepository.createCallEvent.mockResolvedValueOnce({ id: 'event-id' }); + + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await callService.processEvent(payload); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Short call: Call test-call-id ended after only 5 seconds') + ); + + consoleSpy.mockRestore(); + }); + + it('throws error for unsupported event type', async () => { + // @ts-ignore - intentionally testing invalid event type + const payload = { + event: 'unsupported_event', + callId: 'test-call-id', + // Adding required fields to satisfy TypeScript but they won't be used for this test + endReason: 'completed', + duration: 0 + } as CallEndedPayload; + + await expect(callService.processEvent(payload)).rejects.toThrow('Unsupported event type: unsupported_event'); + }); + }); + + describe('getCalls', () => { + it('returns calls with optional filtering', async () => { + const mockRows = [ + { id: 'call1', type: 'voice' as const, status: 'waiting' as const, queueId: 'medical_spanish' as const, startTime: new Date(), endTime: undefined }, + { id: 'call2', type: 'video' as const, status: 'active' as const, queueId: 'medical_english' as const, startTime: new Date(), endTime: undefined } + ]; + + mockCallRepository.getCalls.mockResolvedValueOnce(mockRows); + + const filters = { status: 'waiting' as const }; + const result = await callService.getCalls(filters); + + expect(result).toHaveLength(2); + expect(result[0]).toBeInstanceOf(Call); + expect(result[0].id).toBe('call1'); + expect(result[0].status).toBe('waiting'); + expect(mockCallRepository.getCalls).toHaveBeenCalled(); + }); + + it('returns all calls when no filters provided', async () => { + const mockRows = [ + { id: 'call1', type: 'voice' as const, status: 'waiting' as const, queueId: 'medical_spanish' as const, startTime: new Date(), endTime: undefined } + ]; + + mockCallRepository.getCalls.mockResolvedValueOnce(mockRows); + + const result = await callService.getCalls({}); + + expect(result).toHaveLength(1); + expect(mockCallRepository.getCalls).toHaveBeenCalled(); + }); + }); + + describe('getCallEvents', () => { + it('returns events for a specific call in chronological order', async () => { + const mockRows = [ + { id: 'event1', callId: 'test-call-id', type: 'call_initiated', timestamp: new Date(Date.now() - 10000), metadata: {} }, + { id: 'event2', callId: 'test-call-id', type: 'call_answered', timestamp: new Date(Date.now() - 5000), metadata: { waitTime: 25 } } + ]; + + // Mock call existence check + mockCallRepository.getCallById.mockResolvedValueOnce(new Call( + 'test-call-id', + 'voice' as const, + 'waiting' as const, + 'medical_spanish' as const, + new Date(), + undefined + )); // Call exists + mockCallRepository.getCallEvents.mockResolvedValueOnce(mockRows); // Get events + + const result = await callService.getCallEvents('test-call-id'); + + expect(result).toHaveLength(2); + expect(result[0]).toBeInstanceOf(CallEvent); + expect(result[0].type).toBe('call_initiated'); + expect(result[1].type).toBe('call_answered'); + expect(mockCallRepository.getCallById).toHaveBeenCalledTimes(1); + expect(mockCallRepository.getCallEvents).toHaveBeenCalledTimes(1); + }); + + it('throws error for non-existent call', async () => { + // Mock call existence check - call not found + mockCallRepository.getCallById.mockResolvedValueOnce(null); + + await expect(callService.getCallEvents('non-existent-call')).rejects.toThrow('Call not found'); + }); + }); }); diff --git a/packages/call-service/src/services/CallService.ts b/packages/call-service/src/services/CallService.ts index 3c9b082..9e77dac 100644 --- a/packages/call-service/src/services/CallService.ts +++ b/packages/call-service/src/services/CallService.ts @@ -5,17 +5,57 @@ import { CallServiceContract, EventPayload, } from '../domain/call'; +import { CallRepository } from '../db/callRepository'; +import { CallEventHandler } from './handlers/CallEventHandler'; +import { handlerRegistry } from './HandlerRegistry'; export class CallService implements CallServiceContract { - async processEvent(_payload: EventPayload): Promise { - throw new Error('CallService.processEvent not implemented'); + private callRepository: CallRepository; + private handlers: CallEventHandler[]; + + constructor( + callRepository: CallRepository = new CallRepository() + ) { + this.callRepository = callRepository; + // Initialize handlers from registry + this.handlers = handlerRegistry.getHandlers(callRepository); + } + + async processEvent(payload: EventPayload): Promise { + const handler = this.handlers.find(h => h.canHandle(payload.event)); + if (!handler) { + throw new Error(`Unsupported event type: ${payload.event}`); + } + return handler.handle(payload); } - async getCalls(_filters: CallFilters): Promise { - throw new Error('CallService.getCalls not implemented'); + async getCalls(filters: CallFilters): Promise { + const rows = await this.callRepository.getCalls(filters); + return rows.map(row => new Call( + row.id, + row.type, + row.status, + row.queueId, + row.startTime, + row.endTime, + row.holdStartTime + )); } - async getCallEvents(_callId: string): Promise { - throw new Error('CallService.getCallEvents not implemented'); + async getCallEvents(callId: string): Promise { + // First check if call exists + const call = await this.callRepository.getCallById(callId); + if (!call) { + throw new Error('Call not found'); + } + + const rows = await this.callRepository.getCallEvents(callId); + return rows.map(row => new CallEvent( + row.id, + row.callId, + row.type, + row.timestamp, + row.metadata + )); } } diff --git a/packages/call-service/src/services/HandlerRegistry.ts b/packages/call-service/src/services/HandlerRegistry.ts new file mode 100644 index 0000000..176884a --- /dev/null +++ b/packages/call-service/src/services/HandlerRegistry.ts @@ -0,0 +1,59 @@ +import { CallEventHandler } from './handlers/CallEventHandler'; +import { CallRepository } from '../db/callRepository'; + +/** + * Registry for managing CallEventHandler types and instances. + * Allows for dynamic registration of handlers without modifying CallService. + */ +export class HandlerRegistry { + private handlerTypes: Map CallEventHandler> = new Map(); + private handlerInstances: CallEventHandler[] = []; + + /** + * Register a handler type (class) that can be instantiated when needed + * @param eventType The event type this handler can process + * @param handlerClass The handler class constructor + */ + registerHandlerType( + eventType: string, + handlerClass: new (callRepository: CallRepository) => CallEventHandler + ): void { + this.handlerTypes.set(eventType.toLowerCase(), handlerClass); + } + + /** + * Register a pre-instantiated handler + * @param handler The handler instance to register + */ + registerHandlerInstance(handler: CallEventHandler): void { + this.handlerInstances.push(handler); + } + + /** + * Get all registered handler instances, instantiating types as needed + * @param callRepository The repository to inject into handler constructors + * @returns Array of handler instances + */ + getHandlers(callRepository: CallRepository): CallEventHandler[] { + // Return pre-registered instances + const handlers = [...this.handlerInstances]; + + // Instantiate registered types + for (const [eventType, handlerClass] of this.handlerTypes) { + handlers.push(new handlerClass(callRepository)); + } + + return handlers; + } + + /** + * Clear all registrations (useful for testing) + */ + clear(): void { + this.handlerTypes.clear(); + this.handlerInstances = []; + } +} + +// Global registry instance +export const handlerRegistry = new HandlerRegistry(); \ No newline at end of file diff --git a/packages/call-service/src/services/handlers/CallAnsweredHandler.ts b/packages/call-service/src/services/handlers/CallAnsweredHandler.ts new file mode 100644 index 0000000..80429dd --- /dev/null +++ b/packages/call-service/src/services/handlers/CallAnsweredHandler.ts @@ -0,0 +1,71 @@ +import { CallEventHandler } from './CallEventHandler'; +import {CallAnsweredPayload, EventPayload} from '@voycelink/contracts'; +import { CallEvent } from '../../domain/call'; +import { CallRepository } from '../../db/callRepository'; +import { v4 as uuidv4 } from 'uuid'; +import { publishStatusUpdate } from '../../bus/publisher'; +import { + CALL_STATUS_ACTIVE, + EVENT_CALL_ANSWERED, + SLA_WAIT_TIME_THRESHOLD, +} from '../../constants'; +import { CallNotFoundError } from '../../errors'; + +export class CallAnsweredHandler implements CallEventHandler { + constructor(private callRepository: CallRepository) {} + + canHandle(eventType: string): boolean { + return eventType === EVENT_CALL_ANSWERED; + } + + async handle(payload: EventPayload): Promise { + const answeredPayload = payload as CallAnsweredPayload; + + // Get the call to verify it exists and get current status + const call = await this.callRepository.getCallById(answeredPayload.callId); + if (!call) { + throw new CallNotFoundError(answeredPayload.callId); + } + + // Update call status to active (answered means it's now connected) + await this.callRepository.updateCallStatus(answeredPayload.callId, CALL_STATUS_ACTIVE); + + // Calculate wait time + const waitTime = answeredPayload.waitTime; // This comes from the payload + + // Create call event + const eventId = uuidv4(); + const event = new CallEvent( + eventId, + answeredPayload.callId, + EVENT_CALL_ANSWERED, + new Date(), + { waitTime } + ); + + // Store event in database + await this.callRepository.createCallEvent( + eventId, + answeredPayload.callId, + EVENT_CALL_ANSWERED, + { waitTime } + ); + + // Publish status update + await publishStatusUpdate({ + callId: answeredPayload.callId, + status: CALL_STATUS_ACTIVE, + eventType: EVENT_CALL_ANSWERED, + timestamp: new Date().toISOString(), + metadata: { waitTime } + }); + + // Check if wait time exceeds SLA (30 seconds) and flag if needed + if (waitTime > SLA_WAIT_TIME_THRESHOLD) { + // In a real system, we might send a notification or alert here + console.warn(`SLA breach: Call ${answeredPayload.callId} waited ${waitTime} seconds before being answered`); + } + + return event; + } +} \ No newline at end of file diff --git a/packages/call-service/src/services/handlers/CallEndedHandler.ts b/packages/call-service/src/services/handlers/CallEndedHandler.ts new file mode 100644 index 0000000..3897c0c --- /dev/null +++ b/packages/call-service/src/services/handlers/CallEndedHandler.ts @@ -0,0 +1,72 @@ +import { CallEventHandler } from './CallEventHandler'; +import {CallEndedPayload, EventPayload} from '@voycelink/contracts'; +import { CallEvent } from '../../domain/call'; +import { CallRepository } from '../../db/callRepository'; +import { v4 as uuidv4 } from 'uuid'; +import { publishStatusUpdate } from '../../bus/publisher'; +import { + CALL_STATUS_ENDED, + EVENT_CALL_ENDED, + SHORT_CALL_THRESHOLD, +} from '../../constants'; +import { CallNotFoundError } from '../../errors'; + +export class CallEndedHandler implements CallEventHandler { + constructor(private callRepository: CallRepository) {} + + canHandle(eventType: string): boolean { + return eventType === EVENT_CALL_ENDED; + } + + async handle(payload: EventPayload): Promise { + const endedPayload = payload as CallEndedPayload; + + // Get the call to verify it exists and get current status + const call = await this.callRepository.getCallById(endedPayload.callId); + if (!call) { + throw new CallNotFoundError(endedPayload.callId); + } + + // Update call status to ended + await this.callRepository.updateCallStatus( + endedPayload.callId, + 'ended', + { endTime: new Date() } + ); + + // Create call event + const eventId = uuidv4(); + const event = new CallEvent( + eventId, + endedPayload.callId, + EVENT_CALL_ENDED, + new Date(), + { endReason: endedPayload.endReason, duration: endedPayload.duration } + ); + + // Store event in database + await this.callRepository.createCallEvent( + eventId, + endedPayload.callId, + EVENT_CALL_ENDED, + { endReason: endedPayload.endReason, duration: endedPayload.duration } + ); + + // Publish status update + await publishStatusUpdate({ + callId: endedPayload.callId, + status: CALL_STATUS_ENDED, + eventType: EVENT_CALL_ENDED, + timestamp: new Date().toISOString(), + metadata: { endReason: endedPayload.endReason, duration: endedPayload.duration } + }); + + // Check if call duration is under 10 seconds and flag if needed + if (endedPayload.duration < SHORT_CALL_THRESHOLD) { + // In a real system, we might send a notification or alert here + console.warn(`Short call: Call ${endedPayload.callId} ended after only ${endedPayload.duration} seconds`); + } + + return event; + } +} \ No newline at end of file diff --git a/packages/call-service/src/services/handlers/CallEventHandler.ts b/packages/call-service/src/services/handlers/CallEventHandler.ts new file mode 100644 index 0000000..d6600ba --- /dev/null +++ b/packages/call-service/src/services/handlers/CallEventHandler.ts @@ -0,0 +1,19 @@ +import { EventPayload } from '../../domain/call'; +import { CallEvent } from '../../domain/call'; +import { CallRepository } from '../../db/callRepository'; + +export interface CallEventHandler { + canHandle(eventType: string): boolean; + handle(payload: EventPayload): Promise; +} + +export abstract class BaseCallEventHandler implements CallEventHandler { + protected constructor(protected callRepository: CallRepository) {} + + canHandle(eventType: string): boolean { + return this.getEventType() === eventType; + } + + abstract getEventType(): string; + abstract handle(payload: EventPayload): Promise; +} \ No newline at end of file diff --git a/packages/call-service/src/services/handlers/CallHoldHandler.ts b/packages/call-service/src/services/handlers/CallHoldHandler.ts new file mode 100644 index 0000000..005a4e6 --- /dev/null +++ b/packages/call-service/src/services/handlers/CallHoldHandler.ts @@ -0,0 +1,72 @@ +import { CallEventHandler } from './CallEventHandler'; +import {CallHoldPayload, EventPayload} from '@voycelink/contracts'; +import { CallEvent } from '../../domain/call'; +import { CallRepository } from '../../db/callRepository'; +import { v4 as uuidv4 } from 'uuid'; +import { publishStatusUpdate } from '../../bus/publisher'; +import { + CALL_STATUS_ON_HOLD, + EVENT_CALL_HOLD, + MAX_HOLD_TIME_THRESHOLD, +} from '../../constants'; +import { CallNotFoundError } from '../../errors'; + +export class CallHoldHandler implements CallEventHandler { + constructor(private callRepository: CallRepository) {} + + canHandle(eventType: string): boolean { + return eventType === EVENT_CALL_HOLD; + } + + async handle(payload: EventPayload): Promise { + const holdPayload = payload as CallHoldPayload; + + // Get the call to verify it exists and get current status + const call = await this.callRepository.getCallById(holdPayload.callId); + if (!call) { + throw new CallNotFoundError(holdPayload.callId); + } + + // Update call status to on_hold + await this.callRepository.updateCallStatus( + holdPayload.callId, + CALL_STATUS_ON_HOLD, + { holdStartTime: new Date() } + ); + + // Create call event + const eventId = uuidv4(); + const event = new CallEvent( + eventId, + holdPayload.callId, + EVENT_CALL_HOLD, + new Date(), + { holdDuration: holdPayload.holdDuration } + ); + + // Store event in database + await this.callRepository.createCallEvent( + eventId, + holdPayload.callId, + EVENT_CALL_HOLD, + { holdDuration: holdPayload.holdDuration } + ); + + // Publish status update + await publishStatusUpdate({ + callId: holdPayload.callId, + status: CALL_STATUS_ON_HOLD, + eventType: EVENT_CALL_HOLD, + timestamp: new Date().toISOString(), + metadata: { holdDuration: holdPayload.holdDuration } + }); + + // Check if hold time exceeds max (60 seconds) and flag if needed + if (holdPayload.holdDuration > MAX_HOLD_TIME_THRESHOLD) { + // In a real system, we might send a notification or alert here + console.warn(`Hold time exceeded: Call ${holdPayload.callId} has been on hold for ${holdPayload.holdDuration} seconds`); + } + + return event; + } +} \ No newline at end of file diff --git a/packages/call-service/src/services/handlers/CallInitiatedHandler.ts b/packages/call-service/src/services/handlers/CallInitiatedHandler.ts new file mode 100644 index 0000000..633ff8f --- /dev/null +++ b/packages/call-service/src/services/handlers/CallInitiatedHandler.ts @@ -0,0 +1,74 @@ +import { CallEventHandler } from './CallEventHandler'; +import {CallInitiatedPayload, EventPayload} from '@voycelink/contracts'; +import { CallEvent } from '../../domain/call'; +import { CallRepository } from '../../db/callRepository'; +import { v4 as uuidv4 } from 'uuid'; +import { publishStatusUpdate } from '../../bus/publisher'; +import { + CALL_STATUS_WAITING, + EVENT_CALL_INITIATED, + VALID_QUEUES, +} from '../../constants'; +import { + CallAlreadyExistsError, + InvalidQueueIdError, +} from '../../errors'; + +export class CallInitiatedHandler implements CallEventHandler { + constructor(private callRepository: CallRepository) {} + + canHandle(eventType: string): boolean { + return eventType === EVENT_CALL_INITIATED; + } + + async handle(payload: EventPayload): Promise { + const initiatedPayload = payload as CallInitiatedPayload; + + // Validate queueId exists + if (!VALID_QUEUES.includes(initiatedPayload.queueId as any)) { + throw new InvalidQueueIdError(initiatedPayload.queueId); + } + + // Check if call already exists + const existingCall = await this.callRepository.getCallById(initiatedPayload.callId); + if (existingCall) { + throw new CallAlreadyExistsError(initiatedPayload.callId); + } + + // Create call record + await this.callRepository.createCall( + initiatedPayload.callId, + initiatedPayload.type, + initiatedPayload.queueId + ); + + // Create call event + const eventId = uuidv4(); + const event = new CallEvent( + eventId, + initiatedPayload.callId, + EVENT_CALL_INITIATED, + new Date(), + {} + ); + + // Store event in database + await this.callRepository.createCallEvent( + eventId, + initiatedPayload.callId, + EVENT_CALL_INITIATED, + {} + ); + + // Publish status update + await publishStatusUpdate({ + callId: initiatedPayload.callId, + status: CALL_STATUS_WAITING, + eventType: EVENT_CALL_INITIATED, + timestamp: new Date().toISOString(), + metadata: {} + }); + + return event; + } +} \ No newline at end of file diff --git a/packages/call-service/src/services/handlers/CallRoutedHandler.ts b/packages/call-service/src/services/handlers/CallRoutedHandler.ts new file mode 100644 index 0000000..7f2b604 --- /dev/null +++ b/packages/call-service/src/services/handlers/CallRoutedHandler.ts @@ -0,0 +1,61 @@ +import { CallEventHandler } from './CallEventHandler'; +import {CallRoutedPayload, EventPayload} from '@voycelink/contracts'; +import { CallEvent } from '../../domain/call'; +import { CallRepository } from '../../db/callRepository'; +import { v4 as uuidv4 } from 'uuid'; +import { publishStatusUpdate } from '../../bus/publisher'; +import { + CALL_STATUS_ACTIVE, + EVENT_CALL_ROUTED, +} from '../../constants'; +import { CallNotFoundError } from '../../errors'; + +export class CallRoutedHandler implements CallEventHandler { + constructor(private callRepository: CallRepository) {} + + canHandle(eventType: string): boolean { + return eventType === EVENT_CALL_ROUTED; + } + + async handle(payload: EventPayload): Promise { + const routedPayload = payload as CallRoutedPayload; + + // Get the call to verify it exists and get current status + const call = await this.callRepository.getCallById(routedPayload.callId); + if (!call) { + throw new CallNotFoundError(routedPayload.callId); + } + + // Update call status to active (routed means it's waiting for agent to answer) + await this.callRepository.updateCallStatus(routedPayload.callId, CALL_STATUS_ACTIVE); + + // Create call event + const eventId = uuidv4(); + const event = new CallEvent( + eventId, + routedPayload.callId, + EVENT_CALL_ROUTED, + new Date(), + { agentId: routedPayload.agentId, routingTime: routedPayload.routingTime } + ); + + // Store event in database + await this.callRepository.createCallEvent( + eventId, + routedPayload.callId, + EVENT_CALL_ROUTED, + { agentId: routedPayload.agentId, routingTime: routedPayload.routingTime } + ); + + // Publish status update + await publishStatusUpdate({ + callId: routedPayload.callId, + status: CALL_STATUS_ACTIVE, + eventType: EVENT_CALL_ROUTED, + timestamp: new Date().toISOString(), + metadata: { agentId: routedPayload.agentId, routingTime: routedPayload.routingTime } + }); + + return event; + } +} \ No newline at end of file diff --git a/packages/call-service/src/services/handlers/index.ts b/packages/call-service/src/services/handlers/index.ts new file mode 100644 index 0000000..c2fbe09 --- /dev/null +++ b/packages/call-service/src/services/handlers/index.ts @@ -0,0 +1,26 @@ +import { CallRepository } from '../../db/callRepository'; +import { handlerRegistry } from '../HandlerRegistry'; +import { CallInitiatedHandler } from './CallInitiatedHandler'; +import { CallRoutedHandler } from './CallRoutedHandler'; +import { CallAnsweredHandler } from './CallAnsweredHandler'; +import { CallHoldHandler } from './CallHoldHandler'; +import { CallEndedHandler } from './CallEndedHandler'; +import { + EVENT_CALL_INITIATED, + EVENT_CALL_ROUTED, + EVENT_CALL_ANSWERED, + EVENT_CALL_HOLD, + EVENT_CALL_ENDED, +} from '../../constants'; + +/** + * Initialize and register all default call event handlers + * This should be called during application startup + */ +export function initializeDefaultHandlers(callRepository: CallRepository = new CallRepository()): void { + handlerRegistry.registerHandlerType(EVENT_CALL_INITIATED, CallInitiatedHandler); + handlerRegistry.registerHandlerType(EVENT_CALL_ROUTED, CallRoutedHandler); + handlerRegistry.registerHandlerType(EVENT_CALL_ANSWERED, CallAnsweredHandler); + handlerRegistry.registerHandlerType(EVENT_CALL_HOLD, CallHoldHandler); + handlerRegistry.registerHandlerType(EVENT_CALL_ENDED, CallEndedHandler); +} \ No newline at end of file diff --git a/packages/call-service/src/services/index.ts b/packages/call-service/src/services/index.ts index 1cb0c8c..220ba3b 100644 --- a/packages/call-service/src/services/index.ts +++ b/packages/call-service/src/services/index.ts @@ -1,4 +1,8 @@ import { CallService } from './CallService'; +import { initializeDefaultHandlers } from './handlers/index'; + +// Initialize default handlers before creating the service instance +initializeDefaultHandlers(); export { CallService } from './CallService'; export const callService = new CallService();