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>
224 lines
6.7 KiB
JavaScript
224 lines
6.7 KiB
JavaScript
// services/callReport/formatCallLine.js
|
|
// Readable per-call lines for /callreport.
|
|
|
|
import { DateTime } from 'luxon';
|
|
|
|
export const WAN_THRESHOLDS = {
|
|
mosFair: 4,
|
|
mosUnusable: 3.5,
|
|
jitterMs: 40,
|
|
lossPct: 5,
|
|
};
|
|
|
|
export function formatDurationHuman(seconds) {
|
|
const s = Math.max(0, Math.round(Number(seconds) || 0));
|
|
if (s < 60) return `${s}s`;
|
|
const m = Math.floor(s / 60);
|
|
const rem = s % 60;
|
|
return rem ? `${m}m ${rem}s` : `${m}m`;
|
|
}
|
|
|
|
export function formatLocalTime(iso, timeZone) {
|
|
if (!iso) return '—';
|
|
try {
|
|
return DateTime.fromISO(String(iso), { zone: 'utc' })
|
|
.setZone(timeZone || 'utc')
|
|
.toFormat('h:mma')
|
|
.toLowerCase();
|
|
} catch {
|
|
return String(iso);
|
|
}
|
|
}
|
|
|
|
function fmtPhone(num) {
|
|
if (!num) return null;
|
|
const s = String(num).trim();
|
|
if (!s) return null;
|
|
if (s.startsWith('+')) return s;
|
|
const d = s.replace(/\D/g, '');
|
|
if (d.length === 10) return `+1${d}`;
|
|
if (d.length === 11 && d.startsWith('1')) return `+${d}`;
|
|
return s;
|
|
}
|
|
|
|
function partyLabel(lineId, number) {
|
|
const phone = fmtPhone(number);
|
|
const lid = lineId && String(lineId).toUpperCase() !== 'NA' ? String(lineId).trim() : null;
|
|
if (lid && phone) return `${lid} (${phone})`;
|
|
if (lid) return lid;
|
|
if (phone) return phone;
|
|
return '?';
|
|
}
|
|
|
|
function storeUserLabel(user, number) {
|
|
const phone = fmtPhone(number);
|
|
const u = user && String(user).trim();
|
|
if (u && phone) return `${u} (${phone})`;
|
|
if (u) return u;
|
|
if (phone) return phone;
|
|
return '?';
|
|
}
|
|
|
|
function outboundOriginNumber(number) {
|
|
const numRaw = number && String(number).trim();
|
|
if (!numRaw) return null;
|
|
if (numRaw.length <= 5 && !numRaw.startsWith('+')) return numRaw;
|
|
return fmtPhone(number) || numRaw;
|
|
}
|
|
|
|
function formatOutboundOrigin(call) {
|
|
const user = call.endpointUser || call.leftParty?.user;
|
|
const number = call.leftParty?.number || call.callingNumber;
|
|
const model = call.model || call.endpointModel || call.leftParty?.model;
|
|
const u = user && String(user).trim();
|
|
const num = outboundOriginNumber(number);
|
|
const inner = [num, model && String(model).trim()].filter(Boolean).join(', ');
|
|
|
|
if (u && inner) return `${u} (${inner})`;
|
|
if (u) return u;
|
|
if (inner) return inner;
|
|
return '?';
|
|
}
|
|
|
|
function isShortDial(num) {
|
|
const s = String(num || '').trim();
|
|
if (!s) return false;
|
|
if (/^[#*]?\d{1,4}$/.test(s)) return true;
|
|
const d = s.replace(/\D/g, '');
|
|
return d.length > 0 && d.length <= 4;
|
|
}
|
|
|
|
function formatRemoteParty(call) {
|
|
const num = call.calledNumber || call.rightParty?.number;
|
|
if (call.direction === 'outbound' && isShortDial(num)) {
|
|
return `dialed ${num}`;
|
|
}
|
|
return fmtPhone(num) || '?';
|
|
}
|
|
|
|
function isAaOnly(call) {
|
|
return Boolean(call.reachedAttendant && !call.reachedPhone);
|
|
}
|
|
|
|
function rightSuffix(call) {
|
|
const aaOnly = isAaOnly(call);
|
|
const parts = [];
|
|
if (!aaOnly && call.finalNumber && call.finalNumber !== call.calledNumber && call.finalNumber !== call.storeMainNumber) {
|
|
parts.push(`final ${call.finalNumber}`);
|
|
}
|
|
if (!aaOnly && call.model) parts.push(call.model);
|
|
if (aaOnly && call.aaKeyPress) {
|
|
parts.push(`AA key ${call.aaKeyPress}`);
|
|
}
|
|
if (!parts.length) return '';
|
|
return ` (${parts.join(', ')})`;
|
|
}
|
|
|
|
function roundMetric(value, decimals = 1) {
|
|
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
|
const factor = 10 ** decimals;
|
|
return Math.round(value * factor) / factor;
|
|
}
|
|
|
|
function isCleanSuccess(call) {
|
|
if (call.abnormal || !call.normalOutcome) return false;
|
|
const disposition = String(call.disposition || call.outcome || '').trim();
|
|
return disposition.toLowerCase() === 'success';
|
|
}
|
|
|
|
/**
|
|
* Leading icon for a call line.
|
|
*/
|
|
export function formatCallPrefix(call) {
|
|
if (isAaOnly(call)) return '⚠️ ';
|
|
if (call.abnormal || !call.normalOutcome) return '⚠️ ';
|
|
if (isCleanSuccess(call)) return '✅ ';
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Trailing disposition text (omits redundant "Success" when prefixed with ✅).
|
|
*/
|
|
export function formatDispositionSuffix(call) {
|
|
if (isAaOnly(call)) return '';
|
|
if (isCleanSuccess(call)) return '';
|
|
const disposition = call.disposition || call.outcome || 'unknown';
|
|
if (String(disposition).trim().toLowerCase() === 'success') return '';
|
|
return ` ${disposition}`;
|
|
}
|
|
|
|
function wanMetricLabel(label, value, unit, warnIcon, criticalIcon) {
|
|
let icon = '';
|
|
if (criticalIcon) icon = ` ${criticalIcon}`;
|
|
else if (warnIcon) icon = ` ${warnIcon}`;
|
|
return `${label}: ${value}${unit}${icon}`;
|
|
}
|
|
|
|
/**
|
|
* @param {object} wan
|
|
* @returns {string|null}
|
|
*/
|
|
export function formatWanLine(wan) {
|
|
if (!wan) return null;
|
|
const bits = [];
|
|
const mos = roundMetric(wan.mos, 1);
|
|
const jitter = roundMetric(wan.jitter, 1);
|
|
const loss = roundMetric(wan.loss, 1);
|
|
|
|
if (mos != null) {
|
|
let warn = null;
|
|
let critical = null;
|
|
if (mos < WAN_THRESHOLDS.mosUnusable) critical = '‼️';
|
|
else if (mos < WAN_THRESHOLDS.mosFair) warn = '⚠️';
|
|
bits.push(wanMetricLabel('MOS', mos, '', warn, critical));
|
|
}
|
|
if (jitter != null) {
|
|
const warn = jitter > WAN_THRESHOLDS.jitterMs ? '⚠️' : null;
|
|
bits.push(wanMetricLabel('Jitter', jitter, 'ms', warn, null));
|
|
}
|
|
if (loss != null) {
|
|
const warn = loss > WAN_THRESHOLDS.lossPct ? '⚠️' : null;
|
|
bits.push(wanMetricLabel('Loss', loss, '%', warn, null));
|
|
}
|
|
return bits.length ? bits.join(' ') : null;
|
|
}
|
|
|
|
/**
|
|
* @param {object} call grouped call from groupCdrCalls
|
|
* @param {{ timeZone?: string, includeWan?: boolean, detail?: boolean }} opts
|
|
* @returns {{ line1: string, line2: string|null }}
|
|
*/
|
|
export function formatCallBlock(call, opts = {}) {
|
|
const { timeZone, includeWan = true, detail = false } = opts;
|
|
const time = formatLocalTime(call.start, timeZone);
|
|
const dur = formatDurationHuman(call.duration);
|
|
const prefix = formatCallPrefix(call);
|
|
|
|
let left;
|
|
let right;
|
|
if (call.direction === 'inbound') {
|
|
left = partyLabel(call.callingLineId || call.leftParty?.lineId, call.callingNumber || call.leftParty?.number);
|
|
const main = fmtPhone(call.storeMainNumber || call.calledNumber || call.rightParty?.main) || '?';
|
|
right = `${main}${rightSuffix(call)}`;
|
|
} else if (call.direction === 'outbound') {
|
|
left = formatOutboundOrigin(call);
|
|
right = formatRemoteParty(call);
|
|
} else {
|
|
left = partyLabel(call.callingLineId, call.callingNumber);
|
|
right = fmtPhone(call.calledNumber) || '?';
|
|
}
|
|
|
|
const suffix = formatDispositionSuffix(call);
|
|
let line1 = `${prefix}${time} (${dur}): ${left} → ${right}${suffix}`;
|
|
if (detail && call.correlationId) {
|
|
line1 += ` _(${call.legCount} leg(s), ${call.correlationId.slice(0, 8)}…)_`;
|
|
}
|
|
|
|
let line2 = null;
|
|
if (includeWan && call.wan) {
|
|
const wanText = formatWanLine(call.wan);
|
|
if (wanText) line2 = ` ${wanText}`;
|
|
}
|
|
|
|
return { line1, line2 };
|
|
}
|