Rename /voicereport to /callreport with scoped user and phone targets.

Add store/email/phone filtering, richer call-line formatting, CDR feed pagination and queueing, and split Jira poller enrichment into testable modules.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jmcqueen 2026-07-27 10:10:03 -04:00
parent 1eaabbcee1
commit a25fc08fe2
45 changed files with 3179 additions and 863 deletions

View file

@ -56,8 +56,8 @@ WEBEX_BOT_TOKEN=your-bot-token-here
# - spark-admin:licenses_read (webexhost, findEmptyLocations) # - spark-admin:licenses_read (webexhost, findEmptyLocations)
# - identity:tokens_read (offboarduser: list a user's authorizations) # - identity:tokens_read (offboarduser: list a user's authorizations)
# - identity:tokens_write (offboarduser: revoke a user's authorizations) # - identity:tokens_write (offboarduser: revoke a user's authorizations)
# - spark-admin:calling_cdr_read (/voicereport + /calltest CDR via cdr_feed) # - spark-admin:calling_cdr_read (/callreport + /calltest CDR via cdr_feed)
# - analytics:read_all (/voicereport Webex Reports API — Pro Pack) # - analytics:read_all (/callreport Webex Reports API — Pro Pack)
# The authorizing admin must also hold Full / User / Device Admin role for the # The authorizing admin must also hold Full / User / Device Admin role for the
# token-management calls to succeed. # token-management calls to succeed.
# #
@ -91,7 +91,8 @@ WEBEX_TOKENS_PATH=./config/webex-service-tokens.json
# CDR feed (/cdr_feed) uses analytics-calling.webexapis.com — a different host than # 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 # 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. # 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) # CDR feed rate limit queue (cdr_feed allows ~1 request/minute per token)
# CDR_FEED_COOLDOWN_MS=65000
# WEBEX_CDR_ANALYTICS_BASES=https://analytics-calling.webexapis.com/v1,https://analytics.webexapis.com/v1 # WEBEX_CDR_ANALYTICS_BASES=https://analytics-calling.webexapis.com/v1,https://analytics.webexapis.com/v1
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@ -515,14 +516,15 @@ DECT_RELAY_AGENT_TOKEN=
# #
# Per-store entry overrides: config/calltest-stores.json # Per-store entry overrides: config/calltest-stores.json
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# /voicereport — daily voice digest (CDR + Media Quality + Prisma WAN) # /callreport — daily call digest (CDR + Media Quality + Prisma WAN)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# VOICEREPORT_BUSINESS_START_HOUR=9 # CALLREPORT_BUSINESS_START_HOUR=9
# VOICEREPORT_BUSINESS_END_HOUR=21 # CALLREPORT_BUSINESS_END_HOUR=21
# VOICEREPORT_API_LAG_MS=300000 # CALLREPORT_API_LAG_MS=300000
# VOICEREPORT_MAX_DETAIL_ROWS=25 # CALLREPORT_MAX_DETAIL_ROWS=25
# VOICEREPORT_REPORT_POLL_MS=5000 # CALLREPORT_REPORT_POLL_MS=5000
# VOICEREPORT_REPORT_POLL_MAX_MS=180000 # CALLREPORT_REPORT_POLL_MAX_MS=180000
# (VOICEREPORT_* env vars still accepted for backward compatibility)
# WEBEX_REPORT_TEMPLATE_MEDIA_QUALITY= # WEBEX_REPORT_TEMPLATE_MEDIA_QUALITY=
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Optional. Per-base collect() RPC timeout. Corporate proxies can make # Optional. Per-base collect() RPC timeout. Corporate proxies can make

107
commands/callReport.js Normal file
View file

@ -0,0 +1,107 @@
// commands/callReport.js
import { DateTime } from 'luxon';
import { logger } from '../utils/logger.js';
import { collectCallReport } from '../services/callReport/callReportService.js';
import { renderCallReportMarkdown } from '../services/renderers/callReportRenderer.js';
import { parseCallReportTarget } from '../services/callReport/parseTarget.js';
function pickDateArg(args) {
const targetParsed = parseCallReportTarget(args[0]);
for (let i = 1; i < args.length; i++) {
const a = String(args[i] || '').trim();
if (!a || a.startsWith('--')) continue;
if (i === 1 && targetParsed?.kind === 'store' && /^\d{2,4}$/.test(a)) 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';
}
function formatQueuedRunAt(runAt) {
return DateTime.fromJSDate(runAt).toFormat('h:mm:ss a');
}
function pickTargetArg(args, query) {
return args[0]?.trim()
|| query.target
|| query.storeNum
|| query.store
|| query.email
|| query.number
|| query.s
|| null;
}
function usageMarkdown() {
return (
'**Call report usage**\n\n' +
'`/callreport <store>` — yesterday 9am9pm local (default)\n' +
'`/callreport <email>` — calls for that user at their location\n' +
'`/callreport <phone>` — calls for that assigned number\n' +
'`/callreport <target> today` — today 9am → 5 min ago\n' +
'`/callreport <target> 2026-07-22` — specific day\n' +
'`/callreport <target> --detail` — include per-call table\n\n' +
'HTTP: `?target=782&date=yesterday&detail=true` (also `storeNum`, `email`, `number`)'
);
}
function ackLabel(target) {
if (!target) return 'target';
if (target.kind === 'store') return `store **${target.storeNum}**`;
if (target.locationName) return `**${target.label}** (${target.locationName})`;
return `**${target.label}**`;
}
export async function handleCallReport(bot, trigger) {
logger('callreport', 'Handler entered', 'debug');
const query = trigger.query || {};
const args = trigger.args || [];
const targetToken = pickTargetArg(args, query);
const dateArg = pickDateArg(args) || query.date || null;
const detail = hasDetailFlag(args, query);
if (!targetToken || !parseCallReportTarget(targetToken)) {
await bot.say('markdown', usageMarkdown());
return;
}
await bot.say(
'markdown',
`**Call report** — generating for ${ackLabel({ label: targetToken })} (${dateArg || 'yesterday'})…`,
);
let cdrQueuedNoticeSent = false;
try {
const report = await collectCallReport(targetToken, {
dateArg,
onCdrQueued: async ({ runAt, waitMs }) => {
if (cdrQueuedNoticeSent) return;
cdrQueuedNoticeSent = true;
const secs = Math.max(1, Math.ceil(waitMs / 1000));
await bot.say(
'markdown',
`⏳ **Call report** — CDR query queued. Webex allows one \`cdr_feed\` request per ~90 seconds. ` +
`Your request will run at **${formatQueuedRunAt(runAt)}** (~${secs}s).`,
);
},
});
const md = renderCallReportMarkdown(report, { detail });
await bot.say('markdown', md);
} catch (err) {
logger('callreport', `Failed for target ${targetToken}: ${err.message}`, 'error');
await bot.say('markdown', `❌ Call report failed for **${targetToken}**: ${err.message}`);
}
}

View file

@ -21,7 +21,7 @@ const SHORT_HELP = {
phonestatus: 'DECT + IP phone status for a store (plus 7d WAN follow-up)', phonestatus: 'DECT + IP phone status for a store (plus 7d WAN follow-up)',
dectstatus: 'Full DECT basestation dump via relay (reboot / factory-reset cards)', dectstatus: 'Full DECT basestation dump via relay (reboot / factory-reset cards)',
voicediag: 'Deep voice diagnostic: Webex Calling features + SD-WAN quality with fix 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', callreport: 'Daily call digest: correlated CDR calls + per-call Prisma WAN (store, email, or phone)',
calltest: 'Twilio voice path test (store AA or direct dial, 60s listen)', calltest: 'Twilio voice path test (store AA or direct dial, 60s listen)',
// Jira // Jira
@ -151,26 +151,34 @@ const LONG_HELP = {
'PCAPs land in the System Log bundle downloadable from Control Hub diagnostics.', 'PCAPs land in the System Log bundle downloadable from Control Hub diagnostics.',
], ],
}, },
voicereport: { callreport: {
title: '/voicereport', title: '/callreport',
usage: [ usage: [
'/voicereport <store>', '/callreport <store>',
'/voicereport <store> today', '/callreport <email>',
'/voicereport <store> YYYY-MM-DD', '/callreport <phone>',
'/voicereport <store> --detail', '/callreport <target> today',
'/callreport <target> YYYY-MM-DD',
'/callreport <target> --detail',
], ],
examples: [ examples: [
'/voicereport 782', '/callreport 782',
'/voicereport 782 today', '/callreport mcqueenj@ae.com',
'/voicereport 782 2026-07-22', '/callreport 7247795574',
'/voicereport 782 --detail', '/callreport mcqueenj@ae.com today',
'/callreport 782 2026-07-22',
'/callreport 782 --detail',
], ],
notes: [ notes: [
'Store-scoped daily voice digest for **9am9pm local** (default: **yesterday**). `today` uses 9am → 5 minutes ago.', '**Store** (`782`): full location 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.', '**Email** or **phone number**: resolves the user/line location via Webex, fetches location CDR, then filters to that person/number only.',
'Requires `spark-admin:calling_cdr_read` + Control Hub role **Webex Calling Detailed Call History API access** for CDR.', 'Groups CDR legs by **Correlation ID** into calls; lists inbound reach, auto-attendant, outbound, and abnormal outcomes.',
'Report generation may take 13 minutes; an ack message posts first.', 'Answered/connected calls include **Prisma Webex_Calling_RTP** MOS/jitter/loss for the call window when configured.',
'HTTP: `?storeNum=782&date=yesterday&detail=true`.', 'Requires `spark-admin:calling_cdr_read` + Control Hub role **Webex Calling Detailed Call History API access**.',
'CDR queries are serialized with a **65s cooldown** — a second request in that window is queued and you get a wait notice.',
'`--detail` adds correlation ID and raises per-section row cap.',
'HTTP: `?target=782&date=yesterday&detail=true` (also `storeNum`, `email`, `number`).',
'Alias: `/voicereport` (legacy).',
], ],
}, },
calltest: { calltest: {
@ -240,6 +248,7 @@ const LONG_HELP = {
examples: ['/jirapoll', '/jirapoll prime'], examples: ['/jirapoll', '/jirapoll prime'],
notes: [ notes: [
'Triggers the same Jira poller that normally runs at the top of every hour. Enriches any unlabeled matching tickets with a phone/av snapshot comment and labels them `bot-enriched`. Tickets the AI classifier decides are out-of-scope get labeled `bot-skipped` so they aren\'t re-classified every hour.', 'Triggers the same Jira poller that normally runs at the top of every hour. Enriches any unlabeled matching tickets with a phone/av snapshot comment and labels them `bot-enriched`. Tickets the AI classifier decides are out-of-scope get labeled `bot-skipped` so they aren\'t re-classified every hour.',
'**Communication Services** tickets mentioning call quality (garbled, static, can\'t connect, …) get **phonestatus + callreport (today)**. Spam/robocall tickets get **callreport** only. See `services/jiraPoller/README.md` for adding more rules.',
'Idempotent — labels + JQL prevent double-processing, so running multiple times in a row is safe.', 'Idempotent — labels + JQL prevent double-processing, so running multiple times in a row is safe.',
'`/jirapoll prime` labels every matching ticket without enriching or notifying. Use once after adopting the poller to skip enriching the existing backlog. Same as `JIRA_POLLER_PRIME_ON_START=true` at startup.', '`/jirapoll prime` labels every matching ticket without enriching or notifying. Use once after adopting the poller to skip enriching the existing backlog. Same as `JIRA_POLLER_PRIME_ON_START=true` at startup.',
'A summary of enriched tickets goes to the configured `JIRA_POLLER_ROOM_ID`. The invoking chat also gets a compact result line.', 'A summary of enriched tickets goes to the configured `JIRA_POLLER_ROOM_ID`. The invoking chat also gets a compact result line.',
@ -301,7 +310,7 @@ const LONG_HELP = {
const GROUPS = [ const GROUPS = [
{ title: 'Work orders', keys: ['wohistory', 'wosummary', 'woattachments'] }, { title: 'Work orders', keys: ['wohistory', 'wosummary', 'woattachments'] },
{ title: 'AV & phones', keys: ['avstatus', 'phonestatus', 'dectstatus', 'voicediag', 'voicereport', 'calltest'] }, { title: 'AV & phones', keys: ['avstatus', 'phonestatus', 'dectstatus', 'voicediag', 'callreport', 'calltest'] },
{ title: 'Jira', keys: ['jirahistory', 'jiraticket', 'jirapoll'] }, { title: 'Jira', keys: ['jirahistory', 'jiraticket', 'jirapoll'] },
{ title: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] }, { title: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] },
{ title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] }, { title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] },

View file

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

View file

@ -1,64 +0,0 @@
// 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

@ -13,23 +13,20 @@ import {
export { filterMediaQualityRows, parseMediaQualityCsv, reportDateRangeFromWindow } from './reportsCsv.js'; export { filterMediaQualityRows, parseMediaQualityCsv, reportDateRangeFromWindow } from './reportsCsv.js';
import { callReportEnvInt } from '../../services/callReport/env.js';
const LOG_SCOPE = 'webex:reports'; const LOG_SCOPE = 'webex:reports';
const TEMPLATE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; const TEMPLATE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
let _templateCache = null; let _templateCache = null;
let _templateCacheAt = 0; let _templateCacheAt = 0;
function envInt(name, fallback) {
const v = Number(process.env[name]);
return Number.isFinite(v) ? v : fallback;
}
function pollIntervalMs() { function pollIntervalMs() {
return envInt('VOICEREPORT_REPORT_POLL_MS', 5000); return callReportEnvInt('REPORT_POLL_MS', 5000);
} }
function pollMaxMs() { function pollMaxMs() {
return envInt('VOICEREPORT_REPORT_POLL_MAX_MS', 180_000); return callReportEnvInt('REPORT_POLL_MAX_MS', 180_000);
} }
export function _clearReportTemplateCacheForTests() { export function _clearReportTemplateCacheForTests() {

View file

@ -1,21 +1,17 @@
// services/voiceReport/businessWindow.js // services/callReport/businessWindow.js
// Store-local business-hour windows for /voicereport (default 9am9pm). // Store-local business-hour windows for /callreport (default 9am9pm).
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { DISPLAY_TIMEZONE } from '../../utils/time.js'; import { DISPLAY_TIMEZONE } from '../../utils/time.js';
import { callReportEnvInt } from './env.js';
const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000; 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() { export function getBusinessHourConfig() {
return { return {
startHour: envInt('VOICEREPORT_BUSINESS_START_HOUR', 9), startHour: callReportEnvInt('BUSINESS_START_HOUR', 9),
endHour: envInt('VOICEREPORT_BUSINESS_END_HOUR', 21), endHour: callReportEnvInt('BUSINESS_END_HOUR', 21),
apiLagMs: envInt('VOICEREPORT_API_LAG_MS', 5 * 60 * 1000), apiLagMs: callReportEnvInt('API_LAG_MS', 5 * 60 * 1000),
}; };
} }

View file

@ -1,35 +1,37 @@
// services/voiceReport/voiceReportService.js // services/callReport/callReportService.js
// Compose CDR + Media Quality + Prisma WAN for /voicereport. // Compose CDR + Media Quality + Prisma WAN for /callreport.
import { logger } from '../../utils/logger.js'; import { logger } from '../../utils/logger.js';
import { getHistoricalCallActivity } from '../phoneService.js'; import { getHistoricalCallActivity } from '../phoneService.js';
import { findSdwanSiteForStore } from '../../integrations/paloalto/sites.js'; import { findSdwanSiteForStore } from '../../integrations/paloalto/sites.js';
import { getAppMetric } from '../../integrations/paloalto/metrics.js'; import { getAppMetric } from '../../integrations/paloalto/metrics.js';
import { buildAppDetailsUrl } from '../../integrations/paloalto/urls.js'; import { buildAppDetailsUrl } from '../../integrations/paloalto/urls.js';
import { fetchMediaQualityReport } from '../../integrations/webex/reportsClient.js';
import { import {
resolveVoiceAppConfig, resolveVoiceAppConfig,
summarizeAppSeries, summarizeAppSeries,
} from '../enrichment/sdwanEnrichment.js'; } from '../enrichment/sdwanEnrichment.js';
import { computeBusinessWindow } from './businessWindow.js'; import { computeBusinessWindow } from './businessWindow.js';
import { resolveStoreForVoiceReport } from './storeContext.js'; import { resolveCallReportTarget } from './resolveTarget.js';
import { filterCdrToWindow, joinCallQuality } from './joinCallQuality.js'; import { filterCdrToWindow, joinCallQuality } from './joinCallQuality.js';
import { cdrStartTime } from '../cdrFeedParser.js'; import { cdrStartTime } from '../cdrFeedParser.js';
import { callReportEnvFlag } from './env.js';
const LOG_SCOPE = 'voicereport:service'; const LOG_SCOPE = 'callreport:service';
const MQ_DISABLED = !callReportEnvFlag('ENABLE_WEBEX_MQ');
async function fetchCdrForWindow(store, window) { async function fetchCdrForWindow(target, window, { onCdrQueued } = {}) {
const cdr = await getHistoricalCallActivity(store.personId, 12, { const cdr = await getHistoricalCallActivity(target.personId, 12, {
locationName: store.locationName, locationName: target.locationName,
startTime: window.startTime, startTime: window.startTime,
endTime: window.endTime, endTime: window.endTime,
returnRawItems: true, returnRawItems: true,
skipPersonFilter: true, skipPersonFilter: true,
onQueued: onCdrQueued,
}); });
if (!cdr.available) { if (!cdr.available) {
logger( logger(
LOG_SCOPE, LOG_SCOPE,
`CDR unavailable for store=${store.storeNum} loc=${JSON.stringify(store.locationName)}: ${cdr.reason}`, `CDR unavailable for target=${target.label} loc=${JSON.stringify(target.locationName)}: ${cdr.reason}`,
'warn', 'warn',
); );
return { available: false, reason: cdr.reason, items: [], fetchErrors: cdr.fetchErrors }; return { available: false, reason: cdr.reason, items: [], fetchErrors: cdr.fetchErrors };
@ -41,14 +43,14 @@ async function fetchCdrForWindow(store, window) {
const sample = (cdr.rawItems || []).slice(0, 2).map((r) => cdrStartTime(r) || 'no-start-field'); const sample = (cdr.rawItems || []).slice(0, 2).map((r) => cdrStartTime(r) || 'no-start-field');
logger( logger(
LOG_SCOPE, LOG_SCOPE,
`CDR window filter dropped all ${rawCount} record(s) for store=${store.storeNum} ` + `CDR window filter dropped all ${rawCount} record(s) for target=${target.label} ` +
`(window ${window.startTime}..${window.endTime}); sample start fields: ${sample.join(', ')}`, `(window ${window.startTime}..${window.endTime}); sample start fields: ${sample.join(', ')}`,
'warn', 'warn',
); );
} else { } else {
logger( logger(
LOG_SCOPE, LOG_SCOPE,
`CDR store=${store.storeNum} loc=${JSON.stringify(store.locationName)} ` + `CDR target=${target.label} loc=${JSON.stringify(target.locationName)} ` +
`api=${rawCount} afterWindowFilter=${items.length}`, `api=${rawCount} afterWindowFilter=${items.length}`,
'info', 'info',
); );
@ -67,6 +69,10 @@ async function fetchPrismaAppAudio(storeNum, window) {
return { available: false, reason: 'PRISMA_APP_ID_VOICE not configured' }; return { available: false, reason: 'PRISMA_APP_ID_VOICE not configured' };
} }
if (!storeNum) {
return { available: false, reason: 'store number not resolved from location' };
}
const site = await findSdwanSiteForStore(storeNum); const site = await findSdwanSiteForStore(storeNum);
if (!site?.id) { if (!site?.id) {
return { available: false, reason: 'not a Prisma-managed store' }; return { available: false, reason: 'not a Prisma-managed store' };
@ -92,16 +98,16 @@ async function fetchPrismaAppAudio(storeNum, window) {
} }
/** /**
* @param {string} storeNum * @param {string} targetToken store number, email, or phone number
* @param {object} opts * @param {object} opts
* @param {string} [opts.dateArg] yesterday | today | YYYY-MM-DD * @param {string} [opts.dateArg] yesterday | today | YYYY-MM-DD
* @param {Date} [opts.now] * @param {Date} [opts.now]
*/ */
export async function collectVoiceReport(storeNum, opts = {}) { export async function collectCallReport(targetToken, opts = {}) {
const startedAt = Date.now(); const startedAt = Date.now();
const store = await resolveStoreForVoiceReport(storeNum); const target = await resolveCallReportTarget(targetToken);
const window = computeBusinessWindow({ const window = computeBusinessWindow({
timeZone: store.timeZone, timeZone: target.timeZone,
dateArg: opts.dateArg, dateArg: opts.dateArg,
now: opts.now, now: opts.now,
}); });
@ -109,7 +115,8 @@ export async function collectVoiceReport(storeNum, opts = {}) {
if (!window.ready) { if (!window.ready) {
return { return {
ok: false, ok: false,
store, target,
store: target,
window, window,
reason: window.reason, reason: window.reason,
}; };
@ -117,37 +124,35 @@ export async function collectVoiceReport(storeNum, opts = {}) {
logger( logger(
LOG_SCOPE, LOG_SCOPE,
`Collecting voice report store=${storeNum} loc=${store.locationName} ` + `Collecting call report target=${target.label} kind=${target.kind} loc=${target.locationName} ` +
`window=${window.startTime}..${window.endTime}`, `window=${window.startTime}..${window.endTime}`,
'info', 'info',
); );
const [cdrRes, mqRes, prismaRes] = await Promise.allSettled([ const [cdrRes, prismaRes] = await Promise.allSettled([
fetchCdrForWindow(store, window), fetchCdrForWindow(target, window, { onCdrQueued: opts.onCdrQueued }),
fetchMediaQualityReport({ window, locationName: store.locationName }), fetchPrismaAppAudio(target.storeNum, window),
fetchPrismaAppAudio(storeNum, window),
]); ]);
const cdr = cdrRes.status === 'fulfilled' const cdr = cdrRes.status === 'fulfilled'
? cdrRes.value ? cdrRes.value
: { available: false, reason: cdrRes.reason?.message, items: [] }; : { available: false, reason: cdrRes.reason?.message, items: [] };
const mediaQuality = mqRes.status === 'fulfilled' const mediaQuality = { available: false, reason: MQ_DISABLED ? 'disabled' : 'not fetched', rows: [] };
? mqRes.value
: { available: false, reason: mqRes.reason?.message, rows: [] };
const prisma = prismaRes.status === 'fulfilled' const prisma = prismaRes.status === 'fulfilled'
? prismaRes.value ? prismaRes.value
: { available: false, reason: prismaRes.reason?.message }; : { available: false, reason: prismaRes.reason?.message };
const joined = joinCallQuality({ const joined = joinCallQuality({
cdrItems: cdr.items || [], cdrItems: cdr.items || [],
mqRows: mediaQuality.rows || [],
appAudio: prisma.appAudio || null, appAudio: prisma.appAudio || null,
window, window,
filter: target.filter || null,
}); });
return { return {
ok: true, ok: true,
store, target,
store: target,
window, window,
cdr, cdr,
mediaQuality, mediaQuality,

View file

@ -0,0 +1,57 @@
// services/callReport/collapseDisplayCalls.js
// Collapse repetitive AA-only bursts for readable /callreport output.
import { formatLocalTime } from './formatCallLine.js';
function callerKey(call) {
return [
call.callingLineId || call.leftParty?.lineId || '',
call.callingNumber || call.leftParty?.number || '',
call.aaKeyPress || '',
].join('|');
}
/**
* Collapse 3+ AA-only calls from the same caller into a single summary row.
* @param {object[]} calls
* @param {{ timeZone?: string, minBurst?: number }} opts
* @returns {Array<object|{ kind: 'burst', calls: object[], summary: string }>}
*/
export function collapseAaBursts(calls, opts = {}) {
const minBurst = opts.minBurst ?? 3;
const timeZone = opts.timeZone;
const out = [];
let i = 0;
while (i < (calls || []).length) {
const call = calls[i];
const key = callerKey(call);
let j = i + 1;
while (j < calls.length && callerKey(calls[j]) === key) j += 1;
const group = calls.slice(i, j);
if (group.length >= minBurst) {
const first = group[0];
const last = group[group.length - 1];
const left = first.callingLineId && String(first.callingLineId).toUpperCase() !== 'NA'
? `${first.callingLineId} (${first.callingNumber || ''})`
: (first.callingNumber || '?');
const main = first.storeMainNumber || first.calledNumber || '?';
const start = formatLocalTime(first.start, timeZone);
const end = formatLocalTime(last.start, timeZone);
const keyLabel = first.aaKeyPress ? ` (AA key ${first.aaKeyPress})` : '';
out.push({
kind: 'burst',
calls: group,
summary:
`⚠️ ${start}${end}: ${left}${main}${keyLabel}` +
`${group.length} calls stopped at auto-attendant`,
});
} else {
out.push(...group);
}
i = j;
}
return out;
}

View file

@ -0,0 +1,15 @@
// services/callReport/env.js
// CALLREPORT_* env vars (VOICEREPORT_* accepted for backward compatibility).
export function callReportEnvInt(suffix, fallback) {
const v = Number(process.env[`CALLREPORT_${suffix}`] ?? process.env[`VOICEREPORT_${suffix}`]);
return Number.isFinite(v) ? v : fallback;
}
export function callReportEnvFlag(suffix, defaultValue = false) {
const raw = String(
process.env[`CALLREPORT_${suffix}`] ?? process.env[`VOICEREPORT_${suffix}`] ?? '',
).toLowerCase();
if (!raw) return defaultValue;
return raw === 'true' || raw === '1';
}

View file

@ -0,0 +1,210 @@
// services/callReport/filterCallsByTarget.js
// Post-group CDR filtering for scoped /callreport targets.
import {
cdrCalledNumber,
cdrCallingNumber,
cdrDialedDigits,
cdrField,
cdrUserName,
cdrUserNumber,
} from '../cdrFeedParser.js';
import {
filterAbnormalCalls,
groupCallsByBucket,
summarizeCalls,
} from './groupCdrCalls.js';
function digitsOnly(value) {
return String(value || '').replace(/\D/g, '');
}
/**
* @param {string} value
* @returns {{ full?: string, last10?: string, extension?: string }|null}
*/
export function normalizePhone(value) {
const raw = String(value || '').trim();
if (!raw) return null;
const d = digitsOnly(raw);
if (!d) return null;
if (d.length === 10) return { full: `1${d}`, last10: d };
if (d.length === 11 && d.startsWith('1')) return { full: d, last10: d.slice(1) };
if (d.length > 11) return { full: d, last10: d.slice(-10) };
if (d.length <= 6) return { extension: d };
return { full: d, last10: d.slice(-10) };
}
function phonesEqual(a, b) {
const left = typeof a === 'object' ? a : normalizePhone(a);
const right = typeof b === 'object' ? b : normalizePhone(b);
if (!left || !right) return false;
if (left.full && right.full && left.full === right.full) return true;
if (left.last10 && right.last10 && left.last10 === right.last10) return true;
if (left.extension && right.extension && left.extension === right.extension) return true;
return false;
}
function addPhoneToFilter(filter, value) {
const n = normalizePhone(value);
if (!n) return;
if (n.full) filter.phones.add(n.full);
if (n.last10) filter.phones10.add(n.last10);
if (n.extension) filter.extensions.add(n.extension);
}
/**
* Build a strict scoped filter for user/number /callreport targets.
*/
export function buildScopedCallFilter({
kind,
email = null,
phoneNumber = null,
personId = null,
person = null,
ownedNumbers = [],
locationMain = null,
extension = null,
}) {
const filter = {
kind,
email,
phoneNumber,
personId: personId || null,
phones: new Set(),
phones10: new Set(),
extensions: new Set(),
emails: new Set(),
displayNames: new Set(),
excludeMain: locationMain ? normalizePhone(locationMain) : null,
};
if (phoneNumber) addPhoneToFilter(filter, phoneNumber);
if (extension) filter.extensions.add(String(extension).trim());
for (const rec of ownedNumbers) {
addPhoneToFilter(filter, rec.phoneNumber);
if (rec.extension) filter.extensions.add(String(rec.extension).trim());
}
if (person) {
const name = person.displayName && String(person.displayName).trim();
if (name) filter.displayNames.add(name.toLowerCase());
(person.emails || []).forEach((e) => {
const v = String(e?.value || e || '').trim().toLowerCase();
if (v) filter.emails.add(v);
});
(person.phoneNumbers || []).forEach((p) => addPhoneToFilter(filter, p?.value || p));
}
return filter;
}
function phoneMatchesFilter(filter, value) {
const n = normalizePhone(value);
if (!n) return false;
if (n.full && filter.phones.has(n.full)) return true;
if (n.last10 && filter.phones10.has(n.last10)) return true;
if (n.extension && filter.extensions.has(n.extension)) return true;
return false;
}
function textMatchesFilter(filter, value) {
if (!value) return false;
const low = String(value).trim().toLowerCase();
if (!low) return false;
return filter.displayNames.has(low) || filter.emails.has(low);
}
function isExcludedMain(filter, value) {
if (!filter.excludeMain || !value) return false;
return phonesEqual(filter.excludeMain, value);
}
function fieldMatchesFilter(filter, value, { skipMain = false } = {}) {
if (!value) return false;
if (skipMain && isExcludedMain(filter, value)) return false;
return phoneMatchesFilter(filter, value) || textMatchesFilter(filter, value);
}
function legHasPersonId(leg, personId) {
const uid = cdrField(leg, 'userId', 'personId', 'ownerId') || leg.userId || leg.personId || leg.ownerId;
if (uid && String(uid) === String(personId)) return true;
const userObj = leg.user;
return Boolean(userObj?.id && String(userObj.id) === String(personId));
}
function legMatchesFilter(leg, filter) {
if (!leg || !filter) return false;
if (filter.personId && legHasPersonId(leg, filter.personId)) return true;
const candidates = [
cdrUserNumber(leg),
cdrUserName(leg),
cdrDialedDigits(leg),
cdrCallingNumber(leg),
cdrCalledNumber(leg),
cdrField(leg, 'redirectingNumber', 'callingParty', 'calledParty', 'remoteParty', 'partyNumber'),
cdrField(leg, 'userName', 'user', 'originator', 'terminator'),
cdrField(leg, 'callingName', 'calledName'),
];
return candidates.some((v) => fieldMatchesFilter(filter, v, { skipMain: true }));
}
/**
* @param {object} call grouped call
* @param {object|null} filter
* @returns {boolean}
*/
export function callMatchesFilter(call, filter) {
if (!filter) return true;
if (filter.personId && (call.legs || []).some((leg) => legHasPersonId(leg, filter.personId))) {
return true;
}
if (fieldMatchesFilter(filter, call.finalNumber)) return true;
if (textMatchesFilter(filter, call.endpointUser)) return true;
if (fieldMatchesFilter(filter, call.leftParty?.number, { skipMain: call.direction === 'outbound' })) {
return true;
}
if (textMatchesFilter(filter, call.leftParty?.user)) return true;
if (fieldMatchesFilter(filter, call.rightParty?.final)) return true;
if (fieldMatchesFilter(filter, call.rightParty?.number, { skipMain: true })) return true;
if (call.direction === 'inbound' && fieldMatchesFilter(filter, call.callingNumber)) return true;
if (call.direction === 'outbound' && fieldMatchesFilter(filter, call.callingNumber, { skipMain: true })) {
return true;
}
if (fieldMatchesFilter(filter, call.calledNumber, { skipMain: true })) return true;
return (call.legs || []).some((leg) => legMatchesFilter(leg, filter));
}
/**
* @param {object[]} calls
* @param {object|null} filter
* @returns {object[]}
*/
export function filterCallsByTarget(calls, filter) {
if (!filter) return calls || [];
return (calls || []).filter((call) => callMatchesFilter(call, filter));
}
/**
* Rebuild buckets + summary after scoped filtering.
* @param {object[]} calls
*/
export function rebucketCalls(calls) {
const list = calls || [];
return {
calls: list,
legs: list,
buckets: groupCallsByBucket(list),
abnormalCalls: filterAbnormalCalls(list),
summary: summarizeCalls(list),
};
}

View file

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

View file

@ -1,17 +1,32 @@
// services/voiceReport/groupCdrCalls.js // services/callReport/groupCdrCalls.js
// Group cdr_feed legs by Correlation ID into logical calls. // Group cdr_feed legs by Correlation ID into logical calls.
import { import {
cdrCalledNumber, cdrCalledNumber,
cdrCallerIdNumber,
cdrCallingLineId,
cdrCallingNumber, cdrCallingNumber,
cdrDialedDigits,
cdrDirectionValue, cdrDirectionValue,
cdrDurationSeconds, cdrDurationSeconds,
cdrField, cdrField,
cdrModel,
cdrRecordId, cdrRecordId,
cdrSiteMainNumber,
cdrStartTime, cdrStartTime,
cdrDedupKey, cdrDedupKey,
cdrUserName,
cdrUserNumber,
formatCallDisposition,
} from '../cdrFeedParser.js'; } from '../cdrFeedParser.js';
export const CALL_BUCKETS = {
inboundReachedPhone: 'inboundReachedPhone',
inboundAaOnly: 'inboundAaOnly',
outboundConnected: 'outboundConnected',
other: 'other',
};
export function cdrCorrelationId(item) { export function cdrCorrelationId(item) {
return cdrField(item, 'correlationId', 'Correlation ID', 'correlationID'); return cdrField(item, 'correlationId', 'Correlation ID', 'correlationID');
} }
@ -70,11 +85,57 @@ export function isNormalCallOutcome(item) {
return !reason || reason === 'normal'; return !reason || reason === 'normal';
} }
function findInboundEntryLeg(legs) {
return legs.find((l) => callTypeValue(l).includes('INBOUND')) || legs[0];
}
function findUserPhoneLeg(legs) {
return legs.find((l) => isUserPhoneLeg(l) && isTruthyAnswered(l))
|| legs.find(isUserPhoneLeg);
}
function findOutboundOriginatingLeg(legs) {
return legs.find((l) => cdrDirectionValue(l).includes('ORIGINAT') && isUserPhoneLeg(l))
|| legs.find((l) => cdrDirectionValue(l).includes('ORIGINAT'));
}
function digitsOnly(value) {
return String(value || '').replace(/\D/g, '');
}
function pickFinalNumber(legs, mainNumber, phoneLeg) {
const candidates = [
phoneLeg ? cdrCalledNumber(phoneLeg) : null,
phoneLeg ? cdrUserNumber(phoneLeg) : null,
phoneLeg ? cdrDialedDigits(phoneLeg) : null,
...legs.map(cdrDialedDigits),
...legs.filter(isUserPhoneLeg).map(cdrCalledNumber),
].filter(Boolean);
const mainDigits = digitsOnly(mainNumber);
for (const c of candidates) {
const d = digitsOnly(c);
if (!d) continue;
if (mainDigits && d === mainDigits) continue;
if (c !== mainNumber) return c;
}
return null;
}
function pickCallingLineId(legs, direction) {
if (direction === 'inbound') {
const entry = findInboundEntryLeg(legs);
const clid = cdrCallingLineId(entry);
if (clid && String(clid).toUpperCase() !== 'NA') return clid;
}
return null;
}
function pickExternalNumber(legs, direction) { function pickExternalNumber(legs, direction) {
for (const leg of legs) { for (const leg of legs) {
const ct = callTypeValue(leg); const ct = callTypeValue(leg);
if (direction === 'inbound' && ct.includes('INBOUND')) { if (direction === 'inbound' && ct.includes('INBOUND')) {
return cdrCallingNumber(leg) || cdrField(leg, 'Caller ID number', 'caller id number'); return cdrCallingNumber(leg) || cdrCallerIdNumber(leg);
} }
if (direction === 'outbound' && ct.includes('OUTBOUND')) { if (direction === 'outbound' && ct.includes('OUTBOUND')) {
return cdrCalledNumber(leg); return cdrCalledNumber(leg);
@ -83,13 +144,24 @@ function pickExternalNumber(legs, direction) {
const first = legs[0]; const first = legs[0];
if (!first) return null; if (!first) return null;
if (direction === 'inbound') { if (direction === 'inbound') {
return cdrCallingNumber(first) || cdrField(first, 'Caller ID number', 'caller id number'); return cdrCallingNumber(first) || cdrCallerIdNumber(first);
} }
return cdrCalledNumber(first); return cdrCalledNumber(first);
} }
function pickStoreMainNumber(legs) {
for (const leg of legs) {
const main = cdrSiteMainNumber(leg);
if (main) return main;
}
const entry = findInboundEntryLeg(legs);
if (entry && classifyCallDirection(legs) === 'inbound') {
return cdrCalledNumber(entry);
}
return null;
}
function pickTerminalOutcome(legs) { function pickTerminalOutcome(legs) {
// Prefer the user-phone leg, then any leg with explicit outcome.
const phoneLegs = legs.filter(isUserPhoneLeg); const phoneLegs = legs.filter(isUserPhoneLeg);
const candidates = phoneLegs.length ? phoneLegs : legs; const candidates = phoneLegs.length ? phoneLegs : legs;
let chosen = candidates[candidates.length - 1]; let chosen = candidates[candidates.length - 1];
@ -98,9 +170,12 @@ function pickTerminalOutcome(legs) {
chosen = leg; chosen = leg;
} }
} }
const outcome = cdrField(chosen, 'Call outcome', 'call outcome') || 'unknown';
const reason = cdrField(chosen, 'Call outcome reason', 'call outcome reason') || '';
return { return {
outcome: cdrField(chosen, 'Call outcome', 'call outcome') || 'unknown', outcome,
reason: cdrField(chosen, 'Call outcome reason', 'call outcome reason') || '', reason,
disposition: formatCallDisposition(outcome, reason),
normal: isNormalCallOutcome(chosen), normal: isNormalCallOutcome(chosen),
}; };
} }
@ -115,32 +190,67 @@ function summarizeReach(legs, direction) {
return { return {
reachedPhone: Boolean(answeredPhone), reachedPhone: Boolean(answeredPhone),
reachedAttendant: Boolean(answeredAa), reachedAttendant: Boolean(answeredAa),
endpointUser: answeredPhone endpointUser: answeredPhone ? cdrUserName(answeredPhone) : null,
? cdrField(answeredPhone, 'User', 'user') endpointModel: answeredPhone ? cdrModel(answeredPhone) : null,
: null,
endpointModel: answeredPhone
? cdrField(answeredPhone, 'Model', 'model')
: null,
aaKeyPress: aaLegs.map((l) => cdrField(l, 'Auto Attendant Key Pressed', 'auto attendant key pressed')) aaKeyPress: aaLegs.map((l) => cdrField(l, 'Auto Attendant Key Pressed', 'auto attendant key pressed'))
.find((v) => v && v !== 'NA') || null, .find((v) => v && v !== 'NA') || null,
}; };
} }
// Outbound: originating user/device leg answered, remote side picked up on terminating leg. const originating = findOutboundOriginatingLeg(legs);
const originating = legs.find((l) => cdrDirectionValue(l).includes('ORIGINAT') && isUserPhoneLeg(l));
const remoteAnswered = legs.some((l) => const remoteAnswered = legs.some((l) =>
cdrDirectionValue(l).includes('TERMINAT') && isTruthyAnswered(l) && !isAutomatedAttendantLeg(l), cdrDirectionValue(l).includes('TERMINAT') && isTruthyAnswered(l) && !isAutomatedAttendantLeg(l),
); );
return { return {
reachedPhone: Boolean(originating && isTruthyAnswered(originating)), reachedPhone: Boolean(originating && isTruthyAnswered(originating)),
connected: remoteAnswered || (originating && isTruthyAnswered(originating)), connected: remoteAnswered || Boolean(originating && isTruthyAnswered(originating)),
endpointUser: originating ? cdrField(originating, 'User', 'user') : null, endpointUser: originating ? cdrUserName(originating) : null,
endpointModel: originating ? cdrField(originating, 'Model', 'model') : null, endpointModel: originating ? cdrModel(originating) : null,
reachedAttendant: false, reachedAttendant: false,
aaKeyPress: null, aaKeyPress: null,
}; };
} }
/**
* @param {object} call
* @returns {string}
*/
export function assignCallBucket(call) {
if (call.direction === 'inbound') {
if (call.reachedPhone) return CALL_BUCKETS.inboundReachedPhone;
if (call.reachedAttendant) return CALL_BUCKETS.inboundAaOnly;
return CALL_BUCKETS.other;
}
if (call.direction === 'outbound') {
if (call.connected || call.reachedPhone) return CALL_BUCKETS.outboundConnected;
return CALL_BUCKETS.other;
}
return CALL_BUCKETS.other;
}
/**
* @param {object[]} calls
*/
export function groupCallsByBucket(calls) {
const buckets = {
[CALL_BUCKETS.inboundReachedPhone]: [],
[CALL_BUCKETS.inboundAaOnly]: [],
[CALL_BUCKETS.outboundConnected]: [],
[CALL_BUCKETS.other]: [],
};
for (const call of calls || []) {
buckets[assignCallBucket(call)].push(call);
}
return buckets;
}
/**
* @param {object[]} calls
*/
export function filterAbnormalCalls(calls) {
return (calls || []).filter((c) => c.abnormal || !c.normalOutcome);
}
/** /**
* @param {object[]} rawLegs * @param {object[]} rawLegs
* @returns {object} * @returns {object}
@ -152,11 +262,14 @@ export function summarizeCallGroup(correlationId, rawLegs) {
return ta - tb; return ta - tb;
}); });
const direction = classifyCallDirection(legs); const direction = classifyCallDirection(legs);
const entryLeg = findInboundEntryLeg(legs);
const phoneLeg = findUserPhoneLeg(legs);
const outboundOrig = findOutboundOriginatingLeg(legs);
const storeMain = pickStoreMainNumber(legs);
const starts = legs.map((l) => cdrStartTime(l)).filter(Boolean); const starts = legs.map((l) => cdrStartTime(l)).filter(Boolean);
const start = starts.length ? starts.sort()[0] : null; const start = starts.length ? starts.sort()[0] : null;
const releaseTimes = legs const releaseTimes = legs.map((l) => cdrField(l, 'Release time', 'release time')).filter(Boolean);
.map((l) => cdrField(l, 'Release time', 'release time'))
.filter(Boolean);
const end = releaseTimes.length ? releaseTimes.sort().reverse()[0] : null; const end = releaseTimes.length ? releaseTimes.sort().reverse()[0] : null;
const duration = (() => { const duration = (() => {
if (start && end) { if (start && end) {
@ -168,6 +281,35 @@ export function summarizeCallGroup(correlationId, rawLegs) {
const outcome = pickTerminalOutcome(legs); const outcome = pickTerminalOutcome(legs);
const reach = summarizeReach(legs, direction); const reach = summarizeReach(legs, direction);
const abnormal = legs.some((l) => !isNormalCallOutcome(l)); const abnormal = legs.some((l) => !isNormalCallOutcome(l));
const finalNumber = pickFinalNumber(legs, storeMain, phoneLeg);
const callingLineId = pickCallingLineId(legs, direction);
let leftParty;
let rightParty;
if (direction === 'inbound') {
leftParty = {
lineId: callingLineId,
number: pickExternalNumber(legs, direction) || cdrCallingNumber(entryLeg),
};
rightParty = {
main: storeMain || cdrCalledNumber(entryLeg),
final: finalNumber,
model: reach.endpointModel,
user: reach.endpointUser,
};
} else if (direction === 'outbound') {
leftParty = {
user: reach.endpointUser || cdrUserName(outboundOrig),
number: cdrUserNumber(outboundOrig) || cdrCallingNumber(outboundOrig),
model: reach.endpointModel || (outboundOrig ? cdrModel(outboundOrig) : null),
};
rightParty = {
number: pickExternalNumber(legs, direction) || cdrCalledNumber(legs[legs.length - 1]),
};
} else {
leftParty = { number: cdrCallingNumber(legs[0]) };
rightParty = { number: cdrCalledNumber(legs[legs.length - 1]) };
}
return { return {
correlationId, correlationId,
@ -175,13 +317,23 @@ export function summarizeCallGroup(correlationId, rawLegs) {
start, start,
end, end,
duration, duration,
callingNumber: pickExternalNumber(legs, direction) || cdrCallingNumber(legs[0]), callingLineId,
calledNumber: cdrCalledNumber(legs[legs.length - 1]) || cdrCalledNumber(legs[0]), callingNumber: leftParty.number || pickExternalNumber(legs, direction),
calledNumber: direction === 'inbound'
? (storeMain || cdrCalledNumber(entryLeg))
: (rightParty.number || cdrCalledNumber(legs[legs.length - 1])),
storeMainNumber: storeMain,
finalNumber,
model: reach.endpointModel || (phoneLeg ? cdrModel(phoneLeg) : null),
disposition: outcome.disposition,
outcome: outcome.outcome, outcome: outcome.outcome,
outcomeReason: outcome.reason, outcomeReason: outcome.reason,
normalOutcome: outcome.normal && !abnormal, normalOutcome: outcome.normal && !abnormal,
abnormal, abnormal,
legCount: legs.length, legCount: legs.length,
leftParty,
rightParty,
bucket: null,
...reach, ...reach,
legs, legs,
}; };
@ -203,7 +355,11 @@ export function groupCdrIntoCalls(rawItems) {
} }
return [...groups.values()] return [...groups.values()]
.map((g) => summarizeCallGroup(g.correlationId, g.legs)) .map((g) => {
const call = summarizeCallGroup(g.correlationId, g.legs);
call.bucket = assignCallBucket(call);
return call;
})
.sort((a, b) => new Date(a.start || 0).getTime() - new Date(b.start || 0).getTime()); .sort((a, b) => new Date(a.start || 0).getTime() - new Date(b.start || 0).getTime());
} }
@ -232,7 +388,7 @@ export function summarizeCalls(calls) {
const outcomes = {}; const outcomes = {};
for (const call of calls) { for (const call of calls) {
const key = [call.outcome, call.outcomeReason].filter(Boolean).join(' / ') || 'unknown'; const key = call.disposition || [call.outcome, call.outcomeReason].filter(Boolean).join(' / ') || 'unknown';
outcomes[key] = (outcomes[key] || 0) + 1; outcomes[key] = (outcomes[key] || 0) + 1;
} }

View file

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

View file

@ -0,0 +1,38 @@
// services/callReport/parseTarget.js
// Token parsing for /callreport targets (no Webex dependencies).
import { normalizeE164 } from '../callTest/config.js';
/**
* @param {string} token
* @returns {{ kind: 'store', storeNum: string } | { kind: 'user', email: string } | { kind: 'number', phoneNumber: string } | null}
*/
export function parseCallReportTarget(token) {
const t = String(token || '').trim();
if (!t) return null;
if (t.includes('@')) {
return { kind: 'user', email: t.toLowerCase() };
}
if (/^\d{2,4}$/.test(t)) {
return { kind: 'store', storeNum: t };
}
const e164 = normalizeE164(t);
if (e164) {
return { kind: 'number', phoneNumber: e164 };
}
return null;
}
/**
* Parse store number from location name like "Store 0782".
* @param {string} locationName
* @returns {string|null}
*/
export function parseStoreNumFromLocationName(locationName) {
const m = String(locationName || '').match(/store\s*0*(\d{2,4})\b/i);
return m ? m[1] : null;
}

View file

@ -0,0 +1,207 @@
// services/callReport/resolveTarget.js
// Parse and resolve /callreport targets: store, user email, or phone number.
import webex from '../../integrations/webex/WebexClient.js';
import { logger } from '../../utils/logger.js';
import { DISPLAY_TIMEZONE } from '../../utils/time.js';
import {
resolveNumberAssignment,
resolvePersonLocation,
} from '../callTest/locationResolver.js';
import { buildScopedCallFilter } from './filterCallsByTarget.js';
import {
getDectNetworksForPerson,
getPersonDetails,
getPersonIdByEmail,
} from '../phoneService.js';
import { resolveStoreForCallReport } from './storeContext.js';
import { parseCallReportTarget, parseStoreNumFromLocationName } from './parseTarget.js';
const LOG_SCOPE = 'callreport:target';
async function resolveLocationDetails(locationId) {
if (!locationId) return null;
return webex.request('GET', `telephony/config/locations/${locationId}`).catch(() => null);
}
async function resolveTimezone({ personId, locationId, locationDetails }) {
let timeZone = DISPLAY_TIMEZONE;
try {
const profile = personId
? await webex.request('GET', `telephony/config/people/${personId}`).catch(() => null)
: null;
const location = locationDetails || (locationId
? await resolveLocationDetails(locationId)
: null);
timeZone = profile?.timeZone || location?.timeZone || location?.timezone || timeZone;
} catch (err) {
logger(LOG_SCOPE, `Timezone lookup failed: ${err.message}`, 'debug');
}
return timeZone;
}
function buildStoreContextFromLocation({
kind,
label,
storeNum,
personId,
locationId,
locationName,
dialNumber,
timeZone,
email,
filter,
}) {
return {
kind,
label,
storeNum,
personId: personId || null,
locationId,
locationName,
dialNumber,
timeZone,
email: email || null,
filter: filter || null,
};
}
async function resolveStoreTarget(parsed) {
const store = await resolveStoreForCallReport(parsed.storeNum);
return buildStoreContextFromLocation({
kind: 'store',
label: `Store ${store.storeNum}`,
storeNum: store.storeNum,
personId: store.personId,
locationId: store.locationId,
locationName: store.locationName,
dialNumber: store.dialNumber,
timeZone: store.timeZone,
email: store.email,
filter: null,
});
}
async function resolveNumberTarget(parsed) {
const assignment = await resolveNumberAssignment(parsed.phoneNumber);
if (!assignment?.locationId) {
throw new Error(`No Webex location found for number ${parsed.phoneNumber}`);
}
const locationDetails = await resolveLocationDetails(assignment.locationId);
const dialNumber = locationDetails?.callingLineId?.phoneNumber
|| locationDetails?.phoneNumber
|| null;
const storeNum = parseStoreNumFromLocationName(assignment.locationName);
const timeZone = await resolveTimezone({
personId: assignment.owner?.id,
locationId: assignment.locationId,
locationDetails,
});
const filter = buildScopedCallFilter({
kind: 'number',
phoneNumber: parsed.phoneNumber,
personId: assignment.owner?.id || null,
extension: assignment.extension,
locationMain: dialNumber,
});
return buildStoreContextFromLocation({
kind: 'number',
label: parsed.phoneNumber,
storeNum,
personId: assignment.owner?.id || null,
locationId: assignment.locationId,
locationName: assignment.locationName,
dialNumber,
timeZone,
email: null,
filter,
});
}
async function resolveUserTarget(parsed) {
const personId = await getPersonIdByEmail(parsed.email);
if (!personId) {
throw new Error(`No Webex person found for ${parsed.email}`);
}
const person = await getPersonDetails(personId);
let locationId = null;
let locationName = null;
let ownedNumbers = [];
const personLoc = await resolvePersonLocation(personId);
if (personLoc) {
locationId = personLoc.locationId;
locationName = personLoc.locationName;
ownedNumbers = personLoc.ownedNumbers;
} else {
const dectNets = await getDectNetworksForPerson(personId);
if (dectNets.length > 0) {
locationId = dectNets[0].locationId && dectNets[0].locationId !== '—'
? dectNets[0].locationId
: null;
locationName = dectNets[0].locationName && dectNets[0].locationName !== '—'
? dectNets[0].locationName
: null;
}
}
if (!locationId || !locationName) {
throw new Error(`No Webex Calling location found for ${parsed.email}`);
}
const locationDetails = await resolveLocationDetails(locationId);
const dialNumber = locationDetails?.callingLineId?.phoneNumber
|| locationDetails?.phoneNumber
|| null;
const storeNum = parseStoreNumFromLocationName(locationName);
const timeZone = await resolveTimezone({ personId, locationId, locationDetails });
const filter = buildScopedCallFilter({
kind: 'user',
email: parsed.email,
personId,
person,
ownedNumbers,
locationMain: dialNumber,
});
if (ownedNumbers.length > 1) {
logger(
LOG_SCOPE,
`User ${parsed.email} has numbers at multiple locations; using ${locationName}`,
'warn',
);
}
return buildStoreContextFromLocation({
kind: 'user',
label: parsed.email,
storeNum,
personId,
locationId,
locationName,
dialNumber,
timeZone,
email: parsed.email,
filter,
});
}
/**
* @param {string} token raw first argument
* @returns {Promise<object>} unified call report target context
*/
export async function resolveCallReportTarget(token) {
const parsed = parseCallReportTarget(token);
if (!parsed) {
throw new Error('Invalid target — use a store number (24 digits), email, or phone number');
}
if (parsed.kind === 'store') return resolveStoreTarget(parsed);
if (parsed.kind === 'number') return resolveNumberTarget(parsed);
return resolveUserTarget(parsed);
}

View file

@ -1,4 +1,4 @@
// services/voiceReport/storeContext.js // services/callReport/storeContext.js
// Resolve Webex location + timezone for a store number. // Resolve Webex location + timezone for a store number.
import webex from '../../integrations/webex/WebexClient.js'; import webex from '../../integrations/webex/WebexClient.js';
@ -6,13 +6,13 @@ import { logger } from '../../utils/logger.js';
import { DISPLAY_TIMEZONE } from '../../utils/time.js'; import { DISPLAY_TIMEZONE } from '../../utils/time.js';
import { resolveStoreMainNumber } from '../callTest/storeResolver.js'; import { resolveStoreMainNumber } from '../callTest/storeResolver.js';
const LOG_SCOPE = 'voicereport:store'; const LOG_SCOPE = 'callreport:store';
/** /**
* @param {string} storeNum * @param {string} storeNum
* @returns {Promise<{ storeNum, personId, locationId, locationName, dialNumber, timeZone, email }>} * @returns {Promise<{ storeNum, personId, locationId, locationName, dialNumber, timeZone, email }>}
*/ */
export async function resolveStoreForVoiceReport(storeNum) { export async function resolveStoreForCallReport(storeNum) {
const base = await resolveStoreMainNumber(storeNum); const base = await resolveStoreMainNumber(storeNum);
let timeZone = DISPLAY_TIMEZONE; let timeZone = DISPLAY_TIMEZONE;

View file

@ -152,6 +152,7 @@ export async function fetchMatchedCdrForSession(session) {
endTime, endTime,
returnRawItems: true, returnRawItems: true,
skipPersonFilter: !ctx.personId, skipPersonFilter: !ctx.personId,
onQueued: session.onCdrQueued,
}); });
if (!cdr.available) { if (!cdr.available) {
@ -187,7 +188,17 @@ export function scheduleCdrMatchEnrichment(session, { notifyRoom, renderMarkdown
try { try {
logger(LOG_SCOPE, `CDR match fetch for testId=${session.testId}`, 'info'); logger(LOG_SCOPE, `CDR match fetch for testId=${session.testId}`, 'info');
const result = await fetchMatchedCdrForSession(cur); const result = await fetchMatchedCdrForSession({
...cur,
onCdrQueued: async ({ runAt, waitMs }) => {
const secs = Math.max(1, Math.ceil(waitMs / 1000));
await notifyRoom(
session.roomId,
`⏳ **Call test CDR** — query queued for \`${session.testId}\`. ` +
`Runs at **${runAt.toLocaleTimeString()}** (~${secs}s).`,
);
},
});
updateSession(session.testId, { updateSession(session.testId, {
enrichment: { enrichment: {
...(cur.enrichment || {}), ...(cur.enrichment || {}),

View file

@ -7,8 +7,8 @@ import { normalizeE164 } from './config.js';
const LOG_SCOPE = 'calltest:location'; const LOG_SCOPE = 'calltest:location';
const CACHE_TTL_MS = 60 * 60 * 1000; const CACHE_TTL_MS = 60 * 60 * 1000;
let _phoneIndex = null; let _numbersCache = null;
let _phoneIndexAt = 0; let _numbersCacheAt = 0;
async function getWebex() { async function getWebex() {
const mod = await import('../../integrations/webex/WebexClient.js'); const mod = await import('../../integrations/webex/WebexClient.js');
@ -24,13 +24,27 @@ function parseLinkNext(linkHeader) {
return null; return null;
} }
function phoneDigits(value) { export function phoneDigits(value) {
const d = String(value || '').replace(/\D/g, ''); const d = String(value || '').replace(/\D/g, '');
if (d.length === 10) return `1${d}`; if (d.length === 10) return `1${d}`;
if (d.length === 11 && d.startsWith('1')) return d; if (d.length === 11 && d.startsWith('1')) return d;
return d; return d;
} }
function numberRecordFromApi(n) {
const raw = n.phoneNumber || n.number || n.value;
const loc = n.location;
if (!raw || !loc?.id) return null;
return {
locationId: loc.id,
locationName: loc.name || null,
phoneNumber: raw,
extension: n.extension || n.primaryExtension || null,
owner: n.owner || null,
raw: n,
};
}
async function fetchAllNumbers() { async function fetchAllNumbers() {
const webex = await getWebex(); const webex = await getWebex();
const all = []; const all = [];
@ -52,37 +66,92 @@ async function fetchAllNumbers() {
return all; return all;
} }
async function buildPhoneIndex() { async function buildNumbersCache() {
const now = Date.now(); const now = Date.now();
if (_phoneIndex && now - _phoneIndexAt < CACHE_TTL_MS) { if (_numbersCache && now - _numbersCacheAt < CACHE_TTL_MS) {
return _phoneIndex; return _numbersCache;
} }
logger(LOG_SCOPE, 'Building location phone index from telephony/config/numbers', 'debug'); logger(LOG_SCOPE, 'Building location phone index from telephony/config/numbers', 'debug');
const numbers = await fetchAllNumbers(); const numbers = await fetchAllNumbers();
const index = new Map(); const byPhoneDigits = new Map();
const byPersonId = new Map();
const records = [];
for (const n of numbers) { for (const n of numbers) {
const raw = n.phoneNumber || n.number || n.value; const entry = numberRecordFromApi(n);
const loc = n.location; if (!entry) continue;
if (!raw || !loc?.id) continue; records.push(entry);
const entry = { const keys = new Set([phoneDigits(entry.phoneNumber), phoneDigits(normalizeE164(entry.phoneNumber) || entry.phoneNumber)]);
locationId: loc.id,
locationName: loc.name || null,
phoneNumber: raw,
};
const keys = new Set([phoneDigits(raw), phoneDigits(normalizeE164(raw) || raw)]);
for (const k of keys) { for (const k of keys) {
if (k) index.set(k, entry); if (k) byPhoneDigits.set(k, entry);
}
const ownerId = entry.owner?.id;
if (ownerId) {
if (!byPersonId.has(ownerId)) byPersonId.set(ownerId, []);
byPersonId.get(ownerId).push(entry);
} }
} }
_phoneIndex = index; _numbersCache = { byPhoneDigits, byPersonId, records };
_phoneIndexAt = now; _numbersCacheAt = now;
logger(LOG_SCOPE, `Phone index built: ${index.size} keys from ${numbers.length} numbers`, 'debug'); logger(
return index; LOG_SCOPE,
`Phone index built: ${byPhoneDigits.size} phone keys, ${byPersonId.size} owners from ${numbers.length} numbers`,
'debug',
);
return _numbersCache;
}
function addMatchKey(set, value) {
if (value == null || value === '') return;
const s = String(value).trim();
if (!s) return;
set.add(s.toLowerCase());
const digits = s.replace(/\D/g, '');
if (digits) {
set.add(digits);
if (digits.length >= 4) set.add(digits.slice(-4));
if (digits.length >= 5) set.add(digits.slice(-5));
}
}
/**
* Build CDR match keys for a provisioned phone number record.
* @param {object} record from numberRecordFromApi
* @returns {Set<string>}
*/
export function buildMatchKeysForNumber(record) {
const keys = new Set();
if (!record) return keys;
addMatchKey(keys, record.phoneNumber);
addMatchKey(keys, normalizeE164(record.phoneNumber));
addMatchKey(keys, record.extension);
if (record.owner?.name) addMatchKey(keys, record.owner.name);
return keys;
}
/**
* Build CDR match keys for a Webex person + their owned numbers.
* @param {object} person
* @param {object[]} ownedNumbers
* @returns {Set<string>}
*/
export function buildMatchKeysForPerson(person, ownedNumbers = []) {
const keys = new Set();
if (!person) return keys;
addMatchKey(keys, person.displayName);
(person.emails || []).forEach((e) => addMatchKey(keys, e));
(person.phoneNumbers || []).forEach((p) => addMatchKey(keys, p?.value || p));
for (const record of ownedNumbers) {
for (const k of buildMatchKeysForNumber(record)) keys.add(k);
}
return keys;
} }
/** /**
@ -90,27 +159,66 @@ async function buildPhoneIndex() {
* @returns {Promise<{locationId: string, locationName: string, phoneNumber: string}|null>} * @returns {Promise<{locationId: string, locationName: string, phoneNumber: string}|null>}
*/ */
export async function resolveLocationForDialNumber(dialNumber) { export async function resolveLocationForDialNumber(dialNumber) {
const assignment = await resolveNumberAssignment(dialNumber);
if (!assignment?.locationName) return null;
return {
locationId: assignment.locationId,
locationName: assignment.locationName,
phoneNumber: assignment.phoneNumber,
};
}
/**
* Full number assignment details for a dialed E.164.
* @returns {Promise<object|null>}
*/
export async function resolveNumberAssignment(dialNumber) {
const e164 = normalizeE164(dialNumber); const e164 = normalizeE164(dialNumber);
if (!e164) return null; if (!e164) return null;
try { try {
const index = await buildPhoneIndex(); const cache = await buildNumbersCache();
const key = phoneDigits(e164); const key = phoneDigits(e164);
const hit = index.get(key); return cache.byPhoneDigits.get(key) || null;
if (!hit?.locationName) return null;
return {
locationId: hit.locationId,
locationName: hit.locationName,
phoneNumber: hit.phoneNumber,
};
} catch (err) { } catch (err) {
logger(LOG_SCOPE, `resolveLocationForDialNumber failed: ${err.message}`, 'warn'); logger(LOG_SCOPE, `resolveNumberAssignment failed: ${err.message}`, 'warn');
return null; return null;
} }
} }
export function _clearLocationCacheForTests() { /**
_phoneIndex = null; * Resolve location from numbers owned by a person.
_phoneIndexAt = 0; * @returns {Promise<{ locationId, locationName, ownedNumbers }|null>}
*/
export async function resolvePersonLocation(personId) {
if (!personId) return null;
try {
const cache = await buildNumbersCache();
const ownedNumbers = cache.byPersonId.get(personId) || [];
if (ownedNumbers.length > 0) {
const first = ownedNumbers[0];
return {
locationId: first.locationId,
locationName: first.locationName,
ownedNumbers,
};
}
return null;
} catch (err) {
logger(LOG_SCOPE, `resolvePersonLocation failed: ${err.message}`, 'warn');
return null;
}
}
/**
* @deprecated Use buildNumbersCache internally; kept for tests.
*/
export async function buildPhoneIndex() {
const cache = await buildNumbersCache();
return cache.byPhoneDigits;
}
export function _clearLocationCacheForTests() {
_numbersCache = null;
_numbersCacheAt = 0;
} }

View file

@ -84,6 +84,54 @@ export function cdrCalledNumber(item) {
); );
} }
export function cdrCallingLineId(item) {
return cdrField(item, 'callingLineId', 'Calling line ID', 'calling line id');
}
export function cdrCalledLineId(item) {
return cdrField(item, 'calledLineId', 'Called line ID', 'called line id');
}
export function cdrDialedDigits(item) {
return cdrField(item, 'dialedDigits', 'Dialed digits', 'dialed digits');
}
export function cdrCallOutcome(item) {
return cdrField(item, 'Call outcome', 'call outcome');
}
export function cdrCallOutcomeReason(item) {
return cdrField(item, 'Call outcome reason', 'call outcome reason');
}
export function cdrCallerIdNumber(item) {
return cdrField(item, 'Caller ID number', 'caller id number', 'callerIdNumber');
}
export function cdrUserName(item) {
return cdrField(item, 'User', 'user');
}
export function cdrUserNumber(item) {
return cdrField(item, 'User number', 'user number');
}
export function cdrModel(item) {
return cdrField(item, 'Model', 'model');
}
export function cdrSiteMainNumber(item) {
return cdrField(item, 'Site main number', 'site main number');
}
export function formatCallDisposition(outcome, reason) {
const o = String(outcome || '').trim();
const r = String(reason || '').trim();
if (!o) return 'unknown';
if (!r || r.toLowerCase() === 'normal') return o;
return `${o} / ${r}`;
}
export function cdrDedupKey(item) { export function cdrDedupKey(item) {
const id = cdrRecordId(item); const id = cdrRecordId(item);
if (id) return `id:${id}`; if (id) return `id:${id}`;
@ -130,3 +178,33 @@ export function describeCdrFeedPayload(data) {
} }
return parts.join(' '); return parts.join(' ');
} }
/**
* Parse Webex RFC5988 Link header and return the `rel="next"` URL.
* @param {string|undefined} linkHeader
* @returns {string|null}
*/
export function parseCdrFeedLinkNext(linkHeader) {
if (!linkHeader || typeof linkHeader !== 'string') return null;
for (const part of linkHeader.split(',')) {
const m = part.match(/<([^>]+)>\s*;\s*rel\s*=\s*"?next"?/i);
if (m) return m[1];
}
return null;
}
/**
* Resolve the next cdr_feed page from response body and/or Link header.
* @returns {{ url: string, params: object|null }|null}
*/
export function resolveCdrFeedNextPage(data, headers, baseUrl, baseParams) {
const linkNext = parseCdrFeedLinkNext(headers?.link || headers?.Link);
if (linkNext) return { url: linkNext, params: null };
const token = data?.next || data?.['next'] || data?.metadata?.next || null;
if (!token) return null;
if (typeof token === 'string' && token.startsWith('http')) {
return { url: token, params: null };
}
return { url: baseUrl, params: { ...baseParams, next: token } };
}

92
services/cdrFeedQueue.js Normal file
View file

@ -0,0 +1,92 @@
// services/cdrFeedQueue.js
// Serialize cdr_feed HTTP calls — Webex allows ~1 request/minute per token.
import { logger } from '../utils/logger.js';
const LOG_SCOPE = 'cdr:queue';
let testCooldownOverride = null;
function cooldownMs() {
if (testCooldownOverride != null) return testCooldownOverride;
const v = Number(process.env.CDR_FEED_COOLDOWN_MS);
return Number.isFinite(v) && v > 0 ? v : 65_000;
}
export const CDR_FEED_COOLDOWN_MS = 65_000;
let chain = Promise.resolve();
let lastStartMs = 0;
let pendingCount = 0;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function estimateRunAtMs(position) {
const now = Date.now();
const firstSlot = lastStartMs > 0
? Math.max(now, lastStartMs + cooldownMs())
: now;
if (position <= 0) return firstSlot;
return firstSlot + position * cooldownMs();
}
/**
* Run a cdr_feed fetch when the global cooldown allows. Queues concurrent callers.
*
* @template T
* @param {() => Promise<T>} fn
* @param {{ onQueued?: (info: { runAt: Date, waitMs: number, position: number }) => void|Promise<void>, label?: string }} [opts]
* @returns {Promise<T>}
*/
export function enqueueCdrFeedFetch(fn, opts = {}) {
const { onQueued, label } = opts;
const now = Date.now();
const position = pendingCount;
pendingCount += 1;
const inCooldown = lastStartMs > 0 && now < lastStartMs + cooldownMs();
if ((position > 0 || inCooldown) && onQueued) {
const runAtMs = estimateRunAtMs(position);
Promise.resolve(onQueued({
runAt: new Date(runAtMs),
waitMs: Math.max(0, runAtMs - now),
position: position + 1,
})).catch((err) => {
logger(LOG_SCOPE, `onQueued callback failed${label ? ` (${label})` : ''}: ${err.message}`, 'warn');
});
}
const job = chain.then(async () => {
const waitMs = Math.max(0, (lastStartMs > 0 ? lastStartMs + cooldownMs() : 0) - Date.now());
if (waitMs > 0) {
logger(
LOG_SCOPE,
`Waiting ${Math.ceil(waitMs / 1000)}s for cdr_feed cooldown${label ? ` (${label})` : ''}`,
'info',
);
await sleep(waitMs);
}
lastStartMs = Date.now();
return fn();
});
chain = job.catch(() => {});
return job.finally(() => {
pendingCount = Math.max(0, pendingCount - 1);
});
}
/** @internal test helper */
export function _resetCdrFeedQueueForTests() {
chain = Promise.resolve();
lastStartMs = 0;
pendingCount = 0;
testCooldownOverride = null;
}
/** @internal test helper */
export function _setCdrFeedCooldownForTests(ms) {
testCooldownOverride = ms;
}

View file

@ -339,8 +339,13 @@ export function summarizeAppSeries(raw, expectedName) {
const datapoints = series.data?.[0]?.datapoints; const datapoints = series.data?.[0]?.datapoints;
if (!Array.isArray(datapoints)) return null; if (!Array.isArray(datapoints)) return null;
const values = datapoints const timedPoints = datapoints.map((p) => ({
.map((p) => (typeof p?.value === 'number' && Number.isFinite(p.value) ? p.value : null)) time: p?.time || null,
value: (typeof p?.value === 'number' && Number.isFinite(p.value) ? p.value : null),
}));
const values = timedPoints
.map((p) => p.value)
.filter((v) => v !== null); .filter((v) => v !== null);
const summary = { const summary = {
@ -355,6 +360,8 @@ export function summarizeAppSeries(raw, expectedName) {
// to re-parse the raw response. Small (<= 288 numbers per metric) // to re-parse the raw response. Small (<= 288 numbers per metric)
// so no memory concern. // so no memory concern.
values, values,
// Timestamp-aligned series for per-call WAN overlap (callreport).
timedPoints,
}; };
if (values.length === 0) return summary; if (values.length === 0) return summary;

View file

@ -0,0 +1,67 @@
# Jira poller enrichment
Hourly automation that enriches unassigned Jira tickets with CollabFinder
command output posted as ADF comments. Orchestrated by
[`services/jiraPollerService.js`](../jiraPollerService.js); scheduled in
`index.js` when `JIRA_POLLER_ROOM_ID` is set.
## Flow
```
JQL search → classifyTicket (AI) → resolveEnrichmentPlan (rules)
→ runEnrichmentChecks (collectors + renderers) → Jira ADF comment
→ label bot-enriched → Webex summary
```
Idempotency uses Jira labels (`bot-enriched`, `bot-skipped`), not local
state. Transient failures leave the ticket unlabeled for retry next hour.
## Communication Services symptom rules
For tickets with component **Communication Services** and a resolvable store:
| Symptom keywords | Checks run |
|------------------|------------|
| Call quality / connectivity (garbled, static, can't connect, …) | `phonestatus` + `callreport` |
| Spam / robocall / nuisance | `callreport` only |
| Neither | Default: `phone` → phonestatus, `av` → avstatus |
`callreport` uses the **today** business window (9am local → now minus 5 min).
Store number comes from the AI classifier; if missing but a symptom rule
matched, the planner falls back to `Store NNNN` in the summary.
## Adding a new symptom rule
1. Open [`enrichmentRules.js`](enrichmentRules.js).
2. Add regex patterns to `CALL_QUALITY_PATTERNS`, `SPAM_PATTERNS`, or a new
pattern list.
3. Extend `resolveEnrichmentPlan()` to push a rule id into `matchedRules`
and append check ids to `checks`.
4. Add tests in `tests/jiraPoller.enrichmentRules.test.js`.
## Adding a new enrichment check
1. Register the check in `CHECK_IDS` and `CHECK_RUNNERS` in
[`runEnrichment.js`](runEnrichment.js) (collector + renderer, same as the
chat command).
2. Reference the new check id from a rule in `enrichmentRules.js`.
3. Add tests in `tests/jiraPoller.runEnrichment.test.js`.
## Manual trigger
```
/jirapoll run poller now
/jirapoll prime label backlog without enriching (one-time adoption)
```
## Environment
| Variable | Purpose |
|----------|---------|
| `JIRA_POLLER_ROOM_ID` | Webex room for hourly summary + enables cron |
| `JIRA_STORE_FIELD_ID` | Pin Store Number custom field id |
| `JIRA_POLLER_MODEL` | Override X.AI model for classification |
| `CDR_FEED_COOLDOWN_MS` | cdr_feed rate limit (default 65000) for callreport |
See `.env.example` for full Jira poller section.

View file

@ -0,0 +1,154 @@
// services/jiraPoller/enrichmentRules.js
// Rule-based enrichment planner for the hourly Jira poller.
export const COMM_SERVICES_COMPONENT = 'Communication Services';
export const CHECK_IDS = {
phonestatus: 'phonestatus',
callreport: 'callreport',
avstatus: 'avstatus',
};
const CALL_QUALITY_PATTERNS = [
/\bgarbled\b/i,
/\bstatic(?:y|s)?\b/i,
/\bchoppy\b/i,
/\bmuffled\b/i,
/\bcan(?:'t| ?not) connect\b/i,
/\bnot connecting\b/i,
/\bcan(?:'t| ?not) call\b/i,
/\bno dial tone\b/i,
/\bone[- ]way\b/i,
/\bdropped calls?\b/i,
/\bcall quality\b/i,
/\bbad audio\b/i,
/\bpoor audio\b/i,
/\bvoice quality\b/i,
/\bno audio\b/i,
/\bdead air\b/i,
/\bintermittent\b/i,
];
const SPAM_PATTERNS = [
/\bspam\b/i,
/\brobocall/i,
/\brobo[- ]?call/i,
/\bnuisance calls?\b/i,
/\bunwanted calls?\b/i,
/\bauto[- ]?dial/i,
/\btelemarketer/i,
/\bprank calls?\b/i,
/\bharassing calls?\b/i,
];
function hasComponent(components, name) {
return (components || []).some((c) => c?.name === name);
}
function ticketText(ticket) {
const summary = ticket?.summary || '';
const description = ticket?.description || '';
return `${summary}\n${description}`.toLowerCase();
}
function matchesAny(text, patterns) {
return patterns.some((re) => re.test(text));
}
/**
* Extract store number from summary text (e.g. "Store 2477 - ...").
* @param {string} summary
* @returns {string|null}
*/
export function extractStoreFromSummary(summary) {
if (!summary) return null;
const m = String(summary).match(/\bstore\s+(\d{2,6})\b/i);
return m ? m[1] : null;
}
/**
* @param {object} ticket
* @param {string} ticket.summary
* @param {string} [ticket.description]
* @param {Array<{name?: string}>} [ticket.components]
* @param {object} classification from classifyTicket()
* @param {string|null} classification.storeNum
* @param {'phone'|'av'|'skip'} classification.kind
* @returns {{
* checks: string[],
* matchedRules: string[],
* storeNum: string|null,
* skip: boolean,
* skipReason?: string,
* }}
*/
export function resolveEnrichmentPlan(ticket, classification) {
const text = ticketText(ticket);
const isCommServices = hasComponent(ticket?.components, COMM_SERVICES_COMPONENT);
const callQuality = isCommServices && matchesAny(text, CALL_QUALITY_PATTERNS);
const spam = isCommServices && matchesAny(text, SPAM_PATTERNS);
let storeNum = classification?.storeNum || null;
if (!storeNum && (callQuality || spam)) {
storeNum = extractStoreFromSummary(ticket?.summary);
}
const matchedRules = [];
if (callQuality) matchedRules.push('comm-call-quality');
if (spam) matchedRules.push('comm-spam');
if (callQuality || spam) {
if (!storeNum) {
return {
checks: [],
matchedRules,
storeNum: null,
skip: true,
skipReason: 'Communication Services symptom matched but no store number found',
};
}
const checks = [];
if (callQuality) {
checks.push(CHECK_IDS.phonestatus, CHECK_IDS.callreport);
} else if (spam) {
checks.push(CHECK_IDS.callreport);
}
return { checks: [...new Set(checks)], matchedRules, storeNum, skip: false };
}
if (classification?.kind === 'skip' || !storeNum) {
return {
checks: [],
matchedRules: [],
storeNum: null,
skip: true,
skipReason: classification?.reason || 'no store number',
};
}
if (classification.kind === 'phone') {
return {
checks: [CHECK_IDS.phonestatus],
matchedRules: ['default-phone'],
storeNum,
skip: false,
};
}
if (classification.kind === 'av') {
return {
checks: [CHECK_IDS.avstatus],
matchedRules: ['default-av'],
storeNum,
skip: false,
};
}
return {
checks: [],
matchedRules: [],
storeNum,
skip: true,
skipReason: `unsupported kind: ${classification?.kind}`,
};
}

View file

@ -0,0 +1,11 @@
// services/jiraPoller/formatBody.js
/**
* @param {Array<{title: string, markdown: string}>} sections
* @returns {string}
*/
export function formatEnrichmentBody(sections) {
return (sections || [])
.map((s) => `## ${s.title}\n\n${s.markdown}`)
.join('\n\n');
}

View file

@ -0,0 +1,77 @@
// services/jiraPoller/runEnrichment.js
// Run poller enrichment checks and merge markdown sections.
import { logger } from '../../utils/logger.js';
import { collectPhoneStatus } from '../phoneService.js';
import { collectDeviceStatus } from '../deviceService.js';
import { collectCallReport } from '../callReport/callReportService.js';
import { renderPhoneStatusMarkdown } from '../renderers/phoneStatusRenderer.js';
import { renderAvStatusMarkdown } from '../renderers/avStatusRenderer.js';
import { renderCallReportMarkdown } from '../renderers/callReportRenderer.js';
import { CHECK_IDS } from './enrichmentRules.js';
import { formatEnrichmentBody } from './formatBody.js';
const LOG_SCOPE = 'jira:poller:enrich';
const CHECK_RUNNERS = {
[CHECK_IDS.phonestatus]: async (storeNum) => {
const data = await collectPhoneStatus(storeNum);
return {
title: 'Phone status',
markdown: renderPhoneStatusMarkdown(data, {
storeNum,
detailed: true,
footer: false,
}),
};
},
[CHECK_IDS.avstatus]: async (storeNum) => {
const data = await collectDeviceStatus(storeNum);
return {
title: 'AV status',
markdown: renderAvStatusMarkdown(data, {
storeNum,
detailed: true,
footer: false,
}),
};
},
[CHECK_IDS.callreport]: async (storeNum, opts = {}) => {
const report = await collectCallReport(storeNum, {
dateArg: 'today',
onCdrQueued: ({ runAt, waitMs }) => {
logger(
LOG_SCOPE,
`CDR queued for store ${storeNum} — runs at ${runAt.toISOString()} (~${Math.ceil(waitMs / 1000)}s)`,
'info',
);
},
});
return {
title: 'Call report (today)',
markdown: renderCallReportMarkdown(report, { detail: false }),
};
},
};
/**
* @param {string} storeNum
* @param {string[]} checks ordered check ids
* @param {object} [opts]
* @returns {Promise<{ sections: Array<{title: string, markdown: string}>, bodyMarkdown: string }>}
*/
export async function runEnrichmentChecks(storeNum, checks, opts = {}) {
const sections = [];
for (const checkId of checks) {
const runner = CHECK_RUNNERS[checkId];
if (!runner) {
throw new Error(`Unknown enrichment check: ${checkId}`);
}
const section = await runner(storeNum, opts);
sections.push(section);
}
const bodyMarkdown = formatEnrichmentBody(sections);
return { sections, bodyMarkdown };
}

View file

@ -47,8 +47,8 @@ import { classifyTicket, TicketClassifierError } from './ticketClassifier.js';
import { adfToPlainText } from '../utils/adfToPlainText.js'; import { adfToPlainText } from '../utils/adfToPlainText.js';
import { markdownToAdfContent } from '../utils/markdownToAdf.js'; import { markdownToAdfContent } from '../utils/markdownToAdf.js';
import { buildAdfComment } from '../utils/adfComment.js'; import { buildAdfComment } from '../utils/adfComment.js';
import { renderPhoneStatusMarkdown } from './renderers/phoneStatusRenderer.js'; import { resolveEnrichmentPlan } from './jiraPoller/enrichmentRules.js';
import { renderAvStatusMarkdown } from './renderers/avStatusRenderer.js'; import { runEnrichmentChecks } from './jiraPoller/runEnrichment.js';
const BOT_LABEL = 'bot-enriched'; const BOT_LABEL = 'bot-enriched';
// Applied when the AI classifier decides a ticket is out-of-scope for // Applied when the AI classifier decides a ticket is out-of-scope for
@ -69,17 +69,8 @@ const STORE_FIELD_NAME = 'Store Number';
// drains at MAX_TICKETS_PER_POLL/hour once it exists. // drains at MAX_TICKETS_PER_POLL/hour once it exists.
const MAX_TICKETS_PER_POLL = 50; const MAX_TICKETS_PER_POLL = 50;
// Maps classifier's `kind` to the enrichment collector. Replaces the // Legacy map — COMPONENT_ROUTES is the component-based source of truth.
// component-name -> collector map that used to be the source of truth // Enrichment execution uses jiraPoller/runEnrichment.js.
// pre-AI. The classifier's output space is closed (phone|av|skip), so
// this map only needs the two enrichable kinds — 'skip' short-circuits
// before we get here.
const KIND_TO_COLLECTOR = {
phone: collectPhoneStatus,
av: collectDeviceStatus,
};
// Component name -> enrichment collector. Strict mapping per plan; a
// ticket whose components don't match any key here is skipped (though // ticket whose components don't match any key here is skipped (though
// the poller's JQL should ensure this is never actually hit). // the poller's JQL should ensure this is never actually hit).
export const COMPONENT_ROUTES = { export const COMPONENT_ROUTES = {
@ -339,57 +330,33 @@ export async function pollNewTickets({ prime = false } = {}) {
continue; continue;
} }
if (classification.kind === 'skip' || !classification.storeNum) { const plan = resolveEnrichmentPlan(ticketPayload, classification);
logger('jira:poller', `${key}: SKIP — AI: ${classification.reason}`);
// Label AI-determined skips so we don't burn tokens re-classifying
// the same ticket every hour. If labeling fails (transient Jira
// 5xx, scope drop, whatever) we log and continue — the ticket will
// simply be re-classified next hour; the cost of one duplicate AI
// call is far cheaper than the cost of dropping a poll entirely.
await tagSkipped(key);
skipped.push({ key, reason: classification.reason });
continue;
}
const collect = KIND_TO_COLLECTOR[classification.kind]; if (plan.skip || !plan.storeNum || !plan.checks.length) {
if (!collect) { const reason = plan.skipReason || classification.reason;
// Belt-and-suspenders: parseAndValidate already gates kind to logger('jira:poller', `${key}: SKIP — ${reason}`);
// phone|av|skip, but if the schema ever loosens we don't want to
// silently no-op. Label as skipped for the same "don't retry"
// reasoning as the AI-skip branch above.
logger('jira:poller', `${key}: SKIP — no collector for kind '${classification.kind}'`, 'warn');
await tagSkipped(key); await tagSkipped(key);
skipped.push({ key, reason: `unsupported kind: ${classification.kind}` }); skipped.push({ key, reason });
continue; continue;
} }
try { try {
logger('jira:poller', `${key}: enriching (${classification.kind}, store ${classification.storeNum}) — AI: ${classification.reason}`); logger(
const data = await collect(classification.storeNum); 'jira:poller',
`${key}: enriching (store ${plan.storeNum}, checks=${plan.checks.join('+')}) — ` +
// Same markdown the chat commands emit. `footer: false` strips `rules: ${plan.matchedRules.join(', ')} · AI: ${classification.reason}`,
// the "*Last checked: HH:MM*" line — a Jira comment already has );
// an authoritative timestamp in the header paragraph below and const { bodyMarkdown } = await runEnrichmentChecks(plan.storeNum, plan.checks);
// Jira's own `created` field. Detailed mode always on for Jira
// so triagers get the richest possible per-device info.
const markdown = classification.kind === 'phone'
? renderPhoneStatusMarkdown(data, {
storeNum: classification.storeNum,
detailed: true,
footer: false,
})
: renderAvStatusMarkdown(data, {
storeNum: classification.storeNum,
detailed: true,
footer: false,
});
const rulesLabel = plan.matchedRules.length
? plan.matchedRules.join(', ')
: classification.kind;
const headerLine = const headerLine =
`Auto-enriched by CollabFinder — ${classification.kind} snapshot for store ${classification.storeNum} ` + `Auto-enriched by CollabFinder — store ${plan.storeNum} ` +
`at ${new Date().toISOString()} · AI: ${classification.reason}`; `at ${new Date().toISOString()} · rules: ${rulesLabel} · AI: ${classification.reason}`;
const adf = buildAdfComment({ const adf = buildAdfComment({
headerLine, headerLine,
bodyNodes: markdownToAdfContent(markdown), bodyNodes: markdownToAdfContent(bodyMarkdown),
}); });
await jira.addComment(key, adf); await jira.addComment(key, adf);
@ -398,12 +365,11 @@ export async function pollNewTickets({ prime = false } = {}) {
enriched.push({ enriched.push({
key, key,
summary, summary,
storeNum: classification.storeNum, storeNum: plan.storeNum,
kind: classification.kind, kind: classification.kind,
reason: classification.reason, reason: classification.reason,
// Jira components drive the Webex bullet icon (Mobility vs Comm checks: plan.checks,
// vs AV). Capture at enrich time so the summary post doesn't matchedRules: plan.matchedRules,
// re-walk the issue object.
components: f.components || [], components: f.components || [],
}); });
} catch (err) { } catch (err) {

View file

@ -16,6 +16,7 @@ import { attachMerakiClientWithPorts } from './enrichment/merakiEnrichment.js';
import { import {
extractCdrFeedList, extractCdrFeedList,
describeCdrFeedPayload, describeCdrFeedPayload,
resolveCdrFeedNextPage,
cdrCalledNumber, cdrCalledNumber,
cdrCallingNumber, cdrCallingNumber,
cdrDedupKey, cdrDedupKey,
@ -24,6 +25,7 @@ import {
cdrDurationSeconds, cdrDurationSeconds,
cdrStartTime, cdrStartTime,
} from './cdrFeedParser.js'; } from './cdrFeedParser.js';
import { enqueueCdrFeedFetch } from './cdrFeedQueue.js';
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
// Main public function // Main public function
@ -611,6 +613,108 @@ function tokenFingerprint(token) {
return `${token.slice(-6)}`; return `${token.slice(-6)}`;
} }
async function executeCdrFeedHttpFetch({
locationName,
startTime,
endTime,
tokenHint,
analyticsBases,
cdrPath,
maxPerPage,
}) {
const allRawItems = [];
const fetchErrors = [];
let usedBase = null;
let firstRespData = null;
let lastHttpStatus = null;
const maxPages = Number(process.env.CDR_FEED_MAX_PAGES || 10);
for (const base of analyticsBases) {
const url = `${base}${cdrPath}`;
const params = { startTime, endTime, locations: locationName, max: maxPerPage };
const queryStr = new URLSearchParams(params).toString();
const fullUrl = `${url}?${queryStr}`;
try {
let pageUrl = url;
let pageParams = params;
let page = 0;
let truncated = false;
while (page < maxPages) {
page += 1;
const pageQuery = pageParams ? new URLSearchParams(pageParams).toString() : '';
const pageFull = pageParams ? `${pageUrl}?${pageQuery}` : pageUrl;
if (page === 1) {
logger('phone:service', `cdr_feed GET ${pageFull}`, 'info');
} else {
logger('phone:service', `cdr_feed pagination page ${page} GET ${pageFull}`, 'info');
}
const resp = await webex.analyticsRequestRaw('GET', pageUrl, {
params: pageParams || undefined,
timeout: 30000,
});
lastHttpStatus = resp.status;
const d = resp.data || {};
if (page === 1) firstRespData = d;
const list = extractCdrFeedList(d);
allRawItems.push(...list);
usedBase = base;
logger(
'phone:service',
`cdr_feed ${base} page ${page} → HTTP ${resp.status}, parsed ${list.length} record(s) ` +
`(running total ${allRawItems.length}; ${describeCdrFeedPayload(d)})`,
'info',
);
if (page === 1 && 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',
);
}
const next = resolveCdrFeedNextPage(d, resp.headers, url, params);
if (!next) break;
if (page >= maxPages) {
truncated = true;
break;
}
pageUrl = next.url;
pageParams = next.params;
}
if (truncated) {
logger(
'phone:service',
`cdr_feed pagination stopped at CDR_FEED_MAX_PAGES=${maxPages} ` +
`(${allRawItems.length} records collected; more may exist)`,
'warn',
);
}
break;
} catch (e) {
const st = e.response?.status;
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 });
}
}
return { allRawItems, fetchErrors, usedBase, firstRespData, lastHttpStatus };
}
function getCdrAnalyticsBases() { function getCdrAnalyticsBases() {
const env = process.env.WEBEX_CDR_ANALYTICS_BASES; const env = process.env.WEBEX_CDR_ANALYTICS_BASES;
if (env) { if (env) {
@ -642,6 +746,7 @@ export async function getHistoricalCallActivity(personId, hours = 24, options =
endTime: optEndTime, endTime: optEndTime,
returnRawItems = false, returnRawItems = false,
skipPersonFilter = false, skipPersonFilter = false,
onQueued,
} = options || {}; } = options || {};
if (!personId && !providedLocationName) { if (!personId && !providedLocationName) {
@ -651,7 +756,7 @@ export async function getHistoricalCallActivity(personId, hours = 24, options =
// Prefer analytics-calling (user-confirmed working base for cdr_feed). Fall back to the other if needed. // Prefer analytics-calling (user-confirmed working base for cdr_feed). Fall back to the other if needed.
const ANALYTICS_BASES = getCdrAnalyticsBases(); const ANALYTICS_BASES = getCdrAnalyticsBases();
const CDR_PATH = '/cdr_feed'; const CDR_PATH = '/cdr_feed';
const MAX_PER_PAGE = 1000; const MAX_PER_PAGE = Number(process.env.CDR_FEED_MAX_PER_PAGE || 5000);
try { try {
const person = personId ? await webex.request('GET', `people/${personId}`) : null; const person = personId ? await webex.request('GET', `people/${personId}`) : null;
@ -760,86 +865,23 @@ export async function getHistoricalCallActivity(personId, hours = 24, options =
'info', 'info',
); );
const allRawItems = []; const {
const fetchErrors = []; allRawItems,
fetchErrors,
let usedBase = null; usedBase,
let firstRespData = null; lastHttpStatus,
let lastHttpStatus = null; } = await enqueueCdrFeedFetch(
() => executeCdrFeedHttpFetch({
for (const base of ANALYTICS_BASES) { locationName,
const url = `${base}${CDR_PATH}`; startTime,
const params = { startTime, endTime, locations: locationName, max: MAX_PER_PAGE }; endTime,
const queryStr = new URLSearchParams(params).toString(); tokenHint,
const fullUrl = `${url}?${queryStr}`; analyticsBases: ANALYTICS_BASES,
logger('phone:service', `cdr_feed GET ${fullUrl}`, 'info'); cdrPath: CDR_PATH,
maxPerPage: MAX_PER_PAGE,
try { }),
const resp = await webex.analyticsRequestRaw('GET', url, { params, timeout: 15000 }); { onQueued, label: locationName },
lastHttpStatus = resp.status;
const d = resp.data || {};
firstRespData = d;
const list = extractCdrFeedList(d);
allRawItems.push(...list);
usedBase = base;
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;
let page = 1;
const MAX_PAGES = 10;
while (nextToken && page < MAX_PAGES) {
page++;
let pageUrl = url;
let pageParams = { ...params, next: nextToken };
if (typeof nextToken === 'string' && nextToken.startsWith('http')) {
pageUrl = nextToken;
pageParams = null;
}
const pageQuery = pageParams ? new URLSearchParams(pageParams).toString() : '';
const pageFull = pageParams ? `${pageUrl}?${pageQuery}` : pageUrl;
logger('phone:service', `cdr_feed pagination page ${page} URL: ${pageFull}`, 'debug');
try {
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 = extractCdrFeedList(pd);
allRawItems.push(...pList);
nextToken = pd.next || pd['next'] || (pd.metadata && pd.metadata.next) || null;
} catch (pe) {
const pst = pe.response?.status;
logger('phone:service', `cdr_feed pagination ERROR status=${pst || 'n/a'}: ${pe.message}`, 'warn');
break;
}
}
break; // first successful HTTP response (even if 0 items = empty window is valid)
} catch (e) {
const st = e.response?.status;
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) { if (allRawItems.length === 0 && fetchErrors.length > 0) {
const errSummary = fetchErrors const errSummary = fetchErrors

View file

@ -0,0 +1,164 @@
// services/renderers/callReportRenderer.js
import { CALL_BUCKETS } from '../callReport/groupCdrCalls.js';
import { formatCallBlock } from '../callReport/formatCallLine.js';
import { collapseAaBursts } from '../callReport/collapseDisplayCalls.js';
import { callReportEnvInt } from '../callReport/env.js';
function fmtTime(iso) {
if (!iso) return '—';
try {
return new Date(iso).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC');
} catch {
return String(iso);
}
}
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 renderCallSection(title, calls, { timeZone, includeWan, maxRows, detail, collapseAa = false }) {
if (!calls?.length) return '';
const displayCalls = collapseAa ? collapseAaBursts(calls, { timeZone }) : calls;
const show = displayCalls.slice(0, maxRows);
let md = `\n### ${title} (${calls.length})\n`;
for (const item of show) {
if (item.kind === 'burst') {
md += `${item.summary}\n\n`;
continue;
}
const { line1, line2 } = formatCallBlock(item, { timeZone, includeWan, detail });
md += `${line1}\n`;
if (line2) md += `${line2}\n`;
md += '\n';
}
if (displayCalls.length > maxRows) {
md += `_${displayCalls.length - maxRows} more in this section — use Control Hub for full export._\n`;
}
return md;
}
function renderReportTitle(target) {
if (!target) return 'Call report';
if (target.kind === 'store') return `Call report — Store ${target.storeNum}`;
return `Call report — ${target.label}`;
}
function renderScopeLine(target) {
if (!target || target.kind === 'store') return null;
if (target.kind === 'user') return `- **Scope:** user ${target.email || target.label}`;
if (target.kind === 'number') return `- **Scope:** number ${target.label}`;
return null;
}
/**
* @param {object} report from collectCallReport
* @param {{ detail?: boolean }} opts
*/
export function renderCallReportMarkdown(report, opts = {}) {
if (!report?.ok) {
const target = report?.target || report?.store;
let md = `**Call report** — ${target?.label || target?.storeNum || '?'}\n\n`;
md += `_${report?.reason || report?.window?.reason || 'Unable to build report.'}_\n`;
return md.trim();
}
const { store, target: reportTarget, window, cdr, prisma, joined } = report;
const target = reportTarget || store;
const summary = joined.summary || {};
const buckets = joined.buckets || {};
const maxRows = callReportEnvInt('MAX_DETAIL_ROWS', 25);
const detail = Boolean(opts.detail);
const timeZone = window.timeZone;
let md = `**${renderReportTitle(target)}**\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`;
const scopeLine = renderScopeLine(target);
if (scopeLine) md += `${scopeLine}\n`;
md += '\n**Call volume (CDR)**\n';
if (!cdr.available) {
md += `_CDR unavailable:_ ${cdr.reason || 'unknown'}\n`;
} else {
md += `- **Total calls:** ${summary.total ?? 0}\n`;
md += `- **Inbound:** ${summary.inbound ?? 0} | **Outbound:** ${summary.outbound ?? 0}\n`;
md += `- **CDR legs fetched:** ${cdr.rawCount ?? '—'} → **${summary.total ?? 0}** correlated call(s)\n`;
const reach = renderReachSummary(summary);
if (reach) md += `${reach}\n`;
if (target?.filter && summary.total === 0) {
md += '_No calls matched scope in this window._\n';
}
md += '\n**Call outcomes**\n';
md += `${renderOutcomeBreakdown(summary.outcomes)}\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 && m?.max != null) {
md += `- **Worst MOS in window:** ${m.min} | **Best MOS:** ${m.max}\n`;
} else if (m?.min != null) {
md += `- **Worst MOS in window:** ${m.min}\n`;
}
if (prisma.appAudio?.detailsUrl) {
md += `- [View in Prisma SCM](${prisma.appAudio.detailsUrl})\n`;
}
}
if (cdr.available) {
const sectionOpts = { timeZone, includeWan: true, maxRows, detail };
md += renderCallSection(
'Inbound — reached a phone',
buckets[CALL_BUCKETS.inboundReachedPhone],
sectionOpts,
);
md += renderCallSection(
'Inbound — stopped at auto-attendant',
buckets[CALL_BUCKETS.inboundAaOnly],
{ ...sectionOpts, collapseAa: true },
);
md += renderCallSection(
'Outbound — connected',
buckets[CALL_BUCKETS.outboundConnected],
sectionOpts,
);
md += renderCallSection(
'Other / unanswered',
buckets[CALL_BUCKETS.other],
sectionOpts,
);
}
md += `\n*Generated in ${report.elapsedMs}ms*`;
return md.trim();
}

View file

@ -1,161 +0,0 @@
// 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

@ -1,199 +0,0 @@
// 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

@ -1,4 +1,4 @@
// tests/voiceReport.businessWindow.test.js // tests/callReport.businessWindow.test.js
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@ -7,7 +7,7 @@ import { DateTime } from 'luxon';
import { import {
computeBusinessWindow, computeBusinessWindow,
parseReportDateArg, parseReportDateArg,
} from '../services/voiceReport/businessWindow.js'; } from '../services/callReport/businessWindow.js';
test('parseReportDateArg defaults to yesterday', () => { test('parseReportDateArg defaults to yesterday', () => {
const now = DateTime.fromISO('2026-07-24T15:00:00', { zone: 'America/New_York' }).toJSDate(); const now = DateTime.fromISO('2026-07-24T15:00:00', { zone: 'America/New_York' }).toJSDate();

View file

@ -0,0 +1,61 @@
// tests/callReport.collapseDisplayCalls.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { collapseAaBursts } from '../services/callReport/collapseDisplayCalls.js';
import { formatCallBlock } from '../services/callReport/formatCallLine.js';
test('collapseAaBursts groups 3+ same-caller AA calls', () => {
const mk = (i) => ({
start: `2026-07-23T14:${String(10 + i).padStart(2, '0')}:00.000Z`,
callingLineId: 'CLASE AVA',
callingNumber: '+17188408304',
storeMainNumber: '+12122194600',
calledNumber: '+12122194600',
aaKeyPress: '1',
reachedAttendant: true,
reachedPhone: false,
direction: 'inbound',
duration: 12,
disposition: 'Success',
});
const collapsed = collapseAaBursts([mk(0), mk(1), mk(2), mk(3)], { timeZone: 'America/New_York' });
assert.equal(collapsed.length, 1);
assert.equal(collapsed[0].kind, 'burst');
assert.match(collapsed[0].summary, /4 calls stopped at auto-attendant/);
});
test('formatCallBlock outbound short dial shows dialed', () => {
const call = {
direction: 'outbound',
start: '2026-07-23T16:27:00.000Z',
duration: 22,
endpointUser: 'Store 02477',
leftParty: { user: 'Store 02477', number: '52477' },
calledNumber: '7',
disposition: 'Refusal / UnassignedNumber',
abnormal: true,
normalOutcome: false,
};
const { line1 } = formatCallBlock(call, { timeZone: 'America/New_York', includeWan: false });
assert.match(line1, /→ dialed 7/);
});
test('formatCallBlock outbound connected shows device model', () => {
const call = {
direction: 'outbound',
start: '2026-07-24T14:17:00.000Z',
duration: 312,
endpointUser: 'Store 00482',
leftParty: { user: 'Store 00482', number: '50482', model: 'DBS-210-3PC' },
model: 'DBS-210-3PC',
calledNumber: '+17247795678',
disposition: 'Success',
normalOutcome: true,
abnormal: false,
};
const { line1 } = formatCallBlock(call, { timeZone: 'America/New_York', includeWan: false });
assert.match(line1, /Store 00482 \(50482, DBS-210-3PC\) → \+17247795678/);
assert.match(line1, /^✅ /);
});

View file

@ -0,0 +1,135 @@
// tests/callReport.filterCallsByTarget.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildScopedCallFilter,
callMatchesFilter,
filterCallsByTarget,
rebucketCalls,
} from '../services/callReport/filterCallsByTarget.js';
import { CALL_BUCKETS } from '../services/callReport/groupCdrCalls.js';
const inboundToExtension = {
direction: 'inbound',
finalNumber: '7841',
endpointUser: 'Store 00782',
callingNumber: '+14125551234',
calledNumber: '+14123694426',
storeMainNumber: '+14123694426',
reachedPhone: true,
normalOutcome: true,
abnormal: false,
disposition: 'Success',
legs: [{
'Called number': '7841',
'User type': 'User',
User: 'Store 00782',
}],
};
const inboundToOtherExtension = {
direction: 'inbound',
finalNumber: '52477',
endpointUser: 'Store 00782',
callingNumber: '+14125559999',
calledNumber: '+14123694426',
storeMainNumber: '+14123694426',
reachedPhone: true,
normalOutcome: true,
abnormal: false,
disposition: 'Success',
legs: [],
};
const outboundFromUser = {
direction: 'outbound',
endpointUser: 'Store 00782',
callingNumber: '50782',
calledNumber: '52027',
connected: true,
reachedPhone: true,
normalOutcome: true,
abnormal: false,
disposition: 'Success',
legs: [{
'User number': '50782',
User: 'Store 00782',
'Called number': '52027',
}],
};
const johnInbound = {
direction: 'inbound',
callingNumber: '+17247795251',
calledNumber: '+17247799200',
storeMainNumber: '+17247799200',
finalNumber: '+19362978712',
endpointUser: 'John Skaggs',
legs: [{ 'Calling number': '+17247795251', 'Called number': '+17247799200', User: 'John Skaggs' }],
};
const userInbound = {
direction: 'inbound',
callingNumber: '+14125551234',
calledNumber: '+17247799200',
storeMainNumber: '+17247799200',
finalNumber: '+17247795574',
endpointUser: 'McQueen',
legs: [{ 'Called number': '5574', User: 'McQueen', 'User number': '+17247795574' }],
};
const sharedOutbound = {
direction: 'outbound',
endpointUser: 'WAR_1_LGW',
callingNumber: '+17247799200',
calledNumber: '12399',
storeMainNumber: '+17247799200',
leftParty: { user: 'WAR_1_LGW', number: '+17247799200' },
legs: [{ User: 'WAR_1_LGW', 'Calling number': '+17247799200', 'Called number': '12399' }],
};
test('callMatchesFilter matches inbound final extension', () => {
const filter = buildScopedCallFilter({ kind: 'number', extension: '7841' });
assert.equal(callMatchesFilter(inboundToExtension, filter), true);
assert.equal(callMatchesFilter(inboundToOtherExtension, filter), false);
});
test('callMatchesFilter matches outbound user extension', () => {
const filter = buildScopedCallFilter({ kind: 'number', extension: '50782' });
assert.equal(callMatchesFilter(outboundFromUser, filter), true);
assert.equal(callMatchesFilter(inboundToExtension, filter), false);
});
test('callMatchesFilter excludes other users at same corporate location', () => {
const filter = buildScopedCallFilter({
kind: 'number',
phoneNumber: '+17247795574',
locationMain: '+17247799200',
});
assert.equal(callMatchesFilter(userInbound, filter), true);
assert.equal(callMatchesFilter(johnInbound, filter), false);
assert.equal(callMatchesFilter(sharedOutbound, filter), false);
});
test('filterCallsByTarget and rebucketCalls reduce summary counts', () => {
const calls = [inboundToExtension, inboundToOtherExtension, outboundFromUser];
const filter = buildScopedCallFilter({ kind: 'number', extension: '7841' });
filter.extensions.add('50782');
const filtered = filterCallsByTarget(calls, filter);
assert.equal(filtered.length, 2);
const rebucketed = rebucketCalls(filtered);
assert.equal(rebucketed.summary.total, 2);
assert.equal(rebucketed.summary.inbound, 1);
assert.equal(rebucketed.summary.outbound, 1);
assert.equal(rebucketed.buckets[CALL_BUCKETS.inboundReachedPhone].length, 1);
assert.equal(rebucketed.buckets[CALL_BUCKETS.outboundConnected].length, 1);
});
test('filterCallsByTarget is no-op without filter', () => {
const calls = [inboundToExtension, outboundFromUser];
assert.deepEqual(filterCallsByTarget(calls, null), calls);
});

View file

@ -0,0 +1,122 @@
// tests/callReport.formatCallLine.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { groupCdrIntoCalls } from '../services/callReport/groupCdrCalls.js';
import { formatCallBlock, formatDurationHuman, formatLocalTime, formatWanLine } from '../services/callReport/formatCallLine.js';
const SAMPLE_LEGS = [
{
'Answer time': '2026-07-23T13:06:24.325Z',
Answered: 'true',
Direction: 'TERMINATING',
'Calling line ID': 'WIRELESS CALLER',
'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',
'Site main number': '+12122194600',
'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',
'Release time': '2026-07-23T13:09:28.784Z',
},
{
'Start time': '2026-07-23T13:06:26.570Z',
'Call type': 'SIP_ENTERPRISE',
'Correlation ID': '3c7f5c7c-6f48-40f2-925c-133f6a84dcdb',
'Report ID': '9541d7ea-8ced-3d39-a8ef-c2fc5e341700',
'Called number': '52477',
'Calling number': '+17189864017',
Duration: 134,
},
{
'Start time': '2026-07-23T13:06:26.739Z',
'Call type': 'SIP_ENTERPRISE',
'Client type': 'WXC_DEVICE',
'Correlation ID': '3c7f5c7c-6f48-40f2-925c-133f6a84dcdb',
'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',
Answered: 'true',
Duration: 134,
'Release time': '2026-07-23T13:09:28.784Z',
},
];
test('formatDurationHuman', () => {
assert.equal(formatDurationHuman(45), '45s');
assert.equal(formatDurationHuman(184), '3m 4s');
});
test('formatLocalTime uses store timezone', () => {
const t = formatLocalTime('2026-07-23T13:06:24.316Z', 'America/New_York');
assert.match(t, /9:06am/);
});
test('formatCallBlock inbound with CLID arrow and final', () => {
const [call] = groupCdrIntoCalls(SAMPLE_LEGS);
call.wan = { mos: 4.1, jitter: 3.4, loss: 2.4 };
const { line1, line2 } = formatCallBlock(call, { timeZone: 'America/New_York', includeWan: true });
assert.match(line1, /9:06am \(3m 4s\):/);
assert.match(line1, /WIRELESS CALLER \(\+17189864017\)/);
assert.match(line1, /→ \+12122194600 \(final 52477, DBS-210-3PC\)/);
assert.match(line1, /^✅ /);
assert.doesNotMatch(line1, /Success$/);
assert.match(line2, /MOS: 4\.1/);
assert.match(line2, /Jitter: 3\.4ms/);
assert.match(line2, /Loss: 2\.4%/);
});
test('formatCallBlock abnormal shows warning icon and disposition', () => {
const [call] = groupCdrIntoCalls(SAMPLE_LEGS);
call.abnormal = true;
call.normalOutcome = false;
call.disposition = 'Failed / Busy';
call.wan = { mos: 4.1, jitter: 3.4, loss: 2.4 };
const { line1, line2 } = formatCallBlock(call, { timeZone: 'America/New_York', includeWan: false });
assert.match(line1, /^⚠️ /);
assert.match(line1, /Failed \/ Busy/);
assert.equal(line2, null);
});
test('formatCallBlock AA-only uses warning icon without Success', () => {
const call = {
direction: 'inbound',
start: '2026-07-23T13:59:00.000Z',
duration: 12,
callingLineId: 'PITTSBURGH PA',
callingNumber: '+14122020183',
storeMainNumber: '+14123694426',
calledNumber: '+14123694426',
reachedAttendant: true,
reachedPhone: false,
aaKeyPress: 'No Selection',
disposition: 'Success',
normalOutcome: true,
abnormal: false,
};
const { line1 } = formatCallBlock(call, { timeZone: 'America/New_York', includeWan: false });
assert.match(line1, /^⚠️ /);
assert.match(line1, /AA key No Selection/);
assert.doesNotMatch(line1, /Success/);
});
test('formatWanLine flags MOS and loss thresholds', () => {
assert.match(formatWanLine({ mos: 3.6, jitter: 0, loss: 9.8 }), /MOS: 3\.6 ⚠️/);
assert.match(formatWanLine({ mos: 3.4, jitter: 0, loss: 0 }), /MOS: 3\.4 ‼️/);
assert.match(formatWanLine({ mos: 4.4, jitter: 45, loss: 2 }), /Jitter: 45ms ⚠️/);
assert.match(formatWanLine({ mos: 4.4, jitter: 0, loss: 6 }), /Loss: 6% ⚠️/);
});

View file

@ -1,18 +1,21 @@
// tests/voiceReport.groupCdrCalls.test.js // tests/callReport.groupCdrCalls.test.js
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
assignCallBucket,
groupCdrIntoCalls, groupCdrIntoCalls,
summarizeCalls, summarizeCalls,
classifyCallDirection, classifyCallDirection,
} from '../services/voiceReport/groupCdrCalls.js'; CALL_BUCKETS,
} from '../services/callReport/groupCdrCalls.js';
const SAMPLE_LEGS = [ const SAMPLE_LEGS = [
{ {
'Answer time': '2026-07-23T13:06:24.325Z', 'Answer time': '2026-07-23T13:06:24.325Z',
Answered: 'true', Answered: 'true',
Direction: 'TERMINATING', Direction: 'TERMINATING',
'Calling line ID': 'WIRELESS CALLER',
'Start time': '2026-07-23T13:06:24.316Z', 'Start time': '2026-07-23T13:06:24.316Z',
'Call type': 'SIP_INBOUND', 'Call type': 'SIP_INBOUND',
'Correlation ID': '3c7f5c7c-6f48-40f2-925c-133f6a84dcdb', 'Correlation ID': '3c7f5c7c-6f48-40f2-925c-133f6a84dcdb',
@ -21,6 +24,7 @@ const SAMPLE_LEGS = [
'User type': 'AutomatedAttendantVideo', 'User type': 'AutomatedAttendantVideo',
User: 'Store 2477', User: 'Store 2477',
'Called number': '+12122194600', 'Called number': '+12122194600',
'Site main number': '+12122194600',
'Calling number': '+17189864017', 'Calling number': '+17189864017',
'Call outcome': 'Success', 'Call outcome': 'Success',
'Call outcome reason': 'Normal', 'Call outcome reason': 'Normal',
@ -78,6 +82,11 @@ test('groupCdrIntoCalls collapses legs with same correlation ID', () => {
assert.equal(calls[0].reachedPhone, true); assert.equal(calls[0].reachedPhone, true);
assert.equal(calls[0].endpointUser, 'Store 02477'); assert.equal(calls[0].endpointUser, 'Store 02477');
assert.equal(calls[0].aaKeyPress, '1'); assert.equal(calls[0].aaKeyPress, '1');
assert.equal(calls[0].callingLineId, 'WIRELESS CALLER');
assert.equal(calls[0].finalNumber, '52477');
assert.equal(calls[0].model, 'DBS-210-3PC');
assert.equal(calls[0].storeMainNumber, '+12122194600');
assert.equal(assignCallBucket(calls[0]), CALL_BUCKETS.inboundReachedPhone);
assert.equal(calls[0].normalOutcome, true); assert.equal(calls[0].normalOutcome, true);
}); });

View file

@ -0,0 +1,184 @@
// tests/callReport.join.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildScopedCallFilter } from '../services/callReport/filterCallsByTarget.js';
import { joinCallQuality, worstPrismaBucketForCall } from '../services/callReport/joinCallQuality.js';
import { CALL_BUCKETS } from '../services/callReport/groupCdrCalls.js';
const window = {
startTime: '2026-07-23T13:00:00.000Z',
endTime: '2026-07-24T01:00:00.000Z',
};
const INBOUND_PHONE_LEGS = [
{
'Start time': '2026-07-23T13:00:00.000Z',
'Release time': '2026-07-23T13:05:00.000Z',
'Call type': 'SIP_INBOUND',
'Correlation ID': 'corr-1',
'Report ID': 'r1',
'Calling number': '+15551234567',
'Called number': '+12122194600',
'Site main number': '+12122194600',
'Call outcome': 'Success',
'Call outcome reason': 'Normal',
'Answer indicator': 'Yes',
Answered: 'true',
Duration: 300,
},
{
'Start time': '2026-07-23T13:00:30.000Z',
'Call type': 'SIP_ENTERPRISE',
'Correlation ID': 'corr-1',
'Report ID': 'r2',
'User type': 'User',
User: 'Store 02477',
Model: 'DBS-210-3PC',
'Called number': '52477',
'Calling number': '+15551234567',
'Call outcome': 'Success',
'Call outcome reason': 'Normal',
'Answer indicator': 'Yes',
Answered: 'true',
Duration: 270,
},
];
test('joinCallQuality groups legs into correlated calls', () => {
const joined = joinCallQuality({
cdrItems: [
...INBOUND_PHONE_LEGS,
{
Direction: 'Outbound',
'Start time': '2026-07-23T15:00:00.000Z',
Duration: 10,
'Answer indicator': 'No',
'Report ID': 'r3',
'Call type': 'SIP_OUTBOUND',
'Call outcome': 'Failed',
'Call outcome reason': 'Busy',
},
],
appAudio: null,
window,
});
assert.equal(joined.summary.total, 2);
assert.equal(joined.summary.inbound, 1);
assert.equal(joined.summary.outbound, 1);
assert.equal(joined.summary.abnormal, 1);
assert.equal(joined.abnormalCalls.length, 1);
assert.equal(joined.buckets[CALL_BUCKETS.inboundReachedPhone].length, 1);
});
test('joinCallQuality attaches WAN only for answered inbound', () => {
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 joined = joinCallQuality({
cdrItems: INBOUND_PHONE_LEGS,
appAudio,
window,
});
assert.ok(joined.calls[0].wan);
assert.equal(joined.calls[0].wan.mos, 4.4);
});
test('joinCallQuality skips WAN for unanswered outbound', () => {
const appAudio = {
mos: { values: [4.0, 4.0, 4.0] },
loss: { values: [0, 0, 0] },
jitter: { values: [5, 5, 5] },
};
const joined = joinCallQuality({
cdrItems: [{
'Start time': '2026-07-23T15:00:00.000Z',
'Report ID': 'r4',
'Call type': 'SIP_OUTBOUND',
'Call outcome': 'Failed',
'Call outcome reason': 'Busy',
'Answer indicator': 'No',
}],
appAudio,
window,
});
assert.equal(joined.calls[0].wan, null);
});
test('worstPrismaBucketForCall picks low MOS bucket', () => {
const call = {
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(call, appAudio, window);
assert.ok(w);
assert.equal(w.mos, 3.1);
});
test('worstPrismaBucketForCall matches Prisma datapoint timestamps', () => {
const call = {
start: '2026-07-23T15:18:00.000Z',
duration: 455,
};
const appAudio = {
mos: {
interval: '5min',
values: [4.4, 4.3, 3.1],
timedPoints: [
{ time: '2026-07-23T13:00:00.000Z', value: 4.4 },
{ time: '2026-07-23T13:05:00.000Z', value: null },
{ time: '2026-07-23T15:15:00.000Z', value: 4.3 },
{ time: '2026-07-23T15:20:00.000Z', value: 3.1 },
],
},
loss: {
timedPoints: [
{ time: '2026-07-23T15:15:00.000Z', value: 0 },
{ time: '2026-07-23T15:20:00.000Z', value: 2 },
],
},
jitter: {
timedPoints: [
{ time: '2026-07-23T15:15:00.000Z', value: 1.2 },
{ time: '2026-07-23T15:20:00.000Z', value: 3.4 },
],
},
};
const w = worstPrismaBucketForCall(call, appAudio, window);
assert.ok(w);
assert.equal(w.mos, 3.1);
assert.equal(w.loss, 2);
assert.equal(w.jitter, 3.4);
});
test('joinCallQuality filters scoped calls when filter provided', () => {
const joined = joinCallQuality({
cdrItems: [
...INBOUND_PHONE_LEGS,
{
Direction: 'Outbound',
'Start time': '2026-07-23T15:00:00.000Z',
Duration: 10,
'Answer indicator': 'No',
'Report ID': 'r3',
'Call type': 'SIP_OUTBOUND',
'Call outcome': 'Failed',
'Call outcome reason': 'Busy',
},
],
appAudio: null,
window,
filter: buildScopedCallFilter({ kind: 'number', extension: '52477' }),
});
assert.equal(joined.summary.total, 1);
assert.equal(joined.summary.inbound, 1);
assert.equal(joined.summary.outbound, 0);
});

View file

@ -0,0 +1,154 @@
// tests/callReport.renderer.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { renderCallReportMarkdown } from '../services/renderers/callReportRenderer.js';
import { CALL_BUCKETS } from '../services/callReport/groupCdrCalls.js';
const sampleCall = {
start: '2026-07-23T13:06:24.316Z',
duration: 184,
direction: 'inbound',
callingLineId: 'WIRELESS CALLER',
callingNumber: '+17189864017',
calledNumber: '+12122194600',
storeMainNumber: '+12122194600',
finalNumber: '52477',
model: 'DBS-210-3PC',
disposition: 'Success',
outcome: 'Success',
reachedPhone: true,
normalOutcome: true,
abnormal: false,
legCount: 3,
wan: { mos: 4.1, jitter: 3.4, loss: 2.4 },
};
test('renderCallReportMarkdown summary and sections', () => {
const md = renderCallReportMarkdown({
ok: true,
store: { storeNum: '782', locationName: 'Store 0782', dialNumber: '+17247795574', kind: 'store' },
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 },
prisma: {
available: true,
appAudio: { appName: 'Webex_Calling_RTP', mos: { min: 2.7, max: 4.41 }, detailsUrl: 'https://example.com' },
},
joined: {
summary: {
total: 2,
inbound: 2,
outbound: 0,
inboundReachedPhone: 1,
inboundAaOnly: 0,
outboundConnected: 0,
abnormal: 1,
outcomes: { Success: 1, 'Failed / Busy': 1 },
},
buckets: {
[CALL_BUCKETS.inboundReachedPhone]: [sampleCall],
[CALL_BUCKETS.inboundAaOnly]: [],
[CALL_BUCKETS.outboundConnected]: [],
[CALL_BUCKETS.other]: [],
},
abnormalCalls: [{
...sampleCall,
abnormal: true,
normalOutcome: false,
disposition: 'Failed / Busy',
wan: { mos: 4.1, jitter: 3.4, loss: 2.4 },
}],
},
elapsedMs: 42,
});
assert.match(md, /Call report — Store 782/);
assert.match(md, /Total calls.*2/);
assert.match(md, /Inbound — reached a phone/);
assert.match(md, /\*\*Abnormal outcomes:\*\* 1/);
assert.doesNotMatch(md, /### Abnormal outcomes/);
assert.doesNotMatch(md, /Compact index/);
assert.doesNotMatch(md, /\*\*Scope:\*\*/);
assert.match(md, /✅ 9:06am/);
assert.match(md, /WIRELESS CALLER/);
assert.match(md, /\*\*Worst MOS in window:\*\* 2\.7/);
assert.match(md, /\*\*Best MOS:\*\* 4\.41/);
});
test('renderCallReportMarkdown shows scoped user header', () => {
const md = renderCallReportMarkdown({
ok: true,
target: {
kind: 'user',
label: 'mcqueenj@ae.com',
email: 'mcqueenj@ae.com',
storeNum: '782',
locationName: 'Store 0782',
dialNumber: '+14123694426',
filter: { kind: 'user', email: 'mcqueenj@ae.com' },
},
store: {
kind: 'user',
label: 'mcqueenj@ae.com',
email: 'mcqueenj@ae.com',
storeNum: '782',
locationName: 'Store 0782',
dialNumber: '+14123694426',
},
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: 5 },
prisma: { available: false },
joined: {
summary: { total: 0, inbound: 0, outbound: 0, outcomes: {} },
buckets: {
[CALL_BUCKETS.inboundReachedPhone]: [],
[CALL_BUCKETS.inboundAaOnly]: [],
[CALL_BUCKETS.outboundConnected]: [],
[CALL_BUCKETS.other]: [],
},
abnormalCalls: [],
},
elapsedMs: 12,
});
assert.match(md, /Call report — mcqueenj@ae\.com/);
assert.match(md, /\*\*Scope:\*\* user mcqueenj@ae\.com/);
assert.match(md, /No calls matched scope/);
});
test('renderCallReportMarkdown truncates per section', () => {
const calls = Array.from({ length: 30 }, (_, i) => ({
...sampleCall,
start: `2026-07-23T14:${String(i).padStart(2, '0')}:00.000Z`,
}));
const md = renderCallReportMarkdown({
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 },
prisma: { available: false },
joined: {
summary: { total: 30, inbound: 30, outbound: 0, outcomes: {} },
buckets: {
[CALL_BUCKETS.inboundReachedPhone]: calls,
[CALL_BUCKETS.inboundAaOnly]: [],
[CALL_BUCKETS.outboundConnected]: [],
[CALL_BUCKETS.other]: [],
},
abnormalCalls: [],
},
elapsedMs: 1,
});
assert.match(md, /more in this section/);
});

View file

@ -0,0 +1,74 @@
// tests/callReport.resolveTarget.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import {
parseCallReportTarget,
parseStoreNumFromLocationName,
} from '../services/callReport/parseTarget.js';
import {
buildMatchKeysForNumber,
buildMatchKeysForPerson,
} from '../services/callTest/locationResolver.js';
test('parseCallReportTarget recognizes store numbers', () => {
assert.deepEqual(parseCallReportTarget('782'), { kind: 'store', storeNum: '782' });
assert.deepEqual(parseCallReportTarget('24'), { kind: 'store', storeNum: '24' });
});
test('parseCallReportTarget recognizes email addresses', () => {
assert.deepEqual(parseCallReportTarget('mcqueenj@ae.com'), {
kind: 'user',
email: 'mcqueenj@ae.com',
});
});
test('parseCallReportTarget recognizes phone numbers', () => {
assert.deepEqual(parseCallReportTarget('7247795574'), {
kind: 'number',
phoneNumber: '+17247795574',
});
assert.deepEqual(parseCallReportTarget('+17247795574'), {
kind: 'number',
phoneNumber: '+17247795574',
});
});
test('parseCallReportTarget rejects invalid tokens', () => {
assert.equal(parseCallReportTarget(''), null);
assert.equal(parseCallReportTarget('50782'), null);
assert.equal(parseCallReportTarget('not-a-target'), null);
});
test('parseStoreNumFromLocationName extracts store digits', () => {
assert.equal(parseStoreNumFromLocationName('Store 0782'), '782');
assert.equal(parseStoreNumFromLocationName('store 2477'), '2477');
assert.equal(parseStoreNumFromLocationName('HQ'), null);
});
test('buildMatchKeysForNumber includes phone and extension', () => {
const keys = buildMatchKeysForNumber({
phoneNumber: '+17247795574',
extension: '7841',
owner: { name: 'Jane Doe' },
});
assert.ok(keys.has('17247795574'));
assert.ok(keys.has('7841'));
assert.ok(keys.has('jane doe'));
});
test('buildMatchKeysForPerson includes email and owned numbers', () => {
const keys = buildMatchKeysForPerson(
{
displayName: 'Jane Doe',
emails: ['jane@ae.com'],
phoneNumbers: [{ value: '+15551234567' }],
},
[{ phoneNumber: '+15559876543', extension: '50782' }],
);
assert.ok(keys.has('jane@ae.com'));
assert.ok(keys.has('jane doe'));
assert.ok(keys.has('50782'));
assert.ok(keys.has('15551234567'));
});

View file

@ -0,0 +1,46 @@
// tests/cdrFeedQueue.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import {
enqueueCdrFeedFetch,
_resetCdrFeedQueueForTests,
_setCdrFeedCooldownForTests,
} from '../services/cdrFeedQueue.js';
test.afterEach(() => {
_resetCdrFeedQueueForTests();
});
test('enqueueCdrFeedFetch runs immediately when idle', async () => {
let ran = false;
await enqueueCdrFeedFetch(async () => { ran = true; return 1; });
assert.equal(ran, true);
});
test('enqueueCdrFeedFetch queues second call and invokes onQueued', async () => {
_setCdrFeedCooldownForTests(150);
let releaseFirst;
const firstGate = new Promise((resolve) => { releaseFirst = resolve; });
const first = enqueueCdrFeedFetch(async () => {
await firstGate;
return 'first';
});
let queuedInfo = null;
const second = enqueueCdrFeedFetch(async () => 'second', {
onQueued: (info) => { queuedInfo = info; },
});
await new Promise((r) => setTimeout(r, 20));
assert.ok(queuedInfo, 'onQueued should fire for second caller');
assert.ok(queuedInfo.waitMs >= 100, `expected meaningful wait, got ${queuedInfo.waitMs}`);
assert.equal(queuedInfo.position, 2);
releaseFirst();
assert.equal(await first, 'first');
assert.equal(await second, 'second');
});

View file

@ -0,0 +1,117 @@
// tests/jiraPoller.enrichmentRules.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import {
resolveEnrichmentPlan,
extractStoreFromSummary,
CHECK_IDS,
COMM_SERVICES_COMPONENT,
} from '../services/jiraPoller/enrichmentRules.js';
const comm = [{ name: COMM_SERVICES_COMPONENT }];
const mobility = [{ name: 'Mobility' }];
test('extractStoreFromSummary parses Store NNNN', () => {
assert.equal(extractStoreFromSummary('Store 2477 - garbled calls'), '2477');
assert.equal(extractStoreFromSummary('no store here'), null);
});
test('comm call quality runs phonestatus and callreport', () => {
const plan = resolveEnrichmentPlan(
{
summary: 'Store 2477 - garbled voice on inbound calls',
description: '',
components: comm,
},
{ kind: 'phone', storeNum: '2477', reason: 'desk phone issue' },
);
assert.equal(plan.skip, false);
assert.deepEqual(plan.checks, [CHECK_IDS.phonestatus, CHECK_IDS.callreport]);
assert.ok(plan.matchedRules.includes('comm-call-quality'));
});
test('comm spam runs callreport only', () => {
const plan = resolveEnrichmentPlan(
{
summary: 'Store 782 - spam robocalls to main number',
description: '',
components: comm,
},
{ kind: 'phone', storeNum: '782', reason: 'spam calls' },
);
assert.deepEqual(plan.checks, [CHECK_IDS.callreport]);
assert.ok(plan.matchedRules.includes('comm-spam'));
});
test('comm spam with both symptoms runs phonestatus and callreport', () => {
const plan = resolveEnrichmentPlan(
{
summary: 'Store 100 - spam and garbled audio',
components: comm,
},
{ kind: 'phone', storeNum: '100', reason: 'x' },
);
assert.deepEqual(plan.checks, [CHECK_IDS.phonestatus, CHECK_IDS.callreport]);
});
test('mobility spam does not trigger comm spam rule', () => {
const plan = resolveEnrichmentPlan(
{
summary: 'Store 500 - spam calls',
components: mobility,
},
{ kind: 'phone', storeNum: '500', reason: 'phone' },
);
assert.deepEqual(plan.checks, [CHECK_IDS.phonestatus]);
assert.deepEqual(plan.matchedRules, ['default-phone']);
});
test('default phone kind runs phonestatus only', () => {
const plan = resolveEnrichmentPlan(
{
summary: 'Store 300 - phone not registering',
components: comm,
},
{ kind: 'phone', storeNum: '300', reason: 'registration' },
);
assert.deepEqual(plan.checks, [CHECK_IDS.phonestatus]);
assert.deepEqual(plan.matchedRules, ['default-phone']);
});
test('default av kind runs avstatus', () => {
const plan = resolveEnrichmentPlan(
{
summary: 'Store 400 - signage offline',
components: [{ name: 'Audio Visual' }],
},
{ kind: 'av', storeNum: '400', reason: 'signage' },
);
assert.deepEqual(plan.checks, [CHECK_IDS.avstatus]);
});
test('comm symptom with store from summary when AI has no store', () => {
const plan = resolveEnrichmentPlan(
{
summary: 'Store 2477 - static on all calls',
components: comm,
},
{ kind: 'skip', storeNum: null, reason: 'unclear' },
);
assert.equal(plan.storeNum, '2477');
assert.equal(plan.skip, false);
assert.ok(plan.checks.includes(CHECK_IDS.callreport));
});
test('comm symptom without store skips', () => {
const plan = resolveEnrichmentPlan(
{
summary: 'Garbled calls - no store listed',
components: comm,
},
{ kind: 'skip', storeNum: null, reason: 'no store' },
);
assert.equal(plan.skip, true);
assert.equal(plan.checks.length, 0);
});

View file

@ -0,0 +1,22 @@
// tests/jiraPoller.runEnrichment.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { formatEnrichmentBody } from '../services/jiraPoller/formatBody.js';
import { CHECK_IDS } from '../services/jiraPoller/enrichmentRules.js';
test('formatEnrichmentBody merges sections with headings', () => {
const body = formatEnrichmentBody([
{ title: 'Phone status', markdown: '**Phones OK**' },
{ title: 'Call report (today)', markdown: '**Calls: 5**' },
]);
assert.match(body, /## Phone status\n\n\*\*Phones OK\*\*/);
assert.match(body, /## Call report \(today\)\n\n\*\*Calls: 5\*\*/);
});
test('CHECK_IDS covers expected checks', () => {
assert.equal(CHECK_IDS.phonestatus, 'phonestatus');
assert.equal(CHECK_IDS.callreport, 'callreport');
assert.equal(CHECK_IDS.avstatus, 'avstatus');
});

View file

@ -1,12 +1,12 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { extractCdrFeedList } from '../services/cdrFeedParser.js'; import { extractCdrFeedList, parseCdrFeedLinkNext, resolveCdrFeedNextPage } from '../services/cdrFeedParser.js';
import { import {
cdrDedupKey, cdrDedupKey,
cdrStartTime, cdrStartTime,
cdrCallingNumber, cdrCallingNumber,
} from '../services/cdrFeedParser.js'; } from '../services/cdrFeedParser.js';
import { filterCdrToWindow } from '../services/voiceReport/joinCallQuality.js'; import { filterCdrToWindow } from '../services/callReport/joinCallQuality.js';
test('extractCdrFeedList handles items array', () => { test('extractCdrFeedList handles items array', () => {
const rows = [{ id: 1 }, { id: 2 }]; const rows = [{ id: 1 }, { id: 2 }];
@ -59,3 +59,33 @@ test('filterCdrToWindow keeps API rows when start time is report-labeled', () =>
]; ];
assert.equal(filterCdrToWindow(rows, window).length, 2); assert.equal(filterCdrToWindow(rows, window).length, 2);
}); });
test('parseCdrFeedLinkNext reads rel=next from Link header', () => {
const link = '<https://analytics-calling.webexapis.com/v1/cdr_feed?startTimeForNextFetch=2025-08-15T09:30:00.000Z&max=5000>; rel="next"';
assert.equal(
parseCdrFeedLinkNext(link),
'https://analytics-calling.webexapis.com/v1/cdr_feed?startTimeForNextFetch=2025-08-15T09:30:00.000Z&max=5000',
);
});
test('resolveCdrFeedNextPage prefers Link header over body token', () => {
const baseUrl = 'https://analytics-calling.webexapis.com/v1/cdr_feed';
const baseParams = { startTime: 'a', endTime: 'b', locations: 'Store 0782', max: 5000 };
const next = resolveCdrFeedNextPage(
{ next: 'cursor-token' },
{ link: '<https://example.com/page2>; rel="next"' },
baseUrl,
baseParams,
);
assert.deepEqual(next, { url: 'https://example.com/page2', params: null });
});
test('resolveCdrFeedNextPage falls back to body next cursor', () => {
const baseUrl = 'https://analytics-calling.webexapis.com/v1/cdr_feed';
const baseParams = { startTime: 'a', endTime: 'b', locations: 'Store 0782', max: 5000 };
const next = resolveCdrFeedNextPage({ next: 'cursor-token' }, {}, baseUrl, baseParams);
assert.deepEqual(next, {
url: baseUrl,
params: { ...baseParams, next: 'cursor-token' },
});
});

View file

@ -1,87 +0,0 @@
// 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

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