diff --git a/src/ozonetel/ozonetel-agent.controller.ts b/src/ozonetel/ozonetel-agent.controller.ts index ff84148..e4a84e6 100644 --- a/src/ozonetel/ozonetel-agent.controller.ts +++ b/src/ozonetel/ozonetel-agent.controller.ts @@ -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 = { - 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( - `{ 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( - `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 { diff --git a/src/supervisor/supervisor.service.ts b/src/supervisor/supervisor.service.ts index 056fbb0..b81967e 100644 --- a/src/supervisor/supervisor.service.ts +++ b/src/supervisor/supervisor.service.ts @@ -35,6 +35,8 @@ export class SupervisorService implements OnModuleInit { private readonly activeCalls = new Map(); private readonly agentStates = new Map(); private readonly acwTimers = new Map(); + // monitorUCID → agentUCID. Real-time events carry both; CDR webhook only has monitorUCID. + private readonly ucidMap = new Map(); 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); diff --git a/src/worklist/missed-call-webhook.controller.ts b/src/worklist/missed-call-webhook.controller.ts index 67f6b02..6615a6e 100644 --- a/src/worklist/missed-call-webhook.controller.ts +++ b/src/worklist/missed-call-webhook.controller.ts @@ -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( + `{ 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 = {}; + 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( + `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.