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 { Controller, Post, Body, Logger, HttpCode } from '@nestjs/common';
|
||||||
import { ExotelService } from './exotel.service';
|
import { ExotelService } from './exotel.service';
|
||||||
|
import { CallEventsService } from '../call-events/call-events.service';
|
||||||
import type { ExotelWebhookPayload } from './exotel.types';
|
import type { ExotelWebhookPayload } from './exotel.types';
|
||||||
|
|
||||||
@Controller('webhooks/exotel')
|
@Controller('webhooks/exotel')
|
||||||
export class ExotelController {
|
export class ExotelController {
|
||||||
private readonly logger = new Logger(ExotelController.name);
|
private readonly logger = new Logger(ExotelController.name);
|
||||||
|
|
||||||
constructor(private readonly exotelService: ExotelService) {}
|
constructor(
|
||||||
|
private readonly exotelService: ExotelService,
|
||||||
|
private readonly callEventsService: CallEventsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post('call-status')
|
@Post('call-status')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@@ -15,9 +19,11 @@ export class ExotelController {
|
|||||||
|
|
||||||
const callEvent = this.exotelService.parseWebhook(payload);
|
const callEvent = this.exotelService.parseWebhook(payload);
|
||||||
|
|
||||||
// TODO: Forward to CallEventsService (Task 4)
|
if (callEvent.eventType === 'answered') {
|
||||||
// For now, just log
|
await this.callEventsService.handleIncomingCall(callEvent);
|
||||||
this.logger.log(`Call event: ${JSON.stringify(callEvent)}`);
|
} else if (callEvent.eventType === 'ended') {
|
||||||
|
await this.callEventsService.handleCallEnded(callEvent);
|
||||||
|
}
|
||||||
|
|
||||||
return { status: 'received' };
|
return { status: 'received' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CallEventsModule } from '../call-events/call-events.module';
|
||||||
import { ExotelController } from './exotel.controller';
|
import { ExotelController } from './exotel.controller';
|
||||||
import { ExotelService } from './exotel.service';
|
import { ExotelService } from './exotel.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [CallEventsModule],
|
||||||
controllers: [ExotelController],
|
controllers: [ExotelController],
|
||||||
providers: [ExotelService],
|
providers: [ExotelService],
|
||||||
exports: [ExotelService],
|
exports: [ExotelService],
|
||||||
|
|||||||
Reference in New Issue
Block a user