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
1 change: 1 addition & 0 deletions packages/call-service/src/routes/calls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ router.get('/', async (req: Request, res: Response) => {
const calls = await callService.getCalls(filters);
res.json(calls);
} catch (_error) {
console.log('Error fetching calls:', _error);
res.status(500).json({ message: 'Internal server error' });
}
});
Expand Down
2 changes: 2 additions & 0 deletions packages/call-service/src/routes/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ router.post('/', apiKeyAuth, async (req: Request, res: Response) => {
return;
}

console.log('Error processing event:', error);

res.status(500).json({ message: 'Internal server error' });
}
});
Expand Down
260 changes: 255 additions & 5 deletions packages/call-service/src/services/CallService.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,271 @@
import type { PoolClient } from 'pg';
import { randomUUID } from 'crypto';
import { db } from '../db/client';
// import { publishStatusUpdate } from '../bus/publisher';
import {
Call,
CallStatus,
CallEvent,
CallFilters,
CallServiceContract,
EventPayload,
} from '../domain/call';
import {
CallAnsweredPayload,
CallEndedPayload,
CallHoldPayload,
CallInitiatedPayload,
CallRoutedPayload,
} from '@voycelink/contracts';

type ProcessEventResult = {
callEvent: CallEvent;
status: CallStatus;
};

export class CallService implements CallServiceContract {
async processEvent(_payload: EventPayload): Promise<CallEvent> {
throw new Error('CallService.processEvent not implemented');
private async insertEvent(
client: PoolClient,
callId: string,
type: EventPayload['event'],
timestamp: Date,
metadata: Record<string, unknown>,
): Promise<CallEvent> {
const event = await client.query(
'INSERT INTO call_events (id, call_id, type, timestamp, metadata) VALUES ($1, $2, $3, $4, $5) RETURNING *',
[randomUUID(), callId, type, timestamp, metadata],
);

return new CallEvent(
event.rows[0].id,
event.rows[0].call_id,
event.rows[0].type,
new Date(event.rows[0].timestamp),
event.rows[0].metadata,
);
}

private async callInitiated(
client: PoolClient,
now: Date,
payload: CallInitiatedPayload): Promise<ProcessEventResult> {

await client.query(
'INSERT INTO calls (id, type, status, queue_id, start_time) VALUES ($1, $2, $3, $4, $5) RETURNING *',
[payload.callId, payload.type, 'waiting', payload.queueId, now],
);

const callEvent = await this.insertEvent(client, payload.callId, payload.event, now, {
...payload,
slaMax: 30,
});

return {
callEvent,
status: 'waiting',
};
}

private async callRouted(
client: PoolClient,
now: Date,
payload: CallRoutedPayload): Promise<ProcessEventResult> {

const query = 'SELECT status FROM calls WHERE id = $1';
const routedCallResult = await client.query(query, [payload.callId]);

if (routedCallResult.rowCount === 0) {
throw new Error(`Call with id ${payload.callId} not found`);
}

await client.query(
'UPDATE calls SET status = $1 WHERE id = $2',
['waiting', payload.callId],
);

const callEvent = await this.insertEvent(client, payload.callId, payload.event, now, {
agentId: payload.agentId,
routingTime: payload.routingTime,
rerouteAfterSeconds: 15,
rerouteRequired: payload.routingTime > 15,
});

return {
callEvent,
status: 'waiting',
};
}

private async callAnswered(
client: PoolClient,
now: Date,
payload: CallAnsweredPayload,
): Promise<ProcessEventResult> {
const query = 'SELECT status FROM calls WHERE id = $1';
const answeredCallResult = await client.query(query, [payload.callId]);

if (answeredCallResult.rowCount === 0) {
throw new Error(`Call with id ${payload.callId} not found`);
}

await client.query('UPDATE calls SET status = $1 WHERE id = $2', ['active', payload.callId]);

const callEvent = await this.insertEvent(client, payload.callId, payload.event, now, {
waitTime: payload.waitTime,
slaMax: 30,
});

return {
callEvent,
status: 'active',
};
}

private async callHold(
client: PoolClient,
now: Date,
payload: CallHoldPayload,
): Promise<ProcessEventResult> {
const query = 'SELECT status FROM calls WHERE id = $1';
const holdCallResult = await client.query(query, [payload.callId]);
if (holdCallResult.rowCount === 0) {
throw new Error(`Call with id ${payload.callId} not found`);
}

await client.query('UPDATE calls SET status = $1 WHERE id = $2', ['on_hold', payload.callId]);

const callEvent = await this.insertEvent(client, payload.callId, payload.event, now, {
holdDuration: payload.holdDuration,
maxHoldSeconds: 60,
});

return {
callEvent,
status: 'on_hold',
};
}

async getCalls(_filters: CallFilters): Promise<Call[]> {
throw new Error('CallService.getCalls not implemented');
private async callEnded(
client: PoolClient,
now: Date,
payload: CallEndedPayload,
): Promise<ProcessEventResult> {
const query = 'SELECT status FROM calls WHERE id = $1';
const endedCallResult = await client.query(query, [payload.callId]);
if (endedCallResult.rowCount === 0) {
throw new Error(`Call with id ${payload.callId} not found`);
}

await client.query(
'UPDATE calls SET status = $1, end_time = $2 WHERE id = $3',
['ended', now, payload.callId],
);

const callEvent = await this.insertEvent(client, payload.callId, payload.event, now, {
endReason: payload.endReason,
duration: payload.duration,
});

return {
callEvent,
status: 'ended',
};
}

async processEvent(payload: EventPayload): Promise<CallEvent> {
const client = await db.connect();

try {
const now = new Date();
await client.query('BEGIN');

let result: ProcessEventResult;

if (payload.event === 'call_initiated') {
result = await this.callInitiated(client, now, payload);
} else if (payload.event === 'call_routed') {
result = await this.callRouted(client, now, payload);
} else if (payload.event === 'call_answered') {
result = await this.callAnswered(client, now, payload);
} else if (payload.event === 'call_hold') {
result = await this.callHold(client, now, payload);
} else if (payload.event === 'call_ended') {
result = await this.callEnded(client, now, payload);
} else {
throw new Error('Unsupported event type');
}

await client.query('COMMIT');

try {
// TODO: Publicar en redis
// await publishStatusUpdate({..});
} catch (publishError) {
console.error({ publishError });
}

return result.callEvent;
} catch (error) {
await client.query('ROLLBACK');
console.log('Error in processEvent:', error);
throw error;
} finally {
client.release();
}
}

async getCalls(filters: CallFilters): Promise<Call[]> {
const queryParts: string[] = ['SELECT * FROM calls'];
const whereClauses: string[] = [];
const values: Array<string> = [];

if (filters.status) {
values.push(filters.status);
whereClauses.push(`status = $${values.length}`);
}

if (filters.queueId) {
values.push(filters.queueId);
whereClauses.push(`queue_id = $${values.length}`);
}

if (whereClauses.length > 0) {
// Seria más facil usar un ORM o query builder para esto
queryParts.push(`WHERE ${whereClauses.join(' AND ')}`);
}

queryParts.push('ORDER BY start_time DESC');

const query = queryParts.join(' ');

const calls = await db.query(query, values);
return calls.rows.map(
(row) =>
new Call(
row.id,
row.type,
row.status,
row.queue_id,
new Date(row.start_time),
row.end_time ? new Date(row.end_time) : undefined,
),
);
}

async getCallEvents(_callId: string): Promise<CallEvent[]> {
throw new Error('CallService.getCallEvents not implemented');
const query = 'SELECT * FROM call_events where call_id = $1 ORDER BY timestamp DESC';

const events = await db.query(query, [_callId]);
return events.rows.map(
(row) =>
new CallEvent(
row.id,
row.call_id,
row.type,
new Date(row.timestamp),
row.metadata,
),
);
// throw new Error('CallService.getCallEvents not implemented');
}
}
23 changes: 23 additions & 0 deletions packages/frontend/src/app/api/calls/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { NextResponse } from 'next/server';

const CALL_SERVICE_URL =
process.env.NEXT_PUBLIC_CALL_SERVICE_URL ?? 'http://localhost:3001';

export async function GET(_request: Request) {
// Hago este step para que no se exponga el CALL_SERVICE_URL directamente al cliente
const response = await fetch(`${CALL_SERVICE_URL}/api/calls`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
cache: 'no-store',
});

const body = await response.text();
return new NextResponse(body, {
status: response.status,
headers: {
'Content-Type': response.headers.get('content-type') ?? 'application/json',
},
});
}
11 changes: 6 additions & 5 deletions packages/frontend/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ export default function DashboardPage() {
const [filters, setFilters] = useState<CallFilters>({ status: 'all' });
const [selectedCallId, setSelectedCallId] = useState<string | null>(null);

const { calls, loading } = useCalls(filters);
const { calls, loading, isConnected } = useCalls(filters);
const { events, loading: eventsLoading } = useCallEvents(selectedCallId);

// console.log({ events, eventsLoading })

return (
<div className="min-h-screen bg-gray-50">
{/* ── Header ─────────────────────────────────────────── */}
Expand All @@ -26,10 +28,9 @@ export default function DashboardPage() {
<p className="text-xs text-gray-500">Call Center Dashboard</p>
</div>

{/* TODO: show green dot + "Live" text when Socket.io is connected */}
<span className="inline-flex items-center gap-1.5 text-xs text-gray-400">
<span className="w-2 h-2 rounded-full bg-gray-300" />
Not connected
<span className={`inline-flex items-center gap-1.5 text-xs ${isConnected ? 'text-emerald-600' : 'text-gray-400'}`}>
<span className={`w-2 h-2 rounded-full ${isConnected ? 'bg-emerald-500' : 'bg-gray-300'}`} />
{isConnected ? 'Live' : 'Not connected'}
</span>
</div>
</header>
Expand Down
Loading