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>
406 lines
12 KiB
JavaScript
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,
|
|
};
|
|
}
|