Files
helix-engage-server/src/worklist/missed-call-webhook.controller.ts
saridsa2 3f22166ac0
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
fix: dispose creates inbound Call record, webhook enriches — eliminates UCID mismatch + timing race
Root cause: CDR webhook fires 5s after dispose, stores monitorUCID.
Frontend has agent-side UCID from SIP. They never matched → disposition
not persisted, agent call history empty.

Fix:
- Dispose endpoint now creates Call records for ALL answered calls
  (inbound + outbound), not just outbound. Record gets agent-side UCID
  + correct disposition immediately.
- Webhook checks if Call record already exists (by agent UCID via
  monitorUCID→agentUCID mapping). If found, enriches with recording
  URL, agent chain name, CDR timing. If not found, creates as fallback.
- SupervisorService stores UCID mapping from real-time events (which
  carry both UCIDs). Auto-expires after 10 minutes.

Verified: UCID-MAP logged, dispose creates record, webhook enriches
without duplicating. DB shows correct agent UCID + disposition + recording.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 08:31:07 +05:30

357 lines
17 KiB
TypeScript

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<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 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<any>(
`{ 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<string, any> = {};
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<any>(
`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<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: 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<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': 'NOT_INTERESTED',
'Wrong Number': 'WRONG_NUMBER',
'No Answer': 'NO_ANSWER',
};
return map[disposition] ?? null;
}
}