// services/voiceReport/groupCdrCalls.js // Group cdr_feed legs by Correlation ID into logical calls. import { cdrCalledNumber, cdrCallingNumber, cdrDirectionValue, cdrDurationSeconds, cdrField, cdrRecordId, cdrStartTime, cdrDedupKey, } from '../cdrFeedParser.js'; export function cdrCorrelationId(item) { return cdrField(item, 'correlationId', 'Correlation ID', 'correlationID'); } function isTruthyAnswered(item) { const answered = String(cdrField(item, 'Answered', 'answered') || '').toLowerCase(); const indicator = String(cdrField(item, 'Answer indicator', 'answer indicator') || ''); return answered === 'true' || indicator === 'Yes'; } function isUserPhoneLeg(item) { const userType = String(cdrField(item, 'User type', 'userType') || ''); const clientType = String(cdrField(item, 'Client type', 'clientType') || '').toUpperCase(); const model = String(cdrField(item, 'Model', 'model') || ''); if (userType === 'User') return true; if (clientType === 'WXC_DEVICE') return true; if (model.toUpperCase().includes('DBS')) return true; return false; } function isAutomatedAttendantLeg(item) { const userType = String(cdrField(item, 'User type', 'userType') || '').toLowerCase(); return userType.includes('automatedattendant'); } function callTypeValue(item) { return String(cdrField(item, 'Call type', 'callType') || '').toUpperCase(); } export function classifyCallDirection(legs) { for (const leg of legs) { const ct = callTypeValue(leg); if (ct.includes('INBOUND')) return 'inbound'; if (ct.includes('OUTBOUND')) return 'outbound'; } for (const leg of legs) { const dir = cdrDirectionValue(leg); const inboundTrunk = cdrField(leg, 'Inbound trunk', 'inbound trunk'); const outboundTrunk = cdrField(leg, 'Outbound trunk', 'outbound trunk'); if (inboundTrunk && dir.includes('TERMINAT')) return 'inbound'; if (outboundTrunk && dir.includes('ORIGINAT')) return 'outbound'; } for (const leg of legs) { const dir = cdrDirectionValue(leg); if (dir.includes('ORIGINAT')) return 'outbound'; if (dir.includes('TERMINAT')) return 'inbound'; } return 'unknown'; } export function isNormalCallOutcome(item) { const outcome = String(cdrField(item, 'Call outcome', 'call outcome') || '').toLowerCase(); const reason = String(cdrField(item, 'Call outcome reason', 'call outcome reason') || '').toLowerCase(); if (!outcome) return true; if (outcome !== 'success') return false; return !reason || reason === 'normal'; } function pickExternalNumber(legs, direction) { for (const leg of legs) { const ct = callTypeValue(leg); if (direction === 'inbound' && ct.includes('INBOUND')) { return cdrCallingNumber(leg) || cdrField(leg, 'Caller ID number', 'caller id number'); } if (direction === 'outbound' && ct.includes('OUTBOUND')) { return cdrCalledNumber(leg); } } const first = legs[0]; if (!first) return null; if (direction === 'inbound') { return cdrCallingNumber(first) || cdrField(first, 'Caller ID number', 'caller id number'); } return cdrCalledNumber(first); } function pickTerminalOutcome(legs) { // Prefer the user-phone leg, then any leg with explicit outcome. const phoneLegs = legs.filter(isUserPhoneLeg); const candidates = phoneLegs.length ? phoneLegs : legs; let chosen = candidates[candidates.length - 1]; for (const leg of candidates) { if (cdrField(leg, 'Call outcome', 'call outcome')) { chosen = leg; } } return { outcome: cdrField(chosen, 'Call outcome', 'call outcome') || 'unknown', reason: cdrField(chosen, 'Call outcome reason', 'call outcome reason') || '', normal: isNormalCallOutcome(chosen), }; } function summarizeReach(legs, direction) { const phoneLegs = legs.filter(isUserPhoneLeg); const answeredPhone = phoneLegs.find(isTruthyAnswered); const aaLegs = legs.filter(isAutomatedAttendantLeg); const answeredAa = aaLegs.find(isTruthyAnswered); if (direction === 'inbound') { return { reachedPhone: Boolean(answeredPhone), reachedAttendant: Boolean(answeredAa), endpointUser: answeredPhone ? cdrField(answeredPhone, 'User', 'user') : null, endpointModel: answeredPhone ? cdrField(answeredPhone, 'Model', 'model') : null, aaKeyPress: aaLegs.map((l) => cdrField(l, 'Auto Attendant Key Pressed', 'auto attendant key pressed')) .find((v) => v && v !== 'NA') || null, }; } // Outbound: originating user/device leg answered, remote side picked up on terminating leg. const originating = legs.find((l) => cdrDirectionValue(l).includes('ORIGINAT') && isUserPhoneLeg(l)); const remoteAnswered = legs.some((l) => cdrDirectionValue(l).includes('TERMINAT') && isTruthyAnswered(l) && !isAutomatedAttendantLeg(l), ); return { reachedPhone: Boolean(originating && isTruthyAnswered(originating)), connected: remoteAnswered || (originating && isTruthyAnswered(originating)), endpointUser: originating ? cdrField(originating, 'User', 'user') : null, endpointModel: originating ? cdrField(originating, 'Model', 'model') : null, reachedAttendant: false, aaKeyPress: null, }; } /** * @param {object[]} rawLegs * @returns {object} */ export function summarizeCallGroup(correlationId, rawLegs) { const legs = [...(rawLegs || [])].sort((a, b) => { const ta = new Date(cdrStartTime(a) || 0).getTime(); const tb = new Date(cdrStartTime(b) || 0).getTime(); return ta - tb; }); const direction = classifyCallDirection(legs); const starts = legs.map((l) => cdrStartTime(l)).filter(Boolean); const start = starts.length ? starts.sort()[0] : null; const releaseTimes = legs .map((l) => cdrField(l, 'Release time', 'release time')) .filter(Boolean); const end = releaseTimes.length ? releaseTimes.sort().reverse()[0] : null; const duration = (() => { if (start && end) { return Math.max(0, Math.round((new Date(end).getTime() - new Date(start).getTime()) / 1000)); } return Math.max(...legs.map(cdrDurationSeconds), 0); })(); const outcome = pickTerminalOutcome(legs); const reach = summarizeReach(legs, direction); const abnormal = legs.some((l) => !isNormalCallOutcome(l)); return { correlationId, direction, start, end, duration, callingNumber: pickExternalNumber(legs, direction) || cdrCallingNumber(legs[0]), calledNumber: cdrCalledNumber(legs[legs.length - 1]) || cdrCalledNumber(legs[0]), outcome: outcome.outcome, outcomeReason: outcome.reason, normalOutcome: outcome.normal && !abnormal, abnormal, legCount: legs.length, ...reach, legs, }; } /** * @param {object[]} rawItems deduped cdr_feed rows * @returns {object[]} */ export function groupCdrIntoCalls(rawItems) { const groups = new Map(); for (const item of rawItems || []) { const corr = cdrCorrelationId(item); const key = corr ? `corr:${corr}` : `solo:${cdrRecordId(item) || cdrDedupKey(item)}`; if (!groups.has(key)) groups.set(key, { correlationId: corr, legs: [] }); groups.get(key).legs.push(item); } return [...groups.values()] .map((g) => summarizeCallGroup(g.correlationId, g.legs)) .sort((a, b) => new Date(a.start || 0).getTime() - new Date(b.start || 0).getTime()); } export function summarizeCalls(calls) { let inbound = 0; let outbound = 0; let unknown = 0; let inboundReachedPhone = 0; let inboundAaOnly = 0; let outboundConnected = 0; let abnormal = 0; for (const call of calls) { if (call.direction === 'inbound') { inbound++; if (call.reachedPhone) inboundReachedPhone++; else if (call.reachedAttendant) inboundAaOnly++; } else if (call.direction === 'outbound') { outbound++; if (call.connected || call.reachedPhone) outboundConnected++; } else { unknown++; } if (call.abnormal || !call.normalOutcome) abnormal++; } const outcomes = {}; for (const call of calls) { const key = [call.outcome, call.outcomeReason].filter(Boolean).join(' / ') || 'unknown'; outcomes[key] = (outcomes[key] || 0) + 1; } return { total: calls.length, inbound, outbound, unknown, inboundReachedPhone, inboundAaOnly, outboundConnected, abnormal, outcomes, }; }