diff --git a/src/call-events/.gitkeep b/src/call-events/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/call-events/call-events.gateway.ts b/src/call-events/call-events.gateway.ts new file mode 100644 index 0000000..1efdcf2 --- /dev/null +++ b/src/call-events/call-events.gateway.ts @@ -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}`); + } +} diff --git a/src/call-events/call-events.module.ts b/src/call-events/call-events.module.ts new file mode 100644 index 0000000..ff26097 --- /dev/null +++ b/src/call-events/call-events.module.ts @@ -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 {} diff --git a/src/call-events/call-events.service.ts b/src/call-events/call-events.service.ts new file mode 100644 index 0000000..2f2c787 --- /dev/null +++ b/src/call-events/call-events.service.ts @@ -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 = { + 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 { + 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 { + 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 { + 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}`, + ); + } + } + } +} diff --git a/src/call-events/call-events.types.ts b/src/call-events/call-events.types.ts new file mode 100644 index 0000000..30f7d3e --- /dev/null +++ b/src/call-events/call-events.types.ts @@ -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; +}; diff --git a/src/exotel/exotel.controller.ts b/src/exotel/exotel.controller.ts index 4cbb38f..0422c5b 100644 --- a/src/exotel/exotel.controller.ts +++ b/src/exotel/exotel.controller.ts @@ -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' }; } diff --git a/src/exotel/exotel.module.ts b/src/exotel/exotel.module.ts index 63d412e..227f0bf 100644 --- a/src/exotel/exotel.module.ts +++ b/src/exotel/exotel.module.ts @@ -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],