From a25fc08fe22b22f9654cc9381f8d18539a27f70a Mon Sep 17 00:00:00 2001 From: jmcqueen Date: Mon, 27 Jul 2026 10:10:03 -0400 Subject: [PATCH] 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 --- .env.example | 22 +- commands/callReport.js | 107 +++++++++ commands/help.js | 43 ++-- commands/registry.js | 4 +- commands/voiceReport.js | 64 ----- integrations/webex/reportsClient.js | 11 +- .../businessWindow.js | 16 +- .../callReportService.js} | 57 +++-- services/callReport/collapseDisplayCalls.js | 57 +++++ services/callReport/env.js | 15 ++ services/callReport/filterCallsByTarget.js | 210 ++++++++++++++++ services/callReport/formatCallLine.js | 224 ++++++++++++++++++ .../groupCdrCalls.js | 204 ++++++++++++++-- services/callReport/joinCallQuality.js | 143 +++++++++++ services/callReport/parseTarget.js | 38 +++ services/callReport/resolveTarget.js | 207 ++++++++++++++++ .../storeContext.js | 6 +- services/callTest/cdrMatcher.js | 13 +- services/callTest/locationResolver.js | 178 +++++++++++--- services/cdrFeedParser.js | 78 ++++++ services/cdrFeedQueue.js | 92 +++++++ services/enrichment/sdwanEnrichment.js | 11 +- services/jiraPoller/README.md | 67 ++++++ services/jiraPoller/enrichmentRules.js | 154 ++++++++++++ services/jiraPoller/formatBody.js | 11 + services/jiraPoller/runEnrichment.js | 77 ++++++ services/jiraPollerService.js | 82 ++----- services/phoneService.js | 204 +++++++++------- services/renderers/callReportRenderer.js | 164 +++++++++++++ services/renderers/voiceReportRenderer.js | 161 ------------- services/voiceReport/joinCallQuality.js | 199 ---------------- ...t.js => callReport.businessWindow.test.js} | 4 +- tests/callReport.collapseDisplayCalls.test.js | 61 +++++ tests/callReport.filterCallsByTarget.test.js | 135 +++++++++++ tests/callReport.formatCallLine.test.js | 122 ++++++++++ ...st.js => callReport.groupCdrCalls.test.js} | 13 +- tests/callReport.join.test.js | 184 ++++++++++++++ tests/callReport.renderer.test.js | 154 ++++++++++++ tests/callReport.resolveTarget.test.js | 74 ++++++ tests/cdrFeedQueue.test.js | 46 ++++ tests/jiraPoller.enrichmentRules.test.js | 117 +++++++++ tests/jiraPoller.runEnrichment.test.js | 22 ++ tests/phoneService.cdrFeed.test.js | 34 ++- tests/voiceReport.join.test.js | 87 ------- tests/voiceReport.renderer.test.js | 70 ------ 45 files changed, 3179 insertions(+), 863 deletions(-) create mode 100644 commands/callReport.js delete mode 100644 commands/voiceReport.js rename services/{voiceReport => callReport}/businessWindow.js (90%) rename services/{voiceReport/voiceReportService.js => callReport/callReportService.js} (69%) create mode 100644 services/callReport/collapseDisplayCalls.js create mode 100644 services/callReport/env.js create mode 100644 services/callReport/filterCallsByTarget.js create mode 100644 services/callReport/formatCallLine.js rename services/{voiceReport => callReport}/groupCdrCalls.js (53%) create mode 100644 services/callReport/joinCallQuality.js create mode 100644 services/callReport/parseTarget.js create mode 100644 services/callReport/resolveTarget.js rename services/{voiceReport => callReport}/storeContext.js (89%) create mode 100644 services/cdrFeedQueue.js create mode 100644 services/jiraPoller/README.md create mode 100644 services/jiraPoller/enrichmentRules.js create mode 100644 services/jiraPoller/formatBody.js create mode 100644 services/jiraPoller/runEnrichment.js create mode 100644 services/renderers/callReportRenderer.js delete mode 100644 services/renderers/voiceReportRenderer.js delete mode 100644 services/voiceReport/joinCallQuality.js rename tests/{voiceReport.businessWindow.test.js => callReport.businessWindow.test.js} (95%) create mode 100644 tests/callReport.collapseDisplayCalls.test.js create mode 100644 tests/callReport.filterCallsByTarget.test.js create mode 100644 tests/callReport.formatCallLine.test.js rename tests/{voiceReport.groupCdrCalls.test.js => callReport.groupCdrCalls.test.js} (86%) create mode 100644 tests/callReport.join.test.js create mode 100644 tests/callReport.renderer.test.js create mode 100644 tests/callReport.resolveTarget.test.js create mode 100644 tests/cdrFeedQueue.test.js create mode 100644 tests/jiraPoller.enrichmentRules.test.js create mode 100644 tests/jiraPoller.runEnrichment.test.js delete mode 100644 tests/voiceReport.join.test.js delete mode 100644 tests/voiceReport.renderer.test.js diff --git a/.env.example b/.env.example index f2dd59b..10380b6 100644 --- a/.env.example +++ b/.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 diff --git a/commands/callReport.js b/commands/callReport.js new file mode 100644 index 0000000..b14e78d --- /dev/null +++ b/commands/callReport.js @@ -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 ` — yesterday 9am–9pm local (default)\n' + + '`/callreport ` — calls for that user at their location\n' + + '`/callreport ` — calls for that assigned number\n' + + '`/callreport today` — today 9am → 5 min ago\n' + + '`/callreport 2026-07-22` — specific day\n' + + '`/callreport --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}`); + } +} diff --git a/commands/help.js b/commands/help.js index f6b8ec5..278bf71 100644 --- a/commands/help.js +++ b/commands/help.js @@ -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 ', - '/voicereport today', - '/voicereport YYYY-MM-DD', - '/voicereport --detail', + '/callreport ', + '/callreport ', + '/callreport ', + '/callreport today', + '/callreport YYYY-MM-DD', + '/callreport --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'] }, diff --git a/commands/registry.js b/commands/registry.js index acce74d..99aebd1 100644 --- a/commands/registry.js +++ b/commands/registry.js @@ -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 }, diff --git a/commands/voiceReport.js b/commands/voiceReport.js deleted file mode 100644 index 79bba0e..0000000 --- a/commands/voiceReport.js +++ /dev/null @@ -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 ` — yesterday 9am–9pm local (default)\n' + - '`/voicereport today` — today 9am → 5 min ago\n' + - '`/voicereport 2026-07-22` — specific day\n' + - '`/voicereport --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}`); - } -} diff --git a/integrations/webex/reportsClient.js b/integrations/webex/reportsClient.js index 5327521..d70bec6 100644 --- a/integrations/webex/reportsClient.js +++ b/integrations/webex/reportsClient.js @@ -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() { diff --git a/services/voiceReport/businessWindow.js b/services/callReport/businessWindow.js similarity index 90% rename from services/voiceReport/businessWindow.js rename to services/callReport/businessWindow.js index 9f8ec3e..a970761 100644 --- a/services/voiceReport/businessWindow.js +++ b/services/callReport/businessWindow.js @@ -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), }; } diff --git a/services/voiceReport/voiceReportService.js b/services/callReport/callReportService.js similarity index 69% rename from services/voiceReport/voiceReportService.js rename to services/callReport/callReportService.js index 6ae5b7f..a750ca9 100644 --- a/services/voiceReport/voiceReportService.js +++ b/services/callReport/callReportService.js @@ -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, diff --git a/services/callReport/collapseDisplayCalls.js b/services/callReport/collapseDisplayCalls.js new file mode 100644 index 0000000..c71c95f --- /dev/null +++ b/services/callReport/collapseDisplayCalls.js @@ -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} + */ +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; +} diff --git a/services/callReport/env.js b/services/callReport/env.js new file mode 100644 index 0000000..5e20c46 --- /dev/null +++ b/services/callReport/env.js @@ -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'; +} diff --git a/services/callReport/filterCallsByTarget.js b/services/callReport/filterCallsByTarget.js new file mode 100644 index 0000000..6058f1c --- /dev/null +++ b/services/callReport/filterCallsByTarget.js @@ -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), + }; +} diff --git a/services/callReport/formatCallLine.js b/services/callReport/formatCallLine.js new file mode 100644 index 0000000..1fb43da --- /dev/null +++ b/services/callReport/formatCallLine.js @@ -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 }; +} diff --git a/services/voiceReport/groupCdrCalls.js b/services/callReport/groupCdrCalls.js similarity index 53% rename from services/voiceReport/groupCdrCalls.js rename to services/callReport/groupCdrCalls.js index 887e97f..d3e7dfd 100644 --- a/services/voiceReport/groupCdrCalls.js +++ b/services/callReport/groupCdrCalls.js @@ -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; } diff --git a/services/callReport/joinCallQuality.js b/services/callReport/joinCallQuality.js new file mode 100644 index 0000000..36bb1c6 --- /dev/null +++ b/services/callReport/joinCallQuality.js @@ -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); + }); +} diff --git a/services/callReport/parseTarget.js b/services/callReport/parseTarget.js new file mode 100644 index 0000000..f6d8447 --- /dev/null +++ b/services/callReport/parseTarget.js @@ -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; +} diff --git a/services/callReport/resolveTarget.js b/services/callReport/resolveTarget.js new file mode 100644 index 0000000..88fe6e3 --- /dev/null +++ b/services/callReport/resolveTarget.js @@ -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} 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); +} diff --git a/services/voiceReport/storeContext.js b/services/callReport/storeContext.js similarity index 89% rename from services/voiceReport/storeContext.js rename to services/callReport/storeContext.js index 037d41e..26c210c 100644 --- a/services/voiceReport/storeContext.js +++ b/services/callReport/storeContext.js @@ -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; diff --git a/services/callTest/cdrMatcher.js b/services/callTest/cdrMatcher.js index 0a70d99..17393c5 100644 --- a/services/callTest/cdrMatcher.js +++ b/services/callTest/cdrMatcher.js @@ -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 || {}), diff --git a/services/callTest/locationResolver.js b/services/callTest/locationResolver.js index 7c42f33..2436035 100644 --- a/services/callTest/locationResolver.js +++ b/services/callTest/locationResolver.js @@ -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} + */ +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} + */ +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} + */ +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; } diff --git a/services/cdrFeedParser.js b/services/cdrFeedParser.js index 2ace284..5ac31b8 100644 --- a/services/cdrFeedParser.js +++ b/services/cdrFeedParser.js @@ -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 } }; +} diff --git a/services/cdrFeedQueue.js b/services/cdrFeedQueue.js new file mode 100644 index 0000000..91f418d --- /dev/null +++ b/services/cdrFeedQueue.js @@ -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} fn + * @param {{ onQueued?: (info: { runAt: Date, waitMs: number, position: number }) => void|Promise, label?: string }} [opts] + * @returns {Promise} + */ +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; +} diff --git a/services/enrichment/sdwanEnrichment.js b/services/enrichment/sdwanEnrichment.js index a3d637d..acc432f 100644 --- a/services/enrichment/sdwanEnrichment.js +++ b/services/enrichment/sdwanEnrichment.js @@ -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; diff --git a/services/jiraPoller/README.md b/services/jiraPoller/README.md new file mode 100644 index 0000000..4e93b20 --- /dev/null +++ b/services/jiraPoller/README.md @@ -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. diff --git a/services/jiraPoller/enrichmentRules.js b/services/jiraPoller/enrichmentRules.js new file mode 100644 index 0000000..193de59 --- /dev/null +++ b/services/jiraPoller/enrichmentRules.js @@ -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}`, + }; +} diff --git a/services/jiraPoller/formatBody.js b/services/jiraPoller/formatBody.js new file mode 100644 index 0000000..611023b --- /dev/null +++ b/services/jiraPoller/formatBody.js @@ -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'); +} diff --git a/services/jiraPoller/runEnrichment.js b/services/jiraPoller/runEnrichment.js new file mode 100644 index 0000000..b5a0c68 --- /dev/null +++ b/services/jiraPoller/runEnrichment.js @@ -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 }; +} diff --git a/services/jiraPollerService.js b/services/jiraPollerService.js index 9a3a09e..1a97582 100644 --- a/services/jiraPollerService.js +++ b/services/jiraPollerService.js @@ -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) { diff --git a/services/phoneService.js b/services/phoneService.js index 643ab65..4fcde4f 100644 --- a/services/phoneService.js +++ b/services/phoneService.js @@ -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', - ); - 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 }); - } - } + 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 (allRawItems.length === 0 && fetchErrors.length > 0) { const errSummary = fetchErrors diff --git a/services/renderers/callReportRenderer.js b/services/renderers/callReportRenderer.js new file mode 100644 index 0000000..7511fa4 --- /dev/null +++ b/services/renderers/callReportRenderer.js @@ -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(); +} diff --git a/services/renderers/voiceReportRenderer.js b/services/renderers/voiceReportRenderer.js deleted file mode 100644 index 9c41f43..0000000 --- a/services/renderers/voiceReportRenderer.js +++ /dev/null @@ -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(); -} diff --git a/services/voiceReport/joinCallQuality.js b/services/voiceReport/joinCallQuality.js deleted file mode 100644 index 44ce86e..0000000 --- a/services/voiceReport/joinCallQuality.js +++ /dev/null @@ -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); - }); -} diff --git a/tests/voiceReport.businessWindow.test.js b/tests/callReport.businessWindow.test.js similarity index 95% rename from tests/voiceReport.businessWindow.test.js rename to tests/callReport.businessWindow.test.js index 38d169d..c52647c 100644 --- a/tests/voiceReport.businessWindow.test.js +++ b/tests/callReport.businessWindow.test.js @@ -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(); diff --git a/tests/callReport.collapseDisplayCalls.test.js b/tests/callReport.collapseDisplayCalls.test.js new file mode 100644 index 0000000..9cb3287 --- /dev/null +++ b/tests/callReport.collapseDisplayCalls.test.js @@ -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, /^✅ /); +}); diff --git a/tests/callReport.filterCallsByTarget.test.js b/tests/callReport.filterCallsByTarget.test.js new file mode 100644 index 0000000..ec03fbd --- /dev/null +++ b/tests/callReport.filterCallsByTarget.test.js @@ -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); +}); diff --git a/tests/callReport.formatCallLine.test.js b/tests/callReport.formatCallLine.test.js new file mode 100644 index 0000000..5145417 --- /dev/null +++ b/tests/callReport.formatCallLine.test.js @@ -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% ⚠️/); +}); diff --git a/tests/voiceReport.groupCdrCalls.test.js b/tests/callReport.groupCdrCalls.test.js similarity index 86% rename from tests/voiceReport.groupCdrCalls.test.js rename to tests/callReport.groupCdrCalls.test.js index 8e79f24..f8da48f 100644 --- a/tests/voiceReport.groupCdrCalls.test.js +++ b/tests/callReport.groupCdrCalls.test.js @@ -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); }); diff --git a/tests/callReport.join.test.js b/tests/callReport.join.test.js new file mode 100644 index 0000000..58aee5b --- /dev/null +++ b/tests/callReport.join.test.js @@ -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); +}); diff --git a/tests/callReport.renderer.test.js b/tests/callReport.renderer.test.js new file mode 100644 index 0000000..074ec4b --- /dev/null +++ b/tests/callReport.renderer.test.js @@ -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/); +}); diff --git a/tests/callReport.resolveTarget.test.js b/tests/callReport.resolveTarget.test.js new file mode 100644 index 0000000..d7aee14 --- /dev/null +++ b/tests/callReport.resolveTarget.test.js @@ -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')); +}); diff --git a/tests/cdrFeedQueue.test.js b/tests/cdrFeedQueue.test.js new file mode 100644 index 0000000..57457d2 --- /dev/null +++ b/tests/cdrFeedQueue.test.js @@ -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'); +}); diff --git a/tests/jiraPoller.enrichmentRules.test.js b/tests/jiraPoller.enrichmentRules.test.js new file mode 100644 index 0000000..76b8a9a --- /dev/null +++ b/tests/jiraPoller.enrichmentRules.test.js @@ -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); +}); diff --git a/tests/jiraPoller.runEnrichment.test.js b/tests/jiraPoller.runEnrichment.test.js new file mode 100644 index 0000000..b7f3c33 --- /dev/null +++ b/tests/jiraPoller.runEnrichment.test.js @@ -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'); +}); diff --git a/tests/phoneService.cdrFeed.test.js b/tests/phoneService.cdrFeed.test.js index ce31347..1596ce0 100644 --- a/tests/phoneService.cdrFeed.test.js +++ b/tests/phoneService.cdrFeed.test.js @@ -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 = '; 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: '; 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' }, + }); +}); diff --git a/tests/voiceReport.join.test.js b/tests/voiceReport.join.test.js deleted file mode 100644 index 6997e6e..0000000 --- a/tests/voiceReport.join.test.js +++ /dev/null @@ -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); -}); diff --git a/tests/voiceReport.renderer.test.js b/tests/voiceReport.renderer.test.js deleted file mode 100644 index 332380b..0000000 --- a/tests/voiceReport.renderer.test.js +++ /dev/null @@ -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/); -});