Skip to content
29 changes: 29 additions & 0 deletions packages/call-service/src/constants.ts
Original file line number Diff line number Diff line change
@@ -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}';
94 changes: 94 additions & 0 deletions packages/call-service/src/db/callRepository.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<any> {
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<string, any> = {}): Promise<void> {
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<string, unknown> = {}): Promise<void> {
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<Call[]> {
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<any[]> {
// 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);
}
}
1 change: 1 addition & 0 deletions packages/call-service/src/domain/call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export class Call {
public readonly queueId: QueueId,
public readonly startTime: Date,
public endTime?: Date,
public holdStartTime?: Date,
) {}
}

Expand Down
42 changes: 42 additions & 0 deletions packages/call-service/src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Custom error classes for CallService
import {
ERROR_CALL_ALREADY_EXISTS,
ERROR_CALL_NOT_FOUND,
ERROR_INVALID_QUEUE_ID,
ERROR_UNSUPPORTED_EVENT_TYPE,
} from './constants';

export class CallServiceError extends Error {
constructor(message: string, public readonly code?: string) {
super(message);
this.name = 'CallServiceError';
}
}

export class CallAlreadyExistsError extends CallServiceError {
constructor(callId: string) {
super(ERROR_CALL_ALREADY_EXISTS.replace('{callId}', callId), 'CALL_ALREADY_EXISTS');
this.name = 'CallAlreadyExistsError';
}
}

export class CallNotFoundError extends CallServiceError {
constructor(callId: string) {
super(ERROR_CALL_NOT_FOUND.replace('{callId}', callId), 'CALL_NOT_FOUND');
this.name = 'CallNotFoundError';
}
}

export class InvalidQueueIdError extends CallServiceError {
constructor(queueId: string) {
super(ERROR_INVALID_QUEUE_ID.replace('{queueId}', queueId), 'INVALID_QUEUE_ID');
this.name = 'InvalidQueueIdError';
}
}

export class UnsupportedEventTypeError extends CallServiceError {
constructor(eventType: string) {
super(ERROR_UNSUPPORTED_EVENT_TYPE.replace('{eventType}', eventType), 'UNSUPPORTED_EVENT_TYPE');
this.name = 'UnsupportedEventTypeError';
}
}
10 changes: 7 additions & 3 deletions packages/call-service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
});
}
84 changes: 80 additions & 4 deletions packages/call-service/src/routes/events.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,83 @@
import { describe, it } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import request from 'supertest';
import type { CallInitiatedPayload } from '@voycelink/contracts';
import {db} from "../db/client";
import {createServer} from "../index";

// Mock the database module
vi.mock('../db/client', () => ({
db: {
query: vi.fn()
}
}));

// Mock the publisher
vi.mock('../bus/publisher', () => ({
publishStatusUpdate: vi.fn()
}));

describe('POST /api/events', () => {
it.todo('returns 201 and persists the event for a valid call_initiated payload');
it.todo('returns 400 for an invalid payload');
it.todo('returns 401 when the API key is missing');
let app: ReturnType<typeof createServer>;

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);
});
});
Loading