import { Controller, Post, Body, Headers, Logger } from '@nestjs/common'; import { PlatformGraphqlService } from '../platform/platform-graphql.service'; import { ConfigService } from '@nestjs/config'; @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, ) { 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 ucid = payload.monitorUCID ?? null; 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 }; } // Determine call status for our platform const callStatus = status === 'Answered' ? 'COMPLETED' : 'MISSED'; const direction = type === 'InBound' ? 'INBOUND' : 'OUTBOUND'; // 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: Create call record const callId = await this.createCall({ callerPhone, direction, callStatus, agentName, startTime, endTime, duration, recordingUrl, disposition, ucid, }, authHeader); this.logger.log(`Created call record: ${callId} (${callStatus})`); // Step 2: Find matching lead by phone number const lead = await this.findLeadByPhone(callerPhone, authHeader); if (lead) { // Step 3: Link call to lead await this.updateCall(callId, { leadId: lead.id }, authHeader); // Step 4: Create lead activity 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: lead.id, activityType: callStatus === 'MISSED' ? 'CALL_RECEIVED' : 'CALL_RECEIVED', summary, channel: 'PHONE', performedBy: agentName ?? 'System', durationSeconds: duration, outcome: callStatus === 'MISSED' ? 'NO_ANSWER' : 'SUCCESSFUL', }, authHeader); // Step 5: Update lead contact timestamps await this.updateLead(lead.id, { lastContacted: startTime ? new Date(startTime).toISOString() : new Date().toISOString(), contactAttempts: (lead.contactAttempts ?? 0) + 1, }, authHeader); this.logger.log(`Linked call to lead ${lead.id} (${lead.name}), activity created`); } else { this.logger.log(`No matching lead for ${callerPhone} — call record created without lead link`); } return { received: true, processed: true, callId, leadId: lead?.id ?? 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; }, 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: data.startTime ? new Date(data.startTime).toISOString() : null, endedAt: data.endTime ? new Date(data.endTime).toISOString() : null, durationSec: data.duration, disposition: this.mapDisposition(data.disposition), }; // 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' }; } 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': 'CALLBACK_REQUESTED', 'Wrong Number': 'WRONG_NUMBER', }; return map[disposition] ?? null; } }