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>
250 lines
8.2 KiB
JavaScript
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,
|
|
};
|
|
}
|