mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
feat(supervisor): Phase 2 metrics ingest — AgentEvent/AgentSession rollup
- New AgentHistoryService: persistAgentEvent pairs START/END for durationS, patchCallTiming updates Call SLA fields - Supervisor service wires handleCallEvent (CALL_START on Answered, CALL_END on Disconnect) and handleAgentEvent (LOGIN/LOGOUT/PAUSE/RESUME/ACW_START/ACW_END/READY) via priorState-aware mapping - setInterval-based nightly-ish rollup: every 15min aggregates AgentEvent into AgentSession per IST day (idempotent upsert by agentId+date) - Ozonetel dispose flow extracts HandlingTime/WrapupDuration/HoldDuration from CDR, patches Call timing fields - Field names match platform truncation: durationS, loginDurationS, busyTimeS, idleTimeS, pauseTimeS, wrapupTimeS, avgHandlingTimeS, handlingTimeS, acwDurationS, holdDurationS, responseTimeS, sessionDate → date - Skips cleanly on workspaces where AgentEvent entity isn't synced Known issue: pending-pair map has single slot per agent, so ACW_START overwrites pending CALL_START and CALL_END computes 0s duration. Fix in followup. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,16 @@ import { Topics } from '../events/event-types';
|
||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||
import { SupervisorService } from '../supervisor/supervisor.service';
|
||||
|
||||
// Convert Ozonetel "HH:MM:SS" (or null/empty) to integer seconds.
|
||||
// Returns null when input is missing or all-zero.
|
||||
function parseHmsToSec(raw: any): number | null {
|
||||
if (!raw || typeof raw !== 'string') return null;
|
||||
if (raw === '00:00:00') return null;
|
||||
const parts = raw.split(':').map((p) => parseInt(p, 10));
|
||||
if (parts.length !== 3 || parts.some((n) => isNaN(n))) return null;
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
}
|
||||
|
||||
@Controller('api/ozonetel')
|
||||
export class OzonetelAgentController {
|
||||
private readonly logger = new Logger(OzonetelAgentController.name);
|
||||
@@ -112,6 +122,7 @@ export class OzonetelAgentController {
|
||||
direction?: string;
|
||||
durationSec?: number;
|
||||
leadId?: string;
|
||||
leadName?: string;
|
||||
notes?: string;
|
||||
missedCallId?: string;
|
||||
},
|
||||
@@ -164,6 +175,7 @@ export class OzonetelAgentController {
|
||||
endedAt,
|
||||
};
|
||||
if (body.leadId) callData.leadId = body.leadId;
|
||||
if (body.leadName) callData.leadName = body.leadName;
|
||||
|
||||
const apiKey = process.env.PLATFORM_API_KEY;
|
||||
if (apiKey) {
|
||||
@@ -173,6 +185,45 @@ export class OzonetelAgentController {
|
||||
`Bearer ${apiKey}`,
|
||||
);
|
||||
this.logger.log(`[DISPOSE] Created outbound call record: ${result.createCall.id}`);
|
||||
|
||||
// Fetch recording URL from CDR after a delay (Ozonetel needs time to process)
|
||||
const callId = result.createCall.id;
|
||||
const ucid = body.ucid;
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// fetchCdrByUCID is the targeted lookup — Ozonetel resolves
|
||||
// leg-pair UCIDs server-side, so the agent-facing UCID we
|
||||
// hold reliably returns the call row and its CallAudio.
|
||||
const record = await this.ozonetelAgent.fetchCdrByUCID({ date: dateStr, ucid });
|
||||
const audioUrl = record?.CallAudio || record?.AudioFile;
|
||||
// Compose a single update with recording + SLA timing
|
||||
// fields. CDR exposes HandlingTime, WrapupDuration,
|
||||
// HoldDuration as HH:MM:SS strings.
|
||||
const updateData: Record<string, any> = {};
|
||||
if (audioUrl) {
|
||||
updateData.recording = { primaryLinkUrl: audioUrl, primaryLinkLabel: 'Recording' };
|
||||
}
|
||||
const handlingSec = parseHmsToSec(record?.HandlingTime);
|
||||
const wrapupSec = parseHmsToSec(record?.WrapupDuration);
|
||||
const holdSec = parseHmsToSec(record?.HoldDuration);
|
||||
if (handlingSec !== null) updateData.handlingTimeS = handlingSec;
|
||||
if (wrapupSec !== null) updateData.acwDurationS = wrapupSec;
|
||||
if (holdSec !== null) updateData.holdDurationS = holdSec;
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await this.platform.queryWithAuth<any>(
|
||||
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||
{ id: callId, data: updateData },
|
||||
`Bearer ${apiKey}`,
|
||||
);
|
||||
this.logger.log(`[DISPOSE] Updated outbound call ${callId} ${audioUrl ? 'with recording + ' : ''}timing (handling=${handlingSec ?? 'na'}s wrap=${wrapupSec ?? 'na'}s hold=${holdSec ?? 'na'}s)`);
|
||||
} else {
|
||||
this.logger.warn(`[DISPOSE] No CallAudio or timing for ucid=${ucid} — record=${JSON.stringify(record ?? null)}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[DISPOSE] Failed to fetch recording for outbound call: ${err.message}`);
|
||||
}
|
||||
}, 30_000);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[DISPOSE] Failed to create outbound call record: ${err.message}`);
|
||||
@@ -183,16 +234,20 @@ export class OzonetelAgentController {
|
||||
if (body.missedCallId) {
|
||||
const statusMap: Record<string, string> = {
|
||||
APPOINTMENT_BOOKED: 'CALLBACK_COMPLETED',
|
||||
APPOINTMENT_RESCHEDULED: 'CALLBACK_COMPLETED',
|
||||
APPOINTMENT_CANCELLED: 'CALLBACK_COMPLETED',
|
||||
INFO_PROVIDED: 'CALLBACK_COMPLETED',
|
||||
FOLLOW_UP_SCHEDULED: 'CALLBACK_COMPLETED',
|
||||
CALLBACK_REQUESTED: 'CALLBACK_COMPLETED',
|
||||
NOT_INTERESTED: 'CALLBACK_COMPLETED',
|
||||
WRONG_NUMBER: 'WRONG_NUMBER',
|
||||
NO_ANSWER: 'CALLBACK_ATTEMPTED',
|
||||
};
|
||||
const newStatus = statusMap[body.disposition];
|
||||
if (newStatus) {
|
||||
try {
|
||||
await this.platform.query<any>(
|
||||
`mutation { updateCall(id: "${body.missedCallId}", data: { callbackStatus: ${newStatus} }) { id } }`,
|
||||
`mutation { updateCall(id: "${body.missedCallId}", data: { callbackStatus: ${newStatus}, disposition: ${body.disposition} }) { id } }`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to update missed call status: ${err}`);
|
||||
@@ -336,13 +391,26 @@ export class OzonetelAgentController {
|
||||
]);
|
||||
|
||||
// Filter CDR to this agent only — fetchCDR returns all agents' calls
|
||||
const agentCdr = cdr.filter((c: any) => c.AgentID === agent || c.AgentName === agent);
|
||||
// Use case-insensitive matching — Ozonetel field casing varies
|
||||
const agentLower = agent.toLowerCase();
|
||||
const agentCdr = cdr.filter((c: any) =>
|
||||
(c.AgentID ?? '').toLowerCase() === agentLower ||
|
||||
(c.AgentName ?? '').toLowerCase() === agentLower,
|
||||
);
|
||||
this.logger.log(`[PERFORMANCE] CDR total=${cdr.length} agentFiltered=${agentCdr.length} agent="${agent}"`);
|
||||
if (cdr.length > 0 && agentCdr.length === 0) {
|
||||
const sampleIds = cdr.slice(0, 3).map((c: any) => `AgentID="${c.AgentID}" AgentName="${c.AgentName}"`);
|
||||
this.logger.warn(`[PERFORMANCE] No CDR match for agent "${agent}". Sample CDR agents: ${sampleIds.join(', ')}`);
|
||||
}
|
||||
|
||||
const totalCalls = agentCdr.length;
|
||||
const inbound = agentCdr.filter((c: any) => c.Type === 'InBound').length;
|
||||
const outbound = agentCdr.filter((c: any) => c.Type === 'Manual' || c.Type === 'Progressive').length;
|
||||
const answered = agentCdr.filter((c: any) => c.Status === 'Answered').length;
|
||||
const missed = agentCdr.filter((c: any) => c.Status === 'NotAnswered').length;
|
||||
const inbound = agentCdr.filter((c: any) => (c.Type ?? '').toLowerCase() === 'inbound').length;
|
||||
const outbound = agentCdr.filter((c: any) => {
|
||||
const type = (c.Type ?? '').toLowerCase();
|
||||
return type === 'manual' || type === 'progressive' || type === 'outbound';
|
||||
}).length;
|
||||
const answered = agentCdr.filter((c: any) => (c.Status ?? '').toLowerCase() === 'answered').length;
|
||||
const missed = agentCdr.filter((c: any) => (c.Status ?? '').toLowerCase() === 'notanswered').length;
|
||||
|
||||
const talkTimes = agentCdr
|
||||
.filter((c: any) => c.TalkTime && c.TalkTime !== '00:00:00')
|
||||
@@ -380,10 +448,13 @@ export class OzonetelAgentController {
|
||||
// Campaign only has 'General Enquiry' configured currently
|
||||
const map: Record<string, string> = {
|
||||
'APPOINTMENT_BOOKED': 'General Enquiry',
|
||||
'APPOINTMENT_RESCHEDULED': 'General Enquiry',
|
||||
'APPOINTMENT_CANCELLED': 'General Enquiry',
|
||||
'FOLLOW_UP_SCHEDULED': 'General Enquiry',
|
||||
'INFO_PROVIDED': 'General Enquiry',
|
||||
'NO_ANSWER': 'General Enquiry',
|
||||
'WRONG_NUMBER': 'General Enquiry',
|
||||
'NOT_INTERESTED': 'General Enquiry',
|
||||
'CALLBACK_REQUESTED': 'General Enquiry',
|
||||
};
|
||||
return map[disposition] ?? 'General Enquiry';
|
||||
|
||||
Reference in New Issue
Block a user