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>
143 lines
4.5 KiB
JavaScript
143 lines
4.5 KiB
JavaScript
// services/callReport/joinCallQuality.js
|
|
// Join grouped CDR calls with Prisma WAN buckets.
|
|
|
|
import { isWithinWindow } from './businessWindow.js';
|
|
import { cdrStartTime } from '../cdrFeedParser.js';
|
|
import {
|
|
filterAbnormalCalls,
|
|
groupCallsByBucket,
|
|
groupCdrIntoCalls,
|
|
summarizeCalls,
|
|
} from './groupCdrCalls.js';
|
|
import { filterCallsByTarget, rebucketCalls } from './filterCallsByTarget.js';
|
|
|
|
const DEFAULT_INTERVAL_MS = 5 * 60 * 1000;
|
|
|
|
function shouldAttachWan(call) {
|
|
if (call.direction === 'inbound') return Boolean(call.reachedPhone);
|
|
if (call.direction === 'outbound') return Boolean(call.connected || call.reachedPhone);
|
|
return false;
|
|
}
|
|
|
|
function intervalMsFromSeries(summary) {
|
|
if (summary?.interval === '5min') return DEFAULT_INTERVAL_MS;
|
|
return DEFAULT_INTERVAL_MS;
|
|
}
|
|
|
|
function valueAtTime(series, time) {
|
|
if (!series || time == null) return null;
|
|
if (series.timedPoints?.length) {
|
|
const hit = series.timedPoints.find((p) => p.time === time);
|
|
return hit?.value ?? null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function overlappingBucketTimes(series, startMs, endMs, intervalMs, winStartMs) {
|
|
const times = new Set();
|
|
if (series?.timedPoints?.length) {
|
|
for (const p of series.timedPoints) {
|
|
if (p.value == null || !p.time) continue;
|
|
const t = new Date(p.time).getTime();
|
|
if (!Number.isFinite(t)) continue;
|
|
if (t < endMs && (t + intervalMs) > startMs) times.add(p.time);
|
|
}
|
|
return times;
|
|
}
|
|
|
|
// Fallback for tests / legacy summaries without timedPoints.
|
|
const mosVals = series?.values || [];
|
|
if (!mosVals.length || !Number.isFinite(winStartMs)) return times;
|
|
const idx0 = Math.max(0, Math.floor((startMs - winStartMs) / intervalMs));
|
|
const idx1 = Math.max(idx0, Math.ceil((endMs - winStartMs) / intervalMs));
|
|
for (let i = idx0; i <= idx1 && i < mosVals.length; i++) {
|
|
if (mosVals[i] != null) times.add(`__idx:${i}`);
|
|
}
|
|
return times;
|
|
}
|
|
|
|
function worstFromTimedOverlap(appAudio, startMs, endMs, winStartMs) {
|
|
const mosSeries = appAudio?.mos;
|
|
if (!mosSeries) return null;
|
|
|
|
const intervalMs = intervalMsFromSeries(mosSeries);
|
|
const bucketTimes = overlappingBucketTimes(mosSeries, startMs, endMs, intervalMs, winStartMs);
|
|
if (!bucketTimes.size) return null;
|
|
|
|
let worstMos = null;
|
|
let worstLoss = null;
|
|
let worstJitter = null;
|
|
|
|
for (const time of bucketTimes) {
|
|
let m;
|
|
let l;
|
|
let j;
|
|
if (time.startsWith('__idx:')) {
|
|
const i = Number(time.slice(6));
|
|
m = mosSeries.values?.[i];
|
|
l = appAudio.loss?.values?.[i];
|
|
j = appAudio.jitter?.values?.[i];
|
|
} else {
|
|
m = valueAtTime(mosSeries, time);
|
|
l = valueAtTime(appAudio.loss, time);
|
|
j = valueAtTime(appAudio.jitter, time);
|
|
}
|
|
if (typeof m === 'number' && (worstMos == null || m < worstMos)) worstMos = m;
|
|
if (typeof l === 'number' && (worstLoss == null || l > worstLoss)) worstLoss = l;
|
|
if (typeof j === 'number' && (worstJitter == null || j > worstJitter)) worstJitter = j;
|
|
}
|
|
|
|
if (worstMos == null && worstLoss == null && worstJitter == null) return null;
|
|
return {
|
|
siteLevelApprox: true,
|
|
mos: worstMos,
|
|
loss: worstLoss,
|
|
jitter: worstJitter,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Find worst Prisma 5-min bucket overlapping [start, end].
|
|
*/
|
|
export function worstPrismaBucketForCall(call, appAudio, window) {
|
|
if (!appAudio?.mos?.values?.length && !appAudio?.mos?.timedPoints?.length) return null;
|
|
const startMs = call.start ? new Date(call.start).getTime() : NaN;
|
|
const endMs = Number.isFinite(startMs)
|
|
? startMs + (call.duration || 0) * 1000
|
|
: NaN;
|
|
if (!Number.isFinite(startMs)) return null;
|
|
|
|
const winStartMs = window?.startTime ? new Date(window.startTime).getTime() : startMs;
|
|
return worstFromTimedOverlap(appAudio, startMs, endMs, winStartMs);
|
|
}
|
|
|
|
export function joinCallQuality({ cdrItems, appAudio, window, filter = null }) {
|
|
const calls = groupCdrIntoCalls(cdrItems);
|
|
const joined = calls.map((call) => {
|
|
const wan = shouldAttachWan(call)
|
|
? worstPrismaBucketForCall(call, appAudio, window)
|
|
: null;
|
|
return { ...call, wan };
|
|
});
|
|
|
|
const scoped = filter ? filterCallsByTarget(joined, filter) : joined;
|
|
if (filter) {
|
|
return rebucketCalls(scoped);
|
|
}
|
|
|
|
return {
|
|
calls: joined,
|
|
legs: joined,
|
|
buckets: groupCallsByBucket(joined),
|
|
abnormalCalls: filterAbnormalCalls(joined),
|
|
summary: summarizeCalls(joined),
|
|
};
|
|
}
|
|
|
|
export function filterCdrToWindow(items, window) {
|
|
return (items || []).filter((item) => {
|
|
const start = cdrStartTime(item);
|
|
if (!start) return true;
|
|
return isWithinWindow(start, window);
|
|
});
|
|
}
|