Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions packages/call-service/src/db/mappers.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import type { QueueId } from '@voycelink/contracts';
import { Call, CallEvent } from '../domain/call';
import type { QueueId } from "@voycelink/contracts";
import { Call, CallEvent } from "../domain/call";

export interface CallRow {
id: string;
type: Call['type'];
status: Call['status'];
queue_id: QueueId;
start_time: Date;
end_time: Date | null;
id: Call["id"];
type: Call["type"];
status: Call["status"];
queue_id: Call["queueId"];
start_time: Call["startTime"];
end_time: Call["endTime"] | null;
}

export interface CallEventRow {
id: string;
call_id: string;
type: string;
timestamp: Date;
metadata: Record<string, unknown> | null;
id: CallEvent["id"];
call_id: CallEvent["callId"];
type: CallEvent["type"];
timestamp: CallEvent["timestamp"];
metadata: CallEvent["metadata"] | null;
}

export function mapCallRow(row: CallRow): Call {
Expand Down
19 changes: 17 additions & 2 deletions packages/call-service/src/domain/call.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import type {
CallStatus,
CallStatusUpdate,
CallEventType,
CallType,
EventPayload,
QueueId,
} from '@voycelink/contracts';

export type { CallStatus, CallStatusUpdate, CallType, EventPayload, QueueId };
export type {
CallStatus,
CallStatusUpdate,
CallEventType,
CallType,
EventPayload,
QueueId,
};

export interface CallFilters {
status?: CallStatus;
Expand All @@ -28,12 +36,19 @@ export class CallEvent {
constructor(
public readonly id: string,
public readonly callId: string,
public readonly type: string,
public readonly type: CallEventType,
public readonly timestamp: Date,
public readonly metadata?: Record<string, unknown>,
) {}
}

export class CallNotFoundError extends Error {
constructor(callId: string) {
super(`Call not found: ${callId}`);
this.name = "CallNotFoundError";
}
}

export interface CallServiceContract {
processEvent(payload: EventPayload): Promise<CallEvent>;
getCalls(filters: CallFilters): Promise<Call[]>;
Expand Down
63 changes: 63 additions & 0 deletions packages/call-service/src/routes/events.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import "dotenv/config";
import express, { Express } from "express";
import request from "supertest";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { db } from "../db/client";

describe("POST /api/events (integration)", () => {
let app: Express;

beforeAll(async () => {
process.env.API_KEY = process.env.API_KEY ?? "change-me";
const { default: eventsRouter } = await import("./events");
app = express();
app.use(express.json());
app.use("/api/events", eventsRouter);
});

beforeEach(async () => {
await db.query("DELETE FROM call_events");
await db.query("DELETE FROM calls");
});

afterAll(async () => {
await db.end();
});

it("ingests call_initiated and persists event + call in database", async () => {
const callId = `integration-${Date.now()}`;
const response = await request(app)
.post("/api/events")
.set("X-API-Key", process.env.API_KEY as string)
.send({
event: "call_initiated",
callId,
type: "voice",
queueId: "medical_spanish",
});

expect(response.status).toBe(201);
expect(response.body.callId).toBe(callId);
expect(response.body.type).toBe("call_initiated");

const callResult = await db.query(
"SELECT id, status, queue_id FROM calls WHERE id = $1",
[callId],
);
expect(callResult.rowCount).toBe(1);
expect(callResult.rows[0]).toMatchObject({
id: callId,
status: "waiting",
queue_id: "medical_spanish",
});

const eventResult = await db.query(
"SELECT call_id, type, metadata FROM call_events WHERE call_id = $1 ORDER BY timestamp DESC LIMIT 1",
[callId],
);
expect(eventResult.rowCount).toBe(1);
expect(eventResult.rows[0].call_id).toBe(callId);
expect(eventResult.rows[0].type).toBe("call_initiated");
expect(eventResult.rows[0].metadata).toMatchObject({ slaSeconds: 30 });
});
});
99 changes: 95 additions & 4 deletions packages/call-service/src/routes/events.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,98 @@
import { describe, it } from 'vitest';
import express, { Express } from 'express';
import request from 'supertest';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { CallNotFoundError } from '../domain/call';

const processEventMock = vi.fn();

vi.mock('../services', () => ({
callService: {
processEvent: processEventMock,
},
}));

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: Express;

const validPayload = {
event: 'call_initiated',
callId: 'call-1',
type: 'voice',
queueId: 'medical_spanish',
} as const;

beforeAll(async () => {
process.env.API_KEY = 'change-me';
const { default: eventsRouter } = await import('./events');
app = express();
app.use(express.json());
app.use('/api/events', eventsRouter);
});

beforeEach(() => {
processEventMock.mockReset();
});

it('returns 201 and persists the event for a valid call_initiated payload', async () => {
const persistedEvent = {
id: 'event-1',
callId: 'call-1',
type: 'call_initiated',
timestamp: new Date('2026-01-01T00:00:00.000Z'),
metadata: { slaSeconds: 30 },
};
processEventMock.mockResolvedValueOnce(persistedEvent);

const response = await request(app)
.post('/api/events')
.set('X-API-Key', 'change-me')
.send(validPayload);

expect(response.status).toBe(201);
expect(response.body).toEqual({
...persistedEvent,
timestamp: persistedEvent.timestamp.toISOString(),
});
expect(processEventMock).toHaveBeenCalledOnce();
expect(processEventMock).toHaveBeenCalledWith(validPayload);
});

it('returns 400 for an invalid payload', async () => {
const response = await request(app)
.post('/api/events')
.set('X-API-Key', 'change-me')
.send({
event: 'call_initiated',
callId: 'call-1',
});

expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid event payload');
expect(processEventMock).not.toHaveBeenCalled();
});

it('returns 401 when the API key is missing', async () => {
const response = await request(app).post('/api/events').send(validPayload);

expect(response.status).toBe(401);
expect(response.body).toEqual({ message: 'Unauthorized' });
expect(processEventMock).not.toHaveBeenCalled();
});

it('returns 404 when the call does not exist', async () => {
processEventMock.mockRejectedValueOnce(new CallNotFoundError('missing-call'));

const response = await request(app)
.post('/api/events')
.set('X-API-Key', 'change-me')
.send({
event: 'call_ended',
callId: 'missing-call',
endReason: 'completed',
duration: 90,
});

expect(response.status).toBe(404);
expect(response.body).toEqual({ message: 'Call not found' });
});
});
47 changes: 37 additions & 10 deletions packages/call-service/src/routes/events.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,54 @@
import { Router, Request, Response } from 'express';
import { eventPayloadSchema } from '@voycelink/contracts';
import { ZodError } from 'zod';
import type { EventPayload } from '../domain/call';
import { callService } from '../services';
import { apiKeyAuth } from '../middleware/apiKey';
import { Router, Request, Response } from "express";
import { eventPayloadSchema } from "@voycelink/contracts";
import { ZodError } from "zod";
import { CallNotFoundError, type EventPayload } from "../domain/call";
import { callService } from "../services";
import { apiKeyAuth } from "../middleware/apiKey";

const router = Router();

router.post('/', apiKeyAuth, async (req: Request, res: Response) => {
function isValidationError(error: unknown): error is ZodError {
return (
error instanceof ZodError ||
(typeof error === "object" &&
error !== null &&
"name" in error &&
(error as { name?: string }).name === "ZodError" &&
"issues" in error)
);
}

function isCallNotFoundError(error: unknown): error is CallNotFoundError {
return (
error instanceof CallNotFoundError ||
(typeof error === "object" &&
error !== null &&
"name" in error &&
(error as { name?: string }).name === "CallNotFoundError")
);
}

router.post("/", apiKeyAuth, async (req: Request, res: Response) => {
try {
const payload: EventPayload = eventPayloadSchema.parse(req.body);
const event = await callService.processEvent(payload);
res.status(201).json(event);
} catch (error) {
if (error instanceof ZodError) {
if (isValidationError(error)) {
res.status(400).json({
message: 'Invalid event payload',
message: "Invalid event payload",
issues: error.issues,
});
return;
}
if (isCallNotFoundError(error)) {
res.status(404).json({
message: "Call not found",
});
return;
}

res.status(500).json({ message: 'Internal server error' });
res.status(500).json({ message: "Internal server error" });
}
});

Expand Down
56 changes: 48 additions & 8 deletions packages/call-service/src/services/CallService.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,49 @@
import { describe, it } from 'vitest';

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');
import { describe, it, expect, beforeEach } from "vitest";
import { CALL_EVENTS, CALL_STATUSES } from "@voycelink/contracts";
import { CallService } from "./CallService";

const [CALL_INITIATED, , CALL_ANSWERED] = CALL_EVENTS;
const [STATUS_WAITING] = CALL_STATUSES;

describe("CallService", () => {
let service: CallService;

beforeEach(() => {
service = new CallService();
});

it("processes call_initiated and persists the call", async () => {
const event = await service.processEvent({
event: CALL_INITIATED,
callId: "call-1",
type: "voice",
queueId: "medical_spanish",
});

expect(event.callId).toBe("call-1");

const calls = await service.getCalls({ queueId: "medical_spanish" });
expect(
calls.some((c) => c.id === "call-1" && c.status === STATUS_WAITING),
).toBe(
true,
);
});

it("flags call_answered when waitTime exceeds 30 seconds", async () => {
await service.processEvent({
event: CALL_INITIATED,
callId: "call-2",
type: "video",
queueId: "medical_english",
});

const answered = await service.processEvent({
event: CALL_ANSWERED,
callId: "call-2",
waitTime: 35,
});

expect(answered.metadata?.slaBreached).toBe(true);
});
});
Loading