collabSupport/services/voiceReport/groupCdrCalls.js
jmcqueen 1eaabbcee1 Add /voicereport store digest and harden Webex CDR feed handling.
Groups cdr_feed legs by Correlation ID for call-level summaries, fixes report-column field parsing and Docker proxy routing, and adds /calltest post-call Twilio and CDR enrichment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 13:43:11 -04:00

250 lines
8.2 KiB
JavaScript

// 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,
};
}