collabSupport/services/callReport/groupCdrCalls.js
jmcqueen a25fc08fe2 Rename /voicereport to /callreport with scoped user and phone targets.
Add store/email/phone filtering, richer call-line formatting, CDR feed pagination and queueing, and split Jira poller enrichment into testable modules.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 10:10:03 -04:00

406 lines
12 KiB
JavaScript

// services/callReport/groupCdrCalls.js
// Group cdr_feed legs by Correlation ID into logical calls.
import {
cdrCalledNumber,
cdrCallerIdNumber,
cdrCallingLineId,
cdrCallingNumber,
cdrDialedDigits,
cdrDirectionValue,
cdrDurationSeconds,
cdrField,
cdrModel,
cdrRecordId,
cdrSiteMainNumber,
cdrStartTime,
cdrDedupKey,
cdrUserName,
cdrUserNumber,
formatCallDisposition,
} from '../cdrFeedParser.js';
export const CALL_BUCKETS = {
inboundReachedPhone: 'inboundReachedPhone',
inboundAaOnly: 'inboundAaOnly',
outboundConnected: 'outboundConnected',
other: 'other',
};
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 findInboundEntryLeg(legs) {
return legs.find((l) => callTypeValue(l).includes('INBOUND')) || legs[0];
}
function findUserPhoneLeg(legs) {
return legs.find((l) => isUserPhoneLeg(l) && isTruthyAnswered(l))
|| legs.find(isUserPhoneLeg);
}
function findOutboundOriginatingLeg(legs) {
return legs.find((l) => cdrDirectionValue(l).includes('ORIGINAT') && isUserPhoneLeg(l))
|| legs.find((l) => cdrDirectionValue(l).includes('ORIGINAT'));
}
function digitsOnly(value) {
return String(value || '').replace(/\D/g, '');
}
function pickFinalNumber(legs, mainNumber, phoneLeg) {
const candidates = [
phoneLeg ? cdrCalledNumber(phoneLeg) : null,
phoneLeg ? cdrUserNumber(phoneLeg) : null,
phoneLeg ? cdrDialedDigits(phoneLeg) : null,
...legs.map(cdrDialedDigits),
...legs.filter(isUserPhoneLeg).map(cdrCalledNumber),
].filter(Boolean);
const mainDigits = digitsOnly(mainNumber);
for (const c of candidates) {
const d = digitsOnly(c);
if (!d) continue;
if (mainDigits && d === mainDigits) continue;
if (c !== mainNumber) return c;
}
return null;
}
function pickCallingLineId(legs, direction) {
if (direction === 'inbound') {
const entry = findInboundEntryLeg(legs);
const clid = cdrCallingLineId(entry);
if (clid && String(clid).toUpperCase() !== 'NA') return clid;
}
return null;
}
function pickExternalNumber(legs, direction) {
for (const leg of legs) {
const ct = callTypeValue(leg);
if (direction === 'inbound' && ct.includes('INBOUND')) {
return cdrCallingNumber(leg) || cdrCallerIdNumber(leg);
}
if (direction === 'outbound' && ct.includes('OUTBOUND')) {
return cdrCalledNumber(leg);
}
}
const first = legs[0];
if (!first) return null;
if (direction === 'inbound') {
return cdrCallingNumber(first) || cdrCallerIdNumber(first);
}
return cdrCalledNumber(first);
}
function pickStoreMainNumber(legs) {
for (const leg of legs) {
const main = cdrSiteMainNumber(leg);
if (main) return main;
}
const entry = findInboundEntryLeg(legs);
if (entry && classifyCallDirection(legs) === 'inbound') {
return cdrCalledNumber(entry);
}
return null;
}
function pickTerminalOutcome(legs) {
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;
}
}
const outcome = cdrField(chosen, 'Call outcome', 'call outcome') || 'unknown';
const reason = cdrField(chosen, 'Call outcome reason', 'call outcome reason') || '';
return {
outcome,
reason,
disposition: formatCallDisposition(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 ? cdrUserName(answeredPhone) : null,
endpointModel: answeredPhone ? cdrModel(answeredPhone) : null,
aaKeyPress: aaLegs.map((l) => cdrField(l, 'Auto Attendant Key Pressed', 'auto attendant key pressed'))
.find((v) => v && v !== 'NA') || null,
};
}
const originating = findOutboundOriginatingLeg(legs);
const remoteAnswered = legs.some((l) =>
cdrDirectionValue(l).includes('TERMINAT') && isTruthyAnswered(l) && !isAutomatedAttendantLeg(l),
);
return {
reachedPhone: Boolean(originating && isTruthyAnswered(originating)),
connected: remoteAnswered || Boolean(originating && isTruthyAnswered(originating)),
endpointUser: originating ? cdrUserName(originating) : null,
endpointModel: originating ? cdrModel(originating) : null,
reachedAttendant: false,
aaKeyPress: null,
};
}
/**
* @param {object} call
* @returns {string}
*/
export function assignCallBucket(call) {
if (call.direction === 'inbound') {
if (call.reachedPhone) return CALL_BUCKETS.inboundReachedPhone;
if (call.reachedAttendant) return CALL_BUCKETS.inboundAaOnly;
return CALL_BUCKETS.other;
}
if (call.direction === 'outbound') {
if (call.connected || call.reachedPhone) return CALL_BUCKETS.outboundConnected;
return CALL_BUCKETS.other;
}
return CALL_BUCKETS.other;
}
/**
* @param {object[]} calls
*/
export function groupCallsByBucket(calls) {
const buckets = {
[CALL_BUCKETS.inboundReachedPhone]: [],
[CALL_BUCKETS.inboundAaOnly]: [],
[CALL_BUCKETS.outboundConnected]: [],
[CALL_BUCKETS.other]: [],
};
for (const call of calls || []) {
buckets[assignCallBucket(call)].push(call);
}
return buckets;
}
/**
* @param {object[]} calls
*/
export function filterAbnormalCalls(calls) {
return (calls || []).filter((c) => c.abnormal || !c.normalOutcome);
}
/**
* @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 entryLeg = findInboundEntryLeg(legs);
const phoneLeg = findUserPhoneLeg(legs);
const outboundOrig = findOutboundOriginatingLeg(legs);
const storeMain = pickStoreMainNumber(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));
const finalNumber = pickFinalNumber(legs, storeMain, phoneLeg);
const callingLineId = pickCallingLineId(legs, direction);
let leftParty;
let rightParty;
if (direction === 'inbound') {
leftParty = {
lineId: callingLineId,
number: pickExternalNumber(legs, direction) || cdrCallingNumber(entryLeg),
};
rightParty = {
main: storeMain || cdrCalledNumber(entryLeg),
final: finalNumber,
model: reach.endpointModel,
user: reach.endpointUser,
};
} else if (direction === 'outbound') {
leftParty = {
user: reach.endpointUser || cdrUserName(outboundOrig),
number: cdrUserNumber(outboundOrig) || cdrCallingNumber(outboundOrig),
model: reach.endpointModel || (outboundOrig ? cdrModel(outboundOrig) : null),
};
rightParty = {
number: pickExternalNumber(legs, direction) || cdrCalledNumber(legs[legs.length - 1]),
};
} else {
leftParty = { number: cdrCallingNumber(legs[0]) };
rightParty = { number: cdrCalledNumber(legs[legs.length - 1]) };
}
return {
correlationId,
direction,
start,
end,
duration,
callingLineId,
callingNumber: leftParty.number || pickExternalNumber(legs, direction),
calledNumber: direction === 'inbound'
? (storeMain || cdrCalledNumber(entryLeg))
: (rightParty.number || cdrCalledNumber(legs[legs.length - 1])),
storeMainNumber: storeMain,
finalNumber,
model: reach.endpointModel || (phoneLeg ? cdrModel(phoneLeg) : null),
disposition: outcome.disposition,
outcome: outcome.outcome,
outcomeReason: outcome.reason,
normalOutcome: outcome.normal && !abnormal,
abnormal,
legCount: legs.length,
leftParty,
rightParty,
bucket: null,
...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) => {
const call = summarizeCallGroup(g.correlationId, g.legs);
call.bucket = assignCallBucket(call);
return call;
})
.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.disposition || [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,
};
}