diff --git a/src/maint/maint.controller.ts b/src/maint/maint.controller.ts index 1553d45..7a436e6 100644 --- a/src/maint/maint.controller.ts +++ b/src/maint/maint.controller.ts @@ -972,4 +972,110 @@ export class MaintController { this.logger.log(`[MAINT] Appointment clinic backfill complete: scanned=${appointments.length} patched=${patched} skipped=${skipped} reasons=${JSON.stringify(skippedReasons)}`); return { status: 'ok', scanned: appointments.length, patched, skipped, skippedReasons }; } + + // Backfill disposition + SLA timing on historical calls using CDR data. + // Walks calls from a given date (IST), joins to CDR by UCID, and patches + // disposition (from CDR's mapped value) + timing fields. Idempotent — + // only overwrites null fields (disposition is always overwritten since + // the webhook default is unreliable). + @Post('backfill-call-disposition-timing') + async backfillCallDispositionTiming(@Body() body: { date?: string }) { + const date = body.date ?? new Date(Date.now() + 5.5 * 60 * 60 * 1000).toISOString().slice(0, 10); + this.logger.log(`[MAINT] Backfill disposition+timing for date=${date}`); + + // Fetch CDR for the date + const cdrRows = await this.ozonetel.fetchCDR({ date }).catch(() => []); + if (cdrRows.length === 0) return { status: 'ok', date, scanned: 0, patched: 0, skipped: 0 }; + + // Build UCID + monitorUCID map + const byUcid = new Map(); + for (const row of cdrRows) { + const ucid = String(row.UCID ?? '').trim(); + const monUcid = String(row.monitorUCID ?? '').trim(); + if (ucid) byUcid.set(ucid, row); + if (monUcid && monUcid !== ucid) byUcid.set(monUcid, row); + } + + // Fetch calls for the date that have a UCID + const gte = `${date}T00:00:00+05:30`; + const lte = `${date}T23:59:59+05:30`; + const callsData = await this.platform.query( + `{ calls(first: 500, filter: { + startedAt: { gte: "${gte}", lte: "${lte}" }, + ucid: { is: NOT_NULL } + }) { edges { node { + id ucid disposition assignedAt answeredAt responseTimeS startedAt + } } } }`, + ).catch(() => ({ calls: { edges: [] } })); + + const calls = callsData?.calls?.edges?.map((e: any) => e.node) ?? []; + let patched = 0; + let skipped = 0; + + const dispositionMap: Record = { + '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', + }; + + const parseHms = (hms: string | null | undefined): number | null => { + if (!hms) return null; + const parts = String(hms).split(':').map(Number); + if (parts.length !== 3 || parts.some(isNaN)) return null; + return parts[0] * 3600 + parts[1] * 60 + parts[2]; + }; + + for (const call of calls) { + const cdrRow = byUcid.get(String(call.ucid).trim()); + if (!cdrRow) { skipped++; continue; } + + const patch: Record = {}; + + // Disposition — always overwrite (webhook default is unreliable) + const cdrDisp = dispositionMap[cdrRow.Disposition] ?? null; + if (cdrDisp) patch.disposition = cdrDisp; + + // Timing — only fill if null + if (!call.answeredAt && cdrRow.AnswerTime) { + patch.answeredAt = new Date(cdrRow.AnswerTime).toISOString(); + } + if (!call.assignedAt && cdrRow.StartTime) { + patch.assignedAt = new Date(cdrRow.StartTime).toISOString(); + } + if (!call.responseTimeS && call.startedAt && (patch.answeredAt || call.answeredAt)) { + const start = new Date(call.startedAt).getTime(); + const answered = new Date(patch.answeredAt ?? call.answeredAt).getTime(); + if (!isNaN(start) && !isNaN(answered)) { + patch.responseTimeS = Math.max(0, Math.round((answered - start) / 1000)); + } + } + + // CDR timing fields + const handlingSec = parseHms(cdrRow.HandlingTime); + const wrapupSec = parseHms(cdrRow.WrapupDuration); + const holdSec = parseHms(cdrRow.HoldDuration); + if (handlingSec !== null) patch.handlingTimeS = handlingSec; + if (wrapupSec !== null) patch.acwDurationS = wrapupSec; + if (holdSec !== null) patch.holdDurationS = holdSec; + + if (Object.keys(patch).length === 0) { skipped++; continue; } + + try { + await this.platform.query( + `mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`, + { id: call.id, data: patch }, + ); + patched++; + } catch (err: any) { + this.logger.warn(`[MAINT] Backfill patch failed for ${call.id}: ${err.message}`); + skipped++; + } + } + + this.logger.log(`[MAINT] Disposition+timing backfill complete: date=${date} scanned=${calls.length} patched=${patched} skipped=${skipped}`); + return { status: 'ok', date, scanned: calls.length, patched, skipped }; + } } diff --git a/src/ozonetel/ozonetel-agent.controller.ts b/src/ozonetel/ozonetel-agent.controller.ts index cdac0fe..0bc8411 100644 --- a/src/ozonetel/ozonetel-agent.controller.ts +++ b/src/ozonetel/ozonetel-agent.controller.ts @@ -278,6 +278,34 @@ 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}`); + } + } + // Auto-assign next missed call to this agent try { await this.missedQueue.assignNext(agentId); diff --git a/src/supervisor/supervisor.service.ts b/src/supervisor/supervisor.service.ts index 0a88f2e..c5a64f6 100644 --- a/src/supervisor/supervisor.service.ts +++ b/src/supervisor/supervisor.service.ts @@ -101,6 +101,22 @@ export class SupervisorService implements OnModuleInit { eventType: 'CALL_START', eventAt: iso, }).catch(() => {}); + + // Write answeredAt + responseTimeS to the Call record. + // Look up the Call by UCID, then patch. The "Calling" event + // sets assignedAt (ring start); "Answered" computes response + // time as answered - assigned (queue wait time). + this.patchCallTimingByUcid(ucid, { + answeredAt: iso, + }).catch(() => {}); + } + + // "Calling" = agent's phone is ringing → write assignedAt + // (the moment the call was routed to this agent). + if (action === 'Calling') { + this.patchCallTimingByUcid(ucid, { + assignedAt: iso, + }).catch(() => {}); } } else if (action === 'Disconnect') { const wasActive = this.activeCalls.get(ucid); @@ -306,6 +322,50 @@ export class SupervisorService implements OnModuleInit { return Array.from(this.activeCalls.values()); } + // Look up a Call by UCID and patch its timing fields. Used by + // handleCallEvent to write assignedAt/answeredAt in real-time. + // Also computes responseTimeS when answeredAt is written and + // the Call already has a startedAt. + private async patchCallTimingByUcid(ucid: string, fields: { + assignedAt?: string; + answeredAt?: string; + }): Promise { + try { + const data = await this.platform.query( + `{ calls(first: 1, filter: { ucid: { eq: "${ucid}" } }) { edges { node { id startedAt assignedAt } } } }`, + ); + const call = data?.calls?.edges?.[0]?.node; + if (!call) { + this.logger.warn(`[SLA] No Call for ucid=${ucid} — timing not written`); + return; + } + + const patch: Record = {}; + if (fields.assignedAt) patch.assignedAt = fields.assignedAt; + if (fields.answeredAt) { + patch.answeredAt = fields.answeredAt; + // Compute response time: answered - started (how long the + // caller waited from call creation to agent pickup). + const start = call.startedAt ? new Date(call.startedAt).getTime() : null; + const answered = new Date(fields.answeredAt).getTime(); + if (start && !isNaN(start) && !isNaN(answered)) { + const responseS = Math.max(0, Math.round((answered - start) / 1000)); + patch.responseTimeS = responseS; + } + } + + if (Object.keys(patch).length > 0) { + await this.platform.query( + `mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`, + { id: call.id, data: patch }, + ); + this.logger.log(`[SLA] Patched call ${call.id} — ${Object.entries(patch).map(([k, v]) => `${k}=${v}`).join(' ')}`); + } + } catch (err: any) { + this.logger.warn(`[SLA] patchCallTimingByUcid failed for ${ucid}: ${err.message}`); + } + } + async getTeamPerformance(date: string): Promise { // Get all agents from platform. Field names are label-derived // camelCase on the current platform schema — see