From 237e253f0cd517b653dc500c278fed890ef6d2d4 Mon Sep 17 00:00:00 2001 From: Franco Arratia Date: Mon, 4 May 2026 23:04:37 -0400 Subject: [PATCH 01/10] Added required field to call.ts and implementing the tests for events --- packages/call-service/src/domain/call.ts | 1 + .../call-service/src/routes/events.test.ts | 83 ++++++++++++++++++- 2 files changed, 80 insertions(+), 4 deletions(-) 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/routes/events.test.ts b/packages/call-service/src/routes/events.test.ts index 79c8f7e..d44e3f7 100644 --- a/packages/call-service/src/routes/events.test.ts +++ b/packages/call-service/src/routes/events.test.ts @@ -1,7 +1,82 @@ -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"; + +// 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); + }); }); From 5122b494dfe36ce7912f415a9af3dabc0fb35f58 Mon Sep 17 00:00:00 2001 From: Franco Arratia Date: Mon, 4 May 2026 23:22:28 -0400 Subject: [PATCH 02/10] Ensure that the tests implemented for events pass successfully --- packages/call-service/src/index.ts | 10 +++++++--- packages/call-service/src/routes/events.test.ts | 1 + 2 files changed, 8 insertions(+), 3 deletions(-) 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 d44e3f7..16cf935 100644 --- a/packages/call-service/src/routes/events.test.ts +++ b/packages/call-service/src/routes/events.test.ts @@ -2,6 +2,7 @@ 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', () => ({ From 778e631be821198259b99323d3061d56e608ac1c Mon Sep 17 00:00:00 2001 From: Franco Arratia Date: Mon, 4 May 2026 23:42:25 -0400 Subject: [PATCH 03/10] Implementing the tests for CallService --- .../src/services/CallService.test.ts | 142 +++++++++++++++++- 1 file changed, 136 insertions(+), 6 deletions(-) diff --git a/packages/call-service/src/services/CallService.test.ts b/packages/call-service/src/services/CallService.test.ts index d4c66f4..a86e9e6 100644 --- a/packages/call-service/src/services/CallService.test.ts +++ b/packages/call-service/src/services/CallService.test.ts @@ -1,9 +1,139 @@ -import { describe, it } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { CallService } from "./CallService"; +import { 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() +})); 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; + + beforeEach(() => { + callService = new CallService(); + vi.clearAllMocks(); + }); + + it.todo('processes call_initiated and persists the call', async () => { + const payload: CallInitiatedPayload = { + event: 'call_initiated', + callId: 'test-call-id', + type: 'voice', + queueId: 'medical_spanish' + }; + + // Mock database responses + (db.query as any).mockResolvedValueOnce({ rowCount: 0 }); // No existing call + (db.query as any).mockResolvedValueOnce({}); // Insert call + (db.query as any).mockResolvedValueOnce({}); // Insert event + + 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(db.query).toHaveBeenCalledTimes(3); + }); + + it.todo('processes call_answered and updates call status to active', async () => { + const payload: CallAnsweredPayload = { + event: 'call_answered', + callId: 'test-call-id', + waitTime: 25 + }; + + // Mock database responses + (db.query as any).mockResolvedValueOnce({ rows: [{ id: 'test-call-id', status: 'waiting', startTime: new Date() }] }); // Get call + (db.query as any).mockResolvedValueOnce({}); // Update call + (db.query as any).mockResolvedValueOnce({}); // Insert event + + 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(db.query).toHaveBeenCalledTimes(3); + }); + + it.todo('flags call_answered when waitTime exceeds 30 seconds', async () => { + const payload: CallAnsweredPayload = { + event: 'call_answered', + callId: 'test-call-id', + waitTime: 35 + }; + + // Mock database responses + (db.query as any).mockResolvedValueOnce({ rows: [{ id: 'test-call-id', status: 'waiting', startTime: new Date() }] }); // Get call + (db.query as any).mockResolvedValueOnce({}); // Update call + (db.query as any).mockResolvedValueOnce({}); // Insert event + + 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.todo('flags call_hold when holdDuration exceeds 60 seconds', async () => { + const payload: CallHoldPayload = { + event: 'call_hold', + callId: 'test-call-id', + holdDuration: 75 + }; + + // Mock database responses + (db.query as any).mockResolvedValueOnce({ rows: [{ id: 'test-call-id', status: 'active', holdStartTime: null }] }); // Get call + (db.query as any).mockResolvedValueOnce({}); // Update call + (db.query as any).mockResolvedValueOnce({}); // Insert event + + 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.todo('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 database responses + (db.query as any).mockResolvedValueOnce({ rows: [{ id: 'test-call-id', status: 'active', startTime: new Date() }] }); // Get call + (db.query as any).mockResolvedValueOnce({}); // Update call + (db.query as any).mockResolvedValueOnce({}); // Insert event + + 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(); + }); }); From edf51e475e605e99d6dcd33ae6a35d16f07ca5f9 Mon Sep 17 00:00:00 2001 From: Franco Arratia Date: Tue, 5 May 2026 01:05:04 -0400 Subject: [PATCH 04/10] Initial implementation for CallService --- .../call-service/src/services/CallService.ts | 254 +++++++++++++++++- 1 file changed, 248 insertions(+), 6 deletions(-) diff --git a/packages/call-service/src/services/CallService.ts b/packages/call-service/src/services/CallService.ts index 3c9b082..ebeabf1 100644 --- a/packages/call-service/src/services/CallService.ts +++ b/packages/call-service/src/services/CallService.ts @@ -5,17 +5,259 @@ import { CallServiceContract, EventPayload, } from '../domain/call'; +import { mapCallRow, mapCallEventRow } from '../db/mappers'; +import { publishStatusUpdate } from '../bus/publisher'; +import { v4 as uuidv4 } from 'uuid'; +import type { CallInitiatedPayload, CallRoutedPayload, CallAnsweredPayload, CallHoldPayload, CallEndedPayload } from '@voycelink/contracts'; +import { db } from '../db/client'; export class CallService implements CallServiceContract { - async processEvent(_payload: EventPayload): Promise { - throw new Error('CallService.processEvent not implemented'); + async processEvent(payload: EventPayload): Promise { + switch (payload.event) { + case 'call_initiated': + return this.handleCallInitiated(payload); + case 'call_routed': + return this.handleCallRouted(payload); + case 'call_answered': + return this.handleCallAnswered(payload); + case 'call_hold': + return this.handleCallHold(payload); + case 'call_ended': + return this.handleCallEnded(payload); + default: + throw new Error(`Unsupported event type: ${payload.event}`); + } } - async getCalls(_filters: CallFilters): Promise { - throw new Error('CallService.getCalls not implemented'); + private async handleCallInitiated(payload: CallInitiatedPayload): Promise { + // Validate queueId exists (it's already typed as QueueId, but we can double-check) + const validQueues = ['medical_spanish', 'medical_english', 'legal_spanish', 'legal_english']; + if (!validQueues.includes(payload.queueId)) { + throw new Error(`Invalid queueId: ${payload.queueId}`); + } + + // Check if call already exists + const existingCallResult = await db.query('SELECT id FROM calls WHERE id = $1', [payload.callId]); + if (existingCallResult.rowCount > 0) { + throw new Error(`Call with id ${payload.callId} already exists`); + } + + // Create call record + await db.query( + 'INSERT INTO calls (id, type, status, queue_id, start_time) VALUES ($1, $2, $3, $4, $5)', + [payload.callId, payload.type, 'waiting', payload.queueId, new Date()] + ); + + // Create call event + const eventId = uuidv4(); + const event = new CallEvent( + eventId, + payload.callId, + 'call_initiated', + new Date(), + {} + ); + + // Store event in database + await db.query( + 'INSERT INTO call_events (id, call_id, type, timestamp, metadata) VALUES ($1, $2, $3, $4, $5)', + [eventId, payload.callId, 'call_initiated', new Date(), JSON.stringify({})] + ); + + // Publish status update + await publishStatusUpdate({ + callId: payload.callId, + status: 'waiting', + eventType: 'call_initiated', + timestamp: new Date().toISOString(), + metadata: {} + }); + + return event; + } + + private async handleCallRouted(payload: CallRoutedPayload): Promise { + // Get the call to verify it exists and get current status + const callResult = await db.query('SELECT id, status FROM calls WHERE id = $1', [payload.callId]); + if (callResult.rowCount === 0) { + throw new Error(`Call with id ${payload.callId} not found`); + } + + const call = callResult.rows[0]; + + // Update call status to active (routed means it's waiting for agent to answer) + await db.query( + 'UPDATE calls SET status = $1 WHERE id = $2', + ['active', payload.callId] + ); + + // Create call event + const eventId = uuidv4(); + const event = new CallEvent( + eventId, + payload.callId, + 'call_routed', + new Date(), + { agentId: payload.agentId, routingTime: payload.routingTime } + ); + + // Store event in database + await db.query( + 'INSERT INTO call_events (id, call_id, type, timestamp, metadata) VALUES ($1, $2, $3, $4, $5)', + [eventId, payload.callId, 'call_routed', new Date(), JSON.stringify({ agentId: payload.agentId, routingTime: payload.routingTime })] + ); + + // Publish status update + await publishStatusUpdate({ + callId: payload.callId, + status: 'active', + eventType: 'call_routed', + timestamp: new Date().toISOString(), + metadata: { agentId: payload.agentId, routingTime: payload.routingTime } + }); + + return event; + } + + private async handleCallAnswered(payload: CallAnsweredPayload): Promise { + // Get the call to verify it exists and get current status + const callResult = await db.query('SELECT id, status, startTime FROM calls WHERE id = $1', [payload.callId]); + if (callResult.rowCount === 0) { + throw new Error(`Call with id ${payload.callId} not found`); + } + + const call = callResult.rows[0]; + + // Update call status to active (answered means it's now connected) + await db.query( + 'UPDATE calls SET status = $1 WHERE id = $2', + ['active', payload.callId] + ); + + // Calculate wait time + const waitTime = payload.waitTime; // This comes from the payload + + // Create call event + const eventId = uuidv4(); + const event = new CallEvent( + eventId, + payload.callId, + 'call_answered', + new Date(), + { waitTime } + ); + + // Store event in database + await db.query( + 'INSERT INTO call_events (id, call_id, type, timestamp, metadata) VALUES ($1, $2, $3, $4, $5)', + [eventId, payload.callId, 'call_answered', new Date(), JSON.stringify({ waitTime })] + ); + + // Publish status update + await publishStatusUpdate({ + callId: payload.callId, + status: 'active', + eventType: 'call_answered', + timestamp: new Date().toISOString(), + metadata: { waitTime } + }); + + // Check if wait time exceeds SLA (30 seconds) and flag if needed + if (waitTime > 30) { + // In a real system, we might send a notification or alert here + console.warn(`SLA breach: Call ${payload.callId} waited ${waitTime} seconds before being answered`); + } + + return event; } - async getCallEvents(_callId: string): Promise { - throw new Error('CallService.getCallEvents not implemented'); + private async handleCallHold(payload: CallHoldPayload): Promise { + // Get the call to verify it exists and get current status + const callResult = await db.query('SELECT id, status, holdStartTime FROM calls WHERE id = $1', [payload.callId]); + if (callResult.rowCount === 0) { + throw new Error(`Call with id ${payload.callId} not found`); + } + + const call = callResult.rows[0]; + + // Update call status to on_hold + await db.query( + 'UPDATE calls SET status = $1, holdStartTime = $2 WHERE id = $3', + ['on_hold', new Date(), payload.callId] + ); + + // Create call event + const eventId = uuidv4(); + const event = new CallEvent( + eventId, + payload.callId, + 'call_hold', + new Date(), + { holdDuration: payload.holdDuration } + ); + + // Store event in database + await db.query( + 'INSERT INTO call_events (id, call_id, type, timestamp, metadata) VALUES ($1, $2, $3, $4, $5)', + [eventId, payload.callId, 'call_hold', new Date(), JSON.stringify({ holdDuration: payload.holdDuration })] + ); + + // Publish status update + await publishStatusUpdate({ + callId: payload.callId, + status: 'on_hold', + eventType: 'call_hold', + timestamp: new Date().toISOString(), + metadata: { holdDuration: payload.holdDuration } + }); + + // Check if hold time exceeds max (60 seconds) and flag if needed + if (payload.holdDuration > 60) { + // In a real system, we might send a notification or alert here + console.warn(`Hold time exceeded: Call ${payload.callId} has been on hold for ${payload.holdDuration} seconds`); + } + + return event; + } + + private async handleCallEnded(payload: CallEndedPayload): Promise + + async getCalls(filters: CallFilters): Promise { + let query = 'SELECT id, type, status, queue_id, start_time, end_time FROM 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); + } + + async getCallEvents(callId: string): Promise { + // First verify the call exists + const callResult = await db.query('SELECT id FROM 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 call_events WHERE call_id = $1 ORDER BY timestamp ASC', + [callId] + ); + return result.rows.map(mapCallEventRow); } } From 971f32b0ddb25ed0d281473b19364a070dd88143 Mon Sep 17 00:00:00 2001 From: Franco Arratia Date: Tue, 5 May 2026 01:37:58 -0400 Subject: [PATCH 05/10] Improving initial implementation following best practices (SOLID, clean code, etc) --- packages/call-service/src/constants.ts | 29 ++ .../call-service/src/db/callRepository.ts | 94 +++++++ packages/call-service/src/errors.ts | 35 +++ .../call-service/src/services/CallService.ts | 252 +++++++++--------- 4 files changed, 288 insertions(+), 122 deletions(-) create mode 100644 packages/call-service/src/constants.ts create mode 100644 packages/call-service/src/db/callRepository.ts create mode 100644 packages/call-service/src/errors.ts 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/errors.ts b/packages/call-service/src/errors.ts new file mode 100644 index 0000000..95caf02 --- /dev/null +++ b/packages/call-service/src/errors.ts @@ -0,0 +1,35 @@ +// Custom error classes for CallService +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(`Call with id ${callId} already exists`, 'CALL_ALREADY_EXISTS'); + this.name = 'CallAlreadyExistsError'; + } +} + +export class CallNotFoundError extends CallServiceError { + constructor(callId: string) { + super(`Call with id ${callId} not found`, 'CALL_NOT_FOUND'); + this.name = 'CallNotFoundError'; + } +} + +export class InvalidQueueIdError extends CallServiceError { + constructor(queueId: string) { + super(`Invalid queueId: ${queueId}`, 'INVALID_QUEUE_ID'); + this.name = 'InvalidQueueIdError'; + } +} + +export class UnsupportedEventTypeError extends CallServiceError { + constructor(eventType: string) { + super(`Unsupported event type: ${eventType}`, 'UNSUPPORTED_EVENT_TYPE'); + this.name = 'UnsupportedEventTypeError'; + } +} \ No newline at end of file diff --git a/packages/call-service/src/services/CallService.ts b/packages/call-service/src/services/CallService.ts index ebeabf1..e5040c6 100644 --- a/packages/call-service/src/services/CallService.ts +++ b/packages/call-service/src/services/CallService.ts @@ -5,70 +5,94 @@ import { CallServiceContract, EventPayload, } from '../domain/call'; -import { mapCallRow, mapCallEventRow } from '../db/mappers'; import { publishStatusUpdate } from '../bus/publisher'; import { v4 as uuidv4 } from 'uuid'; -import type { CallInitiatedPayload, CallRoutedPayload, CallAnsweredPayload, CallHoldPayload, CallEndedPayload } from '@voycelink/contracts'; -import { db } from '../db/client'; +import type { + CallInitiatedPayload, + CallRoutedPayload, + CallAnsweredPayload, + CallHoldPayload, + CallEndedPayload, +} from '@voycelink/contracts'; +import { + VALID_QUEUES, + CALL_STATUS_WAITING, + CALL_STATUS_ACTIVE, + CALL_STATUS_ON_HOLD, + SLA_WAIT_TIME_THRESHOLD, + MAX_HOLD_TIME_THRESHOLD, + SHORT_CALL_THRESHOLD, + EVENT_CALL_INITIATED, + EVENT_CALL_ROUTED, + EVENT_CALL_ANSWERED, + EVENT_CALL_HOLD, + EVENT_CALL_ENDED, +} from '../constants'; +import { + CallAlreadyExistsError, + CallNotFoundError, + InvalidQueueIdError, + UnsupportedEventTypeError, +} from '../errors'; +import { CallRepository } from '../db/callRepository'; export class CallService implements CallServiceContract { + private callRepository: CallRepository; + + constructor(callRepository: CallRepository = new CallRepository()) { + this.callRepository = callRepository; + } + async processEvent(payload: EventPayload): Promise { switch (payload.event) { - case 'call_initiated': + case EVENT_CALL_INITIATED: return this.handleCallInitiated(payload); - case 'call_routed': + case EVENT_CALL_ROUTED: return this.handleCallRouted(payload); - case 'call_answered': + case EVENT_CALL_ANSWERED: return this.handleCallAnswered(payload); - case 'call_hold': + case EVENT_CALL_HOLD: return this.handleCallHold(payload); - case 'call_ended': + case EVENT_CALL_ENDED: return this.handleCallEnded(payload); default: - throw new Error(`Unsupported event type: ${payload.event}`); + throw new UnsupportedEventTypeError(payload.event); } } private async handleCallInitiated(payload: CallInitiatedPayload): Promise { - // Validate queueId exists (it's already typed as QueueId, but we can double-check) - const validQueues = ['medical_spanish', 'medical_english', 'legal_spanish', 'legal_english']; - if (!validQueues.includes(payload.queueId)) { - throw new Error(`Invalid queueId: ${payload.queueId}`); + // Validate queueId exists + if (!VALID_QUEUES.includes(payload.queueId as any)) { + throw new InvalidQueueIdError(payload.queueId); } // Check if call already exists - const existingCallResult = await db.query('SELECT id FROM calls WHERE id = $1', [payload.callId]); - if (existingCallResult.rowCount > 0) { - throw new Error(`Call with id ${payload.callId} already exists`); + const existingCall = await this.callRepository.getCallById(payload.callId); + if (existingCall) { + throw new CallAlreadyExistsError(payload.callId); } // Create call record - await db.query( - 'INSERT INTO calls (id, type, status, queue_id, start_time) VALUES ($1, $2, $3, $4, $5)', - [payload.callId, payload.type, 'waiting', payload.queueId, new Date()] - ); + await this.callRepository.createCall(payload.callId, payload.type, payload.queueId); // Create call event const eventId = uuidv4(); const event = new CallEvent( - eventId, - payload.callId, - 'call_initiated', - new Date(), - {} + eventId, + payload.callId, + EVENT_CALL_INITIATED, + new Date(), + {} ); // Store event in database - await db.query( - 'INSERT INTO call_events (id, call_id, type, timestamp, metadata) VALUES ($1, $2, $3, $4, $5)', - [eventId, payload.callId, 'call_initiated', new Date(), JSON.stringify({})] - ); + await this.callRepository.createCallEvent(eventId, payload.callId, EVENT_CALL_INITIATED, {}); // Publish status update await publishStatusUpdate({ callId: payload.callId, - status: 'waiting', - eventType: 'call_initiated', + status: CALL_STATUS_WAITING, + eventType: EVENT_CALL_INITIATED, timestamp: new Date().toISOString(), metadata: {} }); @@ -78,40 +102,32 @@ export class CallService implements CallServiceContract { private async handleCallRouted(payload: CallRoutedPayload): Promise { // Get the call to verify it exists and get current status - const callResult = await db.query('SELECT id, status FROM calls WHERE id = $1', [payload.callId]); - if (callResult.rowCount === 0) { - throw new Error(`Call with id ${payload.callId} not found`); + const call = await this.callRepository.getCallById(payload.callId); + if (!call) { + throw new CallNotFoundError(payload.callId); } - const call = callResult.rows[0]; - // Update call status to active (routed means it's waiting for agent to answer) - await db.query( - 'UPDATE calls SET status = $1 WHERE id = $2', - ['active', payload.callId] - ); + await this.callRepository.updateCallStatus(payload.callId, CALL_STATUS_ACTIVE); // Create call event const eventId = uuidv4(); const event = new CallEvent( - eventId, - payload.callId, - 'call_routed', - new Date(), - { agentId: payload.agentId, routingTime: payload.routingTime } + eventId, + payload.callId, + EVENT_CALL_ROUTED, + new Date(), + { agentId: payload.agentId, routingTime: payload.routingTime } ); // Store event in database - await db.query( - 'INSERT INTO call_events (id, call_id, type, timestamp, metadata) VALUES ($1, $2, $3, $4, $5)', - [eventId, payload.callId, 'call_routed', new Date(), JSON.stringify({ agentId: payload.agentId, routingTime: payload.routingTime })] - ); + await this.callRepository.createCallEvent(eventId, payload.callId, EVENT_CALL_ROUTED, { agentId: payload.agentId, routingTime: payload.routingTime }); // Publish status update await publishStatusUpdate({ callId: payload.callId, - status: 'active', - eventType: 'call_routed', + status: CALL_STATUS_ACTIVE, + eventType: EVENT_CALL_ROUTED, timestamp: new Date().toISOString(), metadata: { agentId: payload.agentId, routingTime: payload.routingTime } }); @@ -121,18 +137,13 @@ export class CallService implements CallServiceContract { private async handleCallAnswered(payload: CallAnsweredPayload): Promise { // Get the call to verify it exists and get current status - const callResult = await db.query('SELECT id, status, startTime FROM calls WHERE id = $1', [payload.callId]); - if (callResult.rowCount === 0) { - throw new Error(`Call with id ${payload.callId} not found`); + const call = await this.callRepository.getCallById(payload.callId); + if (!call) { + throw new CallNotFoundError(payload.callId); } - const call = callResult.rows[0]; - // Update call status to active (answered means it's now connected) - await db.query( - 'UPDATE calls SET status = $1 WHERE id = $2', - ['active', payload.callId] - ); + await this.callRepository.updateCallStatus(payload.callId, CALL_STATUS_ACTIVE); // Calculate wait time const waitTime = payload.waitTime; // This comes from the payload @@ -140,30 +151,27 @@ export class CallService implements CallServiceContract { // Create call event const eventId = uuidv4(); const event = new CallEvent( - eventId, - payload.callId, - 'call_answered', - new Date(), - { waitTime } + eventId, + payload.callId, + EVENT_CALL_ANSWERED, + new Date(), + { waitTime } ); // Store event in database - await db.query( - 'INSERT INTO call_events (id, call_id, type, timestamp, metadata) VALUES ($1, $2, $3, $4, $5)', - [eventId, payload.callId, 'call_answered', new Date(), JSON.stringify({ waitTime })] - ); + await this.callRepository.createCallEvent(eventId, payload.callId, EVENT_CALL_ANSWERED, { waitTime }); // Publish status update await publishStatusUpdate({ callId: payload.callId, - status: 'active', - eventType: 'call_answered', + 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 > 30) { + if (waitTime > SLA_WAIT_TIME_THRESHOLD) { // In a real system, we might send a notification or alert here console.warn(`SLA breach: Call ${payload.callId} waited ${waitTime} seconds before being answered`); } @@ -173,46 +181,38 @@ export class CallService implements CallServiceContract { private async handleCallHold(payload: CallHoldPayload): Promise { // Get the call to verify it exists and get current status - const callResult = await db.query('SELECT id, status, holdStartTime FROM calls WHERE id = $1', [payload.callId]); - if (callResult.rowCount === 0) { - throw new Error(`Call with id ${payload.callId} not found`); + const call = await this.callRepository.getCallById(payload.callId); + if (!call) { + throw new CallNotFoundError(payload.callId); } - const call = callResult.rows[0]; - // Update call status to on_hold - await db.query( - 'UPDATE calls SET status = $1, holdStartTime = $2 WHERE id = $3', - ['on_hold', new Date(), payload.callId] - ); + await this.callRepository.updateCallStatus(payload.callId, CALL_STATUS_ON_HOLD, { holdStartTime: new Date() }); // Create call event const eventId = uuidv4(); const event = new CallEvent( - eventId, - payload.callId, - 'call_hold', - new Date(), - { holdDuration: payload.holdDuration } + eventId, + payload.callId, + EVENT_CALL_HOLD, + new Date(), + { holdDuration: payload.holdDuration } ); // Store event in database - await db.query( - 'INSERT INTO call_events (id, call_id, type, timestamp, metadata) VALUES ($1, $2, $3, $4, $5)', - [eventId, payload.callId, 'call_hold', new Date(), JSON.stringify({ holdDuration: payload.holdDuration })] - ); + await this.callRepository.createCallEvent(eventId, payload.callId, EVENT_CALL_HOLD, { holdDuration: payload.holdDuration }); // Publish status update await publishStatusUpdate({ callId: payload.callId, - status: 'on_hold', - eventType: 'call_hold', + status: CALL_STATUS_ON_HOLD, + eventType: EVENT_CALL_HOLD, timestamp: new Date().toISOString(), metadata: { holdDuration: payload.holdDuration } }); // Check if hold time exceeds max (60 seconds) and flag if needed - if (payload.holdDuration > 60) { + if (payload.holdDuration > MAX_HOLD_TIME_THRESHOLD) { // In a real system, we might send a notification or alert here console.warn(`Hold time exceeded: Call ${payload.callId} has been on hold for ${payload.holdDuration} seconds`); } @@ -220,44 +220,52 @@ export class CallService implements CallServiceContract { return event; } - private async handleCallEnded(payload: CallEndedPayload): Promise + private async handleCallEnded(payload: CallEndedPayload): Promise { + // Get the call to verify it exists and get current status + const call = await this.callRepository.getCallById(payload.callId); + if (!call) { + throw new CallNotFoundError(payload.callId); + } - async getCalls(filters: CallFilters): Promise { - let query = 'SELECT id, type, status, queue_id, start_time, end_time FROM calls'; - const params: any[] = []; - const whereConditions: string[] = []; + // Update call status to ended + await this.callRepository.updateCallStatus(payload.callId, 'ended', { endTime: new Date() }); - if (filters.status && filters.status !== 'all') { - whereConditions.push(`status = $${params.length + 1}`); - params.push(filters.status); - } + // Create call event + const eventId = uuidv4(); + const event = new CallEvent( + eventId, + payload.callId, + EVENT_CALL_ENDED, + new Date(), + { endReason: payload.endReason, duration: payload.duration } + ); - if (filters.queueId) { - whereConditions.push(`queue_id = $${params.length + 1}`); - params.push(filters.queueId); - } + // Store event in database + await this.callRepository.createCallEvent(eventId, payload.callId, EVENT_CALL_ENDED, { endReason: payload.endReason, duration: payload.duration }); - if (whereConditions.length > 0) { - query += ' WHERE ' + whereConditions.join(' AND '); + // Publish status update + await publishStatusUpdate({ + callId: payload.callId, + status: 'ended', + eventType: EVENT_CALL_ENDED, + timestamp: new Date().toISOString(), + metadata: { endReason: payload.endReason, duration: payload.duration } + }); + + // Check if call duration is under 10 seconds and flag if needed + if (payload.duration < SHORT_CALL_THRESHOLD) { + // In a real system, we might send a notification or alert here + console.warn(`Short call: Call ${payload.callId} ended after only ${payload.duration} seconds`); } - query += ' ORDER BY start_time DESC'; + return event; + } - const result = await db.query(query, params); - return result.rows.map(mapCallRow); + async getCalls(filters: CallFilters): Promise { + return this.callRepository.getCalls(filters); } async getCallEvents(callId: string): Promise { - // First verify the call exists - const callResult = await db.query('SELECT id FROM 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 call_events WHERE call_id = $1 ORDER BY timestamp ASC', - [callId] - ); - return result.rows.map(mapCallEventRow); + return this.callRepository.getCallEvents(callId); } } From cbd6a43660c949d01cd73145e9ed91bf9f01f7b9 Mon Sep 17 00:00:00 2001 From: Franco Arratia Date: Tue, 5 May 2026 02:18:37 -0400 Subject: [PATCH 06/10] Applied improvements following OpenClose principle --- .../call-service/src/services/CallService.ts | 256 +----------------- .../src/services/HandlerRegistry.ts | 59 ++++ .../services/handlers/CallAnsweredHandler.ts | 71 +++++ .../src/services/handlers/CallEndedHandler.ts | 71 +++++ .../src/services/handlers/CallEventHandler.ts | 19 ++ .../src/services/handlers/CallHoldHandler.ts | 72 +++++ .../services/handlers/CallInitiatedHandler.ts | 74 +++++ .../services/handlers/CallRoutedHandler.ts | 61 +++++ .../src/services/handlers/index.ts | 26 ++ packages/call-service/src/services/index.ts | 4 + 10 files changed, 469 insertions(+), 244 deletions(-) create mode 100644 packages/call-service/src/services/HandlerRegistry.ts create mode 100644 packages/call-service/src/services/handlers/CallAnsweredHandler.ts create mode 100644 packages/call-service/src/services/handlers/CallEndedHandler.ts create mode 100644 packages/call-service/src/services/handlers/CallEventHandler.ts create mode 100644 packages/call-service/src/services/handlers/CallHoldHandler.ts create mode 100644 packages/call-service/src/services/handlers/CallInitiatedHandler.ts create mode 100644 packages/call-service/src/services/handlers/CallRoutedHandler.ts create mode 100644 packages/call-service/src/services/handlers/index.ts diff --git a/packages/call-service/src/services/CallService.ts b/packages/call-service/src/services/CallService.ts index e5040c6..e8986b8 100644 --- a/packages/call-service/src/services/CallService.ts +++ b/packages/call-service/src/services/CallService.ts @@ -5,260 +5,28 @@ import { CallServiceContract, EventPayload, } from '../domain/call'; -import { publishStatusUpdate } from '../bus/publisher'; -import { v4 as uuidv4 } from 'uuid'; -import type { - CallInitiatedPayload, - CallRoutedPayload, - CallAnsweredPayload, - CallHoldPayload, - CallEndedPayload, -} from '@voycelink/contracts'; -import { - VALID_QUEUES, - CALL_STATUS_WAITING, - CALL_STATUS_ACTIVE, - CALL_STATUS_ON_HOLD, - SLA_WAIT_TIME_THRESHOLD, - MAX_HOLD_TIME_THRESHOLD, - SHORT_CALL_THRESHOLD, - EVENT_CALL_INITIATED, - EVENT_CALL_ROUTED, - EVENT_CALL_ANSWERED, - EVENT_CALL_HOLD, - EVENT_CALL_ENDED, -} from '../constants'; -import { - CallAlreadyExistsError, - CallNotFoundError, - InvalidQueueIdError, - UnsupportedEventTypeError, -} from '../errors'; import { CallRepository } from '../db/callRepository'; +import { CallEventHandler } from './handlers/CallEventHandler'; +import { handlerRegistry } from './HandlerRegistry'; export class CallService implements CallServiceContract { private callRepository: CallRepository; + private handlers: CallEventHandler[]; - constructor(callRepository: CallRepository = new CallRepository()) { + constructor( + callRepository: CallRepository = new CallRepository() + ) { this.callRepository = callRepository; + // Initialize handlers from registry + this.handlers = handlerRegistry.getHandlers(callRepository); } async processEvent(payload: EventPayload): Promise { - switch (payload.event) { - case EVENT_CALL_INITIATED: - return this.handleCallInitiated(payload); - case EVENT_CALL_ROUTED: - return this.handleCallRouted(payload); - case EVENT_CALL_ANSWERED: - return this.handleCallAnswered(payload); - case EVENT_CALL_HOLD: - return this.handleCallHold(payload); - case EVENT_CALL_ENDED: - return this.handleCallEnded(payload); - default: - throw new UnsupportedEventTypeError(payload.event); - } - } - - private async handleCallInitiated(payload: CallInitiatedPayload): Promise { - // Validate queueId exists - if (!VALID_QUEUES.includes(payload.queueId as any)) { - throw new InvalidQueueIdError(payload.queueId); - } - - // Check if call already exists - const existingCall = await this.callRepository.getCallById(payload.callId); - if (existingCall) { - throw new CallAlreadyExistsError(payload.callId); - } - - // Create call record - await this.callRepository.createCall(payload.callId, payload.type, payload.queueId); - - // Create call event - const eventId = uuidv4(); - const event = new CallEvent( - eventId, - payload.callId, - EVENT_CALL_INITIATED, - new Date(), - {} - ); - - // Store event in database - await this.callRepository.createCallEvent(eventId, payload.callId, EVENT_CALL_INITIATED, {}); - - // Publish status update - await publishStatusUpdate({ - callId: payload.callId, - status: CALL_STATUS_WAITING, - eventType: EVENT_CALL_INITIATED, - timestamp: new Date().toISOString(), - metadata: {} - }); - - return event; - } - - private async handleCallRouted(payload: CallRoutedPayload): Promise { - // Get the call to verify it exists and get current status - const call = await this.callRepository.getCallById(payload.callId); - if (!call) { - throw new CallNotFoundError(payload.callId); + const handler = this.handlers.find(h => h.canHandle(payload.event)); + if (!handler) { + throw new Error(`Unsupported event type: ${payload.event}`); } - - // Update call status to active (routed means it's waiting for agent to answer) - await this.callRepository.updateCallStatus(payload.callId, CALL_STATUS_ACTIVE); - - // Create call event - const eventId = uuidv4(); - const event = new CallEvent( - eventId, - payload.callId, - EVENT_CALL_ROUTED, - new Date(), - { agentId: payload.agentId, routingTime: payload.routingTime } - ); - - // Store event in database - await this.callRepository.createCallEvent(eventId, payload.callId, EVENT_CALL_ROUTED, { agentId: payload.agentId, routingTime: payload.routingTime }); - - // Publish status update - await publishStatusUpdate({ - callId: payload.callId, - status: CALL_STATUS_ACTIVE, - eventType: EVENT_CALL_ROUTED, - timestamp: new Date().toISOString(), - metadata: { agentId: payload.agentId, routingTime: payload.routingTime } - }); - - return event; - } - - private async handleCallAnswered(payload: CallAnsweredPayload): Promise { - // Get the call to verify it exists and get current status - const call = await this.callRepository.getCallById(payload.callId); - if (!call) { - throw new CallNotFoundError(payload.callId); - } - - // Update call status to active (answered means it's now connected) - await this.callRepository.updateCallStatus(payload.callId, CALL_STATUS_ACTIVE); - - // Calculate wait time - const waitTime = payload.waitTime; // This comes from the payload - - // Create call event - const eventId = uuidv4(); - const event = new CallEvent( - eventId, - payload.callId, - EVENT_CALL_ANSWERED, - new Date(), - { waitTime } - ); - - // Store event in database - await this.callRepository.createCallEvent(eventId, payload.callId, EVENT_CALL_ANSWERED, { waitTime }); - - // Publish status update - await publishStatusUpdate({ - callId: payload.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 ${payload.callId} waited ${waitTime} seconds before being answered`); - } - - return event; - } - - private async handleCallHold(payload: CallHoldPayload): Promise { - // Get the call to verify it exists and get current status - const call = await this.callRepository.getCallById(payload.callId); - if (!call) { - throw new CallNotFoundError(payload.callId); - } - - // Update call status to on_hold - await this.callRepository.updateCallStatus(payload.callId, CALL_STATUS_ON_HOLD, { holdStartTime: new Date() }); - - // Create call event - const eventId = uuidv4(); - const event = new CallEvent( - eventId, - payload.callId, - EVENT_CALL_HOLD, - new Date(), - { holdDuration: payload.holdDuration } - ); - - // Store event in database - await this.callRepository.createCallEvent(eventId, payload.callId, EVENT_CALL_HOLD, { holdDuration: payload.holdDuration }); - - // Publish status update - await publishStatusUpdate({ - callId: payload.callId, - status: CALL_STATUS_ON_HOLD, - eventType: EVENT_CALL_HOLD, - timestamp: new Date().toISOString(), - metadata: { holdDuration: payload.holdDuration } - }); - - // Check if hold time exceeds max (60 seconds) and flag if needed - if (payload.holdDuration > MAX_HOLD_TIME_THRESHOLD) { - // In a real system, we might send a notification or alert here - console.warn(`Hold time exceeded: Call ${payload.callId} has been on hold for ${payload.holdDuration} seconds`); - } - - return event; - } - - private async handleCallEnded(payload: CallEndedPayload): Promise { - // Get the call to verify it exists and get current status - const call = await this.callRepository.getCallById(payload.callId); - if (!call) { - throw new CallNotFoundError(payload.callId); - } - - // Update call status to ended - await this.callRepository.updateCallStatus(payload.callId, 'ended', { endTime: new Date() }); - - // Create call event - const eventId = uuidv4(); - const event = new CallEvent( - eventId, - payload.callId, - EVENT_CALL_ENDED, - new Date(), - { endReason: payload.endReason, duration: payload.duration } - ); - - // Store event in database - await this.callRepository.createCallEvent(eventId, payload.callId, EVENT_CALL_ENDED, { endReason: payload.endReason, duration: payload.duration }); - - // Publish status update - await publishStatusUpdate({ - callId: payload.callId, - status: 'ended', - eventType: EVENT_CALL_ENDED, - timestamp: new Date().toISOString(), - metadata: { endReason: payload.endReason, duration: payload.duration } - }); - - // Check if call duration is under 10 seconds and flag if needed - if (payload.duration < SHORT_CALL_THRESHOLD) { - // In a real system, we might send a notification or alert here - console.warn(`Short call: Call ${payload.callId} ended after only ${payload.duration} seconds`); - } - - return event; + return handler.handle(payload); } async getCalls(filters: CallFilters): Promise { 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..e7fede6 --- /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..087b802 --- /dev/null +++ b/packages/call-service/src/services/handlers/CallEndedHandler.ts @@ -0,0 +1,71 @@ +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 { + 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: '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..891060d --- /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..e18c8c5 --- /dev/null +++ b/packages/call-service/src/services/handlers/CallHoldHandler.ts @@ -0,0 +1,72 @@ +import { CallEventHandler } from './CallEventHandler'; +import { CallHoldPayload } 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..4bcb9fb --- /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..e103ad5 --- /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(); From d817575b59f43d50b11f60372c462eaf44dcc286 Mon Sep 17 00:00:00 2001 From: Franco Arratia Date: Tue, 5 May 2026 02:36:13 -0400 Subject: [PATCH 07/10] Fixed import issues, all relative paths corrected --- .../src/services/handlers/CallAnsweredHandler.ts | 10 +++++----- .../src/services/handlers/CallEndedHandler.ts | 10 +++++----- .../src/services/handlers/CallEventHandler.ts | 6 +++--- .../src/services/handlers/CallHoldHandler.ts | 12 ++++++------ .../src/services/handlers/CallInitiatedHandler.ts | 10 +++++----- .../src/services/handlers/CallRoutedHandler.ts | 10 +++++----- 6 files changed, 29 insertions(+), 29 deletions(-) diff --git a/packages/call-service/src/services/handlers/CallAnsweredHandler.ts b/packages/call-service/src/services/handlers/CallAnsweredHandler.ts index e7fede6..80429dd 100644 --- a/packages/call-service/src/services/handlers/CallAnsweredHandler.ts +++ b/packages/call-service/src/services/handlers/CallAnsweredHandler.ts @@ -1,15 +1,15 @@ import { CallEventHandler } from './CallEventHandler'; import {CallAnsweredPayload, EventPayload} from '@voycelink/contracts'; -import { CallEvent } from '../../../domain/call'; -import { CallRepository } from '../../../db/callRepository'; +import { CallEvent } from '../../domain/call'; +import { CallRepository } from '../../db/callRepository'; import { v4 as uuidv4 } from 'uuid'; -import { publishStatusUpdate } from '../../../bus/publisher'; +import { publishStatusUpdate } from '../../bus/publisher'; import { CALL_STATUS_ACTIVE, EVENT_CALL_ANSWERED, SLA_WAIT_TIME_THRESHOLD, -} from '../../../constants'; -import { CallNotFoundError } from '../../../errors'; +} from '../../constants'; +import { CallNotFoundError } from '../../errors'; export class CallAnsweredHandler implements CallEventHandler { constructor(private callRepository: CallRepository) {} diff --git a/packages/call-service/src/services/handlers/CallEndedHandler.ts b/packages/call-service/src/services/handlers/CallEndedHandler.ts index 087b802..9257432 100644 --- a/packages/call-service/src/services/handlers/CallEndedHandler.ts +++ b/packages/call-service/src/services/handlers/CallEndedHandler.ts @@ -1,14 +1,14 @@ import { CallEventHandler } from './CallEventHandler'; import {CallEndedPayload, EventPayload} from '@voycelink/contracts'; -import { CallEvent } from '../../../domain/call'; -import { CallRepository } from '../../../db/callRepository'; +import { CallEvent } from '../../domain/call'; +import { CallRepository } from '../../db/callRepository'; import { v4 as uuidv4 } from 'uuid'; -import { publishStatusUpdate } from '../../../bus/publisher'; +import { publishStatusUpdate } from '../../bus/publisher'; import { EVENT_CALL_ENDED, SHORT_CALL_THRESHOLD, -} from '../../../constants'; -import { CallNotFoundError } from '../../../errors'; +} from '../../constants'; +import { CallNotFoundError } from '../../errors'; export class CallEndedHandler implements CallEventHandler { constructor(private callRepository: CallRepository) {} diff --git a/packages/call-service/src/services/handlers/CallEventHandler.ts b/packages/call-service/src/services/handlers/CallEventHandler.ts index 891060d..d6600ba 100644 --- a/packages/call-service/src/services/handlers/CallEventHandler.ts +++ b/packages/call-service/src/services/handlers/CallEventHandler.ts @@ -1,6 +1,6 @@ -import { EventPayload } from '../../../domain/call'; -import { CallEvent } from '../../../domain/call'; -import { CallRepository } from '../../../db/callRepository'; +import { EventPayload } from '../../domain/call'; +import { CallEvent } from '../../domain/call'; +import { CallRepository } from '../../db/callRepository'; export interface CallEventHandler { canHandle(eventType: string): boolean; diff --git a/packages/call-service/src/services/handlers/CallHoldHandler.ts b/packages/call-service/src/services/handlers/CallHoldHandler.ts index e18c8c5..005a4e6 100644 --- a/packages/call-service/src/services/handlers/CallHoldHandler.ts +++ b/packages/call-service/src/services/handlers/CallHoldHandler.ts @@ -1,15 +1,15 @@ import { CallEventHandler } from './CallEventHandler'; -import { CallHoldPayload } from '@voycelink/contracts'; -import { CallEvent } from '../../../domain/call'; -import { CallRepository } from '../../../db/callRepository'; +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 { publishStatusUpdate } from '../../bus/publisher'; import { CALL_STATUS_ON_HOLD, EVENT_CALL_HOLD, MAX_HOLD_TIME_THRESHOLD, -} from '../../../constants'; -import { CallNotFoundError } from '../../../errors'; +} from '../../constants'; +import { CallNotFoundError } from '../../errors'; export class CallHoldHandler implements CallEventHandler { constructor(private callRepository: CallRepository) {} diff --git a/packages/call-service/src/services/handlers/CallInitiatedHandler.ts b/packages/call-service/src/services/handlers/CallInitiatedHandler.ts index 4bcb9fb..633ff8f 100644 --- a/packages/call-service/src/services/handlers/CallInitiatedHandler.ts +++ b/packages/call-service/src/services/handlers/CallInitiatedHandler.ts @@ -1,18 +1,18 @@ import { CallEventHandler } from './CallEventHandler'; import {CallInitiatedPayload, EventPayload} from '@voycelink/contracts'; -import { CallEvent } from '../../../domain/call'; -import { CallRepository } from '../../../db/callRepository'; +import { CallEvent } from '../../domain/call'; +import { CallRepository } from '../../db/callRepository'; import { v4 as uuidv4 } from 'uuid'; -import { publishStatusUpdate } from '../../../bus/publisher'; +import { publishStatusUpdate } from '../../bus/publisher'; import { CALL_STATUS_WAITING, EVENT_CALL_INITIATED, VALID_QUEUES, -} from '../../../constants'; +} from '../../constants'; import { CallAlreadyExistsError, InvalidQueueIdError, -} from '../../../errors'; +} from '../../errors'; export class CallInitiatedHandler implements CallEventHandler { constructor(private callRepository: CallRepository) {} diff --git a/packages/call-service/src/services/handlers/CallRoutedHandler.ts b/packages/call-service/src/services/handlers/CallRoutedHandler.ts index e103ad5..7f2b604 100644 --- a/packages/call-service/src/services/handlers/CallRoutedHandler.ts +++ b/packages/call-service/src/services/handlers/CallRoutedHandler.ts @@ -1,14 +1,14 @@ import { CallEventHandler } from './CallEventHandler'; import {CallRoutedPayload, EventPayload} from '@voycelink/contracts'; -import { CallEvent } from '../../../domain/call'; -import { CallRepository } from '../../../db/callRepository'; +import { CallEvent } from '../../domain/call'; +import { CallRepository } from '../../db/callRepository'; import { v4 as uuidv4 } from 'uuid'; -import { publishStatusUpdate } from '../../../bus/publisher'; +import { publishStatusUpdate } from '../../bus/publisher'; import { CALL_STATUS_ACTIVE, EVENT_CALL_ROUTED, -} from '../../../constants'; -import { CallNotFoundError } from '../../../errors'; +} from '../../constants'; +import { CallNotFoundError } from '../../errors'; export class CallRoutedHandler implements CallEventHandler { constructor(private callRepository: CallRepository) {} From 3cf367807b2a0d72c7031327a80843706311e764 Mon Sep 17 00:00:00 2001 From: Franco Arratia Date: Tue, 5 May 2026 09:44:30 -0400 Subject: [PATCH 08/10] Fixed issues related to codesmells (magic strings, etc) --- packages/call-service/src/errors.ts | 15 +++++++++++---- .../src/services/handlers/CallEndedHandler.ts | 3 ++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/call-service/src/errors.ts b/packages/call-service/src/errors.ts index 95caf02..d6efbf1 100644 --- a/packages/call-service/src/errors.ts +++ b/packages/call-service/src/errors.ts @@ -1,4 +1,11 @@ // 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); @@ -8,28 +15,28 @@ export class CallServiceError extends Error { export class CallAlreadyExistsError extends CallServiceError { constructor(callId: string) { - super(`Call with id ${callId} already exists`, 'CALL_ALREADY_EXISTS'); + super(ERROR_CALL_ALREADY_EXISTS.replace('{callId}', callId), 'CALL_ALREADY_EXISTS'); this.name = 'CallAlreadyExistsError'; } } export class CallNotFoundError extends CallServiceError { constructor(callId: string) { - super(`Call with id ${callId} not found`, 'CALL_NOT_FOUND'); + super(ERROR_CALL_NOT_FOUND.replace('{callId}', callId), 'CALL_NOT_FOUND'); this.name = 'CallNotFoundError'; } } export class InvalidQueueIdError extends CallServiceError { constructor(queueId: string) { - super(`Invalid queueId: ${queueId}`, 'INVALID_QUEUE_ID'); + super(ERROR_INVALID_QUEUE_ID.replace('{queueId}', queueId), 'INVALID_QUEUE_ID'); this.name = 'InvalidQueueIdError'; } } export class UnsupportedEventTypeError extends CallServiceError { constructor(eventType: string) { - super(`Unsupported event type: ${eventType}`, 'UNSUPPORTED_EVENT_TYPE'); + 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/services/handlers/CallEndedHandler.ts b/packages/call-service/src/services/handlers/CallEndedHandler.ts index 9257432..3897c0c 100644 --- a/packages/call-service/src/services/handlers/CallEndedHandler.ts +++ b/packages/call-service/src/services/handlers/CallEndedHandler.ts @@ -5,6 +5,7 @@ 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'; @@ -54,7 +55,7 @@ export class CallEndedHandler implements CallEventHandler { // Publish status update await publishStatusUpdate({ callId: endedPayload.callId, - status: 'ended', + status: CALL_STATUS_ENDED, eventType: EVENT_CALL_ENDED, timestamp: new Date().toISOString(), metadata: { endReason: endedPayload.endReason, duration: endedPayload.duration } From 8eecc7915cd9ae6f6a71b651e07419218cea39ce Mon Sep 17 00:00:00 2001 From: Franco Arratia Date: Tue, 5 May 2026 10:15:58 -0400 Subject: [PATCH 09/10] Added new tests for CallService --- .../src/services/CallService.test.ts | 272 +++++++++++++----- 1 file changed, 196 insertions(+), 76 deletions(-) diff --git a/packages/call-service/src/services/CallService.test.ts b/packages/call-service/src/services/CallService.test.ts index a86e9e6..1f00f6b 100644 --- a/packages/call-service/src/services/CallService.test.ts +++ b/packages/call-service/src/services/CallService.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { CallService } from "./CallService"; +import { CallService } from "./index"; import { CallEvent } from "../domain/call"; import { CallAnsweredPayload, CallEndedPayload, CallHoldPayload, CallInitiatedPayload } from "@voycelink/contracts"; import { db } from '../db/client'; +import { CallRepository } from '../db/callRepository'; // Mock the database module vi.mock('../db/client', () => ({ @@ -16,15 +17,37 @@ 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', () => { let callService: CallService; + let mockCallRepository: any; beforeEach(() => { - callService = new CallService(); + 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(); }); - it.todo('processes call_initiated and persists the call', async () => { + describe('processEvent', () => { + it('processes call_initiated and persists the call', async () => { const payload: CallInitiatedPayload = { event: 'call_initiated', callId: 'test-call-id', @@ -32,10 +55,10 @@ describe('CallService', () => { queueId: 'medical_spanish' }; - // Mock database responses - (db.query as any).mockResolvedValueOnce({ rowCount: 0 }); // No existing call - (db.query as any).mockResolvedValueOnce({}); // Insert call - (db.query as any).mockResolvedValueOnce({}); // Insert event + // 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); @@ -43,97 +66,194 @@ describe('CallService', () => { expect(result).toHaveProperty('id'); expect(result.callId).toBe('test-call-id'); expect(result.type).toBe('call_initiated'); - expect(db.query).toHaveBeenCalledTimes(3); + expect(mockCallRepository.getCallById).toHaveBeenCalledTimes(1); + expect(mockCallRepository.createCall).toHaveBeenCalledTimes(1); + expect(mockCallRepository.createCallEvent).toHaveBeenCalledTimes(1); }); - it.todo('processes call_answered and updates call status to active', async () => { - const payload: CallAnsweredPayload = { - event: 'call_answered', - callId: 'test-call-id', - waitTime: 25 - }; + 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 + }; - // Mock database responses - (db.query as any).mockResolvedValueOnce({ rows: [{ id: 'test-call-id', status: 'waiting', startTime: new Date() }] }); // Get call - (db.query as any).mockResolvedValueOnce({}); // Update call - (db.query as any).mockResolvedValueOnce({}); // Insert event + await expect(callService.processEvent(payload)).rejects.toThrow('Invalid queueId'); + }); - const result = await callService.processEvent(payload); + 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' + }; - expect(result).toBeInstanceOf(CallEvent); - expect(result.callId).toBe('test-call-id'); - expect(result.type).toBe('call_answered'); - expect(result.metadata?.waitTime).toBe(25); - expect(db.query).toHaveBeenCalledTimes(3); - }); + // Mock repository responses - call already exists + mockCallRepository.getCallById.mockResolvedValueOnce({ id: 'test-call-id' }); // Existing call found - it.todo('flags call_answered when waitTime exceeds 30 seconds', async () => { - const payload: CallAnsweredPayload = { - event: 'call_answered', - callId: 'test-call-id', - waitTime: 35 - }; + await expect(callService.processEvent(payload)).rejects.toThrow('already exists'); + }); - // Mock database responses - (db.query as any).mockResolvedValueOnce({ rows: [{ id: 'test-call-id', status: 'waiting', startTime: new Date() }] }); // Get call - (db.query as any).mockResolvedValueOnce({}); // Update call - (db.query as any).mockResolvedValueOnce({}); // Insert event + it('processes call_answered and updates call status to active', async () => { + const payload: CallAnsweredPayload = { + event: 'call_answered', + callId: 'test-call-id', + waitTime: 25 + }; - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + // 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' }); - await callService.processEvent(payload); + const result = await callService.processEvent(payload); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('SLA breach: Call test-call-id waited 35 seconds before being answered') - ); + 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); + }); - consoleSpy.mockRestore(); - }); + it('flags call_answered when waitTime exceeds 30 seconds', async () => { + const payload: CallAnsweredPayload = { + event: 'call_answered', + callId: 'test-call-id', + waitTime: 35 + }; - it.todo('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: 'waiting', startTime: new Date() }; + mockCallRepository.getCallById.mockResolvedValueOnce(mockCall); + mockCallRepository.updateCallStatus.mockResolvedValueOnce(undefined); + mockCallRepository.createCallEvent.mockResolvedValueOnce({ id: 'event-id' }); - // Mock database responses - (db.query as any).mockResolvedValueOnce({ rows: [{ id: 'test-call-id', status: 'active', holdStartTime: null }] }); // Get call - (db.query as any).mockResolvedValueOnce({}); // Update call - (db.query as any).mockResolvedValueOnce({}); // Insert event + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + await callService.processEvent(payload); - await callService.processEvent(payload); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('SLA breach: Call test-call-id waited 35 seconds before being answered') + ); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('Hold time exceeded: Call test-call-id has been on hold for 75 seconds') - ); + consoleSpy.mockRestore(); + }); - 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 + }; - it.todo('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', 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(() => {}); - // Mock database responses - (db.query as any).mockResolvedValueOnce({ rows: [{ id: 'test-call-id', status: 'active', startTime: new Date() }] }); // Get call - (db.query as any).mockResolvedValueOnce({}); // Update call - (db.query as any).mockResolvedValueOnce({}); // Insert event + await callService.processEvent(payload); - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Short call: Call test-call-id ended after only 5 seconds') + ); - await callService.processEvent(payload); + consoleSpy.mockRestore(); + }); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('Short call: Call test-call-id ended after only 5 seconds') - ); + 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; - consoleSpy.mockRestore(); + await expect(callService.processEvent(payload)).rejects.toThrow('Unsupported event type: unsupported_event'); + }); }); }); From fdfc4687dd7739e5e5d83649b1ddce2540afe32d Mon Sep 17 00:00:00 2001 From: Franco Arratia Date: Tue, 5 May 2026 10:40:07 -0400 Subject: [PATCH 10/10] Added more unit tests to improve the coverage --- .../src/services/CallService.test.ts | 112 ++++++++++++++---- .../call-service/src/services/CallService.ts | 26 +++- 2 files changed, 114 insertions(+), 24 deletions(-) diff --git a/packages/call-service/src/services/CallService.test.ts b/packages/call-service/src/services/CallService.test.ts index 1f00f6b..95bccb4 100644 --- a/packages/call-service/src/services/CallService.test.ts +++ b/packages/call-service/src/services/CallService.test.ts @@ -1,9 +1,8 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { CallService } from "./index"; -import { CallEvent } from "../domain/call"; +import {Call, CallEvent} from "../domain/call"; import { CallAnsweredPayload, CallEndedPayload, CallHoldPayload, CallInitiatedPayload } from "@voycelink/contracts"; import { db } from '../db/client'; -import { CallRepository } from '../db/callRepository'; // Mock the database module vi.mock('../db/client', () => ({ @@ -47,29 +46,29 @@ describe('CallService', () => { }); 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' - }; + 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' }); + // 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); + 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); - }); + 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 = { @@ -256,4 +255,73 @@ describe('CallService', () => { 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 e8986b8..9e77dac 100644 --- a/packages/call-service/src/services/CallService.ts +++ b/packages/call-service/src/services/CallService.ts @@ -30,10 +30,32 @@ export class CallService implements CallServiceContract { } async getCalls(filters: CallFilters): Promise { - return this.callRepository.getCalls(filters); + 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 { - return this.callRepository.getCallEvents(callId); + // 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 + )); } }