import { Controller, Post, Body, Headers, Logger, Inject, forwardRef } from '@nestjs/common'; import { PlatformGraphqlService } from '../platform/platform-graphql.service'; import { AgentLookupService } from '../platform/agent-lookup.service'; import { CallerResolutionService } from '../caller/caller-resolution.service'; import { SupervisorService } from '../supervisor/supervisor.service'; import { ConfigService } from '@nestjs/config'; // Ozonetel sends all timestamps in IST — convert to UTC for storage function istToUtc(istDateStr: string | null): string | null { if (!istDateStr) return null; // Parse as-is, then subtract 5:30 to get UTC const d = new Date(istDateStr); if (isNaN(d.getTime())) return null; d.setMinutes(d.getMinutes() - 330); // IST is UTC+5:30 return d.toISOString(); } @Controller('webhooks/ozonetel') export class MissedCallWebhookController { private readonly logger = new Logger(MissedCallWebhookController.name); private readonly apiKey: string; constructor( private readonly platform: PlatformGraphqlService, private readonly config: ConfigService, private readonly caller: CallerResolutionService, private readonly agentLookup: AgentLookupService, @Inject(forwardRef(() => SupervisorService)) private readonly supervisor: SupervisorService, ) { this.apiKey = config.get('platform.apiKey') ?? ''; } @Post('missed-call') async handleCallWebhook(@Body() body: Record) { // Ozonetel sends the payload as a JSON string inside a "data" field let payload: Record; try { payload = typeof body.data === 'string' ? JSON.parse(body.data) : body; } catch { payload = body; } this.logger.log(`Call webhook: ${payload.CallerID} | ${payload.Status} | ${payload.Type}`); const callerPhone = (payload.CallerID ?? '').replace(/^\+?91/, ''); const status = payload.Status; // NotAnswered, Answered, Abandoned const type = payload.Type; // InBound, OutBound const startTime = payload.StartTime; const endTime = payload.EndTime; const duration = this.parseDuration(payload.CallDuration ?? '00:00:00'); const agentName = payload.AgentName ?? null; const recordingUrl = payload.AudioFile ?? null; const monitorUcid = payload.monitorUCID ?? null; // Resolve agent-side UCID from real-time event mapping. // The dispose endpoint creates Call records with the agent UCID; // this lets us find and enrich that record instead of duplicating. const agentUcid = monitorUcid ? this.supervisor.resolveAgentUcid(monitorUcid) : null; const ucid = agentUcid ?? monitorUcid; if (agentUcid) { this.logger.log(`[WEBHOOK] Resolved monitorUCID ${monitorUcid} → agent UCID ${agentUcid}`); } const disposition = payload.Disposition ?? null; const hangupBy = payload.HangupBy ?? null; if (!callerPhone) { this.logger.warn('No caller phone in webhook — skipping'); return { received: true, processed: false }; } // Skip outbound calls — an unanswered outbound dial is NOT a // "missed call" in the call-center sense. Outbound call records // are created by the disposition flow, not the webhook. if (type === 'Manual' || type === 'OutBound') { this.logger.log(`Skipping outbound call webhook (type=${type}, status=${status})`); return { received: true, processed: false, reason: 'outbound' }; } // Determine call status for our platform const callStatus = status === 'Answered' ? 'COMPLETED' : 'MISSED'; const direction = 'INBOUND'; // only inbound reaches here now // Use API key auth for server-to-server writes const authHeader = this.apiKey ? `Bearer ${this.apiKey}` : ''; if (!authHeader) { this.logger.warn('No PLATFORM_API_KEY configured — cannot write call records'); return { received: true, processed: false }; } try { // Step 1: Resolve caller. CallerResolutionService looks up BOTH // leads and patients — for an existing patient with no lead yet // it creates the lead on the fly and returns the name. This is // the single source of truth for caller identity across webhook, // polling, and agent-initiated paths. let resolved: { leadId: string; leadName: string | null; patientId: string } = { leadId: '', leadName: null, patientId: '', }; try { const r = await this.caller.resolve(callerPhone, authHeader); const fullName = `${r.firstName} ${r.lastName}`.trim(); resolved = { leadId: r.leadId, // Resolver returns isNew when no Lead/Patient exists for // this phone. We do NOT auto-create records from the // webhook — agents don't have a name to attach, so we // persist the phone as leadName (honest snapshot). The // first agent action (enquiry, appointment) will create // real Lead+Patient records and retroactive identity // isn't a data-layer concern. leadName: r.isNew ? `+91${callerPhone}` : (fullName || null), patientId: r.patientId, }; this.logger.log(`[WEBHOOK] Resolved ${callerPhone} → lead=${resolved.leadId || 'none'} name=${resolved.leadName ?? 'unresolved'} isNew=${r.isNew}`); } catch (err) { this.logger.warn(`[WEBHOOK] Caller resolution failed for ${callerPhone}: ${err}`); } // Step 2: For answered calls, the dispose endpoint creates the // Call record ~5s before this webhook fires. Check if it already // exists and enrich it instead of creating a duplicate. let callId: string; if (callStatus === 'COMPLETED' && ucid) { const existing = await this.platform.queryWithAuth( `{ calls(first: 1, filter: { ucid: { eq: "${ucid}" } }) { edges { node { id } } } }`, undefined, authHeader, ).catch(() => null); const existingId = existing?.calls?.edges?.[0]?.node?.id; if (existingId) { // Enrich existing record with webhook data (recording, chain name, timing) const enrichData: Record = {}; if (agentName) enrichData.agentName = agentName; if (recordingUrl) enrichData.recording = { primaryLinkUrl: recordingUrl, primaryLinkLabel: 'Recording' }; if (resolved.leadId) enrichData.leadId = resolved.leadId; if (resolved.leadName) enrichData.leadName = resolved.leadName; if (startTime) enrichData.startedAt = istToUtc(startTime); if (endTime) enrichData.endedAt = istToUtc(endTime); if (duration) enrichData.durationSec = duration; if (Object.keys(enrichData).length > 0) { await this.platform.queryWithAuth( `mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`, { id: existingId, data: enrichData }, authHeader, ).catch(err => this.logger.warn(`[WEBHOOK] Failed to enrich call ${existingId}: ${err}`)); } callId = existingId; this.logger.log(`[WEBHOOK] Enriched existing call ${callId} with recording=${recordingUrl ? 'yes' : 'no'} agentName=${agentName}`); } else { // Fallback: dispose didn't create it (edge case) — create normally this.logger.log(`[WEBHOOK] No existing call found for ucid=${ucid} — creating new record`); callId = await this.createCall({ callerPhone, direction, callStatus, agentName, startTime, endTime, duration, recordingUrl, disposition, ucid, leadId: resolved.leadId || null, leadName: resolved.leadName, }, authHeader); this.logger.log(`Created call record: ${callId} (${callStatus})${resolved.leadName ? ` linked to ${resolved.leadName}` : ''}`); } } else { // Missed calls — always create (no dispose fires for unanswered) callId = await this.createCall({ callerPhone, direction, callStatus, agentName, startTime, endTime, duration, recordingUrl, disposition, ucid, leadId: resolved.leadId || null, leadName: resolved.leadName, }, authHeader); this.logger.log(`Created call record: ${callId} (${callStatus})${resolved.leadName ? ` linked to ${resolved.leadName}` : ''}`); } // Push worklist SSE so agents see new calls instantly // instead of waiting for the 30s frontend poll. this.supervisor.emitWorklistUpdate({ type: callStatus === 'MISSED' ? 'missed-call' : 'inbound-call', callerPhone: callerPhone, callerName: resolved.leadName ?? undefined, callId, }); // Step 3: Lead-side side-effects (activity log + contact stats) if (resolved.leadId) { const summary = callStatus === 'MISSED' ? `Missed inbound call from ${callerPhone} (${duration}s, ${hangupBy ?? 'unknown'})` : `Inbound call from ${callerPhone} — ${duration}s, ${disposition || 'no disposition'}`; await this.createLeadActivity({ leadId: resolved.leadId, activityType: 'CALL_RECEIVED', summary, channel: 'PHONE', performedBy: agentName ?? 'System', durationSeconds: duration, outcome: callStatus === 'MISSED' ? 'NO_ANSWER' : 'SUCCESSFUL', }, authHeader); // Bump contact timestamps. Read current contactAttempts first // (kept local rather than extending resolve() signature). const leadMeta = await this.findLeadByPhone(callerPhone, authHeader); await this.updateLead(resolved.leadId, { lastContacted: startTime ? new Date(startTime).toISOString() : new Date().toISOString(), contactAttempts: ((leadMeta?.contactAttempts) ?? 0) + 1, }, authHeader); } return { received: true, processed: true, callId, leadId: resolved.leadId || null }; } catch (err: any) { const responseData = err?.response?.data ? JSON.stringify(err.response.data) : ''; this.logger.error(`Webhook processing failed: ${err.message} ${responseData}`); return { received: true, processed: false, error: String(err) }; } } private async createCall(data: { callerPhone: string; direction: string; callStatus: string; agentName: string | null; startTime: string | null; endTime: string | null; duration: number; recordingUrl: string | null; disposition: string | null; ucid: string | null; leadId?: string | null; leadName?: string | null; }, authHeader: string): Promise { const callData: Record = { name: `${data.direction === 'INBOUND' ? 'Inbound' : 'Outbound'} — ${data.callerPhone}`, direction: data.direction, callStatus: data.callStatus, callerNumber: { primaryPhoneNumber: `+91${data.callerPhone}` }, agentName: data.agentName, startedAt: istToUtc(data.startTime), endedAt: istToUtc(data.endTime), durationSec: data.duration, disposition: this.mapDisposition(data.disposition), }; // Persist UCID so the 30-min CDR enrichment cron and historical // backfill can pair this row to a CDR record and fill in the // authoritative agent relation. if (data.ucid) callData.ucid = data.ucid; if (data.leadId) callData.leadId = data.leadId; if (data.leadName) callData.leadName = data.leadName; // Set callback tracking fields for missed calls so they appear in the worklist if (data.callStatus === 'MISSED') { callData.callbackStatus = 'PENDING_CALLBACK'; callData.missedCallCount = 1; } if (data.recordingUrl) { callData.recording = { primaryLinkUrl: data.recordingUrl, primaryLinkLabel: 'Recording' }; } // Resolve agent relation at write-time so the supervisor dashboard // can bucket the row immediately. Ozonetel sends transferred calls // with a chain-style AgentName like "RamaiahAdmin -> GlobalHealthX" — // the final handler is the last segment, so split on " -> " and // resolve that. Try both ozonetelAgentId (lowercase unique) and // ozonetelDisplayName (mixed-case human label) since Ozonetel mixes // formats across webhook payloads. Leaves agentId null on miss so // the cdr-enrichment cron can still attempt a match by UCID later. if (data.agentName) { const segments = data.agentName.split('->').map((s) => s.trim()).filter(Boolean); const finalHandler = segments[segments.length - 1]; if (finalHandler) { const uuid = (await this.agentLookup.resolveByOzonetelId(finalHandler)) ?? (await this.agentLookup.resolveByDisplayName(finalHandler)); if (uuid) callData.agentId = uuid; } } const result = await this.platform.queryWithAuth( `mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`, { data: callData }, authHeader, ); return result.createCall.id; } private async findLeadByPhone(phone: string, authHeader: string): Promise<{ id: string; name: string; contactAttempts: number } | null> { const result = await this.platform.queryWithAuth( `{ leads(first: 50) { edges { node { id name contactPhone { primaryPhoneNumber } contactAttempts lastContacted } } } }`, undefined, authHeader, ); const leads = result.leads.edges.map((e: any) => e.node); const cleanPhone = phone.replace(/\D/g, ''); return leads.find((l: any) => { const lp = (l.contactPhone?.primaryPhoneNumber ?? '').replace(/\D/g, ''); return lp.endsWith(cleanPhone) || cleanPhone.endsWith(lp); }) ?? null; } private async updateCall(callId: string, data: Record, authHeader: string): Promise { await this.platform.queryWithAuth( `mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`, { id: callId, data }, authHeader, ); } private async createLeadActivity(data: { leadId: string; activityType: string; summary: string; channel: string; performedBy: string; durationSeconds: number; outcome: string; }, authHeader: string): Promise { await this.platform.queryWithAuth( `mutation($data: LeadActivityCreateInput!) { createLeadActivity(data: $data) { id } }`, { data: { name: data.summary.substring(0, 80), activityType: data.activityType, summary: data.summary, occurredAt: new Date().toISOString(), performedBy: data.performedBy, channel: data.channel, durationSec: data.durationSeconds, outcome: data.outcome, leadId: data.leadId, }, }, authHeader, ); } private async updateLead(leadId: string, data: Record, authHeader: string): Promise { await this.platform.queryWithAuth( `mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`, { id: leadId, data }, authHeader, ); } private parseDuration(timeStr: string): number { const parts = timeStr.split(':').map(Number); if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; if (parts.length === 2) return parts[0] * 60 + parts[1]; return parseInt(timeStr) || 0; } private mapDisposition(disposition: string | null): string | null { if (!disposition) return null; const map: Record = { 'General Enquiry': 'INFO_PROVIDED', 'Appointment Booked': 'APPOINTMENT_BOOKED', 'Follow Up': 'FOLLOW_UP_SCHEDULED', 'Not Interested': 'NOT_INTERESTED', 'Wrong Number': 'WRONG_NUMBER', 'No Answer': 'NO_ANSWER', }; return map[disposition] ?? null; } }