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