mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
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:
76
src/call-events/call-events.gateway.ts
Normal file
76
src/call-events/call-events.gateway.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
12
src/call-events/call-events.module.ts
Normal file
12
src/call-events/call-events.module.ts
Normal 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 {}
|
||||
234
src/call-events/call-events.service.ts
Normal file
234
src/call-events/call-events.service.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
38
src/call-events/call-events.types.ts
Normal file
38
src/call-events/call-events.types.ts
Normal 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;
|
||||
};
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user