Add /voicereport store digest and harden Webex CDR feed handling.

Groups cdr_feed legs by Correlation ID for call-level summaries, fixes report-column field parsing and Docker proxy routing, and adds /calltest post-call Twilio and CDR enrichment.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jmcqueen 2026-07-24 13:43:11 -04:00
parent 7aa8c37d1d
commit 1eaabbcee1
36 changed files with 3131 additions and 115 deletions

View file

@ -56,6 +56,8 @@ WEBEX_BOT_TOKEN=your-bot-token-here
# - spark-admin:licenses_read (webexhost, findEmptyLocations)
# - identity:tokens_read (offboarduser: list a user's authorizations)
# - identity:tokens_write (offboarduser: revoke a user's authorizations)
# - spark-admin:calling_cdr_read (/voicereport + /calltest CDR via cdr_feed)
# - analytics:read_all (/voicereport Webex Reports API — Pro Pack)
# The authorizing admin must also hold Full / User / Device Admin role for the
# token-management calls to succeed.
#
@ -86,6 +88,12 @@ WEBEX_TOKENS_PATH=./config/webex-service-tokens.json
# Optional override for the Webex API base URL (default https://webexapis.com/v1).
# WEBEX_BASE_URL=https://webexapis.com/v1
# CDR feed (/cdr_feed) uses analytics-calling.webexapis.com — a different host than
# webexapis.com. In Docker, HTTP(S)_PROXY may route analytics subdomains through a
# broken proxy while webexapis.com is in NO_PROXY. CDR requests bypass proxy by default.
# WEBEX_CDR_USE_PROXY=true # honor HTTP(S)_PROXY for cdr_feed (default: false)
# WEBEX_CDR_ANALYTICS_BASES=https://analytics-calling.webexapis.com/v1,https://analytics.webexapis.com/v1
# -----------------------------------------------------------------------------
# Palo Alto Prisma SD-WAN (formerly CloudGenix) — WAN metrics for /voicediag
# and /phonestatus follow-up
@ -493,12 +501,30 @@ DECT_RELAY_AGENT_TOKEN=
# CALLTEST_INTRO_TTS=Hello. This is an automated connectivity test...
# CALLTEST_OUTRO_TTS=Thank you. The connectivity test is complete. Goodbye.
#
# Store mode CDR follow-up (uses spark-admin:calling_cdr_read):
# Post-call enrichment (separate Webex cards after the result):
# CALLTEST_TWILIO_ENRICH=true
# CALLTEST_TWILIO_DETAILS_DELAY_MS=10000
# CALLTEST_TWILIO_INSIGHTS_DELAY_MS=360000
# CALLTEST_TWILIO_INSIGHTS_RETRIES=3
# CALLTEST_TWILIO_INSIGHTS_RETRY_MS=30000
#
# Webex CDR leg match (store + dial when dialed number is a known location DID):
# CALLTEST_CDR_ENRICH=true
# CALLTEST_CDR_DELAY_MS=360000
# CALLTEST_CDR_MATCH_BUFFER_MS=120000
#
# Per-store entry overrides: config/calltest-stores.json
# -----------------------------------------------------------------------------
# /voicereport — daily voice digest (CDR + Media Quality + Prisma WAN)
# -----------------------------------------------------------------------------
# VOICEREPORT_BUSINESS_START_HOUR=9
# VOICEREPORT_BUSINESS_END_HOUR=21
# VOICEREPORT_API_LAG_MS=300000
# VOICEREPORT_MAX_DETAIL_ROWS=25
# VOICEREPORT_REPORT_POLL_MS=5000
# VOICEREPORT_REPORT_POLL_MAX_MS=180000
# WEBEX_REPORT_TEMPLATE_MEDIA_QUALITY=
# -----------------------------------------------------------------------------
# Optional. Per-base collect() RPC timeout. Corporate proxies can make
# DBS-210 reads slow; 15s is comfortable, 30s is generous.
# DECT_COLLECT_TIMEOUT_MS=15000

View file

@ -9,7 +9,7 @@ import {
startStoreTest,
} from '../services/callTest/callTestService.js';
import { parseCallTestArgs } from '../services/callTest/parseArgs.js';
import { renderCallTestResultMarkdown } from '../services/renderers/callTestRenderer.js';
import { renderCallTestResultMarkdown, renderCallTestEnrichmentStatusMarkdown } from '../services/renderers/callTestRenderer.js';
import { extractRequester } from '../utils/requester.js';
import { logger } from '../utils/logger.js';
@ -65,7 +65,7 @@ export async function handleCallTest(bot, trigger) {
await bot.say('markdown', `No in-memory session for testId \`${parsed.testId}\` (expired or unknown).`);
return;
}
await bot.say('markdown', renderCallTestResultMarkdown(session));
await bot.say('markdown', renderCallTestResultMarkdown(session) + renderCallTestEnrichmentStatusMarkdown(session));
return;
}

View file

@ -21,6 +21,7 @@ const SHORT_HELP = {
phonestatus: 'DECT + IP phone status for a store (plus 7d WAN follow-up)',
dectstatus: 'Full DECT basestation dump via relay (reboot / factory-reset cards)',
voicediag: 'Deep voice diagnostic: Webex Calling features + SD-WAN quality with fix cards',
voicereport: 'Daily voice digest: CDR + Media Quality + Prisma WAN for a store',
calltest: 'Twilio voice path test (store AA or direct dial, 60s listen)',
// Jira
@ -150,6 +151,28 @@ const LONG_HELP = {
'PCAPs land in the System Log bundle downloadable from Control Hub diagnostics.',
],
},
voicereport: {
title: '/voicereport',
usage: [
'/voicereport <store>',
'/voicereport <store> today',
'/voicereport <store> YYYY-MM-DD',
'/voicereport <store> --detail',
],
examples: [
'/voicereport 782',
'/voicereport 782 today',
'/voicereport 782 2026-07-22',
'/voicereport 782 --detail',
],
notes: [
'Store-scoped daily voice digest for **9am9pm local** (default: **yesterday**). `today` uses 9am → 5 minutes ago.',
'Pulls **all CDR legs** for the Webex location, **Calling Media Quality** report (Pro Pack + `analytics:read_all`), and **Prisma Webex_Calling_RTP** WAN overlay when configured.',
'Requires `spark-admin:calling_cdr_read` + Control Hub role **Webex Calling Detailed Call History API access** for CDR.',
'Report generation may take 13 minutes; an ack message posts first.',
'HTTP: `?storeNum=782&date=yesterday&detail=true`.',
],
},
calltest: {
title: '/calltest',
usage: [
@ -169,7 +192,8 @@ const LONG_HELP = {
'**Store path:** dials the store main number (Webex `locationMainNumber`), pauses for the AA greeting, sends DTMF `1`, waits for the store leg, then runs the shared **60-second** listen test (intro → pause → thank you).',
'**Direct dial:** calls any E.164 number; on answer, runs the same 60s test with no AA/DTMF.',
'Requires `TWILIO_*` env vars, `TWILIO_WEBHOOK_BASE_URL` (public HTTPS), and `CALLTEST_ENABLED=true`.',
'Store tests optionally post a **CDR follow-up** ~6 minutes later when `CALLTEST_CDR_ENRICH=true`.',
'After the result card, follow-ups post separately: **Twilio details** (~10s), **Twilio Insights** (~6 min, requires Advanced Features), **Webex CDR match** (~6 min when location is known).',
'CDR match works for store tests and dial tests to a known store main number; legs are matched on **destination (called number)** only.',
'Per-store entry tuning: `config/calltest-stores.json` (`dtmf`, `greetingPauseSec`, `answerWaitSec`).',
],
},
@ -277,7 +301,7 @@ const LONG_HELP = {
const GROUPS = [
{ title: 'Work orders', keys: ['wohistory', 'wosummary', 'woattachments'] },
{ title: 'AV & phones', keys: ['avstatus', 'phonestatus', 'dectstatus', 'voicediag', 'calltest'] },
{ title: 'AV & phones', keys: ['avstatus', 'phonestatus', 'dectstatus', 'voicediag', 'voicereport', 'calltest'] },
{ title: 'Jira', keys: ['jirahistory', 'jiraticket', 'jirapoll'] },
{ title: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] },
{ title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] },

View file

@ -31,6 +31,7 @@ import { handleBulkAvStatusCSV } from './bulkAvStatusCSV.js';
import { handleBulkAvSwitchCSV } from './bulkAvSwitchCSV.js';
import { handleTestDevicesByModel } from './testDevicesByModel.js';
import { handleCallTest } from './callTest.js';
import { handleVoiceReport } from './voiceReport.js';
/**
* Each entry:
@ -57,6 +58,7 @@ export const commands = [
// (mutating: true). See commands/voiceDiag.js + services/voiceDiag/
// for the full behavior contract + required scopes.
{ name: 'voicediag', handler: handleVoiceDiag, mutating: true },
{ name: 'voicereport', handler: handleVoiceReport, mutating: false },
{ name: 'wohistory', handler: handleWoHistory, mutating: false },
{ name: 'wosummary', handler: handleWoSummary, mutating: false },
{ name: 'woattachments', handler: handleWoAttachments, mutating: false },

64
commands/voiceReport.js Normal file
View file

@ -0,0 +1,64 @@
// commands/voiceReport.js
import { logger } from '../utils/logger.js';
import { collectVoiceReport } from '../services/voiceReport/voiceReportService.js';
import { renderVoiceReportMarkdown } from '../services/renderers/voiceReportRenderer.js';
function pickDateArg(args) {
for (let i = 1; i < args.length; i++) {
const a = String(args[i] || '').trim();
if (!a || a.startsWith('--')) continue;
if (/^\d{2,4}$/.test(a) && i === 1) continue;
return a;
}
const flag = args.find((a) => String(a).startsWith('--date='));
if (flag) return flag.split('=').slice(1).join('=');
const idx = args.indexOf('--date');
if (idx >= 0 && args[idx + 1]) return args[idx + 1];
return null;
}
function hasDetailFlag(args, query) {
return args.some((a) => ['detail', 'detailed', '--detail', '--detailed'].includes(String(a).toLowerCase()))
|| query.detail === 'true'
|| query.detailed === 'true';
}
export async function handleVoiceReport(bot, trigger) {
logger('voicereport', 'Handler entered', 'debug');
const query = trigger.query || {};
const args = trigger.args || [];
let storeNum = args[0]?.trim() || query.storeNum || query.store || query.s;
const dateArg = pickDateArg(args) || query.date || null;
const detail = hasDetailFlag(args, query);
if (!storeNum || !/^\d{2,4}$/.test(storeNum)) {
await bot.say(
'markdown',
'**Voice report usage**\n\n' +
'`/voicereport <store>` — yesterday 9am9pm local (default)\n' +
'`/voicereport <store> today` — today 9am → 5 min ago\n' +
'`/voicereport <store> 2026-07-22` — specific day\n' +
'`/voicereport <store> --detail` — include per-call table\n\n' +
'HTTP: `?storeNum=782&date=yesterday&detail=true`',
);
return;
}
await bot.say(
'markdown',
`**Voice report** — generating for store **${storeNum}** ` +
`(${dateArg || 'yesterday'})… This may take 13 minutes while Webex builds the Media Quality report.`,
);
try {
const report = await collectVoiceReport(storeNum, { dateArg });
const md = renderVoiceReportMarkdown(report, { detail });
await bot.say('markdown', md);
} catch (err) {
logger('voicereport', `Failed for store ${storeNum}: ${err.message}`, 'error');
await bot.say('markdown', `❌ Voice report failed for store ${storeNum}: ${err.message}`);
}
}

View file

@ -413,7 +413,7 @@ function appMetricsPath() {
* shipping default for WAN checks
* @returns {Promise<object|null>}
*/
export async function getAppMetric(siteId, appId, metricKey, windowMinutes = 1440) {
export async function getAppMetric(siteId, appId, metricKey, windowOrOpts = 1440) {
const spec = APP_METRIC_NAMES[metricKey];
if (!spec) {
throw new Error(
@ -427,6 +427,27 @@ export async function getAppMetric(siteId, appId, metricKey, windowMinutes = 144
return null;
}
let startTime;
let endTime;
let windowMinutes;
if (
windowOrOpts
&& typeof windowOrOpts === 'object'
&& windowOrOpts.startTime
&& windowOrOpts.endTime
) {
startTime = windowOrOpts.startTime;
endTime = windowOrOpts.endTime;
windowMinutes = Math.max(
1,
Math.round((new Date(endTime).getTime() - new Date(startTime).getTime()) / 60_000),
);
} else {
windowMinutes = typeof windowOrOpts === 'number' ? windowOrOpts : 1440;
startTime = windowStart(windowMinutes);
endTime = nowIso();
}
const filter = {
site: [String(siteId)],
app: [String(appId)],
@ -436,8 +457,8 @@ export async function getAppMetric(siteId, appId, metricKey, windowMinutes = 144
try {
const res = await paloAltoAxios.post(appMetricsPath(), {
start_time: windowStart(windowMinutes),
end_time: nowIso(),
start_time: startTime,
end_time: endTime,
// NOT pickInterval — see pickAppMetricInterval doc for why:
// app metrics need the fine-grained series or the client-side
// worst-window aggregation is meaningless.

View file

@ -82,9 +82,162 @@ export async function createOutboundCall({ to, voiceUrl, statusCallback, timeout
return call;
}
function twilioAxios() {
const { accountSid, authToken } = twilioAuth();
return {
accountSid,
client: axios.create({
auth: { username: accountSid, password: authToken },
timeout: 30_000,
}),
};
}
function unavailable(reason, extra = {}) {
return { available: false, reason, data: null, ...extra };
}
/**
* Fetch the Programmable Voice Call resource (duration, price, timestamps).
*/
export async function fetchCallResource(callSid) {
if (!callSid) return unavailable('no callSid');
try {
const { accountSid, client } = twilioAxios();
const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Calls/${callSid}.json`;
const resp = await client.get(url);
const d = resp.data || {};
return {
available: true,
data: {
sid: d.sid,
status: d.status,
duration: d.duration != null ? Number(d.duration) : null,
startTime: d.start_time || d.date_created,
endTime: d.end_time,
from: d.from,
to: d.to,
direction: d.direction,
price: d.price,
priceUnit: d.price_unit,
answeredBy: d.answered_by,
forwardedFrom: d.forwarded_from,
callerName: d.caller_name,
queueTime: d.queue_time,
},
raw: d,
};
} catch (err) {
const status = err.response?.status;
const msg = err.response?.data?.message || err.message;
logger(LOG_SCOPE, `fetchCallResource ${callSid} failed: ${msg}`, 'warn');
return unavailable(status ? `HTTP ${status}: ${msg}` : msg, { status });
}
}
async function fetchInsightsUrl(path) {
const { client } = twilioAxios();
const url = `https://insights.twilio.com/v1/Voice/${path}`;
const resp = await client.get(url);
return resp.data;
}
/**
* Voice Insights call summary (requires Advanced Features on the account).
*/
export async function fetchCallInsightsSummary(callSid) {
if (!callSid) return unavailable('no callSid');
try {
const data = await fetchInsightsUrl(`${callSid}/Summary`);
return {
available: true,
data: {
callSid: data.call_sid || callSid,
callState: data.call_state,
processingState: data.processing_state,
startTime: data.start_time,
endTime: data.end_time,
duration: data.duration,
connectDuration: data.connect_duration,
from: data.from,
to: data.to,
carrierEdge: data.carrier_edge,
sdkEdge: data.sdk_edge,
tags: data.tags,
attributes: data.attributes,
properties: data.properties,
},
raw: data,
};
} catch (err) {
const status = err.response?.status;
const msg = err.response?.data?.message || err.message;
if (status === 404 || status === 403) {
return unavailable(
status === 403
? 'Voice Insights Advanced Features may not be enabled'
: 'Insights summary not ready or not found',
{ status, retryable: status === 404 },
);
}
logger(LOG_SCOPE, `fetchCallInsightsSummary ${callSid} failed: ${msg}`, 'warn');
return unavailable(status ? `HTTP ${status}: ${msg}` : msg, { status });
}
}
/**
* Voice Insights per-call metrics (carrier_edge for PSTN outbound).
*/
export async function fetchCallInsightsMetrics(callSid, edge = 'carrier_edge') {
if (!callSid) return unavailable('no callSid');
try {
const data = await fetchInsightsUrl(`${callSid}/Metrics?Edge=${encodeURIComponent(edge)}`);
const metrics = Array.isArray(data.metrics) ? data.metrics : [];
const highlights = { maxJitter: null, maxPacketLoss: null, minMos: null };
for (const m of metrics) {
const name = String(m.name || '').toLowerCase();
const vals = m.values || m.samples || [];
for (const v of vals) {
const num = Number(v?.value ?? v?.avg ?? v);
if (!Number.isFinite(num)) continue;
if (name.includes('jitter') && (highlights.maxJitter == null || num > highlights.maxJitter)) {
highlights.maxJitter = num;
}
if (name.includes('packet') && name.includes('loss') && (highlights.maxPacketLoss == null || num > highlights.maxPacketLoss)) {
highlights.maxPacketLoss = num;
}
if (name.includes('mos') && (highlights.minMos == null || num < highlights.minMos)) {
highlights.minMos = num;
}
}
}
return {
available: true,
data: { edge, metrics, highlights },
raw: data,
};
} catch (err) {
const status = err.response?.status;
const msg = err.response?.data?.message || err.message;
if (status === 404 || status === 403) {
return unavailable(
status === 403
? 'Voice Insights Advanced Features may not be enabled'
: 'Insights metrics not ready or not found',
{ status, retryable: status === 404 },
);
}
logger(LOG_SCOPE, `fetchCallInsightsMetrics ${callSid} failed: ${msg}`, 'warn');
return unavailable(status ? `HTTP ${status}: ${msg}` : msg, { status });
}
}
export default {
isTwilioConfigured,
isCallTestEnabled,
createOutboundCall,
validateTwilioSignature,
fetchCallResource,
fetchCallInsightsSummary,
fetchCallInsightsMetrics,
};

View file

@ -51,6 +51,37 @@ class WebexClient {
}
}
/**
* HTTP to analytics-calling / analytics.webexapis.com (cdr_feed, etc.).
* Bypasses HTTP(S)_PROXY by default axios proxy-from-env often sends analytics
* subdomains through a corp proxy while webexapis.com is listed in NO_PROXY.
* Set WEBEX_CDR_USE_PROXY=true to honor the process proxy instead.
*/
async analyticsRequestRaw(method, url, { data = null, params = null, timeout = 15000 } = {}) {
const useProxy = String(process.env.WEBEX_CDR_USE_PROXY || '').toLowerCase() === 'true';
const token = await this.auth.getAccessToken();
try {
const response = await axios({
method,
url,
headers: { Authorization: `Bearer ${token}` },
data,
params,
timeout,
proxy: useProxy ? undefined : false,
});
return { data: response.data, headers: response.headers, status: response.status };
} catch (err) {
if (err.response?.status === 401) {
logger('webex:client', '401 on analytics host — forcing token refresh', 'warn');
await this.auth.forceRefresh();
return this.analyticsRequestRaw(method, url, { data, params, timeout });
}
throw err;
}
}
// Convenience wrappers
async getMe() {
return this.request('GET', 'people/me');

View file

@ -0,0 +1,177 @@
// integrations/webex/reportsClient.js
// Webex Control Hub Reports API (Pro Pack) — Calling Media Quality.
import axios from 'axios';
import AdmZip from 'adm-zip';
import webex from './WebexClient.js';
import { logger } from '../../utils/logger.js';
import {
filterMediaQualityRows,
parseMediaQualityCsv,
reportDateRangeFromWindow,
} from './reportsCsv.js';
export { filterMediaQualityRows, parseMediaQualityCsv, reportDateRangeFromWindow } from './reportsCsv.js';
const LOG_SCOPE = 'webex:reports';
const TEMPLATE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
let _templateCache = null;
let _templateCacheAt = 0;
function envInt(name, fallback) {
const v = Number(process.env[name]);
return Number.isFinite(v) ? v : fallback;
}
function pollIntervalMs() {
return envInt('VOICEREPORT_REPORT_POLL_MS', 5000);
}
function pollMaxMs() {
return envInt('VOICEREPORT_REPORT_POLL_MAX_MS', 180_000);
}
export function _clearReportTemplateCacheForTests() {
_templateCache = null;
_templateCacheAt = 0;
}
export async function listReportTemplates() {
const now = Date.now();
if (_templateCache && now - _templateCacheAt < TEMPLATE_CACHE_TTL_MS) {
return _templateCache;
}
const data = await webex.request('GET', 'reports/templates');
const items = data?.items || data?.reportTemplates || [];
_templateCache = items;
_templateCacheAt = now;
return items;
}
/**
* Resolve template id by env override or fuzzy name match.
*/
export async function resolveMediaQualityTemplateId() {
const override = process.env.WEBEX_REPORT_TEMPLATE_MEDIA_QUALITY;
if (override && String(override).trim()) {
return Number(override);
}
const templates = await listReportTemplates();
const hit = templates.find((t) => {
const name = String(t.name || t.title || '').toLowerCase();
return name.includes('media quality') || name.includes('calling media');
});
return hit ? Number(hit.id ?? hit.templateId) : null;
}
export async function createReport(templateId, { startDate, endDate, siteList } = {}) {
if (!templateId || !Number.isFinite(templateId)) {
return { ok: false, reason: 'no Media Quality report template id (set WEBEX_REPORT_TEMPLATE_MEDIA_QUALITY)' };
}
const body = {
templateId: Number(templateId),
};
if (startDate) body.startDate = startDate;
if (endDate) body.endDate = endDate;
if (siteList) body.siteList = siteList;
try {
const data = await webex.request('POST', 'reports', body);
return { ok: true, report: data };
} catch (err) {
const msg = err.response?.data?.message || err.message;
logger(LOG_SCOPE, `createReport failed: ${msg}`, 'warn');
return { ok: false, reason: msg };
}
}
export async function getReport(reportId) {
return webex.request('GET', `reports/${reportId}`);
}
export async function pollReportUntilDone(reportId) {
const deadline = Date.now() + pollMaxMs();
while (Date.now() < deadline) {
const report = await getReport(reportId);
const status = String(report?.status || '').toLowerCase();
if (status === 'done' || status === 'complete' || status === 'completed') {
return { ok: true, report };
}
if (status === 'failed' || status === 'error') {
return { ok: false, reason: report?.statusMessage || 'report generation failed', report };
}
await new Promise((r) => setTimeout(r, pollIntervalMs()));
}
return { ok: false, reason: 'report poll timed out' };
}
/**
* Download report payload (CSV text or first CSV inside ZIP).
*/
export async function downloadReportText(downloadURL) {
if (!downloadURL) return { ok: false, reason: 'no downloadURL' };
const token = await webex.auth.getAccessToken();
const resp = await axios.get(downloadURL, {
headers: { Authorization: `Bearer ${token}` },
responseType: 'arraybuffer',
timeout: 60_000,
maxContentLength: 50 * 1024 * 1024,
});
const buf = Buffer.from(resp.data);
if (buf[0] === 0x50 && buf[1] === 0x4b) {
return extractCsvFromZip(buf);
}
return { ok: true, text: buf.toString('utf8') };
}
function extractCsvFromZip(buf) {
try {
const zip = new AdmZip(buf);
const entry = zip.getEntries().find((e) => /\.csv$/i.test(e.entryName));
if (!entry) return { ok: false, reason: 'no CSV inside report ZIP' };
return { ok: true, text: entry.getData().toString('utf8') };
} catch (err) {
return { ok: false, reason: `ZIP extract failed: ${err.message}` };
}
}
export async function fetchMediaQualityReport({ window, locationName }) {
const templateId = await resolveMediaQualityTemplateId();
if (!templateId) {
return { available: false, reason: 'Media Quality template not found' };
}
const { startDate, endDate } = reportDateRangeFromWindow(window);
const created = await createReport(templateId, { startDate, endDate });
if (!created.ok) {
return { available: false, reason: created.reason };
}
const reportId = created.report?.id;
if (!reportId) {
return { available: false, reason: 'create report returned no id' };
}
const polled = await pollReportUntilDone(reportId);
if (!polled.ok) {
return { available: false, reason: polled.reason };
}
const downloadURL = polled.report?.downloadURL || polled.report?.downloadUrl;
const dl = await downloadReportText(downloadURL);
if (!dl.ok) {
return { available: false, reason: dl.reason };
}
const parsed = parseMediaQualityCsv(dl.text);
const filtered = filterMediaQualityRows(parsed.rows, { locationName, window });
return {
available: true,
reportId,
rawCount: parsed.rows.length,
rows: filtered,
headers: parsed.headers,
};
}

View file

@ -0,0 +1,73 @@
// integrations/webex/reportsCsv.js
// Pure CSV helpers for Webex Reports (no Webex client import).
export function reportDateRangeFromWindow(window) {
const startDay = String(window.startTime).slice(0, 10);
const endDay = String(window.endTime).slice(0, 10);
return { startDate: startDay, endDate: endDay };
}
function splitCsvLine(line) {
const out = [];
let cur = '';
let inQ = false;
for (let i = 0; i < line.length; i++) {
const c = line[i];
if (c === '"') {
inQ = !inQ;
continue;
}
if (c === ',' && !inQ) {
out.push(cur.trim());
cur = '';
continue;
}
cur += c;
}
out.push(cur.trim());
return out;
}
function normKey(k) {
return String(k || '').toLowerCase().replace(/[^a-z0-9]+/g, '');
}
export function parseMediaQualityCsv(text) {
if (!text || !String(text).trim()) return { headers: [], rows: [] };
const lines = String(text).split(/\r?\n/).filter((l) => l.trim());
if (!lines.length) return { headers: [], rows: [] };
const headers = splitCsvLine(lines[0]);
const rows = [];
for (let i = 1; i < lines.length; i++) {
const cols = splitCsvLine(lines[i]);
if (!cols.some(Boolean)) continue;
const row = {};
headers.forEach((h, idx) => { row[h] = cols[idx] ?? ''; });
row._norm = Object.fromEntries(
headers.map((h, idx) => [normKey(h), cols[idx] ?? '']),
);
rows.push(row);
}
return { headers, rows };
}
export function filterMediaQualityRows(rows, { locationName, window }) {
const loc = String(locationName || '').toLowerCase();
const startMs = new Date(window.startTime).getTime();
const endMs = new Date(window.endTime).getTime();
return (rows || []).filter((row) => {
const n = row._norm || {};
const rowLoc = String(
n.location || n.sitelocation || n.sitename || row.Location || '',
).toLowerCase();
if (loc && rowLoc && !rowLoc.includes(loc) && !loc.includes(rowLoc)) {
return false;
}
const startRaw = n.starttime || n.callstarttime || n.start || row['Start Time'] || '';
if (!startRaw) return true;
const t = new Date(startRaw).getTime();
if (!Number.isFinite(t)) return true;
return t >= startMs && t <= endMs;
});
}

20
package-lock.json generated
View file

@ -8,12 +8,14 @@
"name": "collabfinder",
"version": "1.0.0",
"dependencies": {
"adm-zip": "^0.5.18",
"async-mutex": "^0.5.0",
"axios": "^1.13.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"form-data": "^4.0.5",
"graphql-request": "^7.4.0",
"luxon": "^3.7.2",
"node-cron": "^4.2.1",
"twilio": "^6.0.2",
"webex-node-bot-framework": "^2.5.1",
@ -4517,6 +4519,15 @@
"node": ">=0.4.0"
}
},
"node_modules/adm-zip": {
"version": "0.5.18",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz",
"integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==",
"license": "MIT",
"engines": {
"node": ">=12.0"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@ -8687,6 +8698,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/makeerror": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",

View file

@ -19,12 +19,14 @@
"test": "node --test tests/*.test.js"
},
"dependencies": {
"adm-zip": "^0.5.18",
"async-mutex": "^0.5.0",
"axios": "^1.13.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"form-data": "^4.0.5",
"graphql-request": "^7.4.0",
"luxon": "^3.7.2",
"node-cron": "^4.2.1",
"twilio": "^6.0.2",
"webex-node-bot-framework": "^2.5.1",

View file

@ -26,7 +26,7 @@ import {
isCoreStep,
} from './twiml.js';
import {
renderCallTestCdrMarkdown,
renderCallTestCdrMatchMarkdown,
renderCallTestResultMarkdown,
renderCallTestStartedMarkdown,
} from '../renderers/callTestRenderer.js';
@ -104,28 +104,17 @@ function computeResult(session) {
}
async function scheduleCdrEnrichment(session) {
const cfg = getCallTestConfig(session.storeNum);
if (!cfg.cdrEnrich || session.mode !== 'store' || !session.personId) return;
const delay = cfg.cdrDelayMs;
logger(LOG_SCOPE, `Scheduling CDR enrich for ${session.testId} in ${delay}ms`, 'debug');
setTimeout(async () => {
try {
const { getHistoricalCallActivity } = await import('../phoneService.js');
const cdr = await getHistoricalCallActivity(session.personId, 12, {
locationName: session.locationName,
const { scheduleCdrMatchEnrichment } = await import('./cdrMatcher.js');
scheduleCdrMatchEnrichment(session, {
notifyRoom,
renderMarkdown: (s, result) => renderCallTestCdrMatchMarkdown(s, result),
});
const md = renderCallTestCdrMarkdown(session, cdr);
await notifyRoom(session.roomId, md);
} catch (err) {
logger(LOG_SCOPE, `CDR enrich failed for ${session.testId}: ${err.message}`, 'warn');
await notifyRoom(
session.roomId,
`**Call test CDR** (\`${session.testId}\`)\n\n_CDR fetch failed:_ ${err.message}`,
);
}
}, delay).unref?.();
async function schedulePostCallEnrichment(session) {
const { scheduleTwilioEnrichment } = await import('./twilioEnrichment.js');
scheduleTwilioEnrichment(session, notifyRoom);
await scheduleCdrEnrichment(session);
}
async function finalizeSession(testId) {
@ -145,10 +134,7 @@ async function finalizeSession(testId) {
if (next?.roomId) {
await notifyRoom(next.roomId, renderCallTestResultMarkdown(next));
}
if (next?.mode === 'store') {
scheduleCdrEnrichment(next);
await schedulePostCallEnrichment(next);
}
return next;

View file

@ -0,0 +1,214 @@
// services/callTest/cdrMatcher.js
// Narrow-window Webex CDR fetch + match the specific /calltest leg.
import { logger } from '../../utils/logger.js';
import { getCallTestConfig } from './config.js';
import { getSession, updateSession } from './sessionStore.js';
import {
cdrCalledNumber,
cdrCallingNumber,
cdrDirectionValue,
cdrDispositionValue,
cdrDurationSeconds,
cdrStartTime,
} from '../cdrFeedParser.js';
const LOG_SCOPE = 'calltest:cdr';
const FIVE_MIN_MS = 5 * 60 * 1000;
const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000;
export function phoneDigits(value) {
const d = String(value || '').replace(/\D/g, '');
if (d.length === 10) return `1${d}`;
if (d.length === 11 && d.startsWith('1')) return d;
return d;
}
/**
* Compute cdr_feed window from session events (Webex API rules applied).
*/
export function computeCdrWindow(session, bufferMs) {
const events = session.events || {};
const startMs = new Date(
events.initiated || events.coreStartedAt || session.createdAt || Date.now(),
).getTime() - bufferMs;
let endMs = new Date(events.completed || Date.now()).getTime() + bufferMs;
const latestAllowedEnd = Date.now() - FIVE_MIN_MS;
if (endMs > latestAllowedEnd) endMs = latestAllowedEnd;
let start = startMs;
if (endMs - start > TWELVE_HOURS_MS) {
start = endMs - TWELVE_HOURS_MS;
}
if (start > endMs) {
start = endMs - Math.min(TWELVE_HOURS_MS, 30 * 60 * 1000);
}
return {
startTime: new Date(start).toISOString(),
endTime: new Date(endMs).toISOString(),
};
}
/**
* Score CDR leg by destination (called number) matching the dialed test number.
* Time proximity is only a tiebreaker when multiple legs hit the same DID.
*/
export function scoreCdrLeg(item, session) {
const dialDigits = phoneDigits(session.dialNumber);
if (!dialDigits) return 0;
const called = phoneDigits(cdrCalledNumber(item) || '');
if (called !== dialDigits) return 0;
let score = 100;
const itemStart = new Date(cdrStartTime(item) || 0).getTime();
const anchor = session.events?.initiated
|| session.events?.answered
|| session.events?.coreStartedAt;
if (anchor && itemStart) {
const delta = Math.abs(itemStart - new Date(anchor).getTime());
if (delta < 120_000) score += 20;
else if (delta < 300_000) score += 5;
}
return score;
}
export function pickBestCdrLeg(items, session) {
if (!items?.length) return null;
let best = null;
let bestScore = 0;
for (const item of items) {
const s = scoreCdrLeg(item, session);
if (s > bestScore) {
bestScore = s;
best = item;
}
}
if (!best || bestScore < 100) return null;
return { leg: best, score: bestScore };
}
export function formatCdrLeg(item) {
if (!item) return null;
return {
start: cdrStartTime(item),
direction: cdrDirectionValue(item),
duration: cdrDurationSeconds(item),
status: cdrDispositionValue(item),
callingNumber: cdrCallingNumber(item),
calledNumber: cdrCalledNumber(item),
otherParty: item.otherParty || item.remoteParty,
};
}
async function resolveCdrContext(session) {
if (session.locationName && session.personId) {
return {
personId: session.personId,
locationName: session.locationName,
};
}
if (session.locationName) {
return { personId: session.personId || null, locationName: session.locationName };
}
if (session.mode === 'dial') {
const { resolveLocationForDialNumber } = await import('./locationResolver.js');
const loc = await resolveLocationForDialNumber(session.dialNumber);
if (loc) {
return { personId: null, locationName: loc.locationName, locationId: loc.locationId };
}
}
return null;
}
/**
* Fetch CDR and match the test call leg.
*/
export async function fetchMatchedCdrForSession(session) {
const ctx = await resolveCdrContext(session);
if (!ctx?.locationName) {
return {
available: false,
reason: session.mode === 'dial'
? 'dialed number is not a known Webex location main number'
: 'no locationName on session',
match: null,
};
}
const cfg = session.config || getCallTestConfig(session.storeNum);
const { startTime, endTime } = computeCdrWindow(session, cfg.cdrMatchBufferMs);
const { getHistoricalCallActivity } = await import('../phoneService.js');
const cdr = await getHistoricalCallActivity(ctx.personId, 12, {
locationName: ctx.locationName,
startTime,
endTime,
returnRawItems: true,
skipPersonFilter: !ctx.personId,
});
if (!cdr.available) {
return { available: false, reason: cdr.reason, match: null, window: { startTime, endTime } };
}
const items = cdr.rawItems || [];
const picked = pickBestCdrLeg(items, session);
return {
available: true,
location: ctx.locationName,
window: { startTime, endTime },
rawCount: items.length,
match: picked ? { ...formatCdrLeg(picked.leg), score: picked.score } : null,
reason: picked ? null : `no leg matched (${items.length} records in window)`,
};
}
const _cdrTimers = new Map();
export function scheduleCdrMatchEnrichment(session, { notifyRoom, renderMarkdown }) {
const cfg = session.config || getCallTestConfig(session.storeNum);
if (!cfg.cdrEnrich || !session.roomId) return;
const minDelay = Math.max(cfg.cdrDelayMs, FIVE_MIN_MS + 30_000);
if (_cdrTimers.has(session.testId)) return;
const timer = setTimeout(async () => {
_cdrTimers.delete(session.testId);
const cur = getSession(session.testId);
if (!cur) return;
try {
logger(LOG_SCOPE, `CDR match fetch for testId=${session.testId}`, 'info');
const result = await fetchMatchedCdrForSession(cur);
updateSession(session.testId, {
enrichment: {
...(cur.enrichment || {}),
cdr: { ...result, fetchedAt: new Date().toISOString() },
},
});
await notifyRoom(session.roomId, renderMarkdown(getSession(session.testId), result));
} catch (err) {
logger(LOG_SCOPE, `CDR match failed for ${session.testId}: ${err.message}`, 'warn');
await notifyRoom(
session.roomId,
`**Call test CDR** (\`${session.testId}\`)\n\n_CDR match failed:_ ${err.message}`,
);
}
}, minDelay);
if (typeof timer.unref === 'function') timer.unref();
_cdrTimers.set(session.testId, timer);
}
export function _clearCdrTimersForTests() {
for (const t of _cdrTimers.values()) clearTimeout(t);
_cdrTimers.clear();
}

View file

@ -67,6 +67,15 @@ export function getCallTestConfig(storeNum = null) {
return v === 'true' || v === '1' || v === 'yes';
})(),
cdrDelayMs: envInt('CALLTEST_CDR_DELAY_MS', 360_000),
cdrMatchBufferMs: envInt('CALLTEST_CDR_MATCH_BUFFER_MS', 120_000),
twilioEnrich: (() => {
const v = String(process.env.CALLTEST_TWILIO_ENRICH ?? 'true').toLowerCase();
return v === 'true' || v === '1' || v === 'yes';
})(),
twilioDetailsDelayMs: envInt('CALLTEST_TWILIO_DETAILS_DELAY_MS', 10_000),
twilioInsightsDelayMs: envInt('CALLTEST_TWILIO_INSIGHTS_DELAY_MS', 360_000),
twilioInsightsRetries: envInt('CALLTEST_TWILIO_INSIGHTS_RETRIES', 3),
twilioInsightsRetryIntervalMs: envInt('CALLTEST_TWILIO_INSIGHTS_RETRY_MS', 30_000),
};
}

View file

@ -0,0 +1,116 @@
// services/callTest/locationResolver.js
// Resolve Webex Calling location from a dialed E.164 (store main / AA DID).
import { logger } from '../../utils/logger.js';
import { normalizeE164 } from './config.js';
const LOG_SCOPE = 'calltest:location';
const CACHE_TTL_MS = 60 * 60 * 1000;
let _phoneIndex = null;
let _phoneIndexAt = 0;
async function getWebex() {
const mod = await import('../../integrations/webex/WebexClient.js');
return mod.default;
}
function parseLinkNext(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;
}
function phoneDigits(value) {
const d = String(value || '').replace(/\D/g, '');
if (d.length === 10) return `1${d}`;
if (d.length === 11 && d.startsWith('1')) return d;
return d;
}
async function fetchAllNumbers() {
const webex = await getWebex();
const all = [];
let nextUrl = null;
let data;
let headers;
({ data, headers } = await webex.requestRaw('GET', 'telephony/config/numbers', null, { max: 1000 }));
const batch = data?.phoneNumbers || [];
all.push(...batch);
nextUrl = parseLinkNext(headers?.link || headers?.Link);
while (nextUrl) {
({ data, headers } = await webex.requestRaw('GET', nextUrl));
all.push(...(data?.phoneNumbers || []));
nextUrl = parseLinkNext(headers?.link || headers?.Link);
}
return all;
}
async function buildPhoneIndex() {
const now = Date.now();
if (_phoneIndex && now - _phoneIndexAt < CACHE_TTL_MS) {
return _phoneIndex;
}
logger(LOG_SCOPE, 'Building location phone index from telephony/config/numbers', 'debug');
const numbers = await fetchAllNumbers();
const index = new Map();
for (const n of numbers) {
const raw = n.phoneNumber || n.number || n.value;
const loc = n.location;
if (!raw || !loc?.id) continue;
const entry = {
locationId: loc.id,
locationName: loc.name || null,
phoneNumber: raw,
};
const keys = new Set([phoneDigits(raw), phoneDigits(normalizeE164(raw) || raw)]);
for (const k of keys) {
if (k) index.set(k, entry);
}
}
_phoneIndex = index;
_phoneIndexAt = now;
logger(LOG_SCOPE, `Phone index built: ${index.size} keys from ${numbers.length} numbers`, 'debug');
return index;
}
/**
* Find Webex location for a dialed E.164 (e.g. store main on /calltest dial).
* @returns {Promise<{locationId: string, locationName: string, phoneNumber: string}|null>}
*/
export async function resolveLocationForDialNumber(dialNumber) {
const e164 = normalizeE164(dialNumber);
if (!e164) return null;
try {
const index = await buildPhoneIndex();
const key = phoneDigits(e164);
const hit = index.get(key);
if (!hit?.locationName) return null;
return {
locationId: hit.locationId,
locationName: hit.locationName,
phoneNumber: hit.phoneNumber,
};
} catch (err) {
logger(LOG_SCOPE, `resolveLocationForDialNumber failed: ${err.message}`, 'warn');
return null;
}
}
export function _clearLocationCacheForTests() {
_phoneIndex = null;
_phoneIndexAt = 0;
}

View file

@ -25,6 +25,7 @@ export function createSession(testId, data) {
reachedCore: false,
failedAt: null,
callSid: null,
enrichment: { twilio: {}, cdr: {} },
...data,
};
_store.set(testId, session);

View file

@ -0,0 +1,143 @@
// services/callTest/twilioEnrichment.js
// Post-call Twilio Call resource + Voice Insights enrichment.
import {
fetchCallInsightsMetrics,
fetchCallInsightsSummary,
fetchCallResource,
} from '../../integrations/twilio/client.js';
import { logger } from '../../utils/logger.js';
import { getCallTestConfig } from './config.js';
import { getSession, updateSession } from './sessionStore.js';
import {
renderCallTestTwilioDetailsMarkdown,
renderCallTestTwilioInsightsMarkdown,
} from '../renderers/callTestRenderer.js';
const LOG_SCOPE = 'calltest:twilio-enrich';
const _detailTimers = new Map();
const _insightTimers = new Map();
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function runDetailsEnrichment(testId, notifyRoom) {
const session = getSession(testId);
if (!session?.callSid || !session.roomId) return;
logger(LOG_SCOPE, `Fetching Twilio details for testId=${testId} callSid=${session.callSid}`, 'info');
const details = await fetchCallResource(session.callSid);
const cur = getSession(testId);
if (!cur) return;
updateSession(testId, {
enrichment: {
...(cur.enrichment || {}),
twilio: {
...(cur.enrichment?.twilio || {}),
details,
detailsFetchedAt: new Date().toISOString(),
},
},
});
await notifyRoom(
session.roomId,
renderCallTestTwilioDetailsMarkdown(getSession(testId), details),
);
}
async function runInsightsEnrichment(testId, notifyRoom, cfg) {
const session = getSession(testId);
if (!session?.callSid || !session.roomId) return;
let summary = null;
let metrics = null;
let lastReason = null;
for (let attempt = 0; attempt <= cfg.twilioInsightsRetries; attempt++) {
if (attempt > 0) {
await sleep(cfg.twilioInsightsRetryIntervalMs);
}
summary = await fetchCallInsightsSummary(session.callSid);
metrics = await fetchCallInsightsMetrics(session.callSid);
const summaryOk = summary.available;
const metricsOk = metrics.available;
const retryable = summary.retryable || metrics.retryable;
if (summaryOk || metricsOk) break;
lastReason = summary.reason || metrics.reason;
if (!retryable) break;
logger(
LOG_SCOPE,
`Insights not ready for ${session.callSid} (attempt ${attempt + 1}/${cfg.twilioInsightsRetries + 1}): ${lastReason}`,
'debug',
);
}
const insights = {
available: summary?.available || metrics?.available || false,
reason: lastReason,
summary: summary?.available ? summary.data : null,
metrics: metrics?.available ? metrics.data : null,
summaryRaw: summary?.raw,
metricsRaw: metrics?.raw,
};
const cur = getSession(testId);
if (!cur) return;
updateSession(testId, {
enrichment: {
...(cur.enrichment || {}),
twilio: {
...(cur.enrichment?.twilio || {}),
insights,
insightsFetchedAt: new Date().toISOString(),
},
},
});
await notifyRoom(
session.roomId,
renderCallTestTwilioInsightsMarkdown(getSession(testId), insights),
);
}
export function scheduleTwilioEnrichment(session, notifyRoom) {
const cfg = session.config || getCallTestConfig(session.storeNum);
if (!cfg.twilioEnrich || !session.callSid || !session.roomId) return;
const testId = session.testId;
if (!_detailTimers.has(testId)) {
const t = setTimeout(() => {
_detailTimers.delete(testId);
void runDetailsEnrichment(testId, notifyRoom);
}, cfg.twilioDetailsDelayMs);
if (typeof t.unref === 'function') t.unref();
_detailTimers.set(testId, t);
}
if (!_insightTimers.has(testId)) {
const t = setTimeout(() => {
_insightTimers.delete(testId);
void runInsightsEnrichment(testId, notifyRoom, cfg);
}, cfg.twilioInsightsDelayMs);
if (typeof t.unref === 'function') t.unref();
_insightTimers.set(testId, t);
}
}
export function _clearTwilioEnrichmentTimersForTests() {
for (const t of _detailTimers.values()) clearTimeout(t);
for (const t of _insightTimers.values()) clearTimeout(t);
_detailTimers.clear();
_insightTimers.clear();
}

132
services/cdrFeedParser.js Normal file
View file

@ -0,0 +1,132 @@
// 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 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(' ');
}

View file

@ -13,6 +13,17 @@ import {
import { logger } from '../utils/logger.js';
import { normalizeMac } from './enrichment/normalizers.js';
import { attachMerakiClientWithPorts } from './enrichment/merakiEnrichment.js';
import {
extractCdrFeedList,
describeCdrFeedPayload,
cdrCalledNumber,
cdrCallingNumber,
cdrDedupKey,
cdrDirectionValue,
cdrDispositionValue,
cdrDurationSeconds,
cdrStartTime,
} from './cdrFeedParser.js';
// ──────────────────────────────────────────────
// Main public function
@ -594,29 +605,71 @@ export function computeRecentActivity(phones = [], dectBases = [], dectHandsets
// The `locations` name comes from the person's DECT network (already fetched for main number / dect logic).
// Secondary client-side filter on person numbers inside the location results.
// (recent activity and historical calls features disabled per request)
export async function getHistoricalCallActivity(personId, hours = 24, options = {}) {
if (!personId) return { available: false, reason: 'no personId', calls: [], summary: {} };
/** Last 6 chars of bearer token — enough to confirm curl vs bot use the same credential. */
function tokenFingerprint(token) {
if (!token || typeof token !== 'string') return 'none';
return `${token.slice(-6)}`;
}
const { locationName: providedLocationName } = options || {};
function getCdrAnalyticsBases() {
const env = process.env.WEBEX_CDR_ANALYTICS_BASES;
if (env) {
return env.split(',').map((s) => s.trim()).filter(Boolean);
}
return [
'https://analytics-calling.webexapis.com/v1',
'https://analytics.webexapis.com/v1',
];
}
function formatCdrFetchError(err) {
const msg = err?.message || String(err);
if (msg.includes('ECONNREFUSED') && (msg.includes('0.0.0.0') || msg.includes('127.0.0.1'))) {
return (
`${msg} — analytics host unreachable from this container. ` +
'Often caused by HTTP(S)_PROXY routing analytics-calling.webexapis.com through a broken proxy ' +
'while webexapis.com is in NO_PROXY. CDR requests bypass proxy by default; add *.webexapis.com to NO_PROXY ' +
'or set WEBEX_CDR_USE_PROXY=true if a proxy is required.'
);
}
return msg;
}
export async function getHistoricalCallActivity(personId, hours = 24, options = {}) {
const {
locationName: providedLocationName,
startTime: optStartTime,
endTime: optEndTime,
returnRawItems = false,
skipPersonFilter = false,
} = options || {};
if (!personId && !providedLocationName) {
return { available: false, reason: 'no personId or locationName', calls: [], summary: {} };
}
// Prefer analytics-calling (user-confirmed working base for cdr_feed). Fall back to the other if needed.
const ANALYTICS_BASES = [
'https://analytics-calling.webexapis.com/v1',
'https://analytics.webexapis.com/v1'
];
const ANALYTICS_BASES = getCdrAnalyticsBases();
const CDR_PATH = '/cdr_feed';
const MAX_PER_PAGE = 1000;
try {
const person = await webex.request('GET', `people/${personId}`);
const person = personId ? await webex.request('GET', `people/${personId}`) : null;
const orgId = person?.orgId;
const token = await webex.auth.getAccessToken();
const axiosMod = (await import('axios')).default;
// Get the *required* locations=name from DECT (passed in or self-discover).
let locationName = providedLocationName;
if (!locationName || locationName === '—') {
if (!personId) {
return {
available: false,
reason: 'no locationName (the /cdr_feed API requires the "locations" param)',
calls: [],
summary: {},
};
}
try {
const dectNets = await getDectNetworksForPerson(personId);
if (dectNets.length > 0) {
@ -636,6 +689,7 @@ export async function getHistoricalCallActivity(personId, hours = 24, options =
summary: {},
};
}
locationName = String(locationName).trim();
// Person identifiers for secondary filtering inside the location results.
const userIds = new Set();
@ -682,66 +736,65 @@ export async function getHistoricalCallActivity(personId, hours = 24, options =
const FIVE_MIN_MS = 5 * 60 * 1000;
const TWELVE_HOURS_MS = 12 * 3600 * 1000;
const endMs = Date.now() - FIVE_MIN_MS;
const startMs = endMs - TWELVE_HOURS_MS;
let endMs;
let startMs;
if (optStartTime && optEndTime) {
startMs = new Date(optStartTime).getTime();
endMs = new Date(optEndTime).getTime();
const latestAllowedEnd = Date.now() - FIVE_MIN_MS;
if (endMs > latestAllowedEnd) endMs = latestAllowedEnd;
if (endMs - startMs > TWELVE_HOURS_MS) startMs = endMs - TWELVE_HOURS_MS;
} else {
endMs = Date.now() - FIVE_MIN_MS;
startMs = endMs - TWELVE_HOURS_MS;
}
const startTime = new Date(startMs).toISOString();
const endTime = new Date(endMs).toISOString();
logger('phone:service', `Attempting Detailed Call History via cdr_feed for person ${personId} (org ${orgId || 'unknown'}) location="${locationName}" window=${startTime}..${endTime}`, 'debug');
const tokenHint = tokenFingerprint(token);
logger(
'phone:service',
`cdr_feed query location=${JSON.stringify(locationName)} window=${startTime}..${endTime} ` +
`token=${tokenHint}`,
'info',
);
const allRawItems = [];
const fetchErrors = [];
// Helper to robustly extract list, supporting:
// - direct array
// - .items as array
// - .items as { "0": rec, "1": rec, ... } (items[0], items[1] style)
// - top level numeric keys on the data object
// - fallback to first array value found
function extractList(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) - parseInt(b));
if (keys.length > 0) return keys.map(k => data.items[k]);
}
// top-level numeric keyed?
const topKeys = Object.keys(data).filter(k => /^\d+$/.test(k)).sort((a, b) => parseInt(a) - parseInt(b));
if (topKeys.length > 0) return topKeys.map(k => data[k]);
// any array value
for (const v of Object.values(data)) {
if (Array.isArray(v)) return v;
}
return [];
}
let usedBase = null;
let firstRespData = null;
let lastHttpStatus = null;
for (const base of ANALYTICS_BASES) {
const url = `${base}${CDR_PATH}`;
const params = { startTime, endTime, locations: locationName, max: MAX_PER_PAGE };
const queryStr = new URLSearchParams(params).toString();
const fullUrl = `${url}?${queryStr}`;
logger('phone:service', `cdr_feed request URL: ${fullUrl}`, 'debug');
logger('phone:service', `cdr_feed GET ${fullUrl}`, 'info');
try {
const resp = await axiosMod.get(url, {
headers: { Authorization: `Bearer ${token}` },
params,
timeout: 15000
});
logger('phone:service', `cdr_feed response status=${resp.status} base=${base}`, 'debug');
const resp = await webex.analyticsRequestRaw('GET', url, { params, timeout: 15000 });
lastHttpStatus = resp.status;
const d = resp.data || {};
firstRespData = d;
const list = extractList(d);
const list = extractCdrFeedList(d);
allRawItems.push(...list);
usedBase = base;
logger('phone:service', `cdr_feed hit on ${base} (loc=${locationName}) → ${list.length} raw (keys=${Object.keys(d).slice(0,8).join(',')})`, 'debug');
logger(
'phone:service',
`cdr_feed ${base} → HTTP ${resp.status}, parsed ${list.length} record(s) (${describeCdrFeedPayload(d)})`,
'info',
);
if (list.length === 0 && d && Object.keys(d).length > 0) {
logger(
'phone:service',
`cdr_feed HTTP ${resp.status} but 0 parseable records — payload shape may have changed (${describeCdrFeedPayload(d)})`,
'warn',
);
}
// Pagination support (up to 10 additional pages per rate limit)
let nextToken = d.next || d['next'] || (d.metadata && d.metadata.next) || null;
@ -759,14 +812,13 @@ export async function getHistoricalCallActivity(personId, hours = 24, options =
const pageFull = pageParams ? `${pageUrl}?${pageQuery}` : pageUrl;
logger('phone:service', `cdr_feed pagination page ${page} URL: ${pageFull}`, 'debug');
try {
const pResp = await axiosMod.get(pageUrl, {
headers: { Authorization: `Bearer ${token}` },
params: pageParams,
timeout: 15000
const pResp = await webex.analyticsRequestRaw('GET', pageUrl, {
params: pageParams || undefined,
timeout: 15000,
});
logger('phone:service', `cdr_feed pagination status=${pResp.status}`, 'debug');
const pd = pResp.data || {};
const pList = extractList(pd);
const pList = extractCdrFeedList(pd);
allRawItems.push(...pList);
nextToken = pd.next || pd['next'] || (pd.metadata && pd.metadata.next) || null;
} catch (pe) {
@ -778,28 +830,57 @@ export async function getHistoricalCallActivity(personId, hours = 24, options =
break; // first successful HTTP response (even if 0 items = empty window is valid)
} catch (e) {
const st = e.response?.status;
const bd = e.response?.data || e.message;
logger('phone:service', `cdr_feed ERROR for ${fullUrl} status=${st || 'n/a'}: ${JSON.stringify(bd).slice(0,300)}`, 'debug');
fetchErrors.push({ base, startTime, endTime, status: st, body: bd });
const errMsg = formatCdrFetchError(e);
const bd = e.response?.data || errMsg;
logger(
'phone:service',
`cdr_feed ERROR ${fullUrl} status=${st || 'n/a'}: ${typeof bd === 'string' ? bd : JSON.stringify(bd).slice(0, 300)}`,
'warn',
);
fetchErrors.push({ base, startTime, endTime, status: st, body: bd, message: errMsg });
}
}
if (allRawItems.length === 0 && fetchErrors.length > 0) {
const errSummary = fetchErrors
.map((e) => `${e.base.replace('https://', '')}:${e.status || 'err'}`)
.join(', ');
const first = fetchErrors[0];
const msg = first?.message || first?.body?.message || first?.body?.error || first?.body || 'unknown error';
let reason = `cdr_feed failed on all bases (${errSummary}): ${typeof msg === 'string' ? msg : JSON.stringify(msg).slice(0, 200)}`;
if (fetchErrors.some((e) => e.status === 429)) {
reason = 'rate limited (1 cdr_feed call per minute + 10 pagination per min per token). Wait ~60s and retry.';
}
logger('phone:service', `cdr_feed unavailable — ${reason}`, 'warn');
return {
available: false,
reason,
fetchErrors,
scopesNeeded: fetchErrors.some((e) => e.status === 429)
? 'Respect API rate limits (see doc)'
: 'spark-admin:calling_cdr_read scope + Control Hub administrator role "Webex Calling Detailed Call History API access" enabled for the authorizing user',
calls: [],
summary: {},
};
}
if (allRawItems.length === 0) {
// Legacy fallback (rarely useful now)
try {
const legUrl = `${ANALYTICS_BASES[0]}/callHistory`;
const r = await axiosMod.get(legUrl, {
headers: { Authorization: `Bearer ${token}` },
const r = await webex.analyticsRequestRaw('GET', legUrl, {
params: { orgId, personId, startTime, endTime, max: 100 },
timeout: 15000
timeout: 15000,
});
const d = r.data || {};
const li = extractList(d);
const li = extractCdrFeedList(d);
if (li.length >= 0) {
allRawItems.push(...li);
logger('phone:service', `legacy callHistory fallback gave ${li.length}`, 'debug');
logger('phone:service', `cdr_feed legacy callHistory fallback → ${li.length} record(s)`, 'info');
}
} catch (legacyErr) {
logger('phone:service', `cdr_feed legacy callHistory fallback failed: ${legacyErr.message}`, 'debug');
}
} catch (_) { /* ignore */ }
}
const receivedRaw = allRawItems.length;
@ -808,13 +889,7 @@ export async function getHistoricalCallActivity(personId, hours = 24, options =
const seen = new Set();
const items = [];
for (const c of allRawItems) {
const key = [
c.startTime || c.start || c.callStartTime || '',
c.callingNumber || '',
c.calledNumber || '',
c.duration || c.callDuration || '',
c.callId || c.id || c.uuid || ''
].join('|');
const key = cdrDedupKey(c);
if (!seen.has(key)) {
seen.add(key);
items.push(c);
@ -822,19 +897,27 @@ export async function getHistoricalCallActivity(personId, hours = 24, options =
}
// Secondary person filter inside the location-scoped results.
const userItems = items.filter(matchItemToUser);
const userItems = skipPersonFilter ? items : items.filter(matchItemToUser);
const effectiveItems = (userItems.length > 0) ? userItems : items;
if (userItems.length === 0 && items.length > 0) {
if (userItems.length === 0 && items.length > 0 && !skipPersonFilter) {
logger('phone:service', `cdr_feed: ${items.length} unique (received ${receivedRaw}, loc=${locationName}) but 0 matched person ids; using location results (common for main/AA numbers)`, 'debug');
}
logger(
'phone:service',
`cdr_feed done base=${usedBase || 'none'} http=${lastHttpStatus ?? 'n/a'} ` +
`raw=${receivedRaw} unique=${items.length} returned=${effectiveItems.length} ` +
`personFilter=${skipPersonFilter ? 'off' : 'on'} token=${tokenHint}`,
'info',
);
// Flexible parse (CDR field names vary; covers common report columns)
let inbound = 0, outbound = 0, missed = 0, totalDuration = 0;
const samples = [];
for (const c of effectiveItems) {
const dir = String(c.direction || c.callDirection || c.callType || c.directionIndicator || '').toUpperCase();
const dur = Number(c.durationSeconds || c.duration || c.callDuration || c.talkDuration || c.length || 0);
const status = String(c.status || c.result || c.callResult || c.callOutcome || c.callStatus || '').toLowerCase();
const dir = cdrDirectionValue(c);
const dur = cdrDurationSeconds(c);
const status = cdrDispositionValue(c).toLowerCase();
if (dir.includes('IN') || dir === 'INBOUND' || dir.includes('INCOMING')) inbound++;
else if (dir.includes('OUT') || dir === 'OUTBOUND' || dir.includes('OUTGOING')) outbound++;
@ -845,12 +928,13 @@ export async function getHistoricalCallActivity(personId, hours = 24, options =
if (samples.length < 5) {
samples.push({
start: c.startTime || c.start || c.callStartTime || c.answerTime || c.releaseTime,
start: cdrStartTime(c) || c.releaseTime,
direction: dir || c.direction,
duration: dur,
status: c.status || c.result || c.callResult,
otherParty: c.otherParty || c.calledParty || c.callingParty || c.remoteParty || c.calledNumber || c.callingNumber || 'unknown',
phoneNumber: c.phoneNumber || c.calledNumber || c.callingNumber,
status: status || cdrDispositionValue(c),
otherParty: c.otherParty || c.calledParty || c.callingParty || c.remoteParty
|| cdrCalledNumber(c) || cdrCallingNumber(c) || 'unknown',
phoneNumber: c.phoneNumber || cdrCalledNumber(c) || cdrCallingNumber(c),
});
}
}
@ -872,6 +956,7 @@ export async function getHistoricalCallActivity(personId, hours = 24, options =
samples,
rawCount: receivedRaw,
userMatchedCount: userItems.length,
rawItems: returnRawItems ? effectiveItems : undefined,
};
} catch (err) {
const status = err.response?.status;

View file

@ -85,6 +85,128 @@ export function renderCallTestResultMarkdown(session) {
return md.trim();
}
/**
* @param {object} session
* @param {object} details from fetchCallResource
*/
export function renderCallTestTwilioDetailsMarkdown(session, details) {
let md = `**Call test — Twilio details** (\`${session.testId}\`)\n\n`;
if (!details?.available) {
md += `_Unavailable:_ ${details?.reason || 'unknown'}\n`;
if (session.callSid) md += `- **CallSid:** \`${session.callSid}\`\n`;
return md.trim();
}
const d = details.data || {};
md += `- **CallSid:** \`${d.sid || session.callSid}\`\n`;
md += `- **Status:** ${d.status || '—'}\n`;
md += `- **Duration:** ${d.duration != null ? `${d.duration}s` : '—'}\n`;
if (d.startTime) md += `- **Start:** ${fmtTime(d.startTime)}\n`;
if (d.endTime) md += `- **End:** ${fmtTime(d.endTime)}\n`;
md += `- **From:** ${d.from || '—'}\n`;
md += `- **To:** ${d.to || '—'}\n`;
if (d.price != null) md += `- **Price:** ${d.price} ${d.priceUnit || ''}\n`.trim() + '\n';
if (d.answeredBy) md += `- **Answered by:** ${d.answeredBy}\n`;
return md.trim();
}
/**
* @param {object} session
* @param {object} insights combined summary + metrics
*/
export function renderCallTestTwilioInsightsMarkdown(session, insights) {
let md = `**Call test — Twilio Insights** (\`${session.testId}\`)\n\n`;
if (!insights?.available) {
md += `_Unavailable:_ ${insights?.reason || 'Voice Insights not available'}\n`;
md += '\n_Enable Voice Insights Advanced Features in Twilio Console for MOS/jitter/loss metrics._\n';
return md.trim();
}
const s = insights.summary;
if (s) {
md += '**Summary**\n';
if (s.duration != null) md += `- Duration: ${s.duration}s\n`;
if (s.connectDuration != null) md += `- Connect duration: ${s.connectDuration}s\n`;
if (s.processingState) md += `- Processing: ${s.processingState}\n`;
}
const m = insights.metrics;
if (m?.highlights) {
md += '\n**Quality (carrier edge)**\n';
const h = m.highlights;
if (h.maxJitter != null) md += `- Max jitter: ${h.maxJitter}\n`;
if (h.maxPacketLoss != null) md += `- Max packet loss: ${h.maxPacketLoss}%\n`;
if (h.minMos != null) md += `- Min MOS: ${h.minMos}\n`;
}
return md.trim();
}
/**
* @param {object} session
* @param {object} cdrResult from fetchMatchedCdrForSession
*/
export function renderCallTestCdrMatchMarkdown(session, cdrResult) {
let md = `**Call test — Webex CDR match** (\`${session.testId}\`)\n\n`;
if (!cdrResult?.available) {
md += `_CDR unavailable:_ ${cdrResult?.reason || 'unknown'}\n`;
return md.trim();
}
if (cdrResult.window) {
md += `- **Window:** ${cdrResult.window.startTime}${cdrResult.window.endTime}\n`;
}
if (cdrResult.location) md += `- **Location:** ${cdrResult.location}\n`;
md += `- **Records in window:** ${cdrResult.rawCount ?? '—'}\n`;
const match = cdrResult.match;
if (!match) {
md += `\n_No matching leg found._ ${cdrResult.reason || ''}\n`;
return md.trim();
}
md += '\n**Matched leg**\n';
md += `- **Start:** ${fmtTime(match.start)}\n`;
md += `- **Direction:** ${match.direction || '—'}\n`;
md += `- **Duration:** ${match.duration ?? '—'}s\n`;
md += `- **Status:** ${match.status || '—'}\n`;
if (match.callingNumber) md += `- **Calling:** ${match.callingNumber}\n`;
if (match.calledNumber) md += `- **Called:** ${match.calledNumber}\n`;
if (match.score != null) md += `- **Match score:** ${match.score}\n`;
return md.trim();
}
/**
* Append enrichment status for /calltest status.
*/
export function renderCallTestEnrichmentStatusMarkdown(session) {
const e = session.enrichment || {};
const lines = ['\n**Enrichment:**'];
const tw = e.twilio || {};
if (tw.detailsFetchedAt) {
lines.push(`- Twilio details: ${tw.details?.available ? 'ready' : 'failed'}`);
} else {
lines.push('- Twilio details: pending');
}
if (tw.insightsFetchedAt) {
lines.push(`- Twilio Insights: ${tw.insights?.available ? 'ready' : 'unavailable'}`);
} else {
lines.push('- Twilio Insights: pending');
}
const cdr = e.cdr;
if (cdr?.fetchedAt) {
lines.push(`- Webex CDR: ${cdr.match ? 'matched' : 'no match'}`);
} else {
lines.push('- Webex CDR: pending');
}
return lines.join('\n');
}
/**
* @param {object} session
* @param {object} cdr

View file

@ -0,0 +1,161 @@
// services/renderers/voiceReportRenderer.js
function envInt(name, fallback) {
const v = Number(process.env[name]);
return Number.isFinite(v) ? v : fallback;
}
function fmtTime(iso) {
if (!iso) return '—';
try {
return new Date(iso).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC');
} catch {
return String(iso);
}
}
function countPoorQuality(calls) {
const mosWarn = Number(process.env.WAN_STANDARD_APP_MOS_WARN || 4.0);
const lossWarn = Number(process.env.WAN_STANDARD_APP_LOSS_WARN_PCT || 5);
const jitterWarn = Number(process.env.WAN_STANDARD_APP_JITTER_WARN_MS || 30);
let poor = 0;
for (const call of calls) {
const q = call.media || call.wan || {};
if (q.mos != null && q.mos < mosWarn) poor++;
else if (q.loss != null && q.loss > lossWarn) poor++;
else if (q.jitter != null && q.jitter > jitterWarn) poor++;
}
return poor;
}
function renderOutcomeBreakdown(outcomes) {
const entries = Object.entries(outcomes || {});
if (!entries.length) return '_No outcome data._';
return entries
.sort((a, b) => b[1] - a[1])
.map(([k, v]) => `- **${k}:** ${v}`)
.join('\n');
}
function renderReachSummary(summary) {
const lines = [];
if (summary.inbound > 0) {
lines.push(
`- **Inbound reached a phone:** ${summary.inboundReachedPhone} of ${summary.inbound}`,
);
if (summary.inboundAaOnly > 0) {
lines.push(`- **Inbound stopped at auto-attendant:** ${summary.inboundAaOnly}`);
}
}
if (summary.outbound > 0) {
lines.push(
`- **Outbound connected:** ${summary.outboundConnected} of ${summary.outbound}`,
);
}
if (summary.abnormal > 0) {
lines.push(`- **Abnormal outcomes:** ${summary.abnormal}`);
}
return lines.join('\n');
}
function renderCallReachLine(call) {
if (call.direction === 'inbound') {
if (call.reachedPhone) {
const who = [call.endpointUser, call.endpointModel].filter(Boolean).join(' / ');
return `reached ${who || 'phone'}`;
}
if (call.reachedAttendant) {
const key = call.aaKeyPress ? ` (AA key ${call.aaKeyPress})` : '';
return `auto-attendant only${key}`;
}
return 'not answered';
}
if (call.direction === 'outbound') {
return call.connected || call.reachedPhone ? 'connected' : 'not connected';
}
return call.outcome || '—';
}
/**
* @param {object} report from collectVoiceReport
* @param {{ detail?: boolean }} opts
*/
export function renderVoiceReportMarkdown(report, opts = {}) {
if (!report?.ok) {
let md = `**Voice report** — Store ${report?.store?.storeNum || '?'}\n\n`;
md += `_${report?.reason || report?.window?.reason || 'Unable to build report.'}_\n`;
return md.trim();
}
const { store, window, cdr, mediaQuality, prisma, joined } = report;
const calls = joined.calls || joined.legs || [];
const summary = joined.summary || {};
const maxRows = envInt('VOICEREPORT_MAX_DETAIL_ROWS', 25);
const detail = Boolean(opts.detail);
let md = `**Voice report — Store ${store.storeNum}**\n\n`;
md += `- **Date:** ${window.label} (${window.mode})\n`;
md += `- **Timezone:** ${window.timeZone}\n`;
md += `- **Window:** ${fmtTime(window.startTime)}${fmtTime(window.endTime)}\n`;
md += `- **Location:** ${store.locationName}\n`;
if (store.dialNumber) md += `- **Main:** ${store.dialNumber}\n`;
md += '\n**Call volume (CDR)**\n';
if (!cdr.available) {
md += `_CDR unavailable:_ ${cdr.reason || 'unknown'}\n`;
} else {
md += `- **Total calls:** ${summary.total ?? calls.length}\n`;
md += `- **Inbound:** ${summary.inbound ?? 0} | **Outbound:** ${summary.outbound ?? 0}\n`;
md += `- **CDR legs fetched:** ${cdr.rawCount ?? '—'} → **${summary.total ?? calls.length}** correlated call(s)\n`;
const reach = renderReachSummary(summary);
if (reach) md += `${reach}\n`;
md += '\n**Call outcomes**\n';
md += `${renderOutcomeBreakdown(summary.outcomes)}\n`;
}
md += '\n**Media quality (Webex report)**\n';
if (!mediaQuality.available) {
md += `_Unavailable:_ ${mediaQuality.reason || 'not generated'}\n`;
} else {
md += `- **Rows in window:** ${mediaQuality.rows?.length ?? 0} (from ${mediaQuality.rawCount ?? '?'} downloaded)\n`;
const poor = countPoorQuality(calls.filter((c) => c.media));
md += `- **Calls with quality flags:** ${poor}\n`;
}
md += '\n**WAN voice (Prisma DPI)**\n';
if (!prisma.available) {
md += `_Unavailable:_ ${prisma.reason || 'not configured'}\n`;
} else {
const m = prisma.appAudio?.mos;
md += `- **App:** ${prisma.appAudio.appName}\n`;
if (m?.min != null) md += `- **Worst MOS in window:** ${m.min}\n`;
if (m?.max != null) md += `- **Best MOS in window:** ${m.max}\n`;
if (prisma.appAudio?.detailsUrl) {
md += `- [View in Prisma SCM](${prisma.appAudio.detailsUrl})\n`;
}
}
if (detail && calls.length) {
md += '\n**Call detail**\n';
const show = calls.slice(0, maxRows);
for (const call of show) {
const dir = String(call.direction || 'unknown').toUpperCase();
const outcome = [call.outcome, call.outcomeReason].filter(Boolean).join(' / ');
const flag = call.abnormal || !call.normalOutcome ? ' ⚠️' : '';
md += `\n- **${fmtTime(call.start)}** ${dir} ${call.duration || 0}s — ${outcome || '—'}${flag}\n`;
md += ` ${call.callingNumber || '?'}${call.calledNumber || '?'} (${call.legCount} leg(s), ${renderCallReachLine(call)})\n`;
if (call.media) {
md += ` Media: MOS ${call.media.mos ?? '—'}, jitter ${call.media.jitter ?? '—'}, loss ${call.media.loss ?? '—'}\n`;
}
if (call.wan) {
md += ` WAN≈: MOS ${call.wan.mos ?? '—'}, jitter ${call.wan.jitter ?? '—'}, loss ${call.wan.loss ?? '—'}\n`;
}
}
if (calls.length > maxRows) {
md += `\n_${calls.length - maxRows} more calls — use Control Hub Detailed Call History for full export._\n`;
}
}
md += `\n*Generated in ${report.elapsedMs}ms*`;
return md.trim();
}

View file

@ -0,0 +1,144 @@
// services/voiceReport/businessWindow.js
// Store-local business-hour windows for /voicereport (default 9am9pm).
import { DateTime } from 'luxon';
import { DISPLAY_TIMEZONE } from '../../utils/time.js';
const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000;
function envInt(name, fallback) {
const v = Number(process.env[name]);
return Number.isFinite(v) ? v : fallback;
}
export function getBusinessHourConfig() {
return {
startHour: envInt('VOICEREPORT_BUSINESS_START_HOUR', 9),
endHour: envInt('VOICEREPORT_BUSINESS_END_HOUR', 21),
apiLagMs: envInt('VOICEREPORT_API_LAG_MS', 5 * 60 * 1000),
};
}
/**
* Parse user date arg into a mode + calendar date in store TZ.
* @param {string|null|undefined} raw
* @param {string} timeZone IANA zone
* @param {Date} [now] injectable for tests
*/
export function parseReportDateArg(raw, timeZone, now = new Date()) {
const zone = timeZone || DISPLAY_TIMEZONE;
const nowLocal = DateTime.fromJSDate(now, { zone });
const token = String(raw || 'yesterday').trim().toLowerCase();
if (!token || token === 'yesterday') {
const day = nowLocal.minus({ days: 1 }).startOf('day');
return { mode: 'fullDay', day, label: day.toISODate() };
}
if (token === 'today') {
return { mode: 'today', day: nowLocal.startOf('day'), label: nowLocal.toISODate() };
}
const parsed = DateTime.fromISO(token, { zone });
if (parsed.isValid) {
return { mode: 'fullDay', day: parsed.startOf('day'), label: parsed.toISODate() };
}
return { error: `Unrecognised date "${raw}". Use yesterday, today, or YYYY-MM-DD.` };
}
/**
* Compute UTC ISO window for cdr_feed / Prisma queries.
* @returns {{ startTime: string, endTime: string, timeZone: string, label: string, mode: string, ready: boolean, reason?: string }}
*/
export function computeBusinessWindow({ timeZone, dateArg, now = new Date() }) {
const zone = timeZone || DISPLAY_TIMEZONE;
const cfg = getBusinessHourConfig();
const parsed = parseReportDateArg(dateArg, zone, now);
if (parsed.error) {
return { ready: false, reason: parsed.error, timeZone: zone };
}
const startLocal = parsed.day.set({
hour: cfg.startHour,
minute: 0,
second: 0,
millisecond: 0,
});
let endLocal;
const nowLocal = DateTime.fromJSDate(now, { zone });
if (parsed.mode === 'today') {
const businessEnd = parsed.day.set({
hour: cfg.endHour,
minute: 0,
second: 0,
millisecond: 0,
});
const lagEnd = nowLocal.minus({ milliseconds: cfg.apiLagMs });
if (lagEnd < startLocal.plus({ minutes: 1 })) {
return {
ready: false,
reason: 'Business window has not started yet, or insufficient data after the 5-minute API lag.',
timeZone: zone,
label: parsed.label,
mode: parsed.mode,
};
}
endLocal = DateTime.min(lagEnd, businessEnd);
} else {
endLocal = parsed.day.set({
hour: cfg.endHour,
minute: 0,
second: 0,
millisecond: 0,
});
}
if (endLocal <= startLocal) {
return {
ready: false,
reason: 'Computed window is empty (end is not after start).',
timeZone: zone,
label: parsed.label,
mode: parsed.mode,
};
}
let startMs = startLocal.toUTC().toMillis();
let endMs = endLocal.toUTC().toMillis();
const latestAllowedEnd = now.getTime() - cfg.apiLagMs;
if (endMs > latestAllowedEnd) endMs = latestAllowedEnd;
if (endMs - startMs > TWELVE_HOURS_MS) startMs = endMs - TWELVE_HOURS_MS;
if (startMs >= endMs) {
return {
ready: false,
reason: 'Window invalid after API lag clamp.',
timeZone: zone,
label: parsed.label,
mode: parsed.mode,
};
}
return {
ready: true,
timeZone: zone,
label: parsed.label,
mode: parsed.mode,
startTime: new Date(startMs).toISOString(),
endTime: new Date(endMs).toISOString(),
startLocal: startLocal.toISO(),
endLocal: endLocal.toISO(),
};
}
/**
* True when a timestamp (ISO or ms) falls inside the business window.
*/
export function isWithinWindow(isoOrMs, window) {
if (!window?.startTime || !window?.endTime) return false;
const t = new Date(isoOrMs).getTime();
const s = new Date(window.startTime).getTime();
const e = new Date(window.endTime).getTime();
return t >= s && t <= e;
}

View file

@ -0,0 +1,250 @@
// services/voiceReport/groupCdrCalls.js
// Group cdr_feed legs by Correlation ID into logical calls.
import {
cdrCalledNumber,
cdrCallingNumber,
cdrDirectionValue,
cdrDurationSeconds,
cdrField,
cdrRecordId,
cdrStartTime,
cdrDedupKey,
} from '../cdrFeedParser.js';
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 pickExternalNumber(legs, direction) {
for (const leg of legs) {
const ct = callTypeValue(leg);
if (direction === 'inbound' && ct.includes('INBOUND')) {
return cdrCallingNumber(leg) || cdrField(leg, 'Caller ID number', 'caller id number');
}
if (direction === 'outbound' && ct.includes('OUTBOUND')) {
return cdrCalledNumber(leg);
}
}
const first = legs[0];
if (!first) return null;
if (direction === 'inbound') {
return cdrCallingNumber(first) || cdrField(first, 'Caller ID number', 'caller id number');
}
return cdrCalledNumber(first);
}
function pickTerminalOutcome(legs) {
// Prefer the user-phone leg, then any leg with explicit outcome.
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;
}
}
return {
outcome: cdrField(chosen, 'Call outcome', 'call outcome') || 'unknown',
reason: cdrField(chosen, 'Call outcome reason', 'call 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
? cdrField(answeredPhone, 'User', 'user')
: null,
endpointModel: answeredPhone
? cdrField(answeredPhone, 'Model', 'model')
: null,
aaKeyPress: aaLegs.map((l) => cdrField(l, 'Auto Attendant Key Pressed', 'auto attendant key pressed'))
.find((v) => v && v !== 'NA') || null,
};
}
// Outbound: originating user/device leg answered, remote side picked up on terminating leg.
const originating = legs.find((l) => cdrDirectionValue(l).includes('ORIGINAT') && isUserPhoneLeg(l));
const remoteAnswered = legs.some((l) =>
cdrDirectionValue(l).includes('TERMINAT') && isTruthyAnswered(l) && !isAutomatedAttendantLeg(l),
);
return {
reachedPhone: Boolean(originating && isTruthyAnswered(originating)),
connected: remoteAnswered || (originating && isTruthyAnswered(originating)),
endpointUser: originating ? cdrField(originating, 'User', 'user') : null,
endpointModel: originating ? cdrField(originating, 'Model', 'model') : null,
reachedAttendant: false,
aaKeyPress: null,
};
}
/**
* @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 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));
return {
correlationId,
direction,
start,
end,
duration,
callingNumber: pickExternalNumber(legs, direction) || cdrCallingNumber(legs[0]),
calledNumber: cdrCalledNumber(legs[legs.length - 1]) || cdrCalledNumber(legs[0]),
outcome: outcome.outcome,
outcomeReason: outcome.reason,
normalOutcome: outcome.normal && !abnormal,
abnormal,
legCount: legs.length,
...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) => summarizeCallGroup(g.correlationId, g.legs))
.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.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,
};
}

View file

@ -0,0 +1,199 @@
// services/voiceReport/joinCallQuality.js
// Join CDR legs, Media Quality rows, and Prisma WAN buckets.
import { isWithinWindow } from './businessWindow.js';
import {
cdrCalledNumber,
cdrCallingNumber,
cdrDirectionValue,
cdrDispositionValue,
cdrDurationSeconds,
cdrField,
cdrStartTime,
} from '../cdrFeedParser.js';
import { groupCdrIntoCalls, summarizeCalls } from './groupCdrCalls.js';
export function phoneDigits(value) {
const d = String(value || '').replace(/\D/g, '');
if (d.length === 10) return `1${d}`;
if (d.length === 11 && d.startsWith('1')) return d;
return d;
}
function cdrDisposition(item) {
return cdrDispositionValue(item);
}
function cdrDirection(item) {
return cdrDirectionValue(item);
}
function cdrStart(item) {
return cdrStartTime(item);
}
function cdrDuration(item) {
return cdrDurationSeconds(item);
}
function normalizeCdrLeg(item) {
return {
start: cdrStart(item),
direction: cdrDirection(item),
duration: cdrDuration(item),
disposition: cdrDisposition(item),
callingNumber: cdrCallingNumber(item),
calledNumber: cdrCalledNumber(item),
correlationId: cdrField(item, 'correlationId', 'Correlation ID', 'correlationID'),
raw: item,
};
}
function mqCorrelation(row) {
const n = row._norm || {};
return n.correlationid || n.finalcorrelationid || row['Correlation ID'] || null;
}
function mqStart(row) {
const n = row._norm || {};
return n.starttime || n.callstarttime || row['Start Time'] || null;
}
function mqNumbers(row) {
const n = row._norm || {};
return [
n.callingnumber, n.callednumber, n.remoteparty,
row['Calling Number'], row['Called Number'],
].filter(Boolean).map(phoneDigits);
}
function mqQuality(row) {
const n = row._norm || {};
const mos = Number(n.mos || n.meanopinionscore || n.audiomos || NaN);
const jitter = Number(n.jitter || n.jitterms || NaN);
const loss = Number(n.packetloss || n.packetlosspct || n.loss || NaN);
return {
mos: Number.isFinite(mos) ? mos : null,
jitter: Number.isFinite(jitter) ? jitter : null,
loss: Number.isFinite(loss) ? loss : null,
};
}
function scoreMqMatch(call, row) {
const callCorr = call.correlationId ? String(call.correlationId).toLowerCase() : '';
const rowCorr = mqCorrelation(row);
if (callCorr && rowCorr && String(rowCorr).toLowerCase() === callCorr) return 100;
const legStart = call.start ? new Date(call.start).getTime() : NaN;
const rowStart = mqStart(row) ? new Date(mqStart(row)).getTime() : NaN;
if (!Number.isFinite(legStart) || !Number.isFinite(rowStart)) return 0;
const delta = Math.abs(legStart - rowStart);
if (delta > 120_000) return 0;
let score = 50;
if (delta < 15_000) score += 30;
else if (delta < 60_000) score += 10;
const legNums = new Set([
phoneDigits(call.callingNumber),
phoneDigits(call.calledNumber),
].filter(Boolean));
const rowNums = mqNumbers(row);
if (rowNums.some((n) => legNums.has(n))) score += 20;
return score;
}
export function matchMediaQualityToCall(call, mqRows) {
let best = null;
let bestScore = 0;
for (const row of mqRows || []) {
const s = scoreMqMatch(call, row);
if (s > bestScore) {
bestScore = s;
best = row;
}
}
if (!best || bestScore < 50) return null;
return { ...mqQuality(best), score: bestScore };
}
/** @deprecated use matchMediaQualityToCall */
export function matchMediaQualityToLeg(leg, mqRows) {
return matchMediaQualityToCall(leg, mqRows);
}
/**
* Find worst Prisma 5-min bucket overlapping [start, end].
*/
export function worstPrismaBucketForCall(call, appAudio, window) {
if (!appAudio?.mos?.values?.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 intervalMs = 5 * 60 * 1000;
const winStart = new Date(window.startTime).getTime();
const idx0 = Math.max(0, Math.floor((startMs - winStart) / intervalMs));
const idx1 = Math.max(idx0, Math.ceil((endMs - winStart) / intervalMs));
const mosVals = appAudio.mos.values;
const lossVals = appAudio.loss?.values || [];
const jitterVals = appAudio.jitter?.values || [];
let worstMos = null;
let worstLoss = null;
let worstJitter = null;
for (let i = idx0; i <= idx1 && i < mosVals.length; i++) {
const m = mosVals[i];
if (typeof m === 'number' && (worstMos == null || m < worstMos)) worstMos = m;
const l = lossVals[i];
if (typeof l === 'number' && (worstLoss == null || l > worstLoss)) worstLoss = l;
const j = jitterVals[i];
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,
};
}
export function summarizeDispositions(legs) {
const counts = {};
for (const leg of legs) {
const key = String(leg.disposition || 'unknown').toLowerCase();
counts[key] = (counts[key] || 0) + 1;
}
return counts;
}
export function joinCallQuality({ cdrItems, mqRows, appAudio, window }) {
const calls = groupCdrIntoCalls(cdrItems);
const joined = calls.map((call) => {
const media = matchMediaQualityToCall(call, mqRows);
const wan = worstPrismaBucketForCall(call, appAudio, window);
return { ...call, media, wan };
});
return {
calls: joined,
legs: joined, // backward compat for callers expecting .legs
summary: summarizeCalls(joined),
};
}
export function filterCdrToWindow(items, window) {
return (items || []).filter((item) => {
const start = cdrStartTime(item);
// cdr_feed query params already scope by start time; keep rows when the
// report column label differs from our camelCase guesses.
if (!start) return true;
return isWithinWindow(start, window);
});
}

View file

@ -0,0 +1,37 @@
// services/voiceReport/storeContext.js
// Resolve Webex location + timezone for a store number.
import webex from '../../integrations/webex/WebexClient.js';
import { logger } from '../../utils/logger.js';
import { DISPLAY_TIMEZONE } from '../../utils/time.js';
import { resolveStoreMainNumber } from '../callTest/storeResolver.js';
const LOG_SCOPE = 'voicereport:store';
/**
* @param {string} storeNum
* @returns {Promise<{ storeNum, personId, locationId, locationName, dialNumber, timeZone, email }>}
*/
export async function resolveStoreForVoiceReport(storeNum) {
const base = await resolveStoreMainNumber(storeNum);
let timeZone = DISPLAY_TIMEZONE;
try {
const [profile, location] = await Promise.all([
webex.request('GET', `telephony/config/people/${base.personId}`).catch(() => null),
base.locationId
? webex.request('GET', `telephony/config/locations/${base.locationId}`).catch(() => null)
: null,
]);
timeZone = profile?.timeZone || location?.timeZone || location?.timezone || timeZone;
} catch (err) {
logger(LOG_SCOPE, `Timezone lookup failed for store ${storeNum}: ${err.message}`, 'debug');
}
const padded = String(storeNum).trim().padStart(5, '0');
return {
...base,
timeZone,
email: `ae${padded}@ae.com`,
};
}

View file

@ -0,0 +1,159 @@
// services/voiceReport/voiceReportService.js
// Compose CDR + Media Quality + Prisma WAN for /voicereport.
import { logger } from '../../utils/logger.js';
import { getHistoricalCallActivity } from '../phoneService.js';
import { findSdwanSiteForStore } from '../../integrations/paloalto/sites.js';
import { getAppMetric } from '../../integrations/paloalto/metrics.js';
import { buildAppDetailsUrl } from '../../integrations/paloalto/urls.js';
import { fetchMediaQualityReport } from '../../integrations/webex/reportsClient.js';
import {
resolveVoiceAppConfig,
summarizeAppSeries,
} from '../enrichment/sdwanEnrichment.js';
import { computeBusinessWindow } from './businessWindow.js';
import { resolveStoreForVoiceReport } from './storeContext.js';
import { filterCdrToWindow, joinCallQuality } from './joinCallQuality.js';
import { cdrStartTime } from '../cdrFeedParser.js';
const LOG_SCOPE = 'voicereport:service';
async function fetchCdrForWindow(store, window) {
const cdr = await getHistoricalCallActivity(store.personId, 12, {
locationName: store.locationName,
startTime: window.startTime,
endTime: window.endTime,
returnRawItems: true,
skipPersonFilter: true,
});
if (!cdr.available) {
logger(
LOG_SCOPE,
`CDR unavailable for store=${store.storeNum} loc=${JSON.stringify(store.locationName)}: ${cdr.reason}`,
'warn',
);
return { available: false, reason: cdr.reason, items: [], fetchErrors: cdr.fetchErrors };
}
const beforeFilter = cdr.rawItems?.length || 0;
const items = filterCdrToWindow(cdr.rawItems || [], window);
const rawCount = cdr.rawCount ?? beforeFilter;
if (rawCount > 0 && items.length === 0) {
const sample = (cdr.rawItems || []).slice(0, 2).map((r) => cdrStartTime(r) || 'no-start-field');
logger(
LOG_SCOPE,
`CDR window filter dropped all ${rawCount} record(s) for store=${store.storeNum} ` +
`(window ${window.startTime}..${window.endTime}); sample start fields: ${sample.join(', ')}`,
'warn',
);
} else {
logger(
LOG_SCOPE,
`CDR store=${store.storeNum} loc=${JSON.stringify(store.locationName)} ` +
`api=${rawCount} afterWindowFilter=${items.length}`,
'info',
);
}
return {
available: true,
items,
rawCount,
afterFilterCount: items.length,
};
}
async function fetchPrismaAppAudio(storeNum, window) {
const voiceCfg = resolveVoiceAppConfig();
if (!voiceCfg.appId) {
return { available: false, reason: 'PRISMA_APP_ID_VOICE not configured' };
}
const site = await findSdwanSiteForStore(storeNum);
if (!site?.id) {
return { available: false, reason: 'not a Prisma-managed store' };
}
const opts = { startTime: window.startTime, endTime: window.endTime };
const [mosRes, lossRes, jitterRes] = await Promise.all([
getAppMetric(site.id, voiceCfg.appId, 'mos', opts),
getAppMetric(site.id, voiceCfg.appId, 'loss', opts),
getAppMetric(site.id, voiceCfg.appId, 'jitter', opts),
]);
const appAudio = {
appId: voiceCfg.appId,
appName: voiceCfg.appName,
detailsUrl: buildAppDetailsUrl(site.id, voiceCfg.appId),
mos: summarizeAppSeries(mosRes, 'AppAudioMos'),
loss: summarizeAppSeries(lossRes, 'AppPerfUDPAudioPacketLoss'),
jitter: summarizeAppSeries(jitterRes, 'AppPerfUDPAudioJitter'),
};
return { available: true, site, appAudio };
}
/**
* @param {string} storeNum
* @param {object} opts
* @param {string} [opts.dateArg] yesterday | today | YYYY-MM-DD
* @param {Date} [opts.now]
*/
export async function collectVoiceReport(storeNum, opts = {}) {
const startedAt = Date.now();
const store = await resolveStoreForVoiceReport(storeNum);
const window = computeBusinessWindow({
timeZone: store.timeZone,
dateArg: opts.dateArg,
now: opts.now,
});
if (!window.ready) {
return {
ok: false,
store,
window,
reason: window.reason,
};
}
logger(
LOG_SCOPE,
`Collecting voice report store=${storeNum} loc=${store.locationName} ` +
`window=${window.startTime}..${window.endTime}`,
'info',
);
const [cdrRes, mqRes, prismaRes] = await Promise.allSettled([
fetchCdrForWindow(store, window),
fetchMediaQualityReport({ window, locationName: store.locationName }),
fetchPrismaAppAudio(storeNum, window),
]);
const cdr = cdrRes.status === 'fulfilled'
? cdrRes.value
: { available: false, reason: cdrRes.reason?.message, items: [] };
const mediaQuality = mqRes.status === 'fulfilled'
? mqRes.value
: { available: false, reason: mqRes.reason?.message, rows: [] };
const prisma = prismaRes.status === 'fulfilled'
? prismaRes.value
: { available: false, reason: prismaRes.reason?.message };
const joined = joinCallQuality({
cdrItems: cdr.items || [],
mqRows: mediaQuality.rows || [],
appAudio: prisma.appAudio || null,
window,
});
return {
ok: true,
store,
window,
cdr,
mediaQuality,
prisma,
joined,
fetchedAt: new Date().toISOString(),
elapsedMs: Date.now() - startedAt,
};
}

View file

@ -0,0 +1,86 @@
// tests/callTest.cdrMatcher.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import {
computeCdrWindow,
phoneDigits,
scoreCdrLeg,
pickBestCdrLeg,
} from '../services/callTest/cdrMatcher.js';
test('phoneDigits normalizes US numbers', () => {
assert.equal(phoneDigits('+17247795574'), '17247795574');
assert.equal(phoneDigits('7247795574'), '17247795574');
});
test('computeCdrWindow clamps to 12h and 5min API lag', () => {
const now = Date.now();
const session = {
createdAt: new Date(now - 10 * 60_000).toISOString(),
events: {
initiated: new Date(now - 8 * 60_000).toISOString(),
completed: new Date(now - 6 * 60_000).toISOString(),
},
};
const w = computeCdrWindow(session, 60_000);
const endMs = new Date(w.endTime).getTime();
const startMs = new Date(w.startTime).getTime();
assert.ok(endMs <= now - 5 * 60 * 1000 + 1000);
assert.ok(startMs < endMs);
});
test('pickBestCdrLeg matches inbound store leg from Twilio', () => {
const session = {
dialNumber: '+17247795574',
durationSec: 75,
events: { coreStartedAt: '2026-07-23T21:28:09.295Z' },
};
const items = [
{
direction: 'INBOUND',
calledNumber: '+17247795574',
callingNumber: '+15551234567',
startTime: '2026-07-23T21:28:08.000Z',
durationSeconds: 74,
},
{
direction: 'OUTBOUND',
calledNumber: '+19998887777',
callingNumber: '+17247795574',
durationSeconds: 10,
},
];
const picked = pickBestCdrLeg(items, session);
assert.ok(picked);
assert.equal(picked.leg.calledNumber, '+17247795574');
assert.ok(picked.score >= 100);
});
test('pickBestCdrLeg matches destination even without caller match', () => {
const session = {
dialNumber: '+17247795574',
events: { initiated: '2026-07-23T21:28:09.295Z' },
};
const items = [
{
calledNumber: '+17247795574',
callingNumber: '+19998887777',
startTime: '2026-07-23T21:28:08.000Z',
durationSeconds: 77,
},
];
const picked = pickBestCdrLeg(items, session);
assert.ok(picked);
assert.equal(picked.leg.calledNumber, '+17247795574');
});
test('scoreCdrLeg returns zero for unrelated destination', () => {
const session = { dialNumber: '+17247795574', durationSec: 60, events: {} };
const score = scoreCdrLeg(
{ direction: 'OUTBOUND', calledNumber: '+19998887777', callingNumber: '+11112223333' },
session,
);
assert.equal(score, 0);
});

View file

@ -15,11 +15,20 @@ import {
getSession,
} from '../services/callTest/sessionStore.js';
import { renderCallTestResultMarkdown } from '../services/renderers/callTestRenderer.js';
import { _clearTwilioEnrichmentTimersForTests } from '../services/callTest/twilioEnrichment.js';
import { _clearCdrTimersForTests } from '../services/callTest/cdrMatcher.js';
const TEST_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
function clearCallTestTimers() {
_clearPendingFinalizeTimersForTests();
_clearTwilioEnrichmentTimersForTests();
_clearCdrTimersForTests();
}
test('handleVoiceWebhook: store path marks core on intro', () => {
_clearAllSessionsForTests();
clearCallTestTimers();
createSession(TEST_ID, {
mode: 'store',
dialNumber: '+12125550100',
@ -40,13 +49,13 @@ test('handleVoiceWebhook: store path marks core on intro', () => {
test('handleVoiceWebhook done step finalizes without status callback', async () => {
_clearAllSessionsForTests();
_clearPendingFinalizeTimersForTests();
clearCallTestTimers();
createSession(TEST_ID, {
mode: 'dial',
dialNumber: '+12125550100',
roomId: 'room-1',
reachedCore: true,
config: {},
config: { twilioEnrich: false, cdrEnrich: false },
});
handleVoiceWebhook(TEST_ID, 'dial', 'outro');
@ -58,16 +67,40 @@ test('handleVoiceWebhook done step finalizes without status callback', async ()
const finalized = await tryFinalizeFromVoiceDoneForTests(TEST_ID);
assert.equal(finalized.result, 'pass');
assert.equal(finalized.status, 'completed');
clearCallTestTimers();
});
test('finalize leaves enrichment pending on session', async () => {
_clearAllSessionsForTests();
clearCallTestTimers();
createSession(TEST_ID, {
mode: 'dial',
dialNumber: '+17247795574',
roomId: 'room-1',
callSid: 'CAENRICH',
reachedCore: true,
lastStep: 'done',
config: { twilioEnrich: false, cdrEnrich: false },
});
handleVoiceWebhook(TEST_ID, 'dial', 'done');
await tryFinalizeFromVoiceDoneForTests(TEST_ID);
const s = getSession(TEST_ID);
assert.equal(s.status, 'completed');
assert.deepEqual(s.enrichment, { twilio: {}, cdr: {} });
clearCallTestTimers();
});
test('handleStatusCallback: in-progress maps to answered and passes on completed', async () => {
_clearAllSessionsForTests();
clearCallTestTimers();
createSession(TEST_ID, {
mode: 'dial',
dialNumber: '+12125550100',
reachedCore: true,
lastStep: 'done',
config: {},
config: { twilioEnrich: false, cdrEnrich: false },
});
await handleStatusCallback(TEST_ID, {
@ -83,16 +116,18 @@ test('handleStatusCallback: in-progress maps to answered and passes on completed
const s = getSession(TEST_ID);
assert.ok(s.events.answered);
assert.equal(s.result, 'pass');
clearCallTestTimers();
});
test('handleStatusCallback: completed with core yields pass in renderer', async () => {
_clearAllSessionsForTests();
clearCallTestTimers();
createSession(TEST_ID, {
mode: 'dial',
dialNumber: '+12125550100',
reachedCore: true,
lastStep: 'done',
config: {},
config: { twilioEnrich: false, cdrEnrich: false },
});
await handleStatusCallback(TEST_ID, {
@ -112,14 +147,16 @@ test('handleStatusCallback: completed with core yields pass in renderer', async
const md = renderCallTestResultMarkdown(s);
assert.match(md, /PASSED/);
assert.match(md, /CA123/);
clearCallTestTimers();
});
test('handleStatusCallback: no-answer fails', async () => {
_clearAllSessionsForTests();
clearCallTestTimers();
createSession(TEST_ID, {
mode: 'dial',
dialNumber: '+12125550100',
config: {},
config: { twilioEnrich: false, cdrEnrich: false },
});
await handleStatusCallback(TEST_ID, {
@ -131,6 +168,7 @@ test('handleStatusCallback: no-answer fails', async () => {
assert.equal(s.result, 'fail');
const md = renderCallTestResultMarkdown(s);
assert.match(md, /FAILED/);
clearCallTestTimers();
});
test('renderCallTestResultMarkdown shows in progress before final result', () => {

View file

@ -0,0 +1,116 @@
// tests/callTest.twilioEnrichment.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import {
renderCallTestTwilioDetailsMarkdown,
renderCallTestTwilioInsightsMarkdown,
renderCallTestCdrMatchMarkdown,
} from '../services/renderers/callTestRenderer.js';
import {
scheduleTwilioEnrichment,
_clearTwilioEnrichmentTimersForTests,
} from '../services/callTest/twilioEnrichment.js';
import {
createSession,
_clearAllSessionsForTests,
getSession,
} from '../services/callTest/sessionStore.js';
const TEST_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
function clearTimers() {
_clearTwilioEnrichmentTimersForTests();
}
test('renderCallTestTwilioDetailsMarkdown shows call fields', () => {
const md = renderCallTestTwilioDetailsMarkdown(
{ testId: TEST_ID, callSid: 'CA123' },
{
available: true,
data: {
sid: 'CA123',
status: 'completed',
duration: 75,
from: '+15550001111',
to: '+17247795574',
price: '-0.0140',
priceUnit: 'USD',
},
},
);
assert.match(md, /Twilio details/);
assert.match(md, /completed/);
assert.match(md, /75s/);
});
test('renderCallTestTwilioInsightsMarkdown handles unavailable', () => {
const md = renderCallTestTwilioInsightsMarkdown(
{ testId: TEST_ID },
{ available: false, reason: 'not enabled' },
);
assert.match(md, /Unavailable/);
assert.match(md, /Advanced Features/);
});
test('renderCallTestTwilioInsightsMarkdown shows quality highlights', () => {
const md = renderCallTestTwilioInsightsMarkdown(
{ testId: TEST_ID },
{
available: true,
summary: { duration: 75, processingState: 'complete' },
metrics: { highlights: { maxJitter: 12, maxPacketLoss: 0.5, minMos: 4.1 } },
},
);
assert.match(md, /jitter/i);
assert.match(md, /MOS/);
});
test('renderCallTestCdrMatchMarkdown shows matched leg', () => {
const md = renderCallTestCdrMatchMarkdown(
{ testId: TEST_ID },
{
available: true,
location: 'Store 0782',
rawCount: 3,
window: { startTime: '2026-07-23T21:00:00.000Z', endTime: '2026-07-23T21:10:00.000Z' },
match: {
start: '2026-07-23T21:05:00.000Z',
direction: 'INBOUND',
duration: 75,
status: 'SUCCESS',
callingNumber: '+15551234567',
calledNumber: '+17247795574',
score: 120,
},
},
);
assert.match(md, /CDR match/);
assert.match(md, /INBOUND/);
assert.match(md, /Match score/);
});
test('scheduleTwilioEnrichment is no-op without callSid', () => {
clearTimers();
scheduleTwilioEnrichment({ testId: TEST_ID, roomId: 'room-1', config: { twilioEnrich: true } }, async () => {});
clearTimers();
});
test('scheduleTwilioEnrichment registers timers for valid session', () => {
_clearAllSessionsForTests();
clearTimers();
createSession(TEST_ID, {
callSid: 'CA555',
roomId: 'room-1',
config: {
twilioEnrich: true,
twilioDetailsDelayMs: 300_000,
twilioInsightsDelayMs: 300_000,
},
});
const session = getSession(TEST_ID);
scheduleTwilioEnrichment(session, async () => {});
scheduleTwilioEnrichment(session, async () => {});
clearTimers();
});

View file

@ -0,0 +1,61 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { extractCdrFeedList } from '../services/cdrFeedParser.js';
import {
cdrDedupKey,
cdrStartTime,
cdrCallingNumber,
} from '../services/cdrFeedParser.js';
import { filterCdrToWindow } from '../services/voiceReport/joinCallQuality.js';
test('extractCdrFeedList handles items array', () => {
const rows = [{ id: 1 }, { id: 2 }];
assert.deepEqual(extractCdrFeedList({ items: rows }), rows);
});
test('extractCdrFeedList handles numeric-keyed items object', () => {
const rows = [{ id: 'a' }, { id: 'b' }];
assert.deepEqual(extractCdrFeedList({ items: { 0: rows[0], 1: rows[1] } }), rows);
});
test('extractCdrFeedList handles top-level array', () => {
const rows = [{ id: 1 }];
assert.deepEqual(extractCdrFeedList(rows), rows);
});
test('extractCdrFeedList handles records alias', () => {
const rows = [{ id: 1 }];
assert.deepEqual(extractCdrFeedList({ records: rows }), rows);
});
test('cdrField reads report column labels', () => {
const row = {
'Report ID': 'abc-1',
'Start time': '2026-07-23T14:00:00.000Z',
'Calling number': '+15551234567',
'Called number': '+12122194600',
Duration: 42,
Direction: 'Inbound',
};
assert.equal(cdrStartTime(row), '2026-07-23T14:00:00.000Z');
assert.equal(cdrCallingNumber(row), '+15551234567');
assert.equal(cdrDedupKey(row), 'id:abc-1');
});
test('cdrDedupKey keeps distinct report rows', () => {
const a = { 'Report ID': '1', 'Start time': '2026-07-23T14:00:00.000Z' };
const b = { 'Report ID': '2', 'Start time': '2026-07-23T15:00:00.000Z' };
assert.notEqual(cdrDedupKey(a), cdrDedupKey(b));
});
test('filterCdrToWindow keeps API rows when start time is report-labeled', () => {
const window = {
startTime: '2026-07-23T13:00:00.000Z',
endTime: '2026-07-24T01:00:00.000Z',
};
const rows = [
{ 'Start time': '2026-07-23T14:00:00.000Z', 'Report ID': '1' },
{ 'Start time': '2026-07-23T15:00:00.000Z', 'Report ID': '2' },
];
assert.equal(filterCdrToWindow(rows, window).length, 2);
});

View file

@ -0,0 +1,59 @@
// tests/voiceReport.businessWindow.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { DateTime } from 'luxon';
import {
computeBusinessWindow,
parseReportDateArg,
} from '../services/voiceReport/businessWindow.js';
test('parseReportDateArg defaults to yesterday', () => {
const now = DateTime.fromISO('2026-07-24T15:00:00', { zone: 'America/New_York' }).toJSDate();
const p = parseReportDateArg(null, 'America/New_York', now);
assert.equal(p.label, '2026-07-23');
assert.equal(p.mode, 'fullDay');
});
test('computeBusinessWindow full day yesterday 9-9', () => {
const now = DateTime.fromISO('2026-07-24T12:00:00', { zone: 'America/New_York' }).toJSDate();
const w = computeBusinessWindow({
timeZone: 'America/New_York',
dateArg: 'yesterday',
now,
});
assert.equal(w.ready, true);
assert.equal(w.label, '2026-07-23');
const start = DateTime.fromISO(w.startTime, { zone: 'utc' });
const end = DateTime.fromISO(w.endTime, { zone: 'utc' });
const startLocal = start.setZone('America/New_York');
const endLocal = end.setZone('America/New_York');
assert.equal(startLocal.hour, 9);
assert.equal(endLocal.hour, 21);
});
test('computeBusinessWindow today ends at now minus lag', () => {
const now = DateTime.fromISO('2026-07-24T14:00:00', { zone: 'America/New_York' }).toJSDate();
const w = computeBusinessWindow({
timeZone: 'America/New_York',
dateArg: 'today',
now,
});
assert.equal(w.ready, true);
assert.equal(w.mode, 'today');
const endMs = new Date(w.endTime).getTime();
assert.ok(endMs <= now.getTime() - 5 * 60 * 1000 + 2000);
const startLocal = DateTime.fromISO(w.startTime).setZone('America/New_York');
assert.equal(startLocal.hour, 9);
});
test('computeBusinessWindow today before 9am not ready', () => {
const now = DateTime.fromISO('2026-07-24T08:00:00', { zone: 'America/New_York' }).toJSDate();
const w = computeBusinessWindow({
timeZone: 'America/New_York',
dateArg: 'today',
now,
});
assert.equal(w.ready, false);
});

View file

@ -0,0 +1,97 @@
// tests/voiceReport.groupCdrCalls.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import {
groupCdrIntoCalls,
summarizeCalls,
classifyCallDirection,
} from '../services/voiceReport/groupCdrCalls.js';
const SAMPLE_LEGS = [
{
'Answer time': '2026-07-23T13:06:24.325Z',
Answered: 'true',
Direction: 'TERMINATING',
'Start time': '2026-07-23T13:06:24.316Z',
'Call type': 'SIP_INBOUND',
'Correlation ID': '3c7f5c7c-6f48-40f2-925c-133f6a84dcdb',
Duration: 184,
'Report ID': 'fd1debbf-d2b4-331c-846a-2f2a79de9b08',
'User type': 'AutomatedAttendantVideo',
User: 'Store 2477',
'Called number': '+12122194600',
'Calling number': '+17189864017',
'Call outcome': 'Success',
'Call outcome reason': 'Normal',
'Answer indicator': 'Yes',
'Auto Attendant Key Pressed': '1',
},
{
'Answer time': '2026-07-23T13:07:14.337Z',
Answered: 'true',
Direction: 'ORIGINATING',
'Start time': '2026-07-23T13:06:26.570Z',
'Call type': 'SIP_ENTERPRISE',
'Correlation ID': '3c7f5c7c-6f48-40f2-925c-133f6a84dcdb',
Duration: 134,
'Report ID': '9541d7ea-8ced-3d39-a8ef-c2fc5e341700',
'User type': 'AutomatedAttendantVideo',
'Called number': '52477',
'Calling number': '+17189864017',
'Call outcome': 'Success',
'Call outcome reason': 'Normal',
'Answer indicator': 'Yes',
},
{
'Answer time': '2026-07-23T13:07:14.337Z',
Answered: 'true',
Direction: 'TERMINATING',
'Start time': '2026-07-23T13:06:26.739Z',
'Call type': 'SIP_ENTERPRISE',
'Client type': 'WXC_DEVICE',
'Correlation ID': '3c7f5c7c-6f48-40f2-925c-133f6a84dcdb',
Duration: 134,
'Report ID': '829fcf5b-975c-3f5d-9150-af4743232bd3',
'User type': 'User',
User: 'Store 02477',
Model: 'DBS-210-3PC',
'Called number': '52477',
'Calling number': '+17189864017',
'Call outcome': 'Success',
'Call outcome reason': 'Normal',
'Answer indicator': 'Yes',
'Release time': '2026-07-23T13:09:28.784Z',
},
];
test('classifyCallDirection detects inbound from SIP_INBOUND leg', () => {
assert.equal(classifyCallDirection(SAMPLE_LEGS), 'inbound');
});
test('groupCdrIntoCalls collapses legs with same correlation ID', () => {
const calls = groupCdrIntoCalls(SAMPLE_LEGS);
assert.equal(calls.length, 1);
assert.equal(calls[0].legCount, 3);
assert.equal(calls[0].direction, 'inbound');
assert.equal(calls[0].callingNumber, '+17189864017');
assert.equal(calls[0].reachedPhone, true);
assert.equal(calls[0].endpointUser, 'Store 02477');
assert.equal(calls[0].aaKeyPress, '1');
assert.equal(calls[0].normalOutcome, true);
});
test('summarizeCalls aggregates inbound reach stats', () => {
const calls = groupCdrIntoCalls(SAMPLE_LEGS);
const summary = summarizeCalls(calls);
assert.equal(summary.total, 1);
assert.equal(summary.inbound, 1);
assert.equal(summary.inboundReachedPhone, 1);
assert.equal(summary.abnormal, 0);
});
test('legs without correlation ID stay separate', () => {
const a = { ...SAMPLE_LEGS[0], 'Correlation ID': 'a', 'Report ID': 'r1' };
const b = { ...SAMPLE_LEGS[0], 'Correlation ID': 'b', 'Report ID': 'r2' };
assert.equal(groupCdrIntoCalls([a, b]).length, 2);
});

View file

@ -0,0 +1,87 @@
// tests/voiceReport.join.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import {
joinCallQuality,
matchMediaQualityToCall,
worstPrismaBucketForCall,
} from '../services/voiceReport/joinCallQuality.js';
const window = {
startTime: '2026-07-23T13:00:00.000Z',
endTime: '2026-07-24T01:00:00.000Z',
};
test('matchMediaQualityToCall joins on correlation id', () => {
const call = {
start: '2026-07-23T14:00:00.000Z',
duration: 60,
callingNumber: '+15551234567',
calledNumber: '+17247795574',
correlationId: 'abc-123',
};
const mq = [{
'Correlation ID': 'abc-123',
'Start Time': '2026-07-23T14:00:01.000Z',
_norm: { correlationid: 'abc-123', starttime: '2026-07-23T14:00:01.000Z', mos: '4.2' },
}];
const hit = matchMediaQualityToCall(call, mq);
assert.ok(hit);
assert.equal(hit.mos, 4.2);
});
test('joinCallQuality groups legs into correlated calls', () => {
const joined = joinCallQuality({
cdrItems: [
{
direction: 'INBOUND',
startTime: '2026-07-23T14:00:00.000Z',
durationSeconds: 30,
status: 'SUCCESS',
callingNumber: '+1',
calledNumber: '+2',
'Correlation ID': 'corr-1',
'Report ID': 'r1',
'Call type': 'SIP_INBOUND',
'Call outcome': 'Success',
'Call outcome reason': 'Normal',
},
{
Direction: 'Outbound',
'Start time': '2026-07-23T15:00:00.000Z',
Duration: 10,
'Answer indicator': 'No',
'Report ID': 'r2',
'Call type': 'SIP_OUTBOUND',
'Call outcome': 'Failed',
'Call outcome reason': 'Busy',
},
],
mqRows: [],
appAudio: null,
window,
});
assert.equal(joined.summary.total, 2);
assert.equal(joined.calls.length, 2);
assert.equal(joined.summary.inbound, 1);
assert.equal(joined.summary.outbound, 1);
assert.equal(joined.summary.abnormal, 1);
});
test('worstPrismaBucketForCall picks low MOS bucket', () => {
const leg = {
start: '2026-07-23T13:05:00.000Z',
duration: 600,
};
const appAudio = {
mos: { values: [4.4, 4.4, 3.1, 4.4, 4.4] },
loss: { values: [0, 0, 2, 0, 0] },
jitter: { values: [5, 5, 5, 5, 5] },
};
const w = worstPrismaBucketForCall(leg, appAudio, window);
assert.ok(w);
assert.equal(w.mos, 3.1);
assert.equal(w.siteLevelApprox, true);
});

View file

@ -0,0 +1,70 @@
// tests/voiceReport.renderer.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { renderVoiceReportMarkdown } from '../services/renderers/voiceReportRenderer.js';
test('renderVoiceReportMarkdown summary', () => {
const md = renderVoiceReportMarkdown({
ok: true,
store: { storeNum: '782', locationName: 'Store 0782', dialNumber: '+17247795574' },
window: {
label: '2026-07-23',
mode: 'fullDay',
timeZone: 'America/New_York',
startTime: '2026-07-23T13:00:00.000Z',
endTime: '2026-07-24T01:00:00.000Z',
},
cdr: { available: true, rawCount: 10 },
mediaQuality: { available: false, reason: 'skipped in test' },
prisma: { available: false, reason: 'not configured' },
joined: {
summary: {
total: 5,
inbound: 3,
outbound: 2,
inboundReachedPhone: 2,
outboundConnected: 2,
abnormal: 1,
outcomes: { 'Success / Normal': 4, 'Failed / Busy': 1 },
},
calls: [],
},
elapsedMs: 42,
});
assert.match(md, /Voice report/);
assert.match(md, /Store 0782/);
assert.match(md, /Total calls.*5/);
assert.match(md, /Inbound reached a phone/);
});
test('renderVoiceReportMarkdown detail truncates', () => {
const calls = Array.from({ length: 30 }, (_, i) => ({
start: `2026-07-23T14:${String(i).padStart(2, '0')}:00.000Z`,
direction: 'inbound',
duration: 30,
outcome: 'Success',
outcomeReason: 'Normal',
callingNumber: '+1',
calledNumber: '+2',
legCount: 2,
reachedPhone: true,
normalOutcome: true,
}));
const md = renderVoiceReportMarkdown({
ok: true,
store: { storeNum: '782', locationName: 'Store 0782' },
window: { label: '2026-07-23', mode: 'fullDay', timeZone: 'America/New_York', startTime: 'x', endTime: 'y' },
cdr: { available: true },
mediaQuality: { available: false },
prisma: { available: false },
joined: {
summary: { total: 30, inbound: 30, outbound: 0, outcomes: {} },
calls,
},
elapsedMs: 1,
}, { detail: true });
assert.match(md, /Call detail/);
assert.match(md, /more calls/);
});

View file

@ -0,0 +1,51 @@
// tests/webex.reportsClient.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import {
parseMediaQualityCsv,
filterMediaQualityRows,
reportDateRangeFromWindow,
} from '../integrations/webex/reportsCsv.js';
test('parseMediaQualityCsv parses header rows', () => {
const csv = 'Start Time,Location,MOS\n2026-07-23T14:00:00Z,Store 0782,4.1\n';
const { headers, rows } = parseMediaQualityCsv(csv);
assert.equal(headers.length, 3);
assert.equal(rows.length, 1);
assert.equal(rows[0].MOS, '4.1');
});
test('filterMediaQualityRows filters by location and window', () => {
const window = {
startTime: '2026-07-23T13:00:00.000Z',
endTime: '2026-07-23T18:00:00.000Z',
};
const rows = [
{
Location: 'Warrendale - LGW',
'Start Time': '2026-07-23T14:00:00.000Z',
_norm: { location: 'warrendale - lgw', starttime: '2026-07-23T14:00:00.000Z' },
},
{
Location: 'Other',
'Start Time': '2026-07-23T14:00:00.000Z',
_norm: { location: 'other', starttime: '2026-07-23T14:00:00.000Z' },
},
];
const filtered = filterMediaQualityRows(rows, {
locationName: 'Warrendale - LGW',
window,
});
assert.equal(filtered.length, 1);
});
test('reportDateRangeFromWindow uses ISO date slice', () => {
const r = reportDateRangeFromWindow({
startTime: '2026-07-23T13:00:00.000Z',
endTime: '2026-07-23T23:00:00.000Z',
});
assert.equal(r.startDate, '2026-07-23');
assert.equal(r.endDate, '2026-07-23');
});