collabSupport/services/cdrFeedParser.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

210 lines
6 KiB
JavaScript

// services/cdrFeedParser.js
// Parse cdr_feed JSON payloads (field names vary by Webex release).
function normKey(key) {
return String(key).toLowerCase().replace(/[^a-z0-9]/g, '');
}
function indexCdrRecord(item) {
if (!item || typeof item !== 'object') return {};
const idx = {};
for (const [k, v] of Object.entries(item)) {
idx[normKey(k)] = v;
}
return idx;
}
/** Read a CDR field by any known alias (camelCase or report column label). */
export function cdrField(item, ...aliases) {
if (!item) return null;
const idx = indexCdrRecord(item);
for (const alias of aliases) {
const v = idx[normKey(alias)];
if (v != null && v !== '') return v;
}
return null;
}
export function cdrRecordId(item) {
return cdrField(
item,
'reportId', 'Report ID', 'reportID',
'callRecordingId',
);
}
export function cdrStartTime(item) {
return cdrField(
item,
'startTime', 'start', 'callStartTime',
'Start time', 'Start Time',
'answerTime', 'Answer time', 'Answer Time',
);
}
export function cdrDurationSeconds(item) {
const raw = cdrField(
item,
'durationSeconds', 'duration', 'callDuration', 'talkDuration', 'length',
'Duration',
);
return Number(raw || 0);
}
export function cdrDirectionValue(item) {
return String(cdrField(
item,
'direction', 'callDirection', 'callType', 'directionIndicator',
'Direction', 'Call direction', 'Call Direction',
) || '').toUpperCase();
}
export function cdrDispositionValue(item) {
return String(cdrField(
item,
'status', 'result', 'callResult', 'callOutcome', 'callStatus',
'Disposition', 'Answer indicator', 'Call outcome',
) || 'unknown');
}
export function cdrCallingNumber(item) {
return cdrField(
item,
'callingNumber', 'callingParty', 'callingName',
'Calling number', 'Calling Number',
'User number', 'User Number',
);
}
export function cdrCalledNumber(item) {
return cdrField(
item,
'calledNumber', 'calledParty', 'calledName',
'Called number', 'Called Number',
);
}
export function cdrCallingLineId(item) {
return cdrField(item, 'callingLineId', 'Calling line ID', 'calling line id');
}
export function cdrCalledLineId(item) {
return cdrField(item, 'calledLineId', 'Called line ID', 'called line id');
}
export function cdrDialedDigits(item) {
return cdrField(item, 'dialedDigits', 'Dialed digits', 'dialed digits');
}
export function cdrCallOutcome(item) {
return cdrField(item, 'Call outcome', 'call outcome');
}
export function cdrCallOutcomeReason(item) {
return cdrField(item, 'Call outcome reason', 'call outcome reason');
}
export function cdrCallerIdNumber(item) {
return cdrField(item, 'Caller ID number', 'caller id number', 'callerIdNumber');
}
export function cdrUserName(item) {
return cdrField(item, 'User', 'user');
}
export function cdrUserNumber(item) {
return cdrField(item, 'User number', 'user number');
}
export function cdrModel(item) {
return cdrField(item, 'Model', 'model');
}
export function cdrSiteMainNumber(item) {
return cdrField(item, 'Site main number', 'site main number');
}
export function formatCallDisposition(outcome, reason) {
const o = String(outcome || '').trim();
const r = String(reason || '').trim();
if (!o) return 'unknown';
if (!r || r.toLowerCase() === 'normal') return o;
return `${o} / ${r}`;
}
export function cdrDedupKey(item) {
const id = cdrRecordId(item);
if (id) return `id:${id}`;
return [
cdrStartTime(item) || '',
cdrCallingNumber(item) || '',
cdrCalledNumber(item) || '',
cdrDurationSeconds(item) || '',
].join('|');
}
export function extractCdrFeedList(data) {
if (!data) return [];
if (Array.isArray(data)) return data;
if (Array.isArray(data.items)) return data.items;
if (data.items && typeof data.items === 'object' && data.items !== null) {
const keys = Object.keys(data.items).filter((k) => /^\d+$/.test(k)).sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
if (keys.length > 0) return keys.map((k) => data.items[k]);
}
const topKeys = Object.keys(data).filter((k) => /^\d+$/.test(k)).sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
if (topKeys.length > 0) return topKeys.map((k) => data[k]);
for (const key of ['records', 'cdrRecords', 'callHistory', 'calls']) {
if (Array.isArray(data[key])) return data[key];
}
if (data.data) {
if (Array.isArray(data.data)) return data.data;
if (Array.isArray(data.data.items)) return data.data.items;
}
for (const v of Object.values(data)) {
if (Array.isArray(v)) return v;
}
return [];
}
export function describeCdrFeedPayload(data) {
if (!data) return 'empty body';
if (Array.isArray(data)) return `array[${data.length}]`;
const keys = Object.keys(data);
const parts = [`keys=${keys.slice(0, 12).join(',')}`];
for (const k of ['items', 'records', 'cdrRecords', 'next', 'data']) {
const v = data[k];
if (Array.isArray(v)) parts.push(`${k}.length=${v.length}`);
else if (v && typeof v === 'object') parts.push(`${k}.keys=${Object.keys(v).slice(0, 6).join(',')}`);
}
return parts.join(' ');
}
/**
* Parse Webex RFC5988 Link header and return the `rel="next"` URL.
* @param {string|undefined} linkHeader
* @returns {string|null}
*/
export function parseCdrFeedLinkNext(linkHeader) {
if (!linkHeader || typeof linkHeader !== 'string') return null;
for (const part of linkHeader.split(',')) {
const m = part.match(/<([^>]+)>\s*;\s*rel\s*=\s*"?next"?/i);
if (m) return m[1];
}
return null;
}
/**
* Resolve the next cdr_feed page from response body and/or Link header.
* @returns {{ url: string, params: object|null }|null}
*/
export function resolveCdrFeedNextPage(data, headers, baseUrl, baseParams) {
const linkNext = parseCdrFeedLinkNext(headers?.link || headers?.Link);
if (linkNext) return { url: linkNext, params: null };
const token = data?.next || data?.['next'] || data?.metadata?.next || null;
if (!token) return null;
if (typeof token === 'string' && token.startsWith('http')) {
return { url: token, params: null };
}
return { url: baseUrl, params: { ...baseParams, next: token } };
}