mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
fix: dispose creates inbound Call record, webhook enriches — eliminates UCID mismatch + timing race
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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>
This commit is contained in:
@@ -156,11 +156,13 @@ export class OzonetelAgentController {
|
||||
this.logger.error(`[DISPOSE] FAILED: ${message} ${responseData}`);
|
||||
}
|
||||
|
||||
// Create call record for outbound calls. Inbound calls are
|
||||
// created by the webhook — but we skip outbound in the webhook
|
||||
// (they're not "missed calls"). So the dispose endpoint is the
|
||||
// only place that creates the call record for outbound dials.
|
||||
if (body.direction === 'OUTBOUND' && body.callerPhone) {
|
||||
// Create call record at dispose time for ALL answered calls
|
||||
// (inbound + outbound). The dispose endpoint fires BEFORE the
|
||||
// CDR webhook, so creating here gives us the correct agent-side
|
||||
// UCID and the agent's chosen disposition immediately. The webhook
|
||||
// arrives ~5s later and enriches with recording URL + chain name.
|
||||
if (body.callerPhone) {
|
||||
const isInbound = body.direction !== 'OUTBOUND';
|
||||
try {
|
||||
const durationSec = body.durationSec ?? 0;
|
||||
const endedAt = new Date().toISOString();
|
||||
@@ -168,8 +170,8 @@ export class OzonetelAgentController {
|
||||
? new Date(Date.now() - durationSec * 1000).toISOString()
|
||||
: endedAt;
|
||||
const callData: Record<string, any> = {
|
||||
name: `Outbound — ${body.callerPhone}`,
|
||||
direction: 'OUTBOUND',
|
||||
name: isInbound ? `Inbound — ${body.callerPhone}` : `Outbound — ${body.callerPhone}`,
|
||||
direction: isInbound ? 'INBOUND' : 'OUTBOUND',
|
||||
callStatus: 'COMPLETED',
|
||||
callerNumber: { primaryPhoneNumber: `+91${body.callerPhone.replace(/^\+?91/, '')}` },
|
||||
agentName: agentId,
|
||||
@@ -196,7 +198,7 @@ export class OzonetelAgentController {
|
||||
{ data: callData },
|
||||
`Bearer ${apiKey}`,
|
||||
);
|
||||
this.logger.log(`[DISPOSE] Created outbound call record: ${result.createCall.id}`);
|
||||
this.logger.log(`[DISPOSE] Created ${isInbound ? 'inbound' : 'outbound'} call record: ${result.createCall.id} ucid=${body.ucid} disposition=${body.disposition} phone=${body.callerPhone}`);
|
||||
|
||||
// Fetch recording URL from CDR after a delay (Ozonetel needs time to process)
|
||||
const callId = result.createCall.id;
|
||||
@@ -278,33 +280,9 @@ export class OzonetelAgentController {
|
||||
}
|
||||
}
|
||||
|
||||
// Update disposition on answered inbound calls. The webhook creates
|
||||
// the Call record with the Ozonetel default disposition ("General
|
||||
// Enquiry" → INFO_PROVIDED) before the agent disposes. Now that the
|
||||
// agent has submitted their actual disposition, write it back to the
|
||||
// platform Call record by matching on UCID.
|
||||
//
|
||||
// Skipped for outbound (already created with correct disposition
|
||||
// above) and for missed-call callbacks (handled in the block above).
|
||||
if (!body.missedCallId && body.direction !== 'OUTBOUND' && body.ucid) {
|
||||
try {
|
||||
const callData = await this.platform.query<any>(
|
||||
`{ calls(first: 1, filter: { ucid: { eq: "${body.ucid}" } }) { edges { node { id } } } }`,
|
||||
);
|
||||
const callId = callData?.calls?.edges?.[0]?.node?.id;
|
||||
if (callId) {
|
||||
await this.platform.query<any>(
|
||||
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||
{ id: callId, data: { disposition: body.disposition } },
|
||||
);
|
||||
this.logger.log(`[DISPOSE] Updated inbound call ${callId} disposition → ${body.disposition}`);
|
||||
} else {
|
||||
this.logger.warn(`[DISPOSE] No Call found for ucid=${body.ucid} — disposition not persisted`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[DISPOSE] Failed to update inbound call disposition: ${err.message}`);
|
||||
}
|
||||
}
|
||||
// Inbound disposition is now handled by the call record creation
|
||||
// above — the dispose endpoint creates the record with the correct
|
||||
// disposition. No separate update-by-UCID needed.
|
||||
|
||||
// Auto-assign next missed call to this agent
|
||||
try {
|
||||
|
||||
@@ -35,6 +35,8 @@ export class SupervisorService implements OnModuleInit {
|
||||
private readonly activeCalls = new Map<string, ActiveCall>();
|
||||
private readonly agentStates = new Map<string, AgentStateEntry>();
|
||||
private readonly acwTimers = new Map<string, NodeJS.Timeout>();
|
||||
// monitorUCID → agentUCID. Real-time events carry both; CDR webhook only has monitorUCID.
|
||||
private readonly ucidMap = new Map<string, string>();
|
||||
readonly agentStateSubject = new Subject<{ agentId: string; state: AgentOzonetelState | string; timestamp: string }>();
|
||||
readonly activeCallSubject = new Subject<{ type: 'update' | 'remove'; call?: ActiveCall; ucid: string }>();
|
||||
// Worklist update stream — emitted when a missed call is created or
|
||||
@@ -78,9 +80,14 @@ export class SupervisorService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
resolveAgentUcid(monitorUcid: string): string | null {
|
||||
return this.ucidMap.get(monitorUcid) ?? null;
|
||||
}
|
||||
|
||||
handleCallEvent(event: any) {
|
||||
const action = event.action;
|
||||
const ucid = event.ucid ?? event.monitorUCID;
|
||||
const monitorUcid = event.monitor_ucid ?? event.monitorUCID;
|
||||
const agentId = event.agent_id ?? event.agentID;
|
||||
const callerNumber = event.caller_id ?? event.callerID;
|
||||
const callType = event.call_type ?? event.Type;
|
||||
@@ -89,6 +96,12 @@ export class SupervisorService implements OnModuleInit {
|
||||
|
||||
if (!ucid) return;
|
||||
|
||||
if (monitorUcid && ucid !== monitorUcid) {
|
||||
this.ucidMap.set(monitorUcid, ucid);
|
||||
this.logger.log(`[UCID-MAP] monitor=${monitorUcid} → agent=${ucid}`);
|
||||
setTimeout(() => this.ucidMap.delete(monitorUcid), 600_000);
|
||||
}
|
||||
|
||||
if (action === 'Answered' || action === 'Calling') {
|
||||
// Don't show calls for offline agents (ghost calls)
|
||||
const agentState = this.agentStates.get(agentId);
|
||||
|
||||
@@ -50,7 +50,15 @@ export class MissedCallWebhookController {
|
||||
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 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;
|
||||
|
||||
@@ -109,24 +117,54 @@ export class MissedCallWebhookController {
|
||||
this.logger.warn(`[WEBHOOK] Caller resolution failed for ${callerPhone}: ${err}`);
|
||||
}
|
||||
|
||||
// Step 2: Create call record with leadId + leadName baked in so
|
||||
// the worklist row renders the patient name immediately.
|
||||
const 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}` : ''}`);
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user