Files
helix-engage-server/src/worklist/missed-call-webhook.controller.ts
saridsa2 4b5edc4e55 fix: appointmentStatus→status, missed call visibility, webhook callbackstatus, KB logging
- Renamed appointmentStatus to status in search + call-assist queries
- Missed calls worklist: removed agentName filter (shared FIFO queue)
- Webhook sets callbackstatus: PENDING_CALLBACK on missed calls
- AI chat: added KB content logging for debugging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:42:38 +05:30

233 lines
9.5 KiB
TypeScript

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<string>('platform.apiKey') ?? '';
}
@Post('missed-call')
async handleCallWebhook(@Body() body: Record<string, any>) {
// Ozonetel sends the payload as a JSON string inside a "data" field
let payload: Record<string, any>;
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<string> {
const callData: Record<string, any> = {
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<any>(
`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<any>(
`{ 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<string, any>, authHeader: string): Promise<void> {
await this.platform.queryWithAuth<any>(
`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<void> {
await this.platform.queryWithAuth<any>(
`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<string, any>, authHeader: string): Promise<void> {
await this.platform.queryWithAuth<any>(
`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<string, string> = {
'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;
}
}