fix: disposition for answered inbound calls + SLA timing wiring + backfill

Three related fixes:

1. Disposition for answered inbound calls
   Previously the dispose endpoint sent the agent's choice to Ozonetel
   but never wrote it back to the platform Call record. The webhook's
   pre-disposition value ("General Enquiry" → INFO_PROVIDED) persisted.
   Now: dispose endpoint finds the Call by UCID and updates disposition
   to the agent's actual selection.

2. SLA timing wiring (assignedAt / answeredAt / responseTimeS)
   patchCallTiming() existed but was never called. Now wired into
   handleCallEvent:
   - "Calling" event → writes assignedAt (ring start)
   - "Answered" event → writes answeredAt + computes responseTimeS
     (answeredAt - startedAt = caller wait time)
   Uses patchCallTimingByUcid helper that looks up Call by UCID.

3. Backfill maint endpoint: POST /api/maint/backfill-call-disposition-timing
   Walks calls for a given date, joins to CDR by UCID (both legs),
   patches disposition (from CDR's mapped value, always overwrites),
   timing fields (answeredAt, assignedAt, responseTimeS from CDR),
   and CDR-specific durations (handlingTimeS, acwDurationS, holdDurationS).
   Idempotent — safe to run multiple times.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 18:02:49 +05:30
parent 2d8308bed8
commit a6f4c51ca9
3 changed files with 194 additions and 0 deletions

View File

@@ -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<string, any>();
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<any>(
`{ 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<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',
};
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<string, any> = {};
// 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<any>(
`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 };
}
}

View File

@@ -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<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}`);
}
}
// Auto-assign next missed call to this agent
try {
await this.missedQueue.assignNext(agentId);

View File

@@ -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<void> {
try {
const data = await this.platform.query<any>(
`{ 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<string, any> = {};
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<any>(
`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<any> {
// Get all agents from platform. Field names are label-derived
// camelCase on the current platform schema — see