From 8c0853dd199829b17a0e18eb405dced80127ab65 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Thu, 19 Mar 2026 12:09:54 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20Ozonetel=20webhook=20handler=20?= =?UTF-8?q?=E2=80=94=20create=20call=20records=20from=20call=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Parse Ozonetel POST payload (CallerID, Status, Duration, Recording URL) - Create Call record in platform via API key auth - Match caller to Lead by phone number - Create LeadActivity timeline entry - Update lead contactAttempts and lastContacted - Map Ozonetel dispositions to platform enum values Co-Authored-By: Claude Opus 4.6 (1M context) --- .../missed-call-webhook.controller.ts | 219 +++++++++++++++++- 1 file changed, 213 insertions(+), 6 deletions(-) diff --git a/src/worklist/missed-call-webhook.controller.ts b/src/worklist/missed-call-webhook.controller.ts index c725efb..df5a37c 100644 --- a/src/worklist/missed-call-webhook.controller.ts +++ b/src/worklist/missed-call-webhook.controller.ts @@ -1,16 +1,223 @@ -import { Controller, Post, Body, Logger } from '@nestjs/common'; +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 handleMissedCall(@Body() body: Record) { - this.logger.log(`Received missed call webhook: ${JSON.stringify(body)}`); + 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; + } - // TODO: Auto-assignment, duplicate merging, worklist insertion - // For now, just acknowledge receipt + this.logger.log(`Call webhook: ${payload.CallerID} | ${payload.Status} | ${payload.Type}`); - return { received: true }; + 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) { + this.logger.error(`Webhook processing failed: ${err}`); + 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 result = await this.platform.queryWithAuth( + `mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`, + { + data: { + name: `${data.direction === 'INBOUND' ? 'Inbound' : 'Outbound'} — ${data.callerPhone}`, + direction: data.direction, + callStatus: data.callStatus, + 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), + recordingUrl: data.recordingUrl ? { primaryLinkUrl: data.recordingUrl, primaryLinkLabel: 'Recording' } : undefined, + }, + }, + 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, + durationSeconds: 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; } }