feat: add call events orchestrator with WebSocket gateway, wire Exotel → lookup → enrich → push flow

- CallEventsService orchestrates: Exotel webhook → lead lookup → AI enrichment → WebSocket push
- CallEventsGateway (Socket.IO /call-events namespace) with agent room registration and disposition handling
- EnrichedCallEvent/DispositionPayload types for frontend contract
- Disposition flow: creates Call record, updates lead status, logs lead activity
- Wired ExotelController to forward answered/ended events to CallEventsService
- forwardRef used to resolve circular dependency between gateway and service

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-17 09:08:57 +05:30
parent 3e0d9a4351
commit d488d551ed
7 changed files with 372 additions and 4 deletions

View File

@@ -0,0 +1,76 @@
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
MessageBody,
ConnectedSocket,
} from '@nestjs/websockets';
import { Logger, Inject, forwardRef } from '@nestjs/common';
import { Server, Socket } from 'socket.io';
import type { EnrichedCallEvent, DispositionPayload } from './call-events.types';
import { CallEventsService } from './call-events.service';
@WebSocketGateway({
cors: {
origin: process.env.CORS_ORIGIN ?? 'http://localhost:5173',
credentials: true,
},
namespace: '/call-events',
})
export class CallEventsGateway {
@WebSocketServer()
server: Server;
private readonly logger = new Logger(CallEventsGateway.name);
constructor(
@Inject(forwardRef(() => CallEventsService))
private readonly callEventsService: CallEventsService,
) {}
// Push enriched call event to a specific agent's room
pushCallEvent(agentName: string, event: EnrichedCallEvent) {
const room = `agent:${agentName}`;
this.logger.log(`Pushing ${event.eventType} event to room ${room}`);
this.server.to(room).emit('call:incoming', event);
}
// Agent registers when they open the Call Desk page
@SubscribeMessage('agent:register')
handleAgentRegister(
@ConnectedSocket() client: Socket,
@MessageBody() agentName: string,
) {
const room = `agent:${agentName}`;
client.join(room);
this.logger.log(
`Agent ${agentName} registered in room ${room} (socket: ${client.id})`,
);
client.emit('agent:registered', { agentName, room });
}
// Agent sends disposition after a call
@SubscribeMessage('call:disposition')
async handleDisposition(
@ConnectedSocket() client: Socket,
@MessageBody() payload: DispositionPayload,
) {
this.logger.log(
`Disposition received from ${payload.agentName}: ${payload.disposition}`,
);
await this.callEventsService.handleDisposition(payload);
client.emit('call:disposition:ack', {
status: 'saved',
callSid: payload.callSid,
});
return payload;
}
handleConnection(client: Socket) {
this.logger.log(`Client connected: ${client.id}`);
}
handleDisconnect(client: Socket) {
this.logger.log(`Client disconnected: ${client.id}`);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PlatformModule } from '../platform/platform.module';
import { AiModule } from '../ai/ai.module';
import { CallEventsService } from './call-events.service';
import { CallEventsGateway } from './call-events.gateway';
@Module({
imports: [PlatformModule, AiModule],
providers: [CallEventsService, CallEventsGateway],
exports: [CallEventsService, CallEventsGateway],
})
export class CallEventsModule {}

View File

@@ -0,0 +1,234 @@
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { AiEnrichmentService } from '../ai/ai-enrichment.service';
import { CallEventsGateway } from './call-events.gateway';
import type { CallEvent } from '../exotel/exotel.types';
import type {
EnrichedCallEvent,
DispositionPayload,
} from './call-events.types';
const DISPOSITION_TO_LEAD_STATUS: Record<string, string> = {
APPOINTMENT_BOOKED: 'APPOINTMENT_SET',
FOLLOW_UP_SCHEDULED: 'CONTACTED',
INFO_PROVIDED: 'CONTACTED',
CALLBACK_REQUESTED: 'CONTACTED',
WRONG_NUMBER: 'LOST',
NO_ANSWER: 'CONTACTED',
NOT_INTERESTED: 'LOST',
};
@Injectable()
export class CallEventsService {
private readonly logger = new Logger(CallEventsService.name);
constructor(
private readonly platform: PlatformGraphqlService,
private readonly ai: AiEnrichmentService,
@Inject(forwardRef(() => CallEventsGateway))
private readonly gateway: CallEventsGateway,
) {}
async handleIncomingCall(callEvent: CallEvent): Promise<void> {
this.logger.log(
`Processing incoming call from ${callEvent.callerPhone} to agent ${callEvent.agentName}`,
);
// 1. Lookup lead by phone
let lead = null;
try {
lead = await this.platform.findLeadByPhone(callEvent.callerPhone);
if (lead) {
this.logger.log(
`Matched lead: ${lead.contactName?.firstName} ${lead.contactName?.lastName} (${lead.id})`,
);
} else {
this.logger.log(
`No lead found for phone ${callEvent.callerPhone}`,
);
}
} catch (error) {
this.logger.error(`Lead lookup failed: ${error}`);
}
// 2. AI enrichment (if lead found and no existing summary)
if (lead && !lead.aiSummary) {
try {
const activities = await this.platform.getLeadActivities(
lead.id,
5,
);
const enrichment = await this.ai.enrichLead({
firstName: lead.contactName?.firstName,
lastName: lead.contactName?.lastName,
leadSource: lead.leadSource ?? undefined,
interestedService: lead.interestedService ?? undefined,
leadStatus: lead.leadStatus ?? undefined,
contactAttempts: lead.contactAttempts ?? undefined,
createdAt: lead.createdAt,
activities: activities.map((a) => ({
activityType: a.activityType ?? '',
summary: a.summary ?? '',
})),
});
// Persist AI enrichment back to platform
await this.platform.updateLead(lead.id, enrichment);
lead.aiSummary = enrichment.aiSummary;
lead.aiSuggestedAction = enrichment.aiSuggestedAction;
this.logger.log(`AI enrichment applied for lead ${lead.id}`);
} catch (error) {
this.logger.error(`AI enrichment failed: ${error}`);
}
}
// 3. Get recent activities for display
let recentActivities: {
activityType: string;
summary: string;
occurredAt: string;
performedBy: string;
}[] = [];
if (lead) {
try {
const activities = await this.platform.getLeadActivities(
lead.id,
3,
);
recentActivities = activities.map((a) => ({
activityType: a.activityType ?? '',
summary: a.summary ?? '',
occurredAt: a.occurredAt ?? '',
performedBy: a.performedBy ?? '',
}));
} catch (error) {
this.logger.error(`Failed to fetch activities: ${error}`);
}
}
// 4. Build enriched event
const daysSinceCreation = lead?.createdAt
? Math.floor(
(Date.now() - new Date(lead.createdAt).getTime()) /
(1000 * 60 * 60 * 24),
)
: 0;
const enrichedEvent: EnrichedCallEvent = {
callSid: callEvent.exotelCallSid,
eventType: callEvent.eventType,
lead: lead
? {
id: lead.id,
firstName: lead.contactName?.firstName ?? 'Unknown',
lastName: lead.contactName?.lastName ?? '',
phone: lead.contactPhone?.[0]
? `${lead.contactPhone[0].callingCode} ${lead.contactPhone[0].number}`
: callEvent.callerPhone,
email: lead.contactEmail?.[0]?.address,
source: lead.leadSource ?? undefined,
status: lead.leadStatus ?? undefined,
interestedService:
lead.interestedService ?? undefined,
age: daysSinceCreation,
aiSummary: lead.aiSummary ?? undefined,
aiSuggestedAction:
lead.aiSuggestedAction ?? undefined,
recentActivities,
}
: null,
callerPhone: callEvent.callerPhone,
agentName: callEvent.agentName,
timestamp: callEvent.timestamp,
};
// 5. Push to agent's browser via WebSocket
this.gateway.pushCallEvent(callEvent.agentName, enrichedEvent);
}
async handleCallEnded(callEvent: CallEvent): Promise<void> {
this.logger.log(`Call ended: ${callEvent.exotelCallSid}`);
const enrichedEvent: EnrichedCallEvent = {
callSid: callEvent.exotelCallSid,
eventType: 'ended',
lead: null,
callerPhone: callEvent.callerPhone,
agentName: callEvent.agentName,
timestamp: callEvent.timestamp,
};
this.gateway.pushCallEvent(callEvent.agentName, enrichedEvent);
}
async handleDisposition(payload: DispositionPayload): Promise<void> {
this.logger.log(
`Processing disposition: ${payload.disposition} for call ${payload.callSid}`,
);
// 1. Create Call record in platform
try {
await this.platform.createCall({
callDirection: 'INBOUND',
callStatus: 'COMPLETED',
callerNumber: payload.callerPhone
? [
{
number: payload.callerPhone.replace(/\D/g, ''),
callingCode: '+91',
},
]
: undefined,
agentName: payload.agentName,
startedAt: payload.startedAt,
endedAt: new Date().toISOString(),
durationSeconds: payload.duration,
disposition: payload.disposition,
callNotes: payload.notes || undefined,
leadId: payload.leadId || undefined,
});
this.logger.log(`Call record created for ${payload.callSid}`);
} catch (error) {
this.logger.error(`Failed to create call record: ${error}`);
}
// 2. Update lead status based on disposition
if (payload.leadId) {
const newStatus = DISPOSITION_TO_LEAD_STATUS[payload.disposition];
if (newStatus) {
try {
await this.platform.updateLead(payload.leadId, {
leadStatus: newStatus,
lastContactedAt: new Date().toISOString(),
});
this.logger.log(
`Lead ${payload.leadId} status updated to ${newStatus}`,
);
} catch (error) {
this.logger.error(`Failed to update lead: ${error}`);
}
}
// 3. Create lead activity
try {
await this.platform.createLeadActivity({
activityType: 'CALL_RECEIVED',
summary: `Inbound call — ${payload.disposition.replace(/_/g, ' ')}`,
occurredAt: new Date().toISOString(),
performedBy: payload.agentName,
channel: 'PHONE',
durationSeconds: payload.duration,
leadId: payload.leadId,
});
this.logger.log(
`Lead activity logged for ${payload.leadId}`,
);
} catch (error) {
this.logger.error(
`Failed to create lead activity: ${error}`,
);
}
}
}
}

View File

@@ -0,0 +1,38 @@
export type EnrichedCallEvent = {
callSid: string;
eventType: 'ringing' | 'answered' | 'ended';
lead: {
id: string;
firstName: string;
lastName: string;
phone: string;
email?: string;
source?: string;
status?: string;
campaign?: string;
interestedService?: string;
age: number;
aiSummary?: string;
aiSuggestedAction?: string;
recentActivities: {
activityType: string;
summary: string;
occurredAt: string;
performedBy: string;
}[];
} | null;
callerPhone: string;
agentName: string;
timestamp: string;
};
export type DispositionPayload = {
callSid: string;
leadId: string | null;
disposition: string;
notes: string;
agentName: string;
callerPhone: string;
startedAt: string;
duration: number;
};

View File

@@ -1,12 +1,16 @@
import { Controller, Post, Body, Logger, HttpCode } from '@nestjs/common';
import { ExotelService } from './exotel.service';
import { CallEventsService } from '../call-events/call-events.service';
import type { ExotelWebhookPayload } from './exotel.types';
@Controller('webhooks/exotel')
export class ExotelController {
private readonly logger = new Logger(ExotelController.name);
constructor(private readonly exotelService: ExotelService) {}
constructor(
private readonly exotelService: ExotelService,
private readonly callEventsService: CallEventsService,
) {}
@Post('call-status')
@HttpCode(200)
@@ -15,9 +19,11 @@ export class ExotelController {
const callEvent = this.exotelService.parseWebhook(payload);
// TODO: Forward to CallEventsService (Task 4)
// For now, just log
this.logger.log(`Call event: ${JSON.stringify(callEvent)}`);
if (callEvent.eventType === 'answered') {
await this.callEventsService.handleIncomingCall(callEvent);
} else if (callEvent.eventType === 'ended') {
await this.callEventsService.handleCallEnded(callEvent);
}
return { status: 'received' };
}

View File

@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { CallEventsModule } from '../call-events/call-events.module';
import { ExotelController } from './exotel.controller';
import { ExotelService } from './exotel.service';
@Module({
imports: [CallEventsModule],
controllers: [ExotelController],
providers: [ExotelService],
exports: [ExotelService],