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:
parent
1eaabbcee1
commit
a25fc08fe2
45 changed files with 3179 additions and 863 deletions
22
.env.example
22
.env.example
|
|
@ -56,8 +56,8 @@ WEBEX_BOT_TOKEN=your-bot-token-here
|
|||
# - spark-admin:licenses_read (webexhost, findEmptyLocations)
|
||||
# - identity:tokens_read (offboarduser: list a user's authorizations)
|
||||
# - identity:tokens_write (offboarduser: revoke a user's authorizations)
|
||||
# - spark-admin:calling_cdr_read (/voicereport + /calltest CDR via cdr_feed)
|
||||
# - analytics:read_all (/voicereport Webex Reports API — Pro Pack)
|
||||
# - spark-admin:calling_cdr_read (/callreport + /calltest CDR via cdr_feed)
|
||||
# - analytics:read_all (/callreport Webex Reports API — Pro Pack)
|
||||
# The authorizing admin must also hold Full / User / Device Admin role for the
|
||||
# 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
|
||||
# webexapis.com. In Docker, HTTP(S)_PROXY may route analytics subdomains through a
|
||||
# broken proxy while webexapis.com is in NO_PROXY. CDR requests bypass proxy by default.
|
||||
# WEBEX_CDR_USE_PROXY=true # honor HTTP(S)_PROXY for cdr_feed (default: false)
|
||||
# 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
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
|
@ -515,14 +516,15 @@ DECT_RELAY_AGENT_TOKEN=
|
|||
#
|
||||
# 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
|
||||
# VOICEREPORT_BUSINESS_END_HOUR=21
|
||||
# VOICEREPORT_API_LAG_MS=300000
|
||||
# VOICEREPORT_MAX_DETAIL_ROWS=25
|
||||
# VOICEREPORT_REPORT_POLL_MS=5000
|
||||
# VOICEREPORT_REPORT_POLL_MAX_MS=180000
|
||||
# CALLREPORT_BUSINESS_START_HOUR=9
|
||||
# CALLREPORT_BUSINESS_END_HOUR=21
|
||||
# CALLREPORT_API_LAG_MS=300000
|
||||
# CALLREPORT_MAX_DETAIL_ROWS=25
|
||||
# CALLREPORT_REPORT_POLL_MS=5000
|
||||
# CALLREPORT_REPORT_POLL_MAX_MS=180000
|
||||
# (VOICEREPORT_* env vars still accepted for backward compatibility)
|
||||
# WEBEX_REPORT_TEMPLATE_MEDIA_QUALITY=
|
||||
# -----------------------------------------------------------------------------
|
||||
# Optional. Per-base collect() RPC timeout. Corporate proxies can make
|
||||
|
|
|
|||
107
commands/callReport.js
Normal file
107
commands/callReport.js
Normal 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 9am–9pm 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}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@ const SHORT_HELP = {
|
|||
phonestatus: 'DECT + IP phone status for a store (plus 7d WAN follow-up)',
|
||||
dectstatus: 'Full DECT basestation dump via relay (reboot / factory-reset cards)',
|
||||
voicediag: 'Deep voice diagnostic: Webex Calling features + SD-WAN quality with fix cards',
|
||||
voicereport: 'Daily voice digest: CDR + Media Quality + Prisma WAN for a store',
|
||||
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)',
|
||||
|
||||
// Jira
|
||||
|
|
@ -151,26 +151,34 @@ const LONG_HELP = {
|
|||
'PCAPs land in the System Log bundle downloadable from Control Hub diagnostics.',
|
||||
],
|
||||
},
|
||||
voicereport: {
|
||||
title: '/voicereport',
|
||||
callreport: {
|
||||
title: '/callreport',
|
||||
usage: [
|
||||
'/voicereport <store>',
|
||||
'/voicereport <store> today',
|
||||
'/voicereport <store> YYYY-MM-DD',
|
||||
'/voicereport <store> --detail',
|
||||
'/callreport <store>',
|
||||
'/callreport <email>',
|
||||
'/callreport <phone>',
|
||||
'/callreport <target> today',
|
||||
'/callreport <target> YYYY-MM-DD',
|
||||
'/callreport <target> --detail',
|
||||
],
|
||||
examples: [
|
||||
'/voicereport 782',
|
||||
'/voicereport 782 today',
|
||||
'/voicereport 782 2026-07-22',
|
||||
'/voicereport 782 --detail',
|
||||
'/callreport 782',
|
||||
'/callreport mcqueenj@ae.com',
|
||||
'/callreport 7247795574',
|
||||
'/callreport mcqueenj@ae.com today',
|
||||
'/callreport 782 2026-07-22',
|
||||
'/callreport 782 --detail',
|
||||
],
|
||||
notes: [
|
||||
'Store-scoped daily voice digest for **9am–9pm local** (default: **yesterday**). `today` uses 9am → 5 minutes ago.',
|
||||
'Pulls **all CDR legs** for the Webex location, **Calling Media Quality** report (Pro Pack + `analytics:read_all`), and **Prisma Webex_Calling_RTP** WAN overlay when configured.',
|
||||
'Requires `spark-admin:calling_cdr_read` + Control Hub role **Webex Calling Detailed Call History API access** for CDR.',
|
||||
'Report generation may take 1–3 minutes; an ack message posts first.',
|
||||
'HTTP: `?storeNum=782&date=yesterday&detail=true`.',
|
||||
'**Store** (`782`): full location digest for **9am–9pm local** (default: **yesterday**). `today` uses 9am → 5 minutes ago.',
|
||||
'**Email** or **phone number**: resolves the user/line location via Webex, fetches location CDR, then filters to that person/number only.',
|
||||
'Groups CDR legs by **Correlation ID** into calls; lists inbound reach, auto-attendant, outbound, and abnormal outcomes.',
|
||||
'Answered/connected calls include **Prisma Webex_Calling_RTP** MOS/jitter/loss for the call window when configured.',
|
||||
'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: {
|
||||
|
|
@ -240,6 +248,7 @@ const LONG_HELP = {
|
|||
examples: ['/jirapoll', '/jirapoll prime'],
|
||||
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.',
|
||||
'**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.',
|
||||
'`/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.',
|
||||
|
|
@ -301,7 +310,7 @@ const LONG_HELP = {
|
|||
|
||||
const GROUPS = [
|
||||
{ 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: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] },
|
||||
{ title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] },
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import { handleBulkAvStatusCSV } from './bulkAvStatusCSV.js';
|
|||
import { handleBulkAvSwitchCSV } from './bulkAvSwitchCSV.js';
|
||||
import { handleTestDevicesByModel } from './testDevicesByModel.js';
|
||||
import { handleCallTest } from './callTest.js';
|
||||
import { handleVoiceReport } from './voiceReport.js';
|
||||
import { handleCallReport } from './callReport.js';
|
||||
|
||||
/**
|
||||
* Each entry:
|
||||
|
|
@ -58,7 +58,7 @@ export const commands = [
|
|||
// (mutating: true). See commands/voiceDiag.js + services/voiceDiag/
|
||||
// for the full behavior contract + required scopes.
|
||||
{ name: 'voicediag', handler: handleVoiceDiag, mutating: true },
|
||||
{ name: 'voicereport', handler: handleVoiceReport, mutating: false },
|
||||
{ name: 'callreport', aliases: ['voicereport'], handler: handleCallReport, mutating: false },
|
||||
{ name: 'wohistory', handler: handleWoHistory, mutating: false },
|
||||
{ name: 'wosummary', handler: handleWoSummary, mutating: false },
|
||||
{ name: 'woattachments', handler: handleWoAttachments, mutating: false },
|
||||
|
|
|
|||
|
|
@ -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 9am–9pm 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 1–3 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}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,23 +13,20 @@ import {
|
|||
|
||||
export { filterMediaQualityRows, parseMediaQualityCsv, reportDateRangeFromWindow } from './reportsCsv.js';
|
||||
|
||||
import { callReportEnvInt } from '../../services/callReport/env.js';
|
||||
|
||||
const LOG_SCOPE = 'webex:reports';
|
||||
const TEMPLATE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
let _templateCache = null;
|
||||
let _templateCacheAt = 0;
|
||||
|
||||
function envInt(name, fallback) {
|
||||
const v = Number(process.env[name]);
|
||||
return Number.isFinite(v) ? v : fallback;
|
||||
}
|
||||
|
||||
function pollIntervalMs() {
|
||||
return envInt('VOICEREPORT_REPORT_POLL_MS', 5000);
|
||||
return callReportEnvInt('REPORT_POLL_MS', 5000);
|
||||
}
|
||||
|
||||
function pollMaxMs() {
|
||||
return envInt('VOICEREPORT_REPORT_POLL_MAX_MS', 180_000);
|
||||
return callReportEnvInt('REPORT_POLL_MAX_MS', 180_000);
|
||||
}
|
||||
|
||||
export function _clearReportTemplateCacheForTests() {
|
||||
|
|
|
|||
|
|
@ -1,21 +1,17 @@
|
|||
// services/voiceReport/businessWindow.js
|
||||
// Store-local business-hour windows for /voicereport (default 9am–9pm).
|
||||
// services/callReport/businessWindow.js
|
||||
// Store-local business-hour windows for /callreport (default 9am–9pm).
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
import { DISPLAY_TIMEZONE } from '../../utils/time.js';
|
||||
import { callReportEnvInt } from './env.js';
|
||||
|
||||
const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
function envInt(name, fallback) {
|
||||
const v = Number(process.env[name]);
|
||||
return Number.isFinite(v) ? v : fallback;
|
||||
}
|
||||
|
||||
export function getBusinessHourConfig() {
|
||||
return {
|
||||
startHour: envInt('VOICEREPORT_BUSINESS_START_HOUR', 9),
|
||||
endHour: envInt('VOICEREPORT_BUSINESS_END_HOUR', 21),
|
||||
apiLagMs: envInt('VOICEREPORT_API_LAG_MS', 5 * 60 * 1000),
|
||||
startHour: callReportEnvInt('BUSINESS_START_HOUR', 9),
|
||||
endHour: callReportEnvInt('BUSINESS_END_HOUR', 21),
|
||||
apiLagMs: callReportEnvInt('API_LAG_MS', 5 * 60 * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1,35 +1,37 @@
|
|||
// services/voiceReport/voiceReportService.js
|
||||
// Compose CDR + Media Quality + Prisma WAN for /voicereport.
|
||||
// services/callReport/callReportService.js
|
||||
// Compose CDR + Media Quality + Prisma WAN for /callreport.
|
||||
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { getHistoricalCallActivity } from '../phoneService.js';
|
||||
import { findSdwanSiteForStore } from '../../integrations/paloalto/sites.js';
|
||||
import { getAppMetric } from '../../integrations/paloalto/metrics.js';
|
||||
import { buildAppDetailsUrl } from '../../integrations/paloalto/urls.js';
|
||||
import { fetchMediaQualityReport } from '../../integrations/webex/reportsClient.js';
|
||||
import {
|
||||
resolveVoiceAppConfig,
|
||||
summarizeAppSeries,
|
||||
} from '../enrichment/sdwanEnrichment.js';
|
||||
import { computeBusinessWindow } from './businessWindow.js';
|
||||
import { resolveStoreForVoiceReport } from './storeContext.js';
|
||||
import { resolveCallReportTarget } from './resolveTarget.js';
|
||||
import { filterCdrToWindow, joinCallQuality } from './joinCallQuality.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) {
|
||||
const cdr = await getHistoricalCallActivity(store.personId, 12, {
|
||||
locationName: store.locationName,
|
||||
async function fetchCdrForWindow(target, window, { onCdrQueued } = {}) {
|
||||
const cdr = await getHistoricalCallActivity(target.personId, 12, {
|
||||
locationName: target.locationName,
|
||||
startTime: window.startTime,
|
||||
endTime: window.endTime,
|
||||
returnRawItems: true,
|
||||
skipPersonFilter: true,
|
||||
onQueued: onCdrQueued,
|
||||
});
|
||||
if (!cdr.available) {
|
||||
logger(
|
||||
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',
|
||||
);
|
||||
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');
|
||||
logger(
|
||||
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(', ')}`,
|
||||
'warn',
|
||||
);
|
||||
} else {
|
||||
logger(
|
||||
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}`,
|
||||
'info',
|
||||
);
|
||||
|
|
@ -67,6 +69,10 @@ async function fetchPrismaAppAudio(storeNum, window) {
|
|||
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);
|
||||
if (!site?.id) {
|
||||
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 {string} [opts.dateArg] yesterday | today | YYYY-MM-DD
|
||||
* @param {Date} [opts.now]
|
||||
*/
|
||||
export async function collectVoiceReport(storeNum, opts = {}) {
|
||||
export async function collectCallReport(targetToken, opts = {}) {
|
||||
const startedAt = Date.now();
|
||||
const store = await resolveStoreForVoiceReport(storeNum);
|
||||
const target = await resolveCallReportTarget(targetToken);
|
||||
const window = computeBusinessWindow({
|
||||
timeZone: store.timeZone,
|
||||
timeZone: target.timeZone,
|
||||
dateArg: opts.dateArg,
|
||||
now: opts.now,
|
||||
});
|
||||
|
|
@ -109,7 +115,8 @@ export async function collectVoiceReport(storeNum, opts = {}) {
|
|||
if (!window.ready) {
|
||||
return {
|
||||
ok: false,
|
||||
store,
|
||||
target,
|
||||
store: target,
|
||||
window,
|
||||
reason: window.reason,
|
||||
};
|
||||
|
|
@ -117,37 +124,35 @@ export async function collectVoiceReport(storeNum, opts = {}) {
|
|||
|
||||
logger(
|
||||
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}`,
|
||||
'info',
|
||||
);
|
||||
|
||||
const [cdrRes, mqRes, prismaRes] = await Promise.allSettled([
|
||||
fetchCdrForWindow(store, window),
|
||||
fetchMediaQualityReport({ window, locationName: store.locationName }),
|
||||
fetchPrismaAppAudio(storeNum, window),
|
||||
const [cdrRes, prismaRes] = await Promise.allSettled([
|
||||
fetchCdrForWindow(target, window, { onCdrQueued: opts.onCdrQueued }),
|
||||
fetchPrismaAppAudio(target.storeNum, window),
|
||||
]);
|
||||
|
||||
const cdr = cdrRes.status === 'fulfilled'
|
||||
? cdrRes.value
|
||||
: { available: false, reason: cdrRes.reason?.message, items: [] };
|
||||
const mediaQuality = mqRes.status === 'fulfilled'
|
||||
? mqRes.value
|
||||
: { available: false, reason: mqRes.reason?.message, rows: [] };
|
||||
const mediaQuality = { available: false, reason: MQ_DISABLED ? 'disabled' : 'not fetched', rows: [] };
|
||||
const prisma = prismaRes.status === 'fulfilled'
|
||||
? prismaRes.value
|
||||
: { available: false, reason: prismaRes.reason?.message };
|
||||
|
||||
const joined = joinCallQuality({
|
||||
cdrItems: cdr.items || [],
|
||||
mqRows: mediaQuality.rows || [],
|
||||
appAudio: prisma.appAudio || null,
|
||||
window,
|
||||
filter: target.filter || null,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
store,
|
||||
target,
|
||||
store: target,
|
||||
window,
|
||||
cdr,
|
||||
mediaQuality,
|
||||
57
services/callReport/collapseDisplayCalls.js
Normal file
57
services/callReport/collapseDisplayCalls.js
Normal 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;
|
||||
}
|
||||
15
services/callReport/env.js
Normal file
15
services/callReport/env.js
Normal 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';
|
||||
}
|
||||
210
services/callReport/filterCallsByTarget.js
Normal file
210
services/callReport/filterCallsByTarget.js
Normal 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),
|
||||
};
|
||||
}
|
||||
224
services/callReport/formatCallLine.js
Normal file
224
services/callReport/formatCallLine.js
Normal 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 };
|
||||
}
|
||||
|
|
@ -1,17 +1,32 @@
|
|||
// services/voiceReport/groupCdrCalls.js
|
||||
// services/callReport/groupCdrCalls.js
|
||||
// Group cdr_feed legs by Correlation ID into logical calls.
|
||||
|
||||
import {
|
||||
cdrCalledNumber,
|
||||
cdrCallerIdNumber,
|
||||
cdrCallingLineId,
|
||||
cdrCallingNumber,
|
||||
cdrDialedDigits,
|
||||
cdrDirectionValue,
|
||||
cdrDurationSeconds,
|
||||
cdrField,
|
||||
cdrModel,
|
||||
cdrRecordId,
|
||||
cdrSiteMainNumber,
|
||||
cdrStartTime,
|
||||
cdrDedupKey,
|
||||
cdrUserName,
|
||||
cdrUserNumber,
|
||||
formatCallDisposition,
|
||||
} from '../cdrFeedParser.js';
|
||||
|
||||
export const CALL_BUCKETS = {
|
||||
inboundReachedPhone: 'inboundReachedPhone',
|
||||
inboundAaOnly: 'inboundAaOnly',
|
||||
outboundConnected: 'outboundConnected',
|
||||
other: 'other',
|
||||
};
|
||||
|
||||
export function cdrCorrelationId(item) {
|
||||
return cdrField(item, 'correlationId', 'Correlation ID', 'correlationID');
|
||||
}
|
||||
|
|
@ -70,11 +85,57 @@ export function isNormalCallOutcome(item) {
|
|||
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) {
|
||||
for (const leg of legs) {
|
||||
const ct = callTypeValue(leg);
|
||||
if (direction === 'inbound' && ct.includes('INBOUND')) {
|
||||
return cdrCallingNumber(leg) || cdrField(leg, 'Caller ID number', 'caller id number');
|
||||
return cdrCallingNumber(leg) || cdrCallerIdNumber(leg);
|
||||
}
|
||||
if (direction === 'outbound' && ct.includes('OUTBOUND')) {
|
||||
return cdrCalledNumber(leg);
|
||||
|
|
@ -83,13 +144,24 @@ function pickExternalNumber(legs, direction) {
|
|||
const first = legs[0];
|
||||
if (!first) return null;
|
||||
if (direction === 'inbound') {
|
||||
return cdrCallingNumber(first) || cdrField(first, 'Caller ID number', 'caller id number');
|
||||
return cdrCallingNumber(first) || cdrCallerIdNumber(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) {
|
||||
// Prefer the user-phone leg, then any leg with explicit outcome.
|
||||
const phoneLegs = legs.filter(isUserPhoneLeg);
|
||||
const candidates = phoneLegs.length ? phoneLegs : legs;
|
||||
let chosen = candidates[candidates.length - 1];
|
||||
|
|
@ -98,9 +170,12 @@ function pickTerminalOutcome(legs) {
|
|||
chosen = leg;
|
||||
}
|
||||
}
|
||||
const outcome = cdrField(chosen, 'Call outcome', 'call outcome') || 'unknown';
|
||||
const reason = cdrField(chosen, 'Call outcome reason', 'call outcome reason') || '';
|
||||
return {
|
||||
outcome: cdrField(chosen, 'Call outcome', 'call outcome') || 'unknown',
|
||||
reason: cdrField(chosen, 'Call outcome reason', 'call outcome reason') || '',
|
||||
outcome,
|
||||
reason,
|
||||
disposition: formatCallDisposition(outcome, reason),
|
||||
normal: isNormalCallOutcome(chosen),
|
||||
};
|
||||
}
|
||||
|
|
@ -115,32 +190,67 @@ function summarizeReach(legs, direction) {
|
|||
return {
|
||||
reachedPhone: Boolean(answeredPhone),
|
||||
reachedAttendant: Boolean(answeredAa),
|
||||
endpointUser: answeredPhone
|
||||
? cdrField(answeredPhone, 'User', 'user')
|
||||
: null,
|
||||
endpointModel: answeredPhone
|
||||
? cdrField(answeredPhone, 'Model', 'model')
|
||||
: null,
|
||||
endpointUser: answeredPhone ? cdrUserName(answeredPhone) : null,
|
||||
endpointModel: answeredPhone ? cdrModel(answeredPhone) : null,
|
||||
aaKeyPress: aaLegs.map((l) => cdrField(l, 'Auto Attendant Key Pressed', 'auto attendant key pressed'))
|
||||
.find((v) => v && v !== 'NA') || null,
|
||||
};
|
||||
}
|
||||
|
||||
// Outbound: originating user/device leg answered, remote side picked up on terminating leg.
|
||||
const originating = legs.find((l) => cdrDirectionValue(l).includes('ORIGINAT') && isUserPhoneLeg(l));
|
||||
const originating = findOutboundOriginatingLeg(legs);
|
||||
const remoteAnswered = legs.some((l) =>
|
||||
cdrDirectionValue(l).includes('TERMINAT') && isTruthyAnswered(l) && !isAutomatedAttendantLeg(l),
|
||||
);
|
||||
return {
|
||||
reachedPhone: Boolean(originating && isTruthyAnswered(originating)),
|
||||
connected: remoteAnswered || (originating && isTruthyAnswered(originating)),
|
||||
endpointUser: originating ? cdrField(originating, 'User', 'user') : null,
|
||||
endpointModel: originating ? cdrField(originating, 'Model', 'model') : null,
|
||||
connected: remoteAnswered || Boolean(originating && isTruthyAnswered(originating)),
|
||||
endpointUser: originating ? cdrUserName(originating) : null,
|
||||
endpointModel: originating ? cdrModel(originating) : null,
|
||||
reachedAttendant: false,
|
||||
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
|
||||
* @returns {object}
|
||||
|
|
@ -152,11 +262,14 @@ export function summarizeCallGroup(correlationId, rawLegs) {
|
|||
return ta - tb;
|
||||
});
|
||||
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 start = starts.length ? starts.sort()[0] : null;
|
||||
const releaseTimes = legs
|
||||
.map((l) => cdrField(l, 'Release time', 'release time'))
|
||||
.filter(Boolean);
|
||||
const releaseTimes = legs.map((l) => cdrField(l, 'Release time', 'release time')).filter(Boolean);
|
||||
const end = releaseTimes.length ? releaseTimes.sort().reverse()[0] : null;
|
||||
const duration = (() => {
|
||||
if (start && end) {
|
||||
|
|
@ -168,6 +281,35 @@ export function summarizeCallGroup(correlationId, rawLegs) {
|
|||
const outcome = pickTerminalOutcome(legs);
|
||||
const reach = summarizeReach(legs, direction);
|
||||
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 {
|
||||
correlationId,
|
||||
|
|
@ -175,13 +317,23 @@ export function summarizeCallGroup(correlationId, rawLegs) {
|
|||
start,
|
||||
end,
|
||||
duration,
|
||||
callingNumber: pickExternalNumber(legs, direction) || cdrCallingNumber(legs[0]),
|
||||
calledNumber: cdrCalledNumber(legs[legs.length - 1]) || cdrCalledNumber(legs[0]),
|
||||
callingLineId,
|
||||
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,
|
||||
outcomeReason: outcome.reason,
|
||||
normalOutcome: outcome.normal && !abnormal,
|
||||
abnormal,
|
||||
legCount: legs.length,
|
||||
leftParty,
|
||||
rightParty,
|
||||
bucket: null,
|
||||
...reach,
|
||||
legs,
|
||||
};
|
||||
|
|
@ -203,7 +355,11 @@ export function groupCdrIntoCalls(rawItems) {
|
|||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
|
|
@ -232,7 +388,7 @@ export function summarizeCalls(calls) {
|
|||
|
||||
const outcomes = {};
|
||||
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;
|
||||
}
|
||||
|
||||
143
services/callReport/joinCallQuality.js
Normal file
143
services/callReport/joinCallQuality.js
Normal 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);
|
||||
});
|
||||
}
|
||||
38
services/callReport/parseTarget.js
Normal file
38
services/callReport/parseTarget.js
Normal 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;
|
||||
}
|
||||
207
services/callReport/resolveTarget.js
Normal file
207
services/callReport/resolveTarget.js
Normal 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 (2–4 digits), email, or phone number');
|
||||
}
|
||||
|
||||
if (parsed.kind === 'store') return resolveStoreTarget(parsed);
|
||||
if (parsed.kind === 'number') return resolveNumberTarget(parsed);
|
||||
return resolveUserTarget(parsed);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
// services/voiceReport/storeContext.js
|
||||
// services/callReport/storeContext.js
|
||||
// Resolve Webex location + timezone for a store number.
|
||||
|
||||
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 { resolveStoreMainNumber } from '../callTest/storeResolver.js';
|
||||
|
||||
const LOG_SCOPE = 'voicereport:store';
|
||||
const LOG_SCOPE = 'callreport:store';
|
||||
|
||||
/**
|
||||
* @param {string} storeNum
|
||||
* @returns {Promise<{ storeNum, personId, locationId, locationName, dialNumber, timeZone, email }>}
|
||||
*/
|
||||
export async function resolveStoreForVoiceReport(storeNum) {
|
||||
export async function resolveStoreForCallReport(storeNum) {
|
||||
const base = await resolveStoreMainNumber(storeNum);
|
||||
let timeZone = DISPLAY_TIMEZONE;
|
||||
|
||||
|
|
@ -152,6 +152,7 @@ export async function fetchMatchedCdrForSession(session) {
|
|||
endTime,
|
||||
returnRawItems: true,
|
||||
skipPersonFilter: !ctx.personId,
|
||||
onQueued: session.onCdrQueued,
|
||||
});
|
||||
|
||||
if (!cdr.available) {
|
||||
|
|
@ -187,7 +188,17 @@ export function scheduleCdrMatchEnrichment(session, { notifyRoom, renderMarkdown
|
|||
|
||||
try {
|
||||
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, {
|
||||
enrichment: {
|
||||
...(cur.enrichment || {}),
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import { normalizeE164 } from './config.js';
|
|||
const LOG_SCOPE = 'calltest:location';
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
let _phoneIndex = null;
|
||||
let _phoneIndexAt = 0;
|
||||
let _numbersCache = null;
|
||||
let _numbersCacheAt = 0;
|
||||
|
||||
async function getWebex() {
|
||||
const mod = await import('../../integrations/webex/WebexClient.js');
|
||||
|
|
@ -24,13 +24,27 @@ function parseLinkNext(linkHeader) {
|
|||
return null;
|
||||
}
|
||||
|
||||
function phoneDigits(value) {
|
||||
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 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() {
|
||||
const webex = await getWebex();
|
||||
const all = [];
|
||||
|
|
@ -52,37 +66,92 @@ async function fetchAllNumbers() {
|
|||
return all;
|
||||
}
|
||||
|
||||
async function buildPhoneIndex() {
|
||||
async function buildNumbersCache() {
|
||||
const now = Date.now();
|
||||
if (_phoneIndex && now - _phoneIndexAt < CACHE_TTL_MS) {
|
||||
return _phoneIndex;
|
||||
if (_numbersCache && now - _numbersCacheAt < CACHE_TTL_MS) {
|
||||
return _numbersCache;
|
||||
}
|
||||
|
||||
logger(LOG_SCOPE, 'Building location phone index from telephony/config/numbers', 'debug');
|
||||
const numbers = await fetchAllNumbers();
|
||||
const index = new Map();
|
||||
const byPhoneDigits = new Map();
|
||||
const byPersonId = new Map();
|
||||
const records = [];
|
||||
|
||||
for (const n of numbers) {
|
||||
const raw = n.phoneNumber || n.number || n.value;
|
||||
const loc = n.location;
|
||||
if (!raw || !loc?.id) continue;
|
||||
const entry = numberRecordFromApi(n);
|
||||
if (!entry) continue;
|
||||
records.push(entry);
|
||||
|
||||
const entry = {
|
||||
locationId: loc.id,
|
||||
locationName: loc.name || null,
|
||||
phoneNumber: raw,
|
||||
};
|
||||
|
||||
const keys = new Set([phoneDigits(raw), phoneDigits(normalizeE164(raw) || raw)]);
|
||||
const keys = new Set([phoneDigits(entry.phoneNumber), phoneDigits(normalizeE164(entry.phoneNumber) || entry.phoneNumber)]);
|
||||
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;
|
||||
_phoneIndexAt = now;
|
||||
logger(LOG_SCOPE, `Phone index built: ${index.size} keys from ${numbers.length} numbers`, 'debug');
|
||||
return index;
|
||||
_numbersCache = { byPhoneDigits, byPersonId, records };
|
||||
_numbersCacheAt = now;
|
||||
logger(
|
||||
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>}
|
||||
*/
|
||||
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);
|
||||
if (!e164) return null;
|
||||
|
||||
try {
|
||||
const index = await buildPhoneIndex();
|
||||
const cache = await buildNumbersCache();
|
||||
const key = phoneDigits(e164);
|
||||
const hit = index.get(key);
|
||||
if (!hit?.locationName) return null;
|
||||
|
||||
return {
|
||||
locationId: hit.locationId,
|
||||
locationName: hit.locationName,
|
||||
phoneNumber: hit.phoneNumber,
|
||||
};
|
||||
return cache.byPhoneDigits.get(key) || null;
|
||||
} catch (err) {
|
||||
logger(LOG_SCOPE, `resolveLocationForDialNumber failed: ${err.message}`, 'warn');
|
||||
logger(LOG_SCOPE, `resolveNumberAssignment failed: ${err.message}`, 'warn');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function _clearLocationCacheForTests() {
|
||||
_phoneIndex = null;
|
||||
_phoneIndexAt = 0;
|
||||
/**
|
||||
* Resolve location from numbers owned by a person.
|
||||
* @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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
const id = cdrRecordId(item);
|
||||
if (id) return `id:${id}`;
|
||||
|
|
@ -130,3 +178,33 @@ export function describeCdrFeedPayload(data) {
|
|||
}
|
||||
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
92
services/cdrFeedQueue.js
Normal 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;
|
||||
}
|
||||
|
|
@ -339,8 +339,13 @@ export function summarizeAppSeries(raw, expectedName) {
|
|||
const datapoints = series.data?.[0]?.datapoints;
|
||||
if (!Array.isArray(datapoints)) return null;
|
||||
|
||||
const values = datapoints
|
||||
.map((p) => (typeof p?.value === 'number' && Number.isFinite(p.value) ? p.value : null))
|
||||
const timedPoints = datapoints.map((p) => ({
|
||||
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);
|
||||
|
||||
const summary = {
|
||||
|
|
@ -355,6 +360,8 @@ export function summarizeAppSeries(raw, expectedName) {
|
|||
// to re-parse the raw response. Small (<= 288 numbers per metric)
|
||||
// so no memory concern.
|
||||
values,
|
||||
// Timestamp-aligned series for per-call WAN overlap (callreport).
|
||||
timedPoints,
|
||||
};
|
||||
|
||||
if (values.length === 0) return summary;
|
||||
|
|
|
|||
67
services/jiraPoller/README.md
Normal file
67
services/jiraPoller/README.md
Normal 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.
|
||||
154
services/jiraPoller/enrichmentRules.js
Normal file
154
services/jiraPoller/enrichmentRules.js
Normal 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}`,
|
||||
};
|
||||
}
|
||||
11
services/jiraPoller/formatBody.js
Normal file
11
services/jiraPoller/formatBody.js
Normal 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');
|
||||
}
|
||||
77
services/jiraPoller/runEnrichment.js
Normal file
77
services/jiraPoller/runEnrichment.js
Normal 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 };
|
||||
}
|
||||
|
|
@ -47,8 +47,8 @@ import { classifyTicket, TicketClassifierError } from './ticketClassifier.js';
|
|||
import { adfToPlainText } from '../utils/adfToPlainText.js';
|
||||
import { markdownToAdfContent } from '../utils/markdownToAdf.js';
|
||||
import { buildAdfComment } from '../utils/adfComment.js';
|
||||
import { renderPhoneStatusMarkdown } from './renderers/phoneStatusRenderer.js';
|
||||
import { renderAvStatusMarkdown } from './renderers/avStatusRenderer.js';
|
||||
import { resolveEnrichmentPlan } from './jiraPoller/enrichmentRules.js';
|
||||
import { runEnrichmentChecks } from './jiraPoller/runEnrichment.js';
|
||||
|
||||
const BOT_LABEL = 'bot-enriched';
|
||||
// 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.
|
||||
const MAX_TICKETS_PER_POLL = 50;
|
||||
|
||||
// Maps classifier's `kind` to the enrichment collector. Replaces the
|
||||
// component-name -> collector map that used to be the source of truth
|
||||
// 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
|
||||
// Legacy map — COMPONENT_ROUTES is the component-based source of truth.
|
||||
// Enrichment execution uses jiraPoller/runEnrichment.js.
|
||||
// ticket whose components don't match any key here is skipped (though
|
||||
// the poller's JQL should ensure this is never actually hit).
|
||||
export const COMPONENT_ROUTES = {
|
||||
|
|
@ -339,57 +330,33 @@ export async function pollNewTickets({ prime = false } = {}) {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (classification.kind === 'skip' || !classification.storeNum) {
|
||||
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 plan = resolveEnrichmentPlan(ticketPayload, classification);
|
||||
|
||||
const collect = KIND_TO_COLLECTOR[classification.kind];
|
||||
if (!collect) {
|
||||
// Belt-and-suspenders: parseAndValidate already gates kind to
|
||||
// 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');
|
||||
if (plan.skip || !plan.storeNum || !plan.checks.length) {
|
||||
const reason = plan.skipReason || classification.reason;
|
||||
logger('jira:poller', `${key}: SKIP — ${reason}`);
|
||||
await tagSkipped(key);
|
||||
skipped.push({ key, reason: `unsupported kind: ${classification.kind}` });
|
||||
skipped.push({ key, reason });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
logger('jira:poller', `${key}: enriching (${classification.kind}, store ${classification.storeNum}) — AI: ${classification.reason}`);
|
||||
const data = await collect(classification.storeNum);
|
||||
|
||||
// Same markdown the chat commands emit. `footer: false` strips
|
||||
// the "*Last checked: HH:MM*" line — a Jira comment already has
|
||||
// an authoritative timestamp in the header paragraph below and
|
||||
// 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,
|
||||
});
|
||||
logger(
|
||||
'jira:poller',
|
||||
`${key}: enriching (store ${plan.storeNum}, checks=${plan.checks.join('+')}) — ` +
|
||||
`rules: ${plan.matchedRules.join(', ')} · AI: ${classification.reason}`,
|
||||
);
|
||||
const { bodyMarkdown } = await runEnrichmentChecks(plan.storeNum, plan.checks);
|
||||
|
||||
const rulesLabel = plan.matchedRules.length
|
||||
? plan.matchedRules.join(', ')
|
||||
: classification.kind;
|
||||
const headerLine =
|
||||
`Auto-enriched by CollabFinder — ${classification.kind} snapshot for store ${classification.storeNum} ` +
|
||||
`at ${new Date().toISOString()} · AI: ${classification.reason}`;
|
||||
`Auto-enriched by CollabFinder — store ${plan.storeNum} ` +
|
||||
`at ${new Date().toISOString()} · rules: ${rulesLabel} · AI: ${classification.reason}`;
|
||||
const adf = buildAdfComment({
|
||||
headerLine,
|
||||
bodyNodes: markdownToAdfContent(markdown),
|
||||
bodyNodes: markdownToAdfContent(bodyMarkdown),
|
||||
});
|
||||
|
||||
await jira.addComment(key, adf);
|
||||
|
|
@ -398,12 +365,11 @@ export async function pollNewTickets({ prime = false } = {}) {
|
|||
enriched.push({
|
||||
key,
|
||||
summary,
|
||||
storeNum: classification.storeNum,
|
||||
storeNum: plan.storeNum,
|
||||
kind: classification.kind,
|
||||
reason: classification.reason,
|
||||
// Jira components drive the Webex bullet icon (Mobility vs Comm
|
||||
// vs AV). Capture at enrich time so the summary post doesn't
|
||||
// re-walk the issue object.
|
||||
checks: plan.checks,
|
||||
matchedRules: plan.matchedRules,
|
||||
components: f.components || [],
|
||||
});
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { attachMerakiClientWithPorts } from './enrichment/merakiEnrichment.js';
|
|||
import {
|
||||
extractCdrFeedList,
|
||||
describeCdrFeedPayload,
|
||||
resolveCdrFeedNextPage,
|
||||
cdrCalledNumber,
|
||||
cdrCallingNumber,
|
||||
cdrDedupKey,
|
||||
|
|
@ -24,6 +25,7 @@ import {
|
|||
cdrDurationSeconds,
|
||||
cdrStartTime,
|
||||
} from './cdrFeedParser.js';
|
||||
import { enqueueCdrFeedFetch } from './cdrFeedQueue.js';
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Main public function
|
||||
|
|
@ -611,6 +613,108 @@ function tokenFingerprint(token) {
|
|||
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() {
|
||||
const env = process.env.WEBEX_CDR_ANALYTICS_BASES;
|
||||
if (env) {
|
||||
|
|
@ -642,6 +746,7 @@ export async function getHistoricalCallActivity(personId, hours = 24, options =
|
|||
endTime: optEndTime,
|
||||
returnRawItems = false,
|
||||
skipPersonFilter = false,
|
||||
onQueued,
|
||||
} = options || {};
|
||||
|
||||
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.
|
||||
const ANALYTICS_BASES = getCdrAnalyticsBases();
|
||||
const CDR_PATH = '/cdr_feed';
|
||||
const MAX_PER_PAGE = 1000;
|
||||
const MAX_PER_PAGE = Number(process.env.CDR_FEED_MAX_PER_PAGE || 5000);
|
||||
|
||||
try {
|
||||
const person = personId ? await webex.request('GET', `people/${personId}`) : null;
|
||||
|
|
@ -760,86 +865,23 @@ export async function getHistoricalCallActivity(personId, hours = 24, options =
|
|||
'info',
|
||||
);
|
||||
|
||||
const allRawItems = [];
|
||||
const fetchErrors = [];
|
||||
|
||||
let usedBase = null;
|
||||
let firstRespData = null;
|
||||
let lastHttpStatus = null;
|
||||
|
||||
for (const base of ANALYTICS_BASES) {
|
||||
const url = `${base}${CDR_PATH}`;
|
||||
const params = { startTime, endTime, locations: locationName, max: MAX_PER_PAGE };
|
||||
const queryStr = new URLSearchParams(params).toString();
|
||||
const fullUrl = `${url}?${queryStr}`;
|
||||
logger('phone:service', `cdr_feed GET ${fullUrl}`, 'info');
|
||||
|
||||
try {
|
||||
const resp = await webex.analyticsRequestRaw('GET', url, { params, timeout: 15000 });
|
||||
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',
|
||||
const {
|
||||
allRawItems,
|
||||
fetchErrors,
|
||||
usedBase,
|
||||
lastHttpStatus,
|
||||
} = await enqueueCdrFeedFetch(
|
||||
() => executeCdrFeedHttpFetch({
|
||||
locationName,
|
||||
startTime,
|
||||
endTime,
|
||||
tokenHint,
|
||||
analyticsBases: ANALYTICS_BASES,
|
||||
cdrPath: CDR_PATH,
|
||||
maxPerPage: MAX_PER_PAGE,
|
||||
}),
|
||||
{ onQueued, label: locationName },
|
||||
);
|
||||
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) {
|
||||
const errSummary = fetchErrors
|
||||
|
|
|
|||
164
services/renderers/callReportRenderer.js
Normal file
164
services/renderers/callReportRenderer.js
Normal 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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
// tests/voiceReport.businessWindow.test.js
|
||||
// tests/callReport.businessWindow.test.js
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
|
@ -7,7 +7,7 @@ import { DateTime } from 'luxon';
|
|||
import {
|
||||
computeBusinessWindow,
|
||||
parseReportDateArg,
|
||||
} from '../services/voiceReport/businessWindow.js';
|
||||
} from '../services/callReport/businessWindow.js';
|
||||
|
||||
test('parseReportDateArg defaults to yesterday', () => {
|
||||
const now = DateTime.fromISO('2026-07-24T15:00:00', { zone: 'America/New_York' }).toJSDate();
|
||||
61
tests/callReport.collapseDisplayCalls.test.js
Normal file
61
tests/callReport.collapseDisplayCalls.test.js
Normal 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, /^✅ /);
|
||||
});
|
||||
135
tests/callReport.filterCallsByTarget.test.js
Normal file
135
tests/callReport.filterCallsByTarget.test.js
Normal 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);
|
||||
});
|
||||
122
tests/callReport.formatCallLine.test.js
Normal file
122
tests/callReport.formatCallLine.test.js
Normal 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% ⚠️/);
|
||||
});
|
||||
|
|
@ -1,18 +1,21 @@
|
|||
// tests/voiceReport.groupCdrCalls.test.js
|
||||
// tests/callReport.groupCdrCalls.test.js
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
assignCallBucket,
|
||||
groupCdrIntoCalls,
|
||||
summarizeCalls,
|
||||
classifyCallDirection,
|
||||
} from '../services/voiceReport/groupCdrCalls.js';
|
||||
CALL_BUCKETS,
|
||||
} from '../services/callReport/groupCdrCalls.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',
|
||||
|
|
@ -21,6 +24,7 @@ const SAMPLE_LEGS = [
|
|||
'User type': 'AutomatedAttendantVideo',
|
||||
User: 'Store 2477',
|
||||
'Called number': '+12122194600',
|
||||
'Site main number': '+12122194600',
|
||||
'Calling number': '+17189864017',
|
||||
'Call outcome': 'Success',
|
||||
'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].endpointUser, 'Store 02477');
|
||||
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);
|
||||
});
|
||||
|
||||
184
tests/callReport.join.test.js
Normal file
184
tests/callReport.join.test.js
Normal 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);
|
||||
});
|
||||
154
tests/callReport.renderer.test.js
Normal file
154
tests/callReport.renderer.test.js
Normal 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/);
|
||||
});
|
||||
74
tests/callReport.resolveTarget.test.js
Normal file
74
tests/callReport.resolveTarget.test.js
Normal 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'));
|
||||
});
|
||||
46
tests/cdrFeedQueue.test.js
Normal file
46
tests/cdrFeedQueue.test.js
Normal 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');
|
||||
});
|
||||
117
tests/jiraPoller.enrichmentRules.test.js
Normal file
117
tests/jiraPoller.enrichmentRules.test.js
Normal 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);
|
||||
});
|
||||
22
tests/jiraPoller.runEnrichment.test.js
Normal file
22
tests/jiraPoller.runEnrichment.test.js
Normal 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');
|
||||
});
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { extractCdrFeedList } from '../services/cdrFeedParser.js';
|
||||
import { extractCdrFeedList, parseCdrFeedLinkNext, resolveCdrFeedNextPage } from '../services/cdrFeedParser.js';
|
||||
import {
|
||||
cdrDedupKey,
|
||||
cdrStartTime,
|
||||
cdrCallingNumber,
|
||||
} from '../services/cdrFeedParser.js';
|
||||
import { filterCdrToWindow } from '../services/voiceReport/joinCallQuality.js';
|
||||
import { filterCdrToWindow } from '../services/callReport/joinCallQuality.js';
|
||||
|
||||
test('extractCdrFeedList handles items array', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
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' },
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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/);
|
||||
});
|
||||
Loading…
Reference in a new issue