diff --git a/.env.example b/.env.example index a88f3d7..f2dd59b 100644 --- a/.env.example +++ b/.env.example @@ -56,6 +56,8 @@ WEBEX_BOT_TOKEN=your-bot-token-here # - spark-admin:licenses_read (webexhost, findEmptyLocations) # - identity:tokens_read (offboarduser: list a user's authorizations) # - identity:tokens_write (offboarduser: revoke a user's authorizations) +# - spark-admin:calling_cdr_read (/voicereport + /calltest CDR via cdr_feed) +# - analytics:read_all (/voicereport Webex Reports API — Pro Pack) # The authorizing admin must also hold Full / User / Device Admin role for the # token-management calls to succeed. # @@ -86,6 +88,12 @@ WEBEX_TOKENS_PATH=./config/webex-service-tokens.json # Optional override for the Webex API base URL (default https://webexapis.com/v1). # WEBEX_BASE_URL=https://webexapis.com/v1 +# CDR feed (/cdr_feed) uses analytics-calling.webexapis.com — a different host than +# webexapis.com. In Docker, HTTP(S)_PROXY may route analytics subdomains through a +# broken proxy while webexapis.com is in NO_PROXY. CDR requests bypass proxy by default. +# WEBEX_CDR_USE_PROXY=true # honor HTTP(S)_PROXY for cdr_feed (default: false) +# WEBEX_CDR_ANALYTICS_BASES=https://analytics-calling.webexapis.com/v1,https://analytics.webexapis.com/v1 + # ----------------------------------------------------------------------------- # Palo Alto Prisma SD-WAN (formerly CloudGenix) — WAN metrics for /voicediag # and /phonestatus follow-up @@ -493,12 +501,30 @@ DECT_RELAY_AGENT_TOKEN= # CALLTEST_INTRO_TTS=Hello. This is an automated connectivity test... # CALLTEST_OUTRO_TTS=Thank you. The connectivity test is complete. Goodbye. # -# Store mode CDR follow-up (uses spark-admin:calling_cdr_read): +# Post-call enrichment (separate Webex cards after the result): +# CALLTEST_TWILIO_ENRICH=true +# CALLTEST_TWILIO_DETAILS_DELAY_MS=10000 +# CALLTEST_TWILIO_INSIGHTS_DELAY_MS=360000 +# CALLTEST_TWILIO_INSIGHTS_RETRIES=3 +# CALLTEST_TWILIO_INSIGHTS_RETRY_MS=30000 +# +# Webex CDR leg match (store + dial when dialed number is a known location DID): # CALLTEST_CDR_ENRICH=true # CALLTEST_CDR_DELAY_MS=360000 +# CALLTEST_CDR_MATCH_BUFFER_MS=120000 # # Per-store entry overrides: config/calltest-stores.json # ----------------------------------------------------------------------------- +# /voicereport — daily voice digest (CDR + Media Quality + Prisma WAN) +# ----------------------------------------------------------------------------- +# VOICEREPORT_BUSINESS_START_HOUR=9 +# VOICEREPORT_BUSINESS_END_HOUR=21 +# VOICEREPORT_API_LAG_MS=300000 +# VOICEREPORT_MAX_DETAIL_ROWS=25 +# VOICEREPORT_REPORT_POLL_MS=5000 +# VOICEREPORT_REPORT_POLL_MAX_MS=180000 +# WEBEX_REPORT_TEMPLATE_MEDIA_QUALITY= +# ----------------------------------------------------------------------------- # Optional. Per-base collect() RPC timeout. Corporate proxies can make # DBS-210 reads slow; 15s is comfortable, 30s is generous. # DECT_COLLECT_TIMEOUT_MS=15000 diff --git a/commands/callTest.js b/commands/callTest.js index c195d90..d8dc832 100644 --- a/commands/callTest.js +++ b/commands/callTest.js @@ -9,7 +9,7 @@ import { startStoreTest, } from '../services/callTest/callTestService.js'; import { parseCallTestArgs } from '../services/callTest/parseArgs.js'; -import { renderCallTestResultMarkdown } from '../services/renderers/callTestRenderer.js'; +import { renderCallTestResultMarkdown, renderCallTestEnrichmentStatusMarkdown } from '../services/renderers/callTestRenderer.js'; import { extractRequester } from '../utils/requester.js'; import { logger } from '../utils/logger.js'; @@ -65,7 +65,7 @@ export async function handleCallTest(bot, trigger) { await bot.say('markdown', `No in-memory session for testId \`${parsed.testId}\` (expired or unknown).`); return; } - await bot.say('markdown', renderCallTestResultMarkdown(session)); + await bot.say('markdown', renderCallTestResultMarkdown(session) + renderCallTestEnrichmentStatusMarkdown(session)); return; } diff --git a/commands/help.js b/commands/help.js index 30dd908..f6b8ec5 100644 --- a/commands/help.js +++ b/commands/help.js @@ -21,6 +21,7 @@ const SHORT_HELP = { phonestatus: 'DECT + IP phone status for a store (plus 7d WAN follow-up)', dectstatus: 'Full DECT basestation dump via relay (reboot / factory-reset cards)', voicediag: 'Deep voice diagnostic: Webex Calling features + SD-WAN quality with fix cards', + voicereport: 'Daily voice digest: CDR + Media Quality + Prisma WAN for a store', calltest: 'Twilio voice path test (store AA or direct dial, 60s listen)', // Jira @@ -150,6 +151,28 @@ const LONG_HELP = { 'PCAPs land in the System Log bundle downloadable from Control Hub diagnostics.', ], }, + voicereport: { + title: '/voicereport', + usage: [ + '/voicereport ', + '/voicereport today', + '/voicereport YYYY-MM-DD', + '/voicereport --detail', + ], + examples: [ + '/voicereport 782', + '/voicereport 782 today', + '/voicereport 782 2026-07-22', + '/voicereport 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`.', + ], + }, calltest: { title: '/calltest', usage: [ @@ -169,7 +192,8 @@ const LONG_HELP = { '**Store path:** dials the store main number (Webex `locationMainNumber`), pauses for the AA greeting, sends DTMF `1`, waits for the store leg, then runs the shared **60-second** listen test (intro → pause → thank you).', '**Direct dial:** calls any E.164 number; on answer, runs the same 60s test with no AA/DTMF.', 'Requires `TWILIO_*` env vars, `TWILIO_WEBHOOK_BASE_URL` (public HTTPS), and `CALLTEST_ENABLED=true`.', - 'Store tests optionally post a **CDR follow-up** ~6 minutes later when `CALLTEST_CDR_ENRICH=true`.', + 'After the result card, follow-ups post separately: **Twilio details** (~10s), **Twilio Insights** (~6 min, requires Advanced Features), **Webex CDR match** (~6 min when location is known).', + 'CDR match works for store tests and dial tests to a known store main number; legs are matched on **destination (called number)** only.', 'Per-store entry tuning: `config/calltest-stores.json` (`dtmf`, `greetingPauseSec`, `answerWaitSec`).', ], }, @@ -277,7 +301,7 @@ const LONG_HELP = { const GROUPS = [ { title: 'Work orders', keys: ['wohistory', 'wosummary', 'woattachments'] }, - { title: 'AV & phones', keys: ['avstatus', 'phonestatus', 'dectstatus', 'voicediag', 'calltest'] }, + { title: 'AV & phones', keys: ['avstatus', 'phonestatus', 'dectstatus', 'voicediag', 'voicereport', 'calltest'] }, { title: 'Jira', keys: ['jirahistory', 'jiraticket', 'jirapoll'] }, { title: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] }, { title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] }, diff --git a/commands/registry.js b/commands/registry.js index 7f8d7b3..acce74d 100644 --- a/commands/registry.js +++ b/commands/registry.js @@ -31,6 +31,7 @@ import { handleBulkAvStatusCSV } from './bulkAvStatusCSV.js'; import { handleBulkAvSwitchCSV } from './bulkAvSwitchCSV.js'; import { handleTestDevicesByModel } from './testDevicesByModel.js'; import { handleCallTest } from './callTest.js'; +import { handleVoiceReport } from './voiceReport.js'; /** * Each entry: @@ -57,6 +58,7 @@ export const commands = [ // (mutating: true). See commands/voiceDiag.js + services/voiceDiag/ // for the full behavior contract + required scopes. { name: 'voicediag', handler: handleVoiceDiag, mutating: true }, + { name: 'voicereport', handler: handleVoiceReport, mutating: false }, { name: 'wohistory', handler: handleWoHistory, mutating: false }, { name: 'wosummary', handler: handleWoSummary, mutating: false }, { name: 'woattachments', handler: handleWoAttachments, mutating: false }, diff --git a/commands/voiceReport.js b/commands/voiceReport.js new file mode 100644 index 0000000..79bba0e --- /dev/null +++ b/commands/voiceReport.js @@ -0,0 +1,64 @@ +// commands/voiceReport.js + +import { logger } from '../utils/logger.js'; +import { collectVoiceReport } from '../services/voiceReport/voiceReportService.js'; +import { renderVoiceReportMarkdown } from '../services/renderers/voiceReportRenderer.js'; + +function pickDateArg(args) { + for (let i = 1; i < args.length; i++) { + const a = String(args[i] || '').trim(); + if (!a || a.startsWith('--')) continue; + if (/^\d{2,4}$/.test(a) && i === 1) continue; + return a; + } + const flag = args.find((a) => String(a).startsWith('--date=')); + if (flag) return flag.split('=').slice(1).join('='); + const idx = args.indexOf('--date'); + if (idx >= 0 && args[idx + 1]) return args[idx + 1]; + return null; +} + +function hasDetailFlag(args, query) { + return args.some((a) => ['detail', 'detailed', '--detail', '--detailed'].includes(String(a).toLowerCase())) + || query.detail === 'true' + || query.detailed === 'true'; +} + +export async function handleVoiceReport(bot, trigger) { + logger('voicereport', 'Handler entered', 'debug'); + + const query = trigger.query || {}; + const args = trigger.args || []; + + let storeNum = args[0]?.trim() || query.storeNum || query.store || query.s; + const dateArg = pickDateArg(args) || query.date || null; + const detail = hasDetailFlag(args, query); + + if (!storeNum || !/^\d{2,4}$/.test(storeNum)) { + await bot.say( + 'markdown', + '**Voice report usage**\n\n' + + '`/voicereport ` — 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/paloalto/metrics.js b/integrations/paloalto/metrics.js index a8a22bc..79d2763 100644 --- a/integrations/paloalto/metrics.js +++ b/integrations/paloalto/metrics.js @@ -413,7 +413,7 @@ function appMetricsPath() { * shipping default for WAN checks * @returns {Promise} */ -export async function getAppMetric(siteId, appId, metricKey, windowMinutes = 1440) { +export async function getAppMetric(siteId, appId, metricKey, windowOrOpts = 1440) { const spec = APP_METRIC_NAMES[metricKey]; if (!spec) { throw new Error( @@ -427,6 +427,27 @@ export async function getAppMetric(siteId, appId, metricKey, windowMinutes = 144 return null; } + let startTime; + let endTime; + let windowMinutes; + if ( + windowOrOpts + && typeof windowOrOpts === 'object' + && windowOrOpts.startTime + && windowOrOpts.endTime + ) { + startTime = windowOrOpts.startTime; + endTime = windowOrOpts.endTime; + windowMinutes = Math.max( + 1, + Math.round((new Date(endTime).getTime() - new Date(startTime).getTime()) / 60_000), + ); + } else { + windowMinutes = typeof windowOrOpts === 'number' ? windowOrOpts : 1440; + startTime = windowStart(windowMinutes); + endTime = nowIso(); + } + const filter = { site: [String(siteId)], app: [String(appId)], @@ -436,8 +457,8 @@ export async function getAppMetric(siteId, appId, metricKey, windowMinutes = 144 try { const res = await paloAltoAxios.post(appMetricsPath(), { - start_time: windowStart(windowMinutes), - end_time: nowIso(), + start_time: startTime, + end_time: endTime, // NOT pickInterval — see pickAppMetricInterval doc for why: // app metrics need the fine-grained series or the client-side // worst-window aggregation is meaningless. diff --git a/integrations/twilio/client.js b/integrations/twilio/client.js index c652da6..ba6d6a7 100644 --- a/integrations/twilio/client.js +++ b/integrations/twilio/client.js @@ -82,9 +82,162 @@ export async function createOutboundCall({ to, voiceUrl, statusCallback, timeout return call; } +function twilioAxios() { + const { accountSid, authToken } = twilioAuth(); + return { + accountSid, + client: axios.create({ + auth: { username: accountSid, password: authToken }, + timeout: 30_000, + }), + }; +} + +function unavailable(reason, extra = {}) { + return { available: false, reason, data: null, ...extra }; +} + +/** + * Fetch the Programmable Voice Call resource (duration, price, timestamps). + */ +export async function fetchCallResource(callSid) { + if (!callSid) return unavailable('no callSid'); + try { + const { accountSid, client } = twilioAxios(); + const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Calls/${callSid}.json`; + const resp = await client.get(url); + const d = resp.data || {}; + return { + available: true, + data: { + sid: d.sid, + status: d.status, + duration: d.duration != null ? Number(d.duration) : null, + startTime: d.start_time || d.date_created, + endTime: d.end_time, + from: d.from, + to: d.to, + direction: d.direction, + price: d.price, + priceUnit: d.price_unit, + answeredBy: d.answered_by, + forwardedFrom: d.forwarded_from, + callerName: d.caller_name, + queueTime: d.queue_time, + }, + raw: d, + }; + } catch (err) { + const status = err.response?.status; + const msg = err.response?.data?.message || err.message; + logger(LOG_SCOPE, `fetchCallResource ${callSid} failed: ${msg}`, 'warn'); + return unavailable(status ? `HTTP ${status}: ${msg}` : msg, { status }); + } +} + +async function fetchInsightsUrl(path) { + const { client } = twilioAxios(); + const url = `https://insights.twilio.com/v1/Voice/${path}`; + const resp = await client.get(url); + return resp.data; +} + +/** + * Voice Insights call summary (requires Advanced Features on the account). + */ +export async function fetchCallInsightsSummary(callSid) { + if (!callSid) return unavailable('no callSid'); + try { + const data = await fetchInsightsUrl(`${callSid}/Summary`); + return { + available: true, + data: { + callSid: data.call_sid || callSid, + callState: data.call_state, + processingState: data.processing_state, + startTime: data.start_time, + endTime: data.end_time, + duration: data.duration, + connectDuration: data.connect_duration, + from: data.from, + to: data.to, + carrierEdge: data.carrier_edge, + sdkEdge: data.sdk_edge, + tags: data.tags, + attributes: data.attributes, + properties: data.properties, + }, + raw: data, + }; + } catch (err) { + const status = err.response?.status; + const msg = err.response?.data?.message || err.message; + if (status === 404 || status === 403) { + return unavailable( + status === 403 + ? 'Voice Insights Advanced Features may not be enabled' + : 'Insights summary not ready or not found', + { status, retryable: status === 404 }, + ); + } + logger(LOG_SCOPE, `fetchCallInsightsSummary ${callSid} failed: ${msg}`, 'warn'); + return unavailable(status ? `HTTP ${status}: ${msg}` : msg, { status }); + } +} + +/** + * Voice Insights per-call metrics (carrier_edge for PSTN outbound). + */ +export async function fetchCallInsightsMetrics(callSid, edge = 'carrier_edge') { + if (!callSid) return unavailable('no callSid'); + try { + const data = await fetchInsightsUrl(`${callSid}/Metrics?Edge=${encodeURIComponent(edge)}`); + const metrics = Array.isArray(data.metrics) ? data.metrics : []; + const highlights = { maxJitter: null, maxPacketLoss: null, minMos: null }; + for (const m of metrics) { + const name = String(m.name || '').toLowerCase(); + const vals = m.values || m.samples || []; + for (const v of vals) { + const num = Number(v?.value ?? v?.avg ?? v); + if (!Number.isFinite(num)) continue; + if (name.includes('jitter') && (highlights.maxJitter == null || num > highlights.maxJitter)) { + highlights.maxJitter = num; + } + if (name.includes('packet') && name.includes('loss') && (highlights.maxPacketLoss == null || num > highlights.maxPacketLoss)) { + highlights.maxPacketLoss = num; + } + if (name.includes('mos') && (highlights.minMos == null || num < highlights.minMos)) { + highlights.minMos = num; + } + } + } + return { + available: true, + data: { edge, metrics, highlights }, + raw: data, + }; + } catch (err) { + const status = err.response?.status; + const msg = err.response?.data?.message || err.message; + if (status === 404 || status === 403) { + return unavailable( + status === 403 + ? 'Voice Insights Advanced Features may not be enabled' + : 'Insights metrics not ready or not found', + { status, retryable: status === 404 }, + ); + } + logger(LOG_SCOPE, `fetchCallInsightsMetrics ${callSid} failed: ${msg}`, 'warn'); + return unavailable(status ? `HTTP ${status}: ${msg}` : msg, { status }); + } +} + export default { isTwilioConfigured, isCallTestEnabled, createOutboundCall, validateTwilioSignature, + fetchCallResource, + fetchCallInsightsSummary, + fetchCallInsightsMetrics, }; diff --git a/integrations/webex/WebexClient.js b/integrations/webex/WebexClient.js index 45f127c..737ee36 100644 --- a/integrations/webex/WebexClient.js +++ b/integrations/webex/WebexClient.js @@ -51,6 +51,37 @@ class WebexClient { } } + /** + * HTTP to analytics-calling / analytics.webexapis.com (cdr_feed, etc.). + * Bypasses HTTP(S)_PROXY by default — axios proxy-from-env often sends analytics + * subdomains through a corp proxy while webexapis.com is listed in NO_PROXY. + * Set WEBEX_CDR_USE_PROXY=true to honor the process proxy instead. + */ + async analyticsRequestRaw(method, url, { data = null, params = null, timeout = 15000 } = {}) { + const useProxy = String(process.env.WEBEX_CDR_USE_PROXY || '').toLowerCase() === 'true'; + const token = await this.auth.getAccessToken(); + + try { + const response = await axios({ + method, + url, + headers: { Authorization: `Bearer ${token}` }, + data, + params, + timeout, + proxy: useProxy ? undefined : false, + }); + return { data: response.data, headers: response.headers, status: response.status }; + } catch (err) { + if (err.response?.status === 401) { + logger('webex:client', '401 on analytics host — forcing token refresh', 'warn'); + await this.auth.forceRefresh(); + return this.analyticsRequestRaw(method, url, { data, params, timeout }); + } + throw err; + } + } + // Convenience wrappers async getMe() { return this.request('GET', 'people/me'); diff --git a/integrations/webex/reportsClient.js b/integrations/webex/reportsClient.js new file mode 100644 index 0000000..5327521 --- /dev/null +++ b/integrations/webex/reportsClient.js @@ -0,0 +1,177 @@ +// integrations/webex/reportsClient.js +// Webex Control Hub Reports API (Pro Pack) — Calling Media Quality. + +import axios from 'axios'; +import AdmZip from 'adm-zip'; +import webex from './WebexClient.js'; +import { logger } from '../../utils/logger.js'; +import { + filterMediaQualityRows, + parseMediaQualityCsv, + reportDateRangeFromWindow, +} from './reportsCsv.js'; + +export { filterMediaQualityRows, parseMediaQualityCsv, reportDateRangeFromWindow } from './reportsCsv.js'; + +const LOG_SCOPE = 'webex:reports'; +const TEMPLATE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +let _templateCache = null; +let _templateCacheAt = 0; + +function envInt(name, fallback) { + const v = Number(process.env[name]); + return Number.isFinite(v) ? v : fallback; +} + +function pollIntervalMs() { + return envInt('VOICEREPORT_REPORT_POLL_MS', 5000); +} + +function pollMaxMs() { + return envInt('VOICEREPORT_REPORT_POLL_MAX_MS', 180_000); +} + +export function _clearReportTemplateCacheForTests() { + _templateCache = null; + _templateCacheAt = 0; +} + +export async function listReportTemplates() { + const now = Date.now(); + if (_templateCache && now - _templateCacheAt < TEMPLATE_CACHE_TTL_MS) { + return _templateCache; + } + const data = await webex.request('GET', 'reports/templates'); + const items = data?.items || data?.reportTemplates || []; + _templateCache = items; + _templateCacheAt = now; + return items; +} + +/** + * Resolve template id by env override or fuzzy name match. + */ +export async function resolveMediaQualityTemplateId() { + const override = process.env.WEBEX_REPORT_TEMPLATE_MEDIA_QUALITY; + if (override && String(override).trim()) { + return Number(override); + } + const templates = await listReportTemplates(); + const hit = templates.find((t) => { + const name = String(t.name || t.title || '').toLowerCase(); + return name.includes('media quality') || name.includes('calling media'); + }); + return hit ? Number(hit.id ?? hit.templateId) : null; +} + +export async function createReport(templateId, { startDate, endDate, siteList } = {}) { + if (!templateId || !Number.isFinite(templateId)) { + return { ok: false, reason: 'no Media Quality report template id (set WEBEX_REPORT_TEMPLATE_MEDIA_QUALITY)' }; + } + const body = { + templateId: Number(templateId), + }; + if (startDate) body.startDate = startDate; + if (endDate) body.endDate = endDate; + if (siteList) body.siteList = siteList; + + try { + const data = await webex.request('POST', 'reports', body); + return { ok: true, report: data }; + } catch (err) { + const msg = err.response?.data?.message || err.message; + logger(LOG_SCOPE, `createReport failed: ${msg}`, 'warn'); + return { ok: false, reason: msg }; + } +} + +export async function getReport(reportId) { + return webex.request('GET', `reports/${reportId}`); +} + +export async function pollReportUntilDone(reportId) { + const deadline = Date.now() + pollMaxMs(); + while (Date.now() < deadline) { + const report = await getReport(reportId); + const status = String(report?.status || '').toLowerCase(); + if (status === 'done' || status === 'complete' || status === 'completed') { + return { ok: true, report }; + } + if (status === 'failed' || status === 'error') { + return { ok: false, reason: report?.statusMessage || 'report generation failed', report }; + } + await new Promise((r) => setTimeout(r, pollIntervalMs())); + } + return { ok: false, reason: 'report poll timed out' }; +} + +/** + * Download report payload (CSV text or first CSV inside ZIP). + */ +export async function downloadReportText(downloadURL) { + if (!downloadURL) return { ok: false, reason: 'no downloadURL' }; + const token = await webex.auth.getAccessToken(); + const resp = await axios.get(downloadURL, { + headers: { Authorization: `Bearer ${token}` }, + responseType: 'arraybuffer', + timeout: 60_000, + maxContentLength: 50 * 1024 * 1024, + }); + const buf = Buffer.from(resp.data); + if (buf[0] === 0x50 && buf[1] === 0x4b) { + return extractCsvFromZip(buf); + } + return { ok: true, text: buf.toString('utf8') }; +} + +function extractCsvFromZip(buf) { + try { + const zip = new AdmZip(buf); + const entry = zip.getEntries().find((e) => /\.csv$/i.test(e.entryName)); + if (!entry) return { ok: false, reason: 'no CSV inside report ZIP' }; + return { ok: true, text: entry.getData().toString('utf8') }; + } catch (err) { + return { ok: false, reason: `ZIP extract failed: ${err.message}` }; + } +} + +export async function fetchMediaQualityReport({ window, locationName }) { + const templateId = await resolveMediaQualityTemplateId(); + if (!templateId) { + return { available: false, reason: 'Media Quality template not found' }; + } + + const { startDate, endDate } = reportDateRangeFromWindow(window); + const created = await createReport(templateId, { startDate, endDate }); + if (!created.ok) { + return { available: false, reason: created.reason }; + } + + const reportId = created.report?.id; + if (!reportId) { + return { available: false, reason: 'create report returned no id' }; + } + + const polled = await pollReportUntilDone(reportId); + if (!polled.ok) { + return { available: false, reason: polled.reason }; + } + + const downloadURL = polled.report?.downloadURL || polled.report?.downloadUrl; + const dl = await downloadReportText(downloadURL); + if (!dl.ok) { + return { available: false, reason: dl.reason }; + } + + const parsed = parseMediaQualityCsv(dl.text); + const filtered = filterMediaQualityRows(parsed.rows, { locationName, window }); + + return { + available: true, + reportId, + rawCount: parsed.rows.length, + rows: filtered, + headers: parsed.headers, + }; +} diff --git a/integrations/webex/reportsCsv.js b/integrations/webex/reportsCsv.js new file mode 100644 index 0000000..e9de5d4 --- /dev/null +++ b/integrations/webex/reportsCsv.js @@ -0,0 +1,73 @@ +// integrations/webex/reportsCsv.js +// Pure CSV helpers for Webex Reports (no Webex client import). + +export function reportDateRangeFromWindow(window) { + const startDay = String(window.startTime).slice(0, 10); + const endDay = String(window.endTime).slice(0, 10); + return { startDate: startDay, endDate: endDay }; +} + +function splitCsvLine(line) { + const out = []; + let cur = ''; + let inQ = false; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (c === '"') { + inQ = !inQ; + continue; + } + if (c === ',' && !inQ) { + out.push(cur.trim()); + cur = ''; + continue; + } + cur += c; + } + out.push(cur.trim()); + return out; +} + +function normKey(k) { + return String(k || '').toLowerCase().replace(/[^a-z0-9]+/g, ''); +} + +export function parseMediaQualityCsv(text) { + if (!text || !String(text).trim()) return { headers: [], rows: [] }; + const lines = String(text).split(/\r?\n/).filter((l) => l.trim()); + if (!lines.length) return { headers: [], rows: [] }; + const headers = splitCsvLine(lines[0]); + const rows = []; + for (let i = 1; i < lines.length; i++) { + const cols = splitCsvLine(lines[i]); + if (!cols.some(Boolean)) continue; + const row = {}; + headers.forEach((h, idx) => { row[h] = cols[idx] ?? ''; }); + row._norm = Object.fromEntries( + headers.map((h, idx) => [normKey(h), cols[idx] ?? '']), + ); + rows.push(row); + } + return { headers, rows }; +} + +export function filterMediaQualityRows(rows, { locationName, window }) { + const loc = String(locationName || '').toLowerCase(); + const startMs = new Date(window.startTime).getTime(); + const endMs = new Date(window.endTime).getTime(); + + return (rows || []).filter((row) => { + const n = row._norm || {}; + const rowLoc = String( + n.location || n.sitelocation || n.sitename || row.Location || '', + ).toLowerCase(); + if (loc && rowLoc && !rowLoc.includes(loc) && !loc.includes(rowLoc)) { + return false; + } + const startRaw = n.starttime || n.callstarttime || n.start || row['Start Time'] || ''; + if (!startRaw) return true; + const t = new Date(startRaw).getTime(); + if (!Number.isFinite(t)) return true; + return t >= startMs && t <= endMs; + }); +} diff --git a/package-lock.json b/package-lock.json index 1132c1f..b51840d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,14 @@ "name": "collabfinder", "version": "1.0.0", "dependencies": { + "adm-zip": "^0.5.18", "async-mutex": "^0.5.0", "axios": "^1.13.6", "dotenv": "^17.3.1", "express": "^5.2.1", "form-data": "^4.0.5", "graphql-request": "^7.4.0", + "luxon": "^3.7.2", "node-cron": "^4.2.1", "twilio": "^6.0.2", "webex-node-bot-framework": "^2.5.1", @@ -4517,6 +4519,15 @@ "node": ">=0.4.0" } }, + "node_modules/adm-zip": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz", + "integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -8687,6 +8698,15 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", diff --git a/package.json b/package.json index 3dad156..24e0ecf 100644 --- a/package.json +++ b/package.json @@ -19,12 +19,14 @@ "test": "node --test tests/*.test.js" }, "dependencies": { + "adm-zip": "^0.5.18", "async-mutex": "^0.5.0", "axios": "^1.13.6", "dotenv": "^17.3.1", "express": "^5.2.1", "form-data": "^4.0.5", "graphql-request": "^7.4.0", + "luxon": "^3.7.2", "node-cron": "^4.2.1", "twilio": "^6.0.2", "webex-node-bot-framework": "^2.5.1", diff --git a/services/callTest/callTestService.js b/services/callTest/callTestService.js index 7a54000..8141979 100644 --- a/services/callTest/callTestService.js +++ b/services/callTest/callTestService.js @@ -26,7 +26,7 @@ import { isCoreStep, } from './twiml.js'; import { - renderCallTestCdrMarkdown, + renderCallTestCdrMatchMarkdown, renderCallTestResultMarkdown, renderCallTestStartedMarkdown, } from '../renderers/callTestRenderer.js'; @@ -104,28 +104,17 @@ function computeResult(session) { } async function scheduleCdrEnrichment(session) { - const cfg = getCallTestConfig(session.storeNum); - if (!cfg.cdrEnrich || session.mode !== 'store' || !session.personId) return; + const { scheduleCdrMatchEnrichment } = await import('./cdrMatcher.js'); + scheduleCdrMatchEnrichment(session, { + notifyRoom, + renderMarkdown: (s, result) => renderCallTestCdrMatchMarkdown(s, result), + }); +} - const delay = cfg.cdrDelayMs; - logger(LOG_SCOPE, `Scheduling CDR enrich for ${session.testId} in ${delay}ms`, 'debug'); - - setTimeout(async () => { - try { - const { getHistoricalCallActivity } = await import('../phoneService.js'); - const cdr = await getHistoricalCallActivity(session.personId, 12, { - locationName: session.locationName, - }); - const md = renderCallTestCdrMarkdown(session, cdr); - await notifyRoom(session.roomId, md); - } catch (err) { - logger(LOG_SCOPE, `CDR enrich failed for ${session.testId}: ${err.message}`, 'warn'); - await notifyRoom( - session.roomId, - `**Call test CDR** (\`${session.testId}\`)\n\n_CDR fetch failed:_ ${err.message}`, - ); - } - }, delay).unref?.(); +async function schedulePostCallEnrichment(session) { + const { scheduleTwilioEnrichment } = await import('./twilioEnrichment.js'); + scheduleTwilioEnrichment(session, notifyRoom); + await scheduleCdrEnrichment(session); } async function finalizeSession(testId) { @@ -145,10 +134,7 @@ async function finalizeSession(testId) { if (next?.roomId) { await notifyRoom(next.roomId, renderCallTestResultMarkdown(next)); - } - - if (next?.mode === 'store') { - scheduleCdrEnrichment(next); + await schedulePostCallEnrichment(next); } return next; diff --git a/services/callTest/cdrMatcher.js b/services/callTest/cdrMatcher.js new file mode 100644 index 0000000..0a70d99 --- /dev/null +++ b/services/callTest/cdrMatcher.js @@ -0,0 +1,214 @@ +// services/callTest/cdrMatcher.js +// Narrow-window Webex CDR fetch + match the specific /calltest leg. + +import { logger } from '../../utils/logger.js'; +import { getCallTestConfig } from './config.js'; +import { getSession, updateSession } from './sessionStore.js'; +import { + cdrCalledNumber, + cdrCallingNumber, + cdrDirectionValue, + cdrDispositionValue, + cdrDurationSeconds, + cdrStartTime, +} from '../cdrFeedParser.js'; + +const LOG_SCOPE = 'calltest:cdr'; + +const FIVE_MIN_MS = 5 * 60 * 1000; +const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000; + +export function phoneDigits(value) { + const d = String(value || '').replace(/\D/g, ''); + if (d.length === 10) return `1${d}`; + if (d.length === 11 && d.startsWith('1')) return d; + return d; +} + +/** + * Compute cdr_feed window from session events (Webex API rules applied). + */ +export function computeCdrWindow(session, bufferMs) { + const events = session.events || {}; + const startMs = new Date( + events.initiated || events.coreStartedAt || session.createdAt || Date.now(), + ).getTime() - bufferMs; + + let endMs = new Date(events.completed || Date.now()).getTime() + bufferMs; + + const latestAllowedEnd = Date.now() - FIVE_MIN_MS; + if (endMs > latestAllowedEnd) endMs = latestAllowedEnd; + + let start = startMs; + if (endMs - start > TWELVE_HOURS_MS) { + start = endMs - TWELVE_HOURS_MS; + } + if (start > endMs) { + start = endMs - Math.min(TWELVE_HOURS_MS, 30 * 60 * 1000); + } + + return { + startTime: new Date(start).toISOString(), + endTime: new Date(endMs).toISOString(), + }; +} + +/** + * Score CDR leg by destination (called number) matching the dialed test number. + * Time proximity is only a tiebreaker when multiple legs hit the same DID. + */ +export function scoreCdrLeg(item, session) { + const dialDigits = phoneDigits(session.dialNumber); + if (!dialDigits) return 0; + + const called = phoneDigits(cdrCalledNumber(item) || ''); + if (called !== dialDigits) return 0; + + let score = 100; + + const itemStart = new Date(cdrStartTime(item) || 0).getTime(); + const anchor = session.events?.initiated + || session.events?.answered + || session.events?.coreStartedAt; + if (anchor && itemStart) { + const delta = Math.abs(itemStart - new Date(anchor).getTime()); + if (delta < 120_000) score += 20; + else if (delta < 300_000) score += 5; + } + + return score; +} + +export function pickBestCdrLeg(items, session) { + if (!items?.length) return null; + let best = null; + let bestScore = 0; + for (const item of items) { + const s = scoreCdrLeg(item, session); + if (s > bestScore) { + bestScore = s; + best = item; + } + } + if (!best || bestScore < 100) return null; + return { leg: best, score: bestScore }; +} + +export function formatCdrLeg(item) { + if (!item) return null; + return { + start: cdrStartTime(item), + direction: cdrDirectionValue(item), + duration: cdrDurationSeconds(item), + status: cdrDispositionValue(item), + callingNumber: cdrCallingNumber(item), + calledNumber: cdrCalledNumber(item), + otherParty: item.otherParty || item.remoteParty, + }; +} + +async function resolveCdrContext(session) { + if (session.locationName && session.personId) { + return { + personId: session.personId, + locationName: session.locationName, + }; + } + if (session.locationName) { + return { personId: session.personId || null, locationName: session.locationName }; + } + if (session.mode === 'dial') { + const { resolveLocationForDialNumber } = await import('./locationResolver.js'); + const loc = await resolveLocationForDialNumber(session.dialNumber); + if (loc) { + return { personId: null, locationName: loc.locationName, locationId: loc.locationId }; + } + } + return null; +} + +/** + * Fetch CDR and match the test call leg. + */ +export async function fetchMatchedCdrForSession(session) { + const ctx = await resolveCdrContext(session); + if (!ctx?.locationName) { + return { + available: false, + reason: session.mode === 'dial' + ? 'dialed number is not a known Webex location main number' + : 'no locationName on session', + match: null, + }; + } + + const cfg = session.config || getCallTestConfig(session.storeNum); + const { startTime, endTime } = computeCdrWindow(session, cfg.cdrMatchBufferMs); + + const { getHistoricalCallActivity } = await import('../phoneService.js'); + const cdr = await getHistoricalCallActivity(ctx.personId, 12, { + locationName: ctx.locationName, + startTime, + endTime, + returnRawItems: true, + skipPersonFilter: !ctx.personId, + }); + + if (!cdr.available) { + return { available: false, reason: cdr.reason, match: null, window: { startTime, endTime } }; + } + + const items = cdr.rawItems || []; + const picked = pickBestCdrLeg(items, session); + + return { + available: true, + location: ctx.locationName, + window: { startTime, endTime }, + rawCount: items.length, + match: picked ? { ...formatCdrLeg(picked.leg), score: picked.score } : null, + reason: picked ? null : `no leg matched (${items.length} records in window)`, + }; +} + +const _cdrTimers = new Map(); + +export function scheduleCdrMatchEnrichment(session, { notifyRoom, renderMarkdown }) { + const cfg = session.config || getCallTestConfig(session.storeNum); + if (!cfg.cdrEnrich || !session.roomId) return; + + const minDelay = Math.max(cfg.cdrDelayMs, FIVE_MIN_MS + 30_000); + if (_cdrTimers.has(session.testId)) return; + + const timer = setTimeout(async () => { + _cdrTimers.delete(session.testId); + const cur = getSession(session.testId); + if (!cur) return; + + try { + logger(LOG_SCOPE, `CDR match fetch for testId=${session.testId}`, 'info'); + const result = await fetchMatchedCdrForSession(cur); + updateSession(session.testId, { + enrichment: { + ...(cur.enrichment || {}), + cdr: { ...result, fetchedAt: new Date().toISOString() }, + }, + }); + await notifyRoom(session.roomId, renderMarkdown(getSession(session.testId), result)); + } catch (err) { + logger(LOG_SCOPE, `CDR match failed for ${session.testId}: ${err.message}`, 'warn'); + await notifyRoom( + session.roomId, + `**Call test CDR** (\`${session.testId}\`)\n\n_CDR match failed:_ ${err.message}`, + ); + } + }, minDelay); + + if (typeof timer.unref === 'function') timer.unref(); + _cdrTimers.set(session.testId, timer); +} + +export function _clearCdrTimersForTests() { + for (const t of _cdrTimers.values()) clearTimeout(t); + _cdrTimers.clear(); +} diff --git a/services/callTest/config.js b/services/callTest/config.js index 33acf12..2f2aaf5 100644 --- a/services/callTest/config.js +++ b/services/callTest/config.js @@ -67,6 +67,15 @@ export function getCallTestConfig(storeNum = null) { return v === 'true' || v === '1' || v === 'yes'; })(), cdrDelayMs: envInt('CALLTEST_CDR_DELAY_MS', 360_000), + cdrMatchBufferMs: envInt('CALLTEST_CDR_MATCH_BUFFER_MS', 120_000), + twilioEnrich: (() => { + const v = String(process.env.CALLTEST_TWILIO_ENRICH ?? 'true').toLowerCase(); + return v === 'true' || v === '1' || v === 'yes'; + })(), + twilioDetailsDelayMs: envInt('CALLTEST_TWILIO_DETAILS_DELAY_MS', 10_000), + twilioInsightsDelayMs: envInt('CALLTEST_TWILIO_INSIGHTS_DELAY_MS', 360_000), + twilioInsightsRetries: envInt('CALLTEST_TWILIO_INSIGHTS_RETRIES', 3), + twilioInsightsRetryIntervalMs: envInt('CALLTEST_TWILIO_INSIGHTS_RETRY_MS', 30_000), }; } diff --git a/services/callTest/locationResolver.js b/services/callTest/locationResolver.js new file mode 100644 index 0000000..7c42f33 --- /dev/null +++ b/services/callTest/locationResolver.js @@ -0,0 +1,116 @@ +// services/callTest/locationResolver.js +// Resolve Webex Calling location from a dialed E.164 (store main / AA DID). + +import { logger } from '../../utils/logger.js'; +import { normalizeE164 } from './config.js'; + +const LOG_SCOPE = 'calltest:location'; +const CACHE_TTL_MS = 60 * 60 * 1000; + +let _phoneIndex = null; +let _phoneIndexAt = 0; + +async function getWebex() { + const mod = await import('../../integrations/webex/WebexClient.js'); + return mod.default; +} + +function parseLinkNext(linkHeader) { + if (!linkHeader || typeof linkHeader !== 'string') return null; + for (const part of linkHeader.split(',')) { + const m = part.match(/<([^>]+)>\s*;\s*rel\s*=\s*"?next"?/i); + if (m) return m[1]; + } + return null; +} + +function phoneDigits(value) { + const d = String(value || '').replace(/\D/g, ''); + if (d.length === 10) return `1${d}`; + if (d.length === 11 && d.startsWith('1')) return d; + return d; +} + +async function fetchAllNumbers() { + const webex = await getWebex(); + const all = []; + let nextUrl = null; + let data; + let headers; + + ({ data, headers } = await webex.requestRaw('GET', 'telephony/config/numbers', null, { max: 1000 })); + const batch = data?.phoneNumbers || []; + all.push(...batch); + nextUrl = parseLinkNext(headers?.link || headers?.Link); + + while (nextUrl) { + ({ data, headers } = await webex.requestRaw('GET', nextUrl)); + all.push(...(data?.phoneNumbers || [])); + nextUrl = parseLinkNext(headers?.link || headers?.Link); + } + + return all; +} + +async function buildPhoneIndex() { + const now = Date.now(); + if (_phoneIndex && now - _phoneIndexAt < CACHE_TTL_MS) { + return _phoneIndex; + } + + logger(LOG_SCOPE, 'Building location phone index from telephony/config/numbers', 'debug'); + const numbers = await fetchAllNumbers(); + const index = new Map(); + + for (const n of numbers) { + const raw = n.phoneNumber || n.number || n.value; + const loc = n.location; + if (!raw || !loc?.id) continue; + + const entry = { + locationId: loc.id, + locationName: loc.name || null, + phoneNumber: raw, + }; + + const keys = new Set([phoneDigits(raw), phoneDigits(normalizeE164(raw) || raw)]); + for (const k of keys) { + if (k) index.set(k, entry); + } + } + + _phoneIndex = index; + _phoneIndexAt = now; + logger(LOG_SCOPE, `Phone index built: ${index.size} keys from ${numbers.length} numbers`, 'debug'); + return index; +} + +/** + * Find Webex location for a dialed E.164 (e.g. store main on /calltest dial). + * @returns {Promise<{locationId: string, locationName: string, phoneNumber: string}|null>} + */ +export async function resolveLocationForDialNumber(dialNumber) { + const e164 = normalizeE164(dialNumber); + if (!e164) return null; + + try { + const index = await buildPhoneIndex(); + const key = phoneDigits(e164); + const hit = index.get(key); + if (!hit?.locationName) return null; + + return { + locationId: hit.locationId, + locationName: hit.locationName, + phoneNumber: hit.phoneNumber, + }; + } catch (err) { + logger(LOG_SCOPE, `resolveLocationForDialNumber failed: ${err.message}`, 'warn'); + return null; + } +} + +export function _clearLocationCacheForTests() { + _phoneIndex = null; + _phoneIndexAt = 0; +} diff --git a/services/callTest/sessionStore.js b/services/callTest/sessionStore.js index d840288..bcaec08 100644 --- a/services/callTest/sessionStore.js +++ b/services/callTest/sessionStore.js @@ -25,6 +25,7 @@ export function createSession(testId, data) { reachedCore: false, failedAt: null, callSid: null, + enrichment: { twilio: {}, cdr: {} }, ...data, }; _store.set(testId, session); diff --git a/services/callTest/twilioEnrichment.js b/services/callTest/twilioEnrichment.js new file mode 100644 index 0000000..6f3a9cb --- /dev/null +++ b/services/callTest/twilioEnrichment.js @@ -0,0 +1,143 @@ +// services/callTest/twilioEnrichment.js +// Post-call Twilio Call resource + Voice Insights enrichment. + +import { + fetchCallInsightsMetrics, + fetchCallInsightsSummary, + fetchCallResource, +} from '../../integrations/twilio/client.js'; +import { logger } from '../../utils/logger.js'; +import { getCallTestConfig } from './config.js'; +import { getSession, updateSession } from './sessionStore.js'; +import { + renderCallTestTwilioDetailsMarkdown, + renderCallTestTwilioInsightsMarkdown, +} from '../renderers/callTestRenderer.js'; + +const LOG_SCOPE = 'calltest:twilio-enrich'; + +const _detailTimers = new Map(); +const _insightTimers = new Map(); + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function runDetailsEnrichment(testId, notifyRoom) { + const session = getSession(testId); + if (!session?.callSid || !session.roomId) return; + + logger(LOG_SCOPE, `Fetching Twilio details for testId=${testId} callSid=${session.callSid}`, 'info'); + const details = await fetchCallResource(session.callSid); + + const cur = getSession(testId); + if (!cur) return; + + updateSession(testId, { + enrichment: { + ...(cur.enrichment || {}), + twilio: { + ...(cur.enrichment?.twilio || {}), + details, + detailsFetchedAt: new Date().toISOString(), + }, + }, + }); + + await notifyRoom( + session.roomId, + renderCallTestTwilioDetailsMarkdown(getSession(testId), details), + ); +} + +async function runInsightsEnrichment(testId, notifyRoom, cfg) { + const session = getSession(testId); + if (!session?.callSid || !session.roomId) return; + + let summary = null; + let metrics = null; + let lastReason = null; + + for (let attempt = 0; attempt <= cfg.twilioInsightsRetries; attempt++) { + if (attempt > 0) { + await sleep(cfg.twilioInsightsRetryIntervalMs); + } + + summary = await fetchCallInsightsSummary(session.callSid); + metrics = await fetchCallInsightsMetrics(session.callSid); + + const summaryOk = summary.available; + const metricsOk = metrics.available; + const retryable = summary.retryable || metrics.retryable; + + if (summaryOk || metricsOk) break; + lastReason = summary.reason || metrics.reason; + if (!retryable) break; + + logger( + LOG_SCOPE, + `Insights not ready for ${session.callSid} (attempt ${attempt + 1}/${cfg.twilioInsightsRetries + 1}): ${lastReason}`, + 'debug', + ); + } + + const insights = { + available: summary?.available || metrics?.available || false, + reason: lastReason, + summary: summary?.available ? summary.data : null, + metrics: metrics?.available ? metrics.data : null, + summaryRaw: summary?.raw, + metricsRaw: metrics?.raw, + }; + + const cur = getSession(testId); + if (!cur) return; + + updateSession(testId, { + enrichment: { + ...(cur.enrichment || {}), + twilio: { + ...(cur.enrichment?.twilio || {}), + insights, + insightsFetchedAt: new Date().toISOString(), + }, + }, + }); + + await notifyRoom( + session.roomId, + renderCallTestTwilioInsightsMarkdown(getSession(testId), insights), + ); +} + +export function scheduleTwilioEnrichment(session, notifyRoom) { + const cfg = session.config || getCallTestConfig(session.storeNum); + if (!cfg.twilioEnrich || !session.callSid || !session.roomId) return; + + const testId = session.testId; + + if (!_detailTimers.has(testId)) { + const t = setTimeout(() => { + _detailTimers.delete(testId); + void runDetailsEnrichment(testId, notifyRoom); + }, cfg.twilioDetailsDelayMs); + if (typeof t.unref === 'function') t.unref(); + _detailTimers.set(testId, t); + } + + if (!_insightTimers.has(testId)) { + const t = setTimeout(() => { + _insightTimers.delete(testId); + void runInsightsEnrichment(testId, notifyRoom, cfg); + }, cfg.twilioInsightsDelayMs); + if (typeof t.unref === 'function') t.unref(); + _insightTimers.set(testId, t); + } +} + +export function _clearTwilioEnrichmentTimersForTests() { + for (const t of _detailTimers.values()) clearTimeout(t); + for (const t of _insightTimers.values()) clearTimeout(t); + _detailTimers.clear(); + _insightTimers.clear(); +} diff --git a/services/cdrFeedParser.js b/services/cdrFeedParser.js new file mode 100644 index 0000000..2ace284 --- /dev/null +++ b/services/cdrFeedParser.js @@ -0,0 +1,132 @@ +// services/cdrFeedParser.js +// Parse cdr_feed JSON payloads (field names vary by Webex release). + +function normKey(key) { + return String(key).toLowerCase().replace(/[^a-z0-9]/g, ''); +} + +function indexCdrRecord(item) { + if (!item || typeof item !== 'object') return {}; + const idx = {}; + for (const [k, v] of Object.entries(item)) { + idx[normKey(k)] = v; + } + return idx; +} + +/** Read a CDR field by any known alias (camelCase or report column label). */ +export function cdrField(item, ...aliases) { + if (!item) return null; + const idx = indexCdrRecord(item); + for (const alias of aliases) { + const v = idx[normKey(alias)]; + if (v != null && v !== '') return v; + } + return null; +} + +export function cdrRecordId(item) { + return cdrField( + item, + 'reportId', 'Report ID', 'reportID', + 'callRecordingId', + ); +} + +export function cdrStartTime(item) { + return cdrField( + item, + 'startTime', 'start', 'callStartTime', + 'Start time', 'Start Time', + 'answerTime', 'Answer time', 'Answer Time', + ); +} + +export function cdrDurationSeconds(item) { + const raw = cdrField( + item, + 'durationSeconds', 'duration', 'callDuration', 'talkDuration', 'length', + 'Duration', + ); + return Number(raw || 0); +} + +export function cdrDirectionValue(item) { + return String(cdrField( + item, + 'direction', 'callDirection', 'callType', 'directionIndicator', + 'Direction', 'Call direction', 'Call Direction', + ) || '').toUpperCase(); +} + +export function cdrDispositionValue(item) { + return String(cdrField( + item, + 'status', 'result', 'callResult', 'callOutcome', 'callStatus', + 'Disposition', 'Answer indicator', 'Call outcome', + ) || 'unknown'); +} + +export function cdrCallingNumber(item) { + return cdrField( + item, + 'callingNumber', 'callingParty', 'callingName', + 'Calling number', 'Calling Number', + 'User number', 'User Number', + ); +} + +export function cdrCalledNumber(item) { + return cdrField( + item, + 'calledNumber', 'calledParty', 'calledName', + 'Called number', 'Called Number', + ); +} + +export function cdrDedupKey(item) { + const id = cdrRecordId(item); + if (id) return `id:${id}`; + return [ + cdrStartTime(item) || '', + cdrCallingNumber(item) || '', + cdrCalledNumber(item) || '', + cdrDurationSeconds(item) || '', + ].join('|'); +} + +export function extractCdrFeedList(data) { + if (!data) return []; + if (Array.isArray(data)) return data; + if (Array.isArray(data.items)) return data.items; + if (data.items && typeof data.items === 'object' && data.items !== null) { + const keys = Object.keys(data.items).filter((k) => /^\d+$/.test(k)).sort((a, b) => parseInt(a, 10) - parseInt(b, 10)); + if (keys.length > 0) return keys.map((k) => data.items[k]); + } + const topKeys = Object.keys(data).filter((k) => /^\d+$/.test(k)).sort((a, b) => parseInt(a, 10) - parseInt(b, 10)); + if (topKeys.length > 0) return topKeys.map((k) => data[k]); + for (const key of ['records', 'cdrRecords', 'callHistory', 'calls']) { + if (Array.isArray(data[key])) return data[key]; + } + if (data.data) { + if (Array.isArray(data.data)) return data.data; + if (Array.isArray(data.data.items)) return data.data.items; + } + for (const v of Object.values(data)) { + if (Array.isArray(v)) return v; + } + return []; +} + +export function describeCdrFeedPayload(data) { + if (!data) return 'empty body'; + if (Array.isArray(data)) return `array[${data.length}]`; + const keys = Object.keys(data); + const parts = [`keys=${keys.slice(0, 12).join(',')}`]; + for (const k of ['items', 'records', 'cdrRecords', 'next', 'data']) { + const v = data[k]; + if (Array.isArray(v)) parts.push(`${k}.length=${v.length}`); + else if (v && typeof v === 'object') parts.push(`${k}.keys=${Object.keys(v).slice(0, 6).join(',')}`); + } + return parts.join(' '); +} diff --git a/services/phoneService.js b/services/phoneService.js index eaf44f9..643ab65 100644 --- a/services/phoneService.js +++ b/services/phoneService.js @@ -13,6 +13,17 @@ import { import { logger } from '../utils/logger.js'; import { normalizeMac } from './enrichment/normalizers.js'; import { attachMerakiClientWithPorts } from './enrichment/merakiEnrichment.js'; +import { + extractCdrFeedList, + describeCdrFeedPayload, + cdrCalledNumber, + cdrCallingNumber, + cdrDedupKey, + cdrDirectionValue, + cdrDispositionValue, + cdrDurationSeconds, + cdrStartTime, +} from './cdrFeedParser.js'; // ────────────────────────────────────────────── // Main public function @@ -594,29 +605,71 @@ export function computeRecentActivity(phones = [], dectBases = [], dectHandsets // The `locations` name comes from the person's DECT network (already fetched for main number / dect logic). // Secondary client-side filter on person numbers inside the location results. // (recent activity and historical calls features disabled per request) -export async function getHistoricalCallActivity(personId, hours = 24, options = {}) { - if (!personId) return { available: false, reason: 'no personId', calls: [], summary: {} }; +/** Last 6 chars of bearer token — enough to confirm curl vs bot use the same credential. */ +function tokenFingerprint(token) { + if (!token || typeof token !== 'string') return 'none'; + return `…${token.slice(-6)}`; +} - const { locationName: providedLocationName } = options || {}; +function getCdrAnalyticsBases() { + const env = process.env.WEBEX_CDR_ANALYTICS_BASES; + if (env) { + return env.split(',').map((s) => s.trim()).filter(Boolean); + } + return [ + 'https://analytics-calling.webexapis.com/v1', + 'https://analytics.webexapis.com/v1', + ]; +} + +function formatCdrFetchError(err) { + const msg = err?.message || String(err); + if (msg.includes('ECONNREFUSED') && (msg.includes('0.0.0.0') || msg.includes('127.0.0.1'))) { + return ( + `${msg} — analytics host unreachable from this container. ` + + 'Often caused by HTTP(S)_PROXY routing analytics-calling.webexapis.com through a broken proxy ' + + 'while webexapis.com is in NO_PROXY. CDR requests bypass proxy by default; add *.webexapis.com to NO_PROXY ' + + 'or set WEBEX_CDR_USE_PROXY=true if a proxy is required.' + ); + } + return msg; +} + +export async function getHistoricalCallActivity(personId, hours = 24, options = {}) { + const { + locationName: providedLocationName, + startTime: optStartTime, + endTime: optEndTime, + returnRawItems = false, + skipPersonFilter = false, + } = options || {}; + + if (!personId && !providedLocationName) { + return { available: false, reason: 'no personId or locationName', calls: [], summary: {} }; + } // Prefer analytics-calling (user-confirmed working base for cdr_feed). Fall back to the other if needed. - const ANALYTICS_BASES = [ - 'https://analytics-calling.webexapis.com/v1', - 'https://analytics.webexapis.com/v1' - ]; + const ANALYTICS_BASES = getCdrAnalyticsBases(); const CDR_PATH = '/cdr_feed'; const MAX_PER_PAGE = 1000; try { - const person = await webex.request('GET', `people/${personId}`); + const person = personId ? await webex.request('GET', `people/${personId}`) : null; const orgId = person?.orgId; const token = await webex.auth.getAccessToken(); - const axiosMod = (await import('axios')).default; // Get the *required* locations=name from DECT (passed in or self-discover). let locationName = providedLocationName; if (!locationName || locationName === '—') { + if (!personId) { + return { + available: false, + reason: 'no locationName (the /cdr_feed API requires the "locations" param)', + calls: [], + summary: {}, + }; + } try { const dectNets = await getDectNetworksForPerson(personId); if (dectNets.length > 0) { @@ -636,6 +689,7 @@ export async function getHistoricalCallActivity(personId, hours = 24, options = summary: {}, }; } + locationName = String(locationName).trim(); // Person identifiers for secondary filtering inside the location results. const userIds = new Set(); @@ -682,66 +736,65 @@ export async function getHistoricalCallActivity(personId, hours = 24, options = const FIVE_MIN_MS = 5 * 60 * 1000; const TWELVE_HOURS_MS = 12 * 3600 * 1000; - const endMs = Date.now() - FIVE_MIN_MS; - const startMs = endMs - TWELVE_HOURS_MS; + let endMs; + let startMs; + if (optStartTime && optEndTime) { + startMs = new Date(optStartTime).getTime(); + endMs = new Date(optEndTime).getTime(); + const latestAllowedEnd = Date.now() - FIVE_MIN_MS; + if (endMs > latestAllowedEnd) endMs = latestAllowedEnd; + if (endMs - startMs > TWELVE_HOURS_MS) startMs = endMs - TWELVE_HOURS_MS; + } else { + endMs = Date.now() - FIVE_MIN_MS; + startMs = endMs - TWELVE_HOURS_MS; + } const startTime = new Date(startMs).toISOString(); const endTime = new Date(endMs).toISOString(); - logger('phone:service', `Attempting Detailed Call History via cdr_feed for person ${personId} (org ${orgId || 'unknown'}) location="${locationName}" window=${startTime}..${endTime}`, 'debug'); + const tokenHint = tokenFingerprint(token); + logger( + 'phone:service', + `cdr_feed query location=${JSON.stringify(locationName)} window=${startTime}..${endTime} ` + + `token=${tokenHint}`, + 'info', + ); const allRawItems = []; const fetchErrors = []; - // Helper to robustly extract list, supporting: - // - direct array - // - .items as array - // - .items as { "0": rec, "1": rec, ... } (items[0], items[1] style) - // - top level numeric keys on the data object - // - fallback to first array value found - function extractList(data) { - if (!data) return []; - if (Array.isArray(data)) return data; - if (Array.isArray(data.items)) return data.items; - if (data.items && typeof data.items === 'object' && data.items !== null) { - const keys = Object.keys(data.items).filter(k => /^\d+$/.test(k)).sort((a, b) => parseInt(a) - parseInt(b)); - if (keys.length > 0) return keys.map(k => data.items[k]); - } - // top-level numeric keyed? - const topKeys = Object.keys(data).filter(k => /^\d+$/.test(k)).sort((a, b) => parseInt(a) - parseInt(b)); - if (topKeys.length > 0) return topKeys.map(k => data[k]); - // any array value - for (const v of Object.values(data)) { - if (Array.isArray(v)) return v; - } - return []; - } - let usedBase = null; let firstRespData = null; + let lastHttpStatus = null; for (const base of ANALYTICS_BASES) { const url = `${base}${CDR_PATH}`; const params = { startTime, endTime, locations: locationName, max: MAX_PER_PAGE }; const queryStr = new URLSearchParams(params).toString(); const fullUrl = `${url}?${queryStr}`; - logger('phone:service', `cdr_feed request URL: ${fullUrl}`, 'debug'); + logger('phone:service', `cdr_feed GET ${fullUrl}`, 'info'); try { - const resp = await axiosMod.get(url, { - headers: { Authorization: `Bearer ${token}` }, - params, - timeout: 15000 - }); - logger('phone:service', `cdr_feed response status=${resp.status} base=${base}`, 'debug'); - + const resp = await webex.analyticsRequestRaw('GET', url, { params, timeout: 15000 }); + lastHttpStatus = resp.status; const d = resp.data || {}; firstRespData = d; - const list = extractList(d); + const list = extractCdrFeedList(d); allRawItems.push(...list); usedBase = base; - logger('phone:service', `cdr_feed hit on ${base} (loc=${locationName}) → ${list.length} raw (keys=${Object.keys(d).slice(0,8).join(',')})`, 'debug'); + logger( + 'phone:service', + `cdr_feed ${base} → HTTP ${resp.status}, parsed ${list.length} record(s) (${describeCdrFeedPayload(d)})`, + 'info', + ); + if (list.length === 0 && d && Object.keys(d).length > 0) { + logger( + 'phone:service', + `cdr_feed HTTP ${resp.status} but 0 parseable records — payload shape may have changed (${describeCdrFeedPayload(d)})`, + 'warn', + ); + } // Pagination support (up to 10 additional pages per rate limit) let nextToken = d.next || d['next'] || (d.metadata && d.metadata.next) || null; @@ -759,14 +812,13 @@ export async function getHistoricalCallActivity(personId, hours = 24, options = const pageFull = pageParams ? `${pageUrl}?${pageQuery}` : pageUrl; logger('phone:service', `cdr_feed pagination page ${page} URL: ${pageFull}`, 'debug'); try { - const pResp = await axiosMod.get(pageUrl, { - headers: { Authorization: `Bearer ${token}` }, - params: pageParams, - timeout: 15000 + const pResp = await webex.analyticsRequestRaw('GET', pageUrl, { + params: pageParams || undefined, + timeout: 15000, }); logger('phone:service', `cdr_feed pagination status=${pResp.status}`, 'debug'); const pd = pResp.data || {}; - const pList = extractList(pd); + const pList = extractCdrFeedList(pd); allRawItems.push(...pList); nextToken = pd.next || pd['next'] || (pd.metadata && pd.metadata.next) || null; } catch (pe) { @@ -778,28 +830,57 @@ export async function getHistoricalCallActivity(personId, hours = 24, options = break; // first successful HTTP response (even if 0 items = empty window is valid) } catch (e) { const st = e.response?.status; - const bd = e.response?.data || e.message; - logger('phone:service', `cdr_feed ERROR for ${fullUrl} status=${st || 'n/a'}: ${JSON.stringify(bd).slice(0,300)}`, 'debug'); - fetchErrors.push({ base, startTime, endTime, status: st, body: bd }); + const errMsg = formatCdrFetchError(e); + const bd = e.response?.data || errMsg; + logger( + 'phone:service', + `cdr_feed ERROR ${fullUrl} status=${st || 'n/a'}: ${typeof bd === 'string' ? bd : JSON.stringify(bd).slice(0, 300)}`, + 'warn', + ); + fetchErrors.push({ base, startTime, endTime, status: st, body: bd, message: errMsg }); } } + if (allRawItems.length === 0 && fetchErrors.length > 0) { + const errSummary = fetchErrors + .map((e) => `${e.base.replace('https://', '')}:${e.status || 'err'}`) + .join(', '); + const first = fetchErrors[0]; + const msg = first?.message || first?.body?.message || first?.body?.error || first?.body || 'unknown error'; + let reason = `cdr_feed failed on all bases (${errSummary}): ${typeof msg === 'string' ? msg : JSON.stringify(msg).slice(0, 200)}`; + if (fetchErrors.some((e) => e.status === 429)) { + reason = 'rate limited (1 cdr_feed call per minute + 10 pagination per min per token). Wait ~60s and retry.'; + } + logger('phone:service', `cdr_feed unavailable — ${reason}`, 'warn'); + return { + available: false, + reason, + fetchErrors, + scopesNeeded: fetchErrors.some((e) => e.status === 429) + ? 'Respect API rate limits (see doc)' + : 'spark-admin:calling_cdr_read scope + Control Hub administrator role "Webex Calling Detailed Call History API access" enabled for the authorizing user', + calls: [], + summary: {}, + }; + } + if (allRawItems.length === 0) { // Legacy fallback (rarely useful now) try { const legUrl = `${ANALYTICS_BASES[0]}/callHistory`; - const r = await axiosMod.get(legUrl, { - headers: { Authorization: `Bearer ${token}` }, + const r = await webex.analyticsRequestRaw('GET', legUrl, { params: { orgId, personId, startTime, endTime, max: 100 }, - timeout: 15000 + timeout: 15000, }); const d = r.data || {}; - const li = extractList(d); + const li = extractCdrFeedList(d); if (li.length >= 0) { allRawItems.push(...li); - logger('phone:service', `legacy callHistory fallback gave ${li.length}`, 'debug'); + logger('phone:service', `cdr_feed legacy callHistory fallback → ${li.length} record(s)`, 'info'); } - } catch (_) { /* ignore */ } + } catch (legacyErr) { + logger('phone:service', `cdr_feed legacy callHistory fallback failed: ${legacyErr.message}`, 'debug'); + } } const receivedRaw = allRawItems.length; @@ -808,13 +889,7 @@ export async function getHistoricalCallActivity(personId, hours = 24, options = const seen = new Set(); const items = []; for (const c of allRawItems) { - const key = [ - c.startTime || c.start || c.callStartTime || '', - c.callingNumber || '', - c.calledNumber || '', - c.duration || c.callDuration || '', - c.callId || c.id || c.uuid || '' - ].join('|'); + const key = cdrDedupKey(c); if (!seen.has(key)) { seen.add(key); items.push(c); @@ -822,19 +897,27 @@ export async function getHistoricalCallActivity(personId, hours = 24, options = } // Secondary person filter inside the location-scoped results. - const userItems = items.filter(matchItemToUser); + const userItems = skipPersonFilter ? items : items.filter(matchItemToUser); const effectiveItems = (userItems.length > 0) ? userItems : items; - if (userItems.length === 0 && items.length > 0) { + if (userItems.length === 0 && items.length > 0 && !skipPersonFilter) { logger('phone:service', `cdr_feed: ${items.length} unique (received ${receivedRaw}, loc=${locationName}) but 0 matched person ids; using location results (common for main/AA numbers)`, 'debug'); } + logger( + 'phone:service', + `cdr_feed done base=${usedBase || 'none'} http=${lastHttpStatus ?? 'n/a'} ` + + `raw=${receivedRaw} unique=${items.length} returned=${effectiveItems.length} ` + + `personFilter=${skipPersonFilter ? 'off' : 'on'} token=${tokenHint}`, + 'info', + ); + // Flexible parse (CDR field names vary; covers common report columns) let inbound = 0, outbound = 0, missed = 0, totalDuration = 0; const samples = []; for (const c of effectiveItems) { - const dir = String(c.direction || c.callDirection || c.callType || c.directionIndicator || '').toUpperCase(); - const dur = Number(c.durationSeconds || c.duration || c.callDuration || c.talkDuration || c.length || 0); - const status = String(c.status || c.result || c.callResult || c.callOutcome || c.callStatus || '').toLowerCase(); + const dir = cdrDirectionValue(c); + const dur = cdrDurationSeconds(c); + const status = cdrDispositionValue(c).toLowerCase(); if (dir.includes('IN') || dir === 'INBOUND' || dir.includes('INCOMING')) inbound++; else if (dir.includes('OUT') || dir === 'OUTBOUND' || dir.includes('OUTGOING')) outbound++; @@ -845,12 +928,13 @@ export async function getHistoricalCallActivity(personId, hours = 24, options = if (samples.length < 5) { samples.push({ - start: c.startTime || c.start || c.callStartTime || c.answerTime || c.releaseTime, + start: cdrStartTime(c) || c.releaseTime, direction: dir || c.direction, duration: dur, - status: c.status || c.result || c.callResult, - otherParty: c.otherParty || c.calledParty || c.callingParty || c.remoteParty || c.calledNumber || c.callingNumber || 'unknown', - phoneNumber: c.phoneNumber || c.calledNumber || c.callingNumber, + status: status || cdrDispositionValue(c), + otherParty: c.otherParty || c.calledParty || c.callingParty || c.remoteParty + || cdrCalledNumber(c) || cdrCallingNumber(c) || 'unknown', + phoneNumber: c.phoneNumber || cdrCalledNumber(c) || cdrCallingNumber(c), }); } } @@ -872,6 +956,7 @@ export async function getHistoricalCallActivity(personId, hours = 24, options = samples, rawCount: receivedRaw, userMatchedCount: userItems.length, + rawItems: returnRawItems ? effectiveItems : undefined, }; } catch (err) { const status = err.response?.status; diff --git a/services/renderers/callTestRenderer.js b/services/renderers/callTestRenderer.js index a1ebe3c..5eb6575 100644 --- a/services/renderers/callTestRenderer.js +++ b/services/renderers/callTestRenderer.js @@ -85,6 +85,128 @@ export function renderCallTestResultMarkdown(session) { return md.trim(); } +/** + * @param {object} session + * @param {object} details from fetchCallResource + */ +export function renderCallTestTwilioDetailsMarkdown(session, details) { + let md = `**Call test — Twilio details** (\`${session.testId}\`)\n\n`; + if (!details?.available) { + md += `_Unavailable:_ ${details?.reason || 'unknown'}\n`; + if (session.callSid) md += `- **CallSid:** \`${session.callSid}\`\n`; + return md.trim(); + } + + const d = details.data || {}; + md += `- **CallSid:** \`${d.sid || session.callSid}\`\n`; + md += `- **Status:** ${d.status || '—'}\n`; + md += `- **Duration:** ${d.duration != null ? `${d.duration}s` : '—'}\n`; + if (d.startTime) md += `- **Start:** ${fmtTime(d.startTime)}\n`; + if (d.endTime) md += `- **End:** ${fmtTime(d.endTime)}\n`; + md += `- **From:** ${d.from || '—'}\n`; + md += `- **To:** ${d.to || '—'}\n`; + if (d.price != null) md += `- **Price:** ${d.price} ${d.priceUnit || ''}\n`.trim() + '\n'; + if (d.answeredBy) md += `- **Answered by:** ${d.answeredBy}\n`; + return md.trim(); +} + +/** + * @param {object} session + * @param {object} insights combined summary + metrics + */ +export function renderCallTestTwilioInsightsMarkdown(session, insights) { + let md = `**Call test — Twilio Insights** (\`${session.testId}\`)\n\n`; + if (!insights?.available) { + md += `_Unavailable:_ ${insights?.reason || 'Voice Insights not available'}\n`; + md += '\n_Enable Voice Insights Advanced Features in Twilio Console for MOS/jitter/loss metrics._\n'; + return md.trim(); + } + + const s = insights.summary; + if (s) { + md += '**Summary**\n'; + if (s.duration != null) md += `- Duration: ${s.duration}s\n`; + if (s.connectDuration != null) md += `- Connect duration: ${s.connectDuration}s\n`; + if (s.processingState) md += `- Processing: ${s.processingState}\n`; + } + + const m = insights.metrics; + if (m?.highlights) { + md += '\n**Quality (carrier edge)**\n'; + const h = m.highlights; + if (h.maxJitter != null) md += `- Max jitter: ${h.maxJitter}\n`; + if (h.maxPacketLoss != null) md += `- Max packet loss: ${h.maxPacketLoss}%\n`; + if (h.minMos != null) md += `- Min MOS: ${h.minMos}\n`; + } + + return md.trim(); +} + +/** + * @param {object} session + * @param {object} cdrResult from fetchMatchedCdrForSession + */ +export function renderCallTestCdrMatchMarkdown(session, cdrResult) { + let md = `**Call test — Webex CDR match** (\`${session.testId}\`)\n\n`; + + if (!cdrResult?.available) { + md += `_CDR unavailable:_ ${cdrResult?.reason || 'unknown'}\n`; + return md.trim(); + } + + if (cdrResult.window) { + md += `- **Window:** ${cdrResult.window.startTime} → ${cdrResult.window.endTime}\n`; + } + if (cdrResult.location) md += `- **Location:** ${cdrResult.location}\n`; + md += `- **Records in window:** ${cdrResult.rawCount ?? '—'}\n`; + + const match = cdrResult.match; + if (!match) { + md += `\n_No matching leg found._ ${cdrResult.reason || ''}\n`; + return md.trim(); + } + + md += '\n**Matched leg**\n'; + md += `- **Start:** ${fmtTime(match.start)}\n`; + md += `- **Direction:** ${match.direction || '—'}\n`; + md += `- **Duration:** ${match.duration ?? '—'}s\n`; + md += `- **Status:** ${match.status || '—'}\n`; + if (match.callingNumber) md += `- **Calling:** ${match.callingNumber}\n`; + if (match.calledNumber) md += `- **Called:** ${match.calledNumber}\n`; + if (match.score != null) md += `- **Match score:** ${match.score}\n`; + + return md.trim(); +} + +/** + * Append enrichment status for /calltest status. + */ +export function renderCallTestEnrichmentStatusMarkdown(session) { + const e = session.enrichment || {}; + const lines = ['\n**Enrichment:**']; + + const tw = e.twilio || {}; + if (tw.detailsFetchedAt) { + lines.push(`- Twilio details: ${tw.details?.available ? 'ready' : 'failed'}`); + } else { + lines.push('- Twilio details: pending'); + } + if (tw.insightsFetchedAt) { + lines.push(`- Twilio Insights: ${tw.insights?.available ? 'ready' : 'unavailable'}`); + } else { + lines.push('- Twilio Insights: pending'); + } + + const cdr = e.cdr; + if (cdr?.fetchedAt) { + lines.push(`- Webex CDR: ${cdr.match ? 'matched' : 'no match'}`); + } else { + lines.push('- Webex CDR: pending'); + } + + return lines.join('\n'); +} + /** * @param {object} session * @param {object} cdr diff --git a/services/renderers/voiceReportRenderer.js b/services/renderers/voiceReportRenderer.js new file mode 100644 index 0000000..9c41f43 --- /dev/null +++ b/services/renderers/voiceReportRenderer.js @@ -0,0 +1,161 @@ +// services/renderers/voiceReportRenderer.js + +function envInt(name, fallback) { + const v = Number(process.env[name]); + return Number.isFinite(v) ? v : fallback; +} + +function fmtTime(iso) { + if (!iso) return '—'; + try { + return new Date(iso).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC'); + } catch { + return String(iso); + } +} + +function countPoorQuality(calls) { + const mosWarn = Number(process.env.WAN_STANDARD_APP_MOS_WARN || 4.0); + const lossWarn = Number(process.env.WAN_STANDARD_APP_LOSS_WARN_PCT || 5); + const jitterWarn = Number(process.env.WAN_STANDARD_APP_JITTER_WARN_MS || 30); + let poor = 0; + for (const call of calls) { + const q = call.media || call.wan || {}; + if (q.mos != null && q.mos < mosWarn) poor++; + else if (q.loss != null && q.loss > lossWarn) poor++; + else if (q.jitter != null && q.jitter > jitterWarn) poor++; + } + return poor; +} + +function renderOutcomeBreakdown(outcomes) { + const entries = Object.entries(outcomes || {}); + if (!entries.length) return '_No outcome data._'; + return entries + .sort((a, b) => b[1] - a[1]) + .map(([k, v]) => `- **${k}:** ${v}`) + .join('\n'); +} + +function renderReachSummary(summary) { + const lines = []; + if (summary.inbound > 0) { + lines.push( + `- **Inbound reached a phone:** ${summary.inboundReachedPhone} of ${summary.inbound}`, + ); + if (summary.inboundAaOnly > 0) { + lines.push(`- **Inbound stopped at auto-attendant:** ${summary.inboundAaOnly}`); + } + } + if (summary.outbound > 0) { + lines.push( + `- **Outbound connected:** ${summary.outboundConnected} of ${summary.outbound}`, + ); + } + if (summary.abnormal > 0) { + lines.push(`- **Abnormal outcomes:** ${summary.abnormal}`); + } + return lines.join('\n'); +} + +function renderCallReachLine(call) { + if (call.direction === 'inbound') { + if (call.reachedPhone) { + const who = [call.endpointUser, call.endpointModel].filter(Boolean).join(' / '); + return `reached ${who || 'phone'}`; + } + if (call.reachedAttendant) { + const key = call.aaKeyPress ? ` (AA key ${call.aaKeyPress})` : ''; + return `auto-attendant only${key}`; + } + return 'not answered'; + } + if (call.direction === 'outbound') { + return call.connected || call.reachedPhone ? 'connected' : 'not connected'; + } + return call.outcome || '—'; +} + +/** + * @param {object} report from collectVoiceReport + * @param {{ detail?: boolean }} opts + */ +export function renderVoiceReportMarkdown(report, opts = {}) { + if (!report?.ok) { + let md = `**Voice report** — Store ${report?.store?.storeNum || '?'}\n\n`; + md += `_${report?.reason || report?.window?.reason || 'Unable to build report.'}_\n`; + return md.trim(); + } + + const { store, window, cdr, mediaQuality, prisma, joined } = report; + const calls = joined.calls || joined.legs || []; + const summary = joined.summary || {}; + const maxRows = envInt('VOICEREPORT_MAX_DETAIL_ROWS', 25); + const detail = Boolean(opts.detail); + + let md = `**Voice report — Store ${store.storeNum}**\n\n`; + md += `- **Date:** ${window.label} (${window.mode})\n`; + md += `- **Timezone:** ${window.timeZone}\n`; + md += `- **Window:** ${fmtTime(window.startTime)} → ${fmtTime(window.endTime)}\n`; + md += `- **Location:** ${store.locationName}\n`; + if (store.dialNumber) md += `- **Main:** ${store.dialNumber}\n`; + + md += '\n**Call volume (CDR)**\n'; + if (!cdr.available) { + md += `_CDR unavailable:_ ${cdr.reason || 'unknown'}\n`; + } else { + md += `- **Total calls:** ${summary.total ?? calls.length}\n`; + md += `- **Inbound:** ${summary.inbound ?? 0} | **Outbound:** ${summary.outbound ?? 0}\n`; + md += `- **CDR legs fetched:** ${cdr.rawCount ?? '—'} → **${summary.total ?? calls.length}** correlated call(s)\n`; + const reach = renderReachSummary(summary); + if (reach) md += `${reach}\n`; + md += '\n**Call outcomes**\n'; + md += `${renderOutcomeBreakdown(summary.outcomes)}\n`; + } + + md += '\n**Media quality (Webex report)**\n'; + if (!mediaQuality.available) { + md += `_Unavailable:_ ${mediaQuality.reason || 'not generated'}\n`; + } else { + md += `- **Rows in window:** ${mediaQuality.rows?.length ?? 0} (from ${mediaQuality.rawCount ?? '?'} downloaded)\n`; + const poor = countPoorQuality(calls.filter((c) => c.media)); + md += `- **Calls with quality flags:** ${poor}\n`; + } + + md += '\n**WAN voice (Prisma DPI)**\n'; + if (!prisma.available) { + md += `_Unavailable:_ ${prisma.reason || 'not configured'}\n`; + } else { + const m = prisma.appAudio?.mos; + md += `- **App:** ${prisma.appAudio.appName}\n`; + if (m?.min != null) md += `- **Worst MOS in window:** ${m.min}\n`; + if (m?.max != null) md += `- **Best MOS in window:** ${m.max}\n`; + if (prisma.appAudio?.detailsUrl) { + md += `- [View in Prisma SCM](${prisma.appAudio.detailsUrl})\n`; + } + } + + if (detail && calls.length) { + md += '\n**Call detail**\n'; + const show = calls.slice(0, maxRows); + for (const call of show) { + const dir = String(call.direction || 'unknown').toUpperCase(); + const outcome = [call.outcome, call.outcomeReason].filter(Boolean).join(' / '); + const flag = call.abnormal || !call.normalOutcome ? ' ⚠️' : ''; + md += `\n- **${fmtTime(call.start)}** ${dir} ${call.duration || 0}s — ${outcome || '—'}${flag}\n`; + md += ` ${call.callingNumber || '?'} → ${call.calledNumber || '?'} (${call.legCount} leg(s), ${renderCallReachLine(call)})\n`; + if (call.media) { + md += ` Media: MOS ${call.media.mos ?? '—'}, jitter ${call.media.jitter ?? '—'}, loss ${call.media.loss ?? '—'}\n`; + } + if (call.wan) { + md += ` WAN≈: MOS ${call.wan.mos ?? '—'}, jitter ${call.wan.jitter ?? '—'}, loss ${call.wan.loss ?? '—'}\n`; + } + } + if (calls.length > maxRows) { + md += `\n_${calls.length - maxRows} more calls — use Control Hub Detailed Call History for full export._\n`; + } + } + + md += `\n*Generated in ${report.elapsedMs}ms*`; + return md.trim(); +} diff --git a/services/voiceReport/businessWindow.js b/services/voiceReport/businessWindow.js new file mode 100644 index 0000000..9f8ec3e --- /dev/null +++ b/services/voiceReport/businessWindow.js @@ -0,0 +1,144 @@ +// services/voiceReport/businessWindow.js +// Store-local business-hour windows for /voicereport (default 9am–9pm). + +import { DateTime } from 'luxon'; +import { DISPLAY_TIMEZONE } from '../../utils/time.js'; + +const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000; + +function envInt(name, fallback) { + const v = Number(process.env[name]); + return Number.isFinite(v) ? v : fallback; +} + +export function getBusinessHourConfig() { + return { + startHour: envInt('VOICEREPORT_BUSINESS_START_HOUR', 9), + endHour: envInt('VOICEREPORT_BUSINESS_END_HOUR', 21), + apiLagMs: envInt('VOICEREPORT_API_LAG_MS', 5 * 60 * 1000), + }; +} + +/** + * Parse user date arg into a mode + calendar date in store TZ. + * @param {string|null|undefined} raw + * @param {string} timeZone IANA zone + * @param {Date} [now] injectable for tests + */ +export function parseReportDateArg(raw, timeZone, now = new Date()) { + const zone = timeZone || DISPLAY_TIMEZONE; + const nowLocal = DateTime.fromJSDate(now, { zone }); + const token = String(raw || 'yesterday').trim().toLowerCase(); + + if (!token || token === 'yesterday') { + const day = nowLocal.minus({ days: 1 }).startOf('day'); + return { mode: 'fullDay', day, label: day.toISODate() }; + } + if (token === 'today') { + return { mode: 'today', day: nowLocal.startOf('day'), label: nowLocal.toISODate() }; + } + const parsed = DateTime.fromISO(token, { zone }); + if (parsed.isValid) { + return { mode: 'fullDay', day: parsed.startOf('day'), label: parsed.toISODate() }; + } + return { error: `Unrecognised date "${raw}". Use yesterday, today, or YYYY-MM-DD.` }; +} + +/** + * Compute UTC ISO window for cdr_feed / Prisma queries. + * @returns {{ startTime: string, endTime: string, timeZone: string, label: string, mode: string, ready: boolean, reason?: string }} + */ +export function computeBusinessWindow({ timeZone, dateArg, now = new Date() }) { + const zone = timeZone || DISPLAY_TIMEZONE; + const cfg = getBusinessHourConfig(); + const parsed = parseReportDateArg(dateArg, zone, now); + if (parsed.error) { + return { ready: false, reason: parsed.error, timeZone: zone }; + } + + const startLocal = parsed.day.set({ + hour: cfg.startHour, + minute: 0, + second: 0, + millisecond: 0, + }); + + let endLocal; + const nowLocal = DateTime.fromJSDate(now, { zone }); + + if (parsed.mode === 'today') { + const businessEnd = parsed.day.set({ + hour: cfg.endHour, + minute: 0, + second: 0, + millisecond: 0, + }); + const lagEnd = nowLocal.minus({ milliseconds: cfg.apiLagMs }); + if (lagEnd < startLocal.plus({ minutes: 1 })) { + return { + ready: false, + reason: 'Business window has not started yet, or insufficient data after the 5-minute API lag.', + timeZone: zone, + label: parsed.label, + mode: parsed.mode, + }; + } + endLocal = DateTime.min(lagEnd, businessEnd); + } else { + endLocal = parsed.day.set({ + hour: cfg.endHour, + minute: 0, + second: 0, + millisecond: 0, + }); + } + + if (endLocal <= startLocal) { + return { + ready: false, + reason: 'Computed window is empty (end is not after start).', + timeZone: zone, + label: parsed.label, + mode: parsed.mode, + }; + } + + let startMs = startLocal.toUTC().toMillis(); + let endMs = endLocal.toUTC().toMillis(); + + const latestAllowedEnd = now.getTime() - cfg.apiLagMs; + if (endMs > latestAllowedEnd) endMs = latestAllowedEnd; + if (endMs - startMs > TWELVE_HOURS_MS) startMs = endMs - TWELVE_HOURS_MS; + + if (startMs >= endMs) { + return { + ready: false, + reason: 'Window invalid after API lag clamp.', + timeZone: zone, + label: parsed.label, + mode: parsed.mode, + }; + } + + return { + ready: true, + timeZone: zone, + label: parsed.label, + mode: parsed.mode, + startTime: new Date(startMs).toISOString(), + endTime: new Date(endMs).toISOString(), + startLocal: startLocal.toISO(), + endLocal: endLocal.toISO(), + }; +} + +/** + * True when a timestamp (ISO or ms) falls inside the business window. + */ +export function isWithinWindow(isoOrMs, window) { + if (!window?.startTime || !window?.endTime) return false; + const t = new Date(isoOrMs).getTime(); + const s = new Date(window.startTime).getTime(); + const e = new Date(window.endTime).getTime(); + return t >= s && t <= e; +} diff --git a/services/voiceReport/groupCdrCalls.js b/services/voiceReport/groupCdrCalls.js new file mode 100644 index 0000000..887e97f --- /dev/null +++ b/services/voiceReport/groupCdrCalls.js @@ -0,0 +1,250 @@ +// services/voiceReport/groupCdrCalls.js +// Group cdr_feed legs by Correlation ID into logical calls. + +import { + cdrCalledNumber, + cdrCallingNumber, + cdrDirectionValue, + cdrDurationSeconds, + cdrField, + cdrRecordId, + cdrStartTime, + cdrDedupKey, +} from '../cdrFeedParser.js'; + +export function cdrCorrelationId(item) { + return cdrField(item, 'correlationId', 'Correlation ID', 'correlationID'); +} + +function isTruthyAnswered(item) { + const answered = String(cdrField(item, 'Answered', 'answered') || '').toLowerCase(); + const indicator = String(cdrField(item, 'Answer indicator', 'answer indicator') || ''); + return answered === 'true' || indicator === 'Yes'; +} + +function isUserPhoneLeg(item) { + const userType = String(cdrField(item, 'User type', 'userType') || ''); + const clientType = String(cdrField(item, 'Client type', 'clientType') || '').toUpperCase(); + const model = String(cdrField(item, 'Model', 'model') || ''); + if (userType === 'User') return true; + if (clientType === 'WXC_DEVICE') return true; + if (model.toUpperCase().includes('DBS')) return true; + return false; +} + +function isAutomatedAttendantLeg(item) { + const userType = String(cdrField(item, 'User type', 'userType') || '').toLowerCase(); + return userType.includes('automatedattendant'); +} + +function callTypeValue(item) { + return String(cdrField(item, 'Call type', 'callType') || '').toUpperCase(); +} + +export function classifyCallDirection(legs) { + for (const leg of legs) { + const ct = callTypeValue(leg); + if (ct.includes('INBOUND')) return 'inbound'; + if (ct.includes('OUTBOUND')) return 'outbound'; + } + for (const leg of legs) { + const dir = cdrDirectionValue(leg); + const inboundTrunk = cdrField(leg, 'Inbound trunk', 'inbound trunk'); + const outboundTrunk = cdrField(leg, 'Outbound trunk', 'outbound trunk'); + if (inboundTrunk && dir.includes('TERMINAT')) return 'inbound'; + if (outboundTrunk && dir.includes('ORIGINAT')) return 'outbound'; + } + for (const leg of legs) { + const dir = cdrDirectionValue(leg); + if (dir.includes('ORIGINAT')) return 'outbound'; + if (dir.includes('TERMINAT')) return 'inbound'; + } + return 'unknown'; +} + +export function isNormalCallOutcome(item) { + const outcome = String(cdrField(item, 'Call outcome', 'call outcome') || '').toLowerCase(); + const reason = String(cdrField(item, 'Call outcome reason', 'call outcome reason') || '').toLowerCase(); + if (!outcome) return true; + if (outcome !== 'success') return false; + return !reason || reason === 'normal'; +} + +function pickExternalNumber(legs, direction) { + for (const leg of legs) { + const ct = callTypeValue(leg); + if (direction === 'inbound' && ct.includes('INBOUND')) { + return cdrCallingNumber(leg) || cdrField(leg, 'Caller ID number', 'caller id number'); + } + if (direction === 'outbound' && ct.includes('OUTBOUND')) { + return cdrCalledNumber(leg); + } + } + const first = legs[0]; + if (!first) return null; + if (direction === 'inbound') { + return cdrCallingNumber(first) || cdrField(first, 'Caller ID number', 'caller id number'); + } + return cdrCalledNumber(first); +} + +function pickTerminalOutcome(legs) { + // Prefer the user-phone leg, then any leg with explicit outcome. + const phoneLegs = legs.filter(isUserPhoneLeg); + const candidates = phoneLegs.length ? phoneLegs : legs; + let chosen = candidates[candidates.length - 1]; + for (const leg of candidates) { + if (cdrField(leg, 'Call outcome', 'call outcome')) { + chosen = leg; + } + } + return { + outcome: cdrField(chosen, 'Call outcome', 'call outcome') || 'unknown', + reason: cdrField(chosen, 'Call outcome reason', 'call outcome reason') || '', + normal: isNormalCallOutcome(chosen), + }; +} + +function summarizeReach(legs, direction) { + const phoneLegs = legs.filter(isUserPhoneLeg); + const answeredPhone = phoneLegs.find(isTruthyAnswered); + const aaLegs = legs.filter(isAutomatedAttendantLeg); + const answeredAa = aaLegs.find(isTruthyAnswered); + + if (direction === 'inbound') { + return { + reachedPhone: Boolean(answeredPhone), + reachedAttendant: Boolean(answeredAa), + endpointUser: answeredPhone + ? cdrField(answeredPhone, 'User', 'user') + : null, + endpointModel: answeredPhone + ? cdrField(answeredPhone, 'Model', 'model') + : null, + aaKeyPress: aaLegs.map((l) => cdrField(l, 'Auto Attendant Key Pressed', 'auto attendant key pressed')) + .find((v) => v && v !== 'NA') || null, + }; + } + + // Outbound: originating user/device leg answered, remote side picked up on terminating leg. + const originating = legs.find((l) => cdrDirectionValue(l).includes('ORIGINAT') && isUserPhoneLeg(l)); + const remoteAnswered = legs.some((l) => + cdrDirectionValue(l).includes('TERMINAT') && isTruthyAnswered(l) && !isAutomatedAttendantLeg(l), + ); + return { + reachedPhone: Boolean(originating && isTruthyAnswered(originating)), + connected: remoteAnswered || (originating && isTruthyAnswered(originating)), + endpointUser: originating ? cdrField(originating, 'User', 'user') : null, + endpointModel: originating ? cdrField(originating, 'Model', 'model') : null, + reachedAttendant: false, + aaKeyPress: null, + }; +} + +/** + * @param {object[]} rawLegs + * @returns {object} + */ +export function summarizeCallGroup(correlationId, rawLegs) { + const legs = [...(rawLegs || [])].sort((a, b) => { + const ta = new Date(cdrStartTime(a) || 0).getTime(); + const tb = new Date(cdrStartTime(b) || 0).getTime(); + return ta - tb; + }); + const direction = classifyCallDirection(legs); + const starts = legs.map((l) => cdrStartTime(l)).filter(Boolean); + const start = starts.length ? starts.sort()[0] : null; + const releaseTimes = legs + .map((l) => cdrField(l, 'Release time', 'release time')) + .filter(Boolean); + const end = releaseTimes.length ? releaseTimes.sort().reverse()[0] : null; + const duration = (() => { + if (start && end) { + return Math.max(0, Math.round((new Date(end).getTime() - new Date(start).getTime()) / 1000)); + } + return Math.max(...legs.map(cdrDurationSeconds), 0); + })(); + + const outcome = pickTerminalOutcome(legs); + const reach = summarizeReach(legs, direction); + const abnormal = legs.some((l) => !isNormalCallOutcome(l)); + + return { + correlationId, + direction, + start, + end, + duration, + callingNumber: pickExternalNumber(legs, direction) || cdrCallingNumber(legs[0]), + calledNumber: cdrCalledNumber(legs[legs.length - 1]) || cdrCalledNumber(legs[0]), + outcome: outcome.outcome, + outcomeReason: outcome.reason, + normalOutcome: outcome.normal && !abnormal, + abnormal, + legCount: legs.length, + ...reach, + legs, + }; +} + +/** + * @param {object[]} rawItems deduped cdr_feed rows + * @returns {object[]} + */ +export function groupCdrIntoCalls(rawItems) { + const groups = new Map(); + for (const item of rawItems || []) { + const corr = cdrCorrelationId(item); + const key = corr + ? `corr:${corr}` + : `solo:${cdrRecordId(item) || cdrDedupKey(item)}`; + if (!groups.has(key)) groups.set(key, { correlationId: corr, legs: [] }); + groups.get(key).legs.push(item); + } + + return [...groups.values()] + .map((g) => summarizeCallGroup(g.correlationId, g.legs)) + .sort((a, b) => new Date(a.start || 0).getTime() - new Date(b.start || 0).getTime()); +} + +export function summarizeCalls(calls) { + let inbound = 0; + let outbound = 0; + let unknown = 0; + let inboundReachedPhone = 0; + let inboundAaOnly = 0; + let outboundConnected = 0; + let abnormal = 0; + + for (const call of calls) { + if (call.direction === 'inbound') { + inbound++; + if (call.reachedPhone) inboundReachedPhone++; + else if (call.reachedAttendant) inboundAaOnly++; + } else if (call.direction === 'outbound') { + outbound++; + if (call.connected || call.reachedPhone) outboundConnected++; + } else { + unknown++; + } + if (call.abnormal || !call.normalOutcome) abnormal++; + } + + const outcomes = {}; + for (const call of calls) { + const key = [call.outcome, call.outcomeReason].filter(Boolean).join(' / ') || 'unknown'; + outcomes[key] = (outcomes[key] || 0) + 1; + } + + return { + total: calls.length, + inbound, + outbound, + unknown, + inboundReachedPhone, + inboundAaOnly, + outboundConnected, + abnormal, + outcomes, + }; +} diff --git a/services/voiceReport/joinCallQuality.js b/services/voiceReport/joinCallQuality.js new file mode 100644 index 0000000..44ce86e --- /dev/null +++ b/services/voiceReport/joinCallQuality.js @@ -0,0 +1,199 @@ +// services/voiceReport/joinCallQuality.js +// Join CDR legs, Media Quality rows, and Prisma WAN buckets. + +import { isWithinWindow } from './businessWindow.js'; +import { + cdrCalledNumber, + cdrCallingNumber, + cdrDirectionValue, + cdrDispositionValue, + cdrDurationSeconds, + cdrField, + cdrStartTime, +} from '../cdrFeedParser.js'; +import { groupCdrIntoCalls, summarizeCalls } from './groupCdrCalls.js'; + +export function phoneDigits(value) { + const d = String(value || '').replace(/\D/g, ''); + if (d.length === 10) return `1${d}`; + if (d.length === 11 && d.startsWith('1')) return d; + return d; +} + +function cdrDisposition(item) { + return cdrDispositionValue(item); +} + +function cdrDirection(item) { + return cdrDirectionValue(item); +} + +function cdrStart(item) { + return cdrStartTime(item); +} + +function cdrDuration(item) { + return cdrDurationSeconds(item); +} + +function normalizeCdrLeg(item) { + return { + start: cdrStart(item), + direction: cdrDirection(item), + duration: cdrDuration(item), + disposition: cdrDisposition(item), + callingNumber: cdrCallingNumber(item), + calledNumber: cdrCalledNumber(item), + correlationId: cdrField(item, 'correlationId', 'Correlation ID', 'correlationID'), + raw: item, + }; +} + +function mqCorrelation(row) { + const n = row._norm || {}; + return n.correlationid || n.finalcorrelationid || row['Correlation ID'] || null; +} + +function mqStart(row) { + const n = row._norm || {}; + return n.starttime || n.callstarttime || row['Start Time'] || null; +} + +function mqNumbers(row) { + const n = row._norm || {}; + return [ + n.callingnumber, n.callednumber, n.remoteparty, + row['Calling Number'], row['Called Number'], + ].filter(Boolean).map(phoneDigits); +} + +function mqQuality(row) { + const n = row._norm || {}; + const mos = Number(n.mos || n.meanopinionscore || n.audiomos || NaN); + const jitter = Number(n.jitter || n.jitterms || NaN); + const loss = Number(n.packetloss || n.packetlosspct || n.loss || NaN); + return { + mos: Number.isFinite(mos) ? mos : null, + jitter: Number.isFinite(jitter) ? jitter : null, + loss: Number.isFinite(loss) ? loss : null, + }; +} + +function scoreMqMatch(call, row) { + const callCorr = call.correlationId ? String(call.correlationId).toLowerCase() : ''; + const rowCorr = mqCorrelation(row); + if (callCorr && rowCorr && String(rowCorr).toLowerCase() === callCorr) return 100; + + const legStart = call.start ? new Date(call.start).getTime() : NaN; + const rowStart = mqStart(row) ? new Date(mqStart(row)).getTime() : NaN; + if (!Number.isFinite(legStart) || !Number.isFinite(rowStart)) return 0; + + const delta = Math.abs(legStart - rowStart); + if (delta > 120_000) return 0; + + let score = 50; + if (delta < 15_000) score += 30; + else if (delta < 60_000) score += 10; + + const legNums = new Set([ + phoneDigits(call.callingNumber), + phoneDigits(call.calledNumber), + ].filter(Boolean)); + const rowNums = mqNumbers(row); + if (rowNums.some((n) => legNums.has(n))) score += 20; + return score; +} + +export function matchMediaQualityToCall(call, mqRows) { + let best = null; + let bestScore = 0; + for (const row of mqRows || []) { + const s = scoreMqMatch(call, row); + if (s > bestScore) { + bestScore = s; + best = row; + } + } + if (!best || bestScore < 50) return null; + return { ...mqQuality(best), score: bestScore }; +} + +/** @deprecated use matchMediaQualityToCall */ +export function matchMediaQualityToLeg(leg, mqRows) { + return matchMediaQualityToCall(leg, mqRows); +} + +/** + * Find worst Prisma 5-min bucket overlapping [start, end]. + */ +export function worstPrismaBucketForCall(call, appAudio, window) { + if (!appAudio?.mos?.values?.length) return null; + const startMs = call.start ? new Date(call.start).getTime() : NaN; + const endMs = Number.isFinite(startMs) + ? startMs + (call.duration || 0) * 1000 + : NaN; + if (!Number.isFinite(startMs)) return null; + + const intervalMs = 5 * 60 * 1000; + const winStart = new Date(window.startTime).getTime(); + const idx0 = Math.max(0, Math.floor((startMs - winStart) / intervalMs)); + const idx1 = Math.max(idx0, Math.ceil((endMs - winStart) / intervalMs)); + + const mosVals = appAudio.mos.values; + const lossVals = appAudio.loss?.values || []; + const jitterVals = appAudio.jitter?.values || []; + + let worstMos = null; + let worstLoss = null; + let worstJitter = null; + for (let i = idx0; i <= idx1 && i < mosVals.length; i++) { + const m = mosVals[i]; + if (typeof m === 'number' && (worstMos == null || m < worstMos)) worstMos = m; + const l = lossVals[i]; + if (typeof l === 'number' && (worstLoss == null || l > worstLoss)) worstLoss = l; + const j = jitterVals[i]; + if (typeof j === 'number' && (worstJitter == null || j > worstJitter)) worstJitter = j; + } + + if (worstMos == null && worstLoss == null && worstJitter == null) return null; + return { + siteLevelApprox: true, + mos: worstMos, + loss: worstLoss, + jitter: worstJitter, + }; +} + +export function summarizeDispositions(legs) { + const counts = {}; + for (const leg of legs) { + const key = String(leg.disposition || 'unknown').toLowerCase(); + counts[key] = (counts[key] || 0) + 1; + } + return counts; +} + +export function joinCallQuality({ cdrItems, mqRows, appAudio, window }) { + const calls = groupCdrIntoCalls(cdrItems); + const joined = calls.map((call) => { + const media = matchMediaQualityToCall(call, mqRows); + const wan = worstPrismaBucketForCall(call, appAudio, window); + return { ...call, media, wan }; + }); + + return { + calls: joined, + legs: joined, // backward compat for callers expecting .legs + summary: summarizeCalls(joined), + }; +} + +export function filterCdrToWindow(items, window) { + return (items || []).filter((item) => { + const start = cdrStartTime(item); + // cdr_feed query params already scope by start time; keep rows when the + // report column label differs from our camelCase guesses. + if (!start) return true; + return isWithinWindow(start, window); + }); +} diff --git a/services/voiceReport/storeContext.js b/services/voiceReport/storeContext.js new file mode 100644 index 0000000..037d41e --- /dev/null +++ b/services/voiceReport/storeContext.js @@ -0,0 +1,37 @@ +// services/voiceReport/storeContext.js +// Resolve Webex location + timezone for a store number. + +import webex from '../../integrations/webex/WebexClient.js'; +import { logger } from '../../utils/logger.js'; +import { DISPLAY_TIMEZONE } from '../../utils/time.js'; +import { resolveStoreMainNumber } from '../callTest/storeResolver.js'; + +const LOG_SCOPE = 'voicereport:store'; + +/** + * @param {string} storeNum + * @returns {Promise<{ storeNum, personId, locationId, locationName, dialNumber, timeZone, email }>} + */ +export async function resolveStoreForVoiceReport(storeNum) { + const base = await resolveStoreMainNumber(storeNum); + let timeZone = DISPLAY_TIMEZONE; + + try { + const [profile, location] = await Promise.all([ + webex.request('GET', `telephony/config/people/${base.personId}`).catch(() => null), + base.locationId + ? webex.request('GET', `telephony/config/locations/${base.locationId}`).catch(() => null) + : null, + ]); + timeZone = profile?.timeZone || location?.timeZone || location?.timezone || timeZone; + } catch (err) { + logger(LOG_SCOPE, `Timezone lookup failed for store ${storeNum}: ${err.message}`, 'debug'); + } + + const padded = String(storeNum).trim().padStart(5, '0'); + return { + ...base, + timeZone, + email: `ae${padded}@ae.com`, + }; +} diff --git a/services/voiceReport/voiceReportService.js b/services/voiceReport/voiceReportService.js new file mode 100644 index 0000000..6ae5b7f --- /dev/null +++ b/services/voiceReport/voiceReportService.js @@ -0,0 +1,159 @@ +// services/voiceReport/voiceReportService.js +// Compose CDR + Media Quality + Prisma WAN for /voicereport. + +import { logger } from '../../utils/logger.js'; +import { getHistoricalCallActivity } from '../phoneService.js'; +import { findSdwanSiteForStore } from '../../integrations/paloalto/sites.js'; +import { getAppMetric } from '../../integrations/paloalto/metrics.js'; +import { buildAppDetailsUrl } from '../../integrations/paloalto/urls.js'; +import { fetchMediaQualityReport } from '../../integrations/webex/reportsClient.js'; +import { + resolveVoiceAppConfig, + summarizeAppSeries, +} from '../enrichment/sdwanEnrichment.js'; +import { computeBusinessWindow } from './businessWindow.js'; +import { resolveStoreForVoiceReport } from './storeContext.js'; +import { filterCdrToWindow, joinCallQuality } from './joinCallQuality.js'; +import { cdrStartTime } from '../cdrFeedParser.js'; + +const LOG_SCOPE = 'voicereport:service'; + +async function fetchCdrForWindow(store, window) { + const cdr = await getHistoricalCallActivity(store.personId, 12, { + locationName: store.locationName, + startTime: window.startTime, + endTime: window.endTime, + returnRawItems: true, + skipPersonFilter: true, + }); + if (!cdr.available) { + logger( + LOG_SCOPE, + `CDR unavailable for store=${store.storeNum} loc=${JSON.stringify(store.locationName)}: ${cdr.reason}`, + 'warn', + ); + return { available: false, reason: cdr.reason, items: [], fetchErrors: cdr.fetchErrors }; + } + const beforeFilter = cdr.rawItems?.length || 0; + const items = filterCdrToWindow(cdr.rawItems || [], window); + const rawCount = cdr.rawCount ?? beforeFilter; + if (rawCount > 0 && items.length === 0) { + const sample = (cdr.rawItems || []).slice(0, 2).map((r) => cdrStartTime(r) || 'no-start-field'); + logger( + LOG_SCOPE, + `CDR window filter dropped all ${rawCount} record(s) for store=${store.storeNum} ` + + `(window ${window.startTime}..${window.endTime}); sample start fields: ${sample.join(', ')}`, + 'warn', + ); + } else { + logger( + LOG_SCOPE, + `CDR store=${store.storeNum} loc=${JSON.stringify(store.locationName)} ` + + `api=${rawCount} afterWindowFilter=${items.length}`, + 'info', + ); + } + return { + available: true, + items, + rawCount, + afterFilterCount: items.length, + }; +} + +async function fetchPrismaAppAudio(storeNum, window) { + const voiceCfg = resolveVoiceAppConfig(); + if (!voiceCfg.appId) { + return { available: false, reason: 'PRISMA_APP_ID_VOICE not configured' }; + } + + const site = await findSdwanSiteForStore(storeNum); + if (!site?.id) { + return { available: false, reason: 'not a Prisma-managed store' }; + } + + const opts = { startTime: window.startTime, endTime: window.endTime }; + const [mosRes, lossRes, jitterRes] = await Promise.all([ + getAppMetric(site.id, voiceCfg.appId, 'mos', opts), + getAppMetric(site.id, voiceCfg.appId, 'loss', opts), + getAppMetric(site.id, voiceCfg.appId, 'jitter', opts), + ]); + + const appAudio = { + appId: voiceCfg.appId, + appName: voiceCfg.appName, + detailsUrl: buildAppDetailsUrl(site.id, voiceCfg.appId), + mos: summarizeAppSeries(mosRes, 'AppAudioMos'), + loss: summarizeAppSeries(lossRes, 'AppPerfUDPAudioPacketLoss'), + jitter: summarizeAppSeries(jitterRes, 'AppPerfUDPAudioJitter'), + }; + + return { available: true, site, appAudio }; +} + +/** + * @param {string} storeNum + * @param {object} opts + * @param {string} [opts.dateArg] yesterday | today | YYYY-MM-DD + * @param {Date} [opts.now] + */ +export async function collectVoiceReport(storeNum, opts = {}) { + const startedAt = Date.now(); + const store = await resolveStoreForVoiceReport(storeNum); + const window = computeBusinessWindow({ + timeZone: store.timeZone, + dateArg: opts.dateArg, + now: opts.now, + }); + + if (!window.ready) { + return { + ok: false, + store, + window, + reason: window.reason, + }; + } + + logger( + LOG_SCOPE, + `Collecting voice report store=${storeNum} loc=${store.locationName} ` + + `window=${window.startTime}..${window.endTime}`, + 'info', + ); + + const [cdrRes, mqRes, prismaRes] = await Promise.allSettled([ + fetchCdrForWindow(store, window), + fetchMediaQualityReport({ window, locationName: store.locationName }), + fetchPrismaAppAudio(storeNum, window), + ]); + + const cdr = cdrRes.status === 'fulfilled' + ? cdrRes.value + : { available: false, reason: cdrRes.reason?.message, items: [] }; + const mediaQuality = mqRes.status === 'fulfilled' + ? mqRes.value + : { available: false, reason: mqRes.reason?.message, rows: [] }; + const prisma = prismaRes.status === 'fulfilled' + ? prismaRes.value + : { available: false, reason: prismaRes.reason?.message }; + + const joined = joinCallQuality({ + cdrItems: cdr.items || [], + mqRows: mediaQuality.rows || [], + appAudio: prisma.appAudio || null, + window, + }); + + return { + ok: true, + store, + window, + cdr, + mediaQuality, + prisma, + joined, + fetchedAt: new Date().toISOString(), + elapsedMs: Date.now() - startedAt, + }; +} diff --git a/tests/callTest.cdrMatcher.test.js b/tests/callTest.cdrMatcher.test.js new file mode 100644 index 0000000..048b757 --- /dev/null +++ b/tests/callTest.cdrMatcher.test.js @@ -0,0 +1,86 @@ +// tests/callTest.cdrMatcher.test.js + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + computeCdrWindow, + phoneDigits, + scoreCdrLeg, + pickBestCdrLeg, +} from '../services/callTest/cdrMatcher.js'; + +test('phoneDigits normalizes US numbers', () => { + assert.equal(phoneDigits('+17247795574'), '17247795574'); + assert.equal(phoneDigits('7247795574'), '17247795574'); +}); + +test('computeCdrWindow clamps to 12h and 5min API lag', () => { + const now = Date.now(); + const session = { + createdAt: new Date(now - 10 * 60_000).toISOString(), + events: { + initiated: new Date(now - 8 * 60_000).toISOString(), + completed: new Date(now - 6 * 60_000).toISOString(), + }, + }; + const w = computeCdrWindow(session, 60_000); + const endMs = new Date(w.endTime).getTime(); + const startMs = new Date(w.startTime).getTime(); + assert.ok(endMs <= now - 5 * 60 * 1000 + 1000); + assert.ok(startMs < endMs); +}); + +test('pickBestCdrLeg matches inbound store leg from Twilio', () => { + const session = { + dialNumber: '+17247795574', + durationSec: 75, + events: { coreStartedAt: '2026-07-23T21:28:09.295Z' }, + }; + const items = [ + { + direction: 'INBOUND', + calledNumber: '+17247795574', + callingNumber: '+15551234567', + startTime: '2026-07-23T21:28:08.000Z', + durationSeconds: 74, + }, + { + direction: 'OUTBOUND', + calledNumber: '+19998887777', + callingNumber: '+17247795574', + durationSeconds: 10, + }, + ]; + const picked = pickBestCdrLeg(items, session); + assert.ok(picked); + assert.equal(picked.leg.calledNumber, '+17247795574'); + assert.ok(picked.score >= 100); +}); + +test('pickBestCdrLeg matches destination even without caller match', () => { + const session = { + dialNumber: '+17247795574', + events: { initiated: '2026-07-23T21:28:09.295Z' }, + }; + const items = [ + { + calledNumber: '+17247795574', + callingNumber: '+19998887777', + startTime: '2026-07-23T21:28:08.000Z', + durationSeconds: 77, + }, + ]; + const picked = pickBestCdrLeg(items, session); + assert.ok(picked); + assert.equal(picked.leg.calledNumber, '+17247795574'); +}); + +test('scoreCdrLeg returns zero for unrelated destination', () => { + const session = { dialNumber: '+17247795574', durationSec: 60, events: {} }; + const score = scoreCdrLeg( + { direction: 'OUTBOUND', calledNumber: '+19998887777', callingNumber: '+11112223333' }, + session, + ); + assert.equal(score, 0); +}); diff --git a/tests/callTest.service.test.js b/tests/callTest.service.test.js index 2edbc57..fe2cdff 100644 --- a/tests/callTest.service.test.js +++ b/tests/callTest.service.test.js @@ -15,11 +15,20 @@ import { getSession, } from '../services/callTest/sessionStore.js'; import { renderCallTestResultMarkdown } from '../services/renderers/callTestRenderer.js'; +import { _clearTwilioEnrichmentTimersForTests } from '../services/callTest/twilioEnrichment.js'; +import { _clearCdrTimersForTests } from '../services/callTest/cdrMatcher.js'; const TEST_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; +function clearCallTestTimers() { + _clearPendingFinalizeTimersForTests(); + _clearTwilioEnrichmentTimersForTests(); + _clearCdrTimersForTests(); +} + test('handleVoiceWebhook: store path marks core on intro', () => { _clearAllSessionsForTests(); + clearCallTestTimers(); createSession(TEST_ID, { mode: 'store', dialNumber: '+12125550100', @@ -40,13 +49,13 @@ test('handleVoiceWebhook: store path marks core on intro', () => { test('handleVoiceWebhook done step finalizes without status callback', async () => { _clearAllSessionsForTests(); - _clearPendingFinalizeTimersForTests(); + clearCallTestTimers(); createSession(TEST_ID, { mode: 'dial', dialNumber: '+12125550100', roomId: 'room-1', reachedCore: true, - config: {}, + config: { twilioEnrich: false, cdrEnrich: false }, }); handleVoiceWebhook(TEST_ID, 'dial', 'outro'); @@ -58,16 +67,40 @@ test('handleVoiceWebhook done step finalizes without status callback', async () const finalized = await tryFinalizeFromVoiceDoneForTests(TEST_ID); assert.equal(finalized.result, 'pass'); assert.equal(finalized.status, 'completed'); + clearCallTestTimers(); +}); + +test('finalize leaves enrichment pending on session', async () => { + _clearAllSessionsForTests(); + clearCallTestTimers(); + createSession(TEST_ID, { + mode: 'dial', + dialNumber: '+17247795574', + roomId: 'room-1', + callSid: 'CAENRICH', + reachedCore: true, + lastStep: 'done', + config: { twilioEnrich: false, cdrEnrich: false }, + }); + + handleVoiceWebhook(TEST_ID, 'dial', 'done'); + await tryFinalizeFromVoiceDoneForTests(TEST_ID); + + const s = getSession(TEST_ID); + assert.equal(s.status, 'completed'); + assert.deepEqual(s.enrichment, { twilio: {}, cdr: {} }); + clearCallTestTimers(); }); test('handleStatusCallback: in-progress maps to answered and passes on completed', async () => { _clearAllSessionsForTests(); + clearCallTestTimers(); createSession(TEST_ID, { mode: 'dial', dialNumber: '+12125550100', reachedCore: true, lastStep: 'done', - config: {}, + config: { twilioEnrich: false, cdrEnrich: false }, }); await handleStatusCallback(TEST_ID, { @@ -83,16 +116,18 @@ test('handleStatusCallback: in-progress maps to answered and passes on completed const s = getSession(TEST_ID); assert.ok(s.events.answered); assert.equal(s.result, 'pass'); + clearCallTestTimers(); }); test('handleStatusCallback: completed with core yields pass in renderer', async () => { _clearAllSessionsForTests(); + clearCallTestTimers(); createSession(TEST_ID, { mode: 'dial', dialNumber: '+12125550100', reachedCore: true, lastStep: 'done', - config: {}, + config: { twilioEnrich: false, cdrEnrich: false }, }); await handleStatusCallback(TEST_ID, { @@ -112,14 +147,16 @@ test('handleStatusCallback: completed with core yields pass in renderer', async const md = renderCallTestResultMarkdown(s); assert.match(md, /PASSED/); assert.match(md, /CA123/); + clearCallTestTimers(); }); test('handleStatusCallback: no-answer fails', async () => { _clearAllSessionsForTests(); + clearCallTestTimers(); createSession(TEST_ID, { mode: 'dial', dialNumber: '+12125550100', - config: {}, + config: { twilioEnrich: false, cdrEnrich: false }, }); await handleStatusCallback(TEST_ID, { @@ -131,6 +168,7 @@ test('handleStatusCallback: no-answer fails', async () => { assert.equal(s.result, 'fail'); const md = renderCallTestResultMarkdown(s); assert.match(md, /FAILED/); + clearCallTestTimers(); }); test('renderCallTestResultMarkdown shows in progress before final result', () => { diff --git a/tests/callTest.twilioEnrichment.test.js b/tests/callTest.twilioEnrichment.test.js new file mode 100644 index 0000000..d5a19ec --- /dev/null +++ b/tests/callTest.twilioEnrichment.test.js @@ -0,0 +1,116 @@ +// tests/callTest.twilioEnrichment.test.js + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + renderCallTestTwilioDetailsMarkdown, + renderCallTestTwilioInsightsMarkdown, + renderCallTestCdrMatchMarkdown, +} from '../services/renderers/callTestRenderer.js'; +import { + scheduleTwilioEnrichment, + _clearTwilioEnrichmentTimersForTests, +} from '../services/callTest/twilioEnrichment.js'; +import { + createSession, + _clearAllSessionsForTests, + getSession, +} from '../services/callTest/sessionStore.js'; + +const TEST_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + +function clearTimers() { + _clearTwilioEnrichmentTimersForTests(); +} + +test('renderCallTestTwilioDetailsMarkdown shows call fields', () => { + const md = renderCallTestTwilioDetailsMarkdown( + { testId: TEST_ID, callSid: 'CA123' }, + { + available: true, + data: { + sid: 'CA123', + status: 'completed', + duration: 75, + from: '+15550001111', + to: '+17247795574', + price: '-0.0140', + priceUnit: 'USD', + }, + }, + ); + assert.match(md, /Twilio details/); + assert.match(md, /completed/); + assert.match(md, /75s/); +}); + +test('renderCallTestTwilioInsightsMarkdown handles unavailable', () => { + const md = renderCallTestTwilioInsightsMarkdown( + { testId: TEST_ID }, + { available: false, reason: 'not enabled' }, + ); + assert.match(md, /Unavailable/); + assert.match(md, /Advanced Features/); +}); + +test('renderCallTestTwilioInsightsMarkdown shows quality highlights', () => { + const md = renderCallTestTwilioInsightsMarkdown( + { testId: TEST_ID }, + { + available: true, + summary: { duration: 75, processingState: 'complete' }, + metrics: { highlights: { maxJitter: 12, maxPacketLoss: 0.5, minMos: 4.1 } }, + }, + ); + assert.match(md, /jitter/i); + assert.match(md, /MOS/); +}); + +test('renderCallTestCdrMatchMarkdown shows matched leg', () => { + const md = renderCallTestCdrMatchMarkdown( + { testId: TEST_ID }, + { + available: true, + location: 'Store 0782', + rawCount: 3, + window: { startTime: '2026-07-23T21:00:00.000Z', endTime: '2026-07-23T21:10:00.000Z' }, + match: { + start: '2026-07-23T21:05:00.000Z', + direction: 'INBOUND', + duration: 75, + status: 'SUCCESS', + callingNumber: '+15551234567', + calledNumber: '+17247795574', + score: 120, + }, + }, + ); + assert.match(md, /CDR match/); + assert.match(md, /INBOUND/); + assert.match(md, /Match score/); +}); + +test('scheduleTwilioEnrichment is no-op without callSid', () => { + clearTimers(); + scheduleTwilioEnrichment({ testId: TEST_ID, roomId: 'room-1', config: { twilioEnrich: true } }, async () => {}); + clearTimers(); +}); + +test('scheduleTwilioEnrichment registers timers for valid session', () => { + _clearAllSessionsForTests(); + clearTimers(); + createSession(TEST_ID, { + callSid: 'CA555', + roomId: 'room-1', + config: { + twilioEnrich: true, + twilioDetailsDelayMs: 300_000, + twilioInsightsDelayMs: 300_000, + }, + }); + const session = getSession(TEST_ID); + scheduleTwilioEnrichment(session, async () => {}); + scheduleTwilioEnrichment(session, async () => {}); + clearTimers(); +}); diff --git a/tests/phoneService.cdrFeed.test.js b/tests/phoneService.cdrFeed.test.js new file mode 100644 index 0000000..ce31347 --- /dev/null +++ b/tests/phoneService.cdrFeed.test.js @@ -0,0 +1,61 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { extractCdrFeedList } from '../services/cdrFeedParser.js'; +import { + cdrDedupKey, + cdrStartTime, + cdrCallingNumber, +} from '../services/cdrFeedParser.js'; +import { filterCdrToWindow } from '../services/voiceReport/joinCallQuality.js'; + +test('extractCdrFeedList handles items array', () => { + const rows = [{ id: 1 }, { id: 2 }]; + assert.deepEqual(extractCdrFeedList({ items: rows }), rows); +}); + +test('extractCdrFeedList handles numeric-keyed items object', () => { + const rows = [{ id: 'a' }, { id: 'b' }]; + assert.deepEqual(extractCdrFeedList({ items: { 0: rows[0], 1: rows[1] } }), rows); +}); + +test('extractCdrFeedList handles top-level array', () => { + const rows = [{ id: 1 }]; + assert.deepEqual(extractCdrFeedList(rows), rows); +}); + +test('extractCdrFeedList handles records alias', () => { + const rows = [{ id: 1 }]; + assert.deepEqual(extractCdrFeedList({ records: rows }), rows); +}); + +test('cdrField reads report column labels', () => { + const row = { + 'Report ID': 'abc-1', + 'Start time': '2026-07-23T14:00:00.000Z', + 'Calling number': '+15551234567', + 'Called number': '+12122194600', + Duration: 42, + Direction: 'Inbound', + }; + assert.equal(cdrStartTime(row), '2026-07-23T14:00:00.000Z'); + assert.equal(cdrCallingNumber(row), '+15551234567'); + assert.equal(cdrDedupKey(row), 'id:abc-1'); +}); + +test('cdrDedupKey keeps distinct report rows', () => { + const a = { 'Report ID': '1', 'Start time': '2026-07-23T14:00:00.000Z' }; + const b = { 'Report ID': '2', 'Start time': '2026-07-23T15:00:00.000Z' }; + assert.notEqual(cdrDedupKey(a), cdrDedupKey(b)); +}); + +test('filterCdrToWindow keeps API rows when start time is report-labeled', () => { + const window = { + startTime: '2026-07-23T13:00:00.000Z', + endTime: '2026-07-24T01:00:00.000Z', + }; + const rows = [ + { 'Start time': '2026-07-23T14:00:00.000Z', 'Report ID': '1' }, + { 'Start time': '2026-07-23T15:00:00.000Z', 'Report ID': '2' }, + ]; + assert.equal(filterCdrToWindow(rows, window).length, 2); +}); diff --git a/tests/voiceReport.businessWindow.test.js b/tests/voiceReport.businessWindow.test.js new file mode 100644 index 0000000..38d169d --- /dev/null +++ b/tests/voiceReport.businessWindow.test.js @@ -0,0 +1,59 @@ +// tests/voiceReport.businessWindow.test.js + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DateTime } from 'luxon'; + +import { + computeBusinessWindow, + parseReportDateArg, +} from '../services/voiceReport/businessWindow.js'; + +test('parseReportDateArg defaults to yesterday', () => { + const now = DateTime.fromISO('2026-07-24T15:00:00', { zone: 'America/New_York' }).toJSDate(); + const p = parseReportDateArg(null, 'America/New_York', now); + assert.equal(p.label, '2026-07-23'); + assert.equal(p.mode, 'fullDay'); +}); + +test('computeBusinessWindow full day yesterday 9-9', () => { + const now = DateTime.fromISO('2026-07-24T12:00:00', { zone: 'America/New_York' }).toJSDate(); + const w = computeBusinessWindow({ + timeZone: 'America/New_York', + dateArg: 'yesterday', + now, + }); + assert.equal(w.ready, true); + assert.equal(w.label, '2026-07-23'); + const start = DateTime.fromISO(w.startTime, { zone: 'utc' }); + const end = DateTime.fromISO(w.endTime, { zone: 'utc' }); + const startLocal = start.setZone('America/New_York'); + const endLocal = end.setZone('America/New_York'); + assert.equal(startLocal.hour, 9); + assert.equal(endLocal.hour, 21); +}); + +test('computeBusinessWindow today ends at now minus lag', () => { + const now = DateTime.fromISO('2026-07-24T14:00:00', { zone: 'America/New_York' }).toJSDate(); + const w = computeBusinessWindow({ + timeZone: 'America/New_York', + dateArg: 'today', + now, + }); + assert.equal(w.ready, true); + assert.equal(w.mode, 'today'); + const endMs = new Date(w.endTime).getTime(); + assert.ok(endMs <= now.getTime() - 5 * 60 * 1000 + 2000); + const startLocal = DateTime.fromISO(w.startTime).setZone('America/New_York'); + assert.equal(startLocal.hour, 9); +}); + +test('computeBusinessWindow today before 9am not ready', () => { + const now = DateTime.fromISO('2026-07-24T08:00:00', { zone: 'America/New_York' }).toJSDate(); + const w = computeBusinessWindow({ + timeZone: 'America/New_York', + dateArg: 'today', + now, + }); + assert.equal(w.ready, false); +}); diff --git a/tests/voiceReport.groupCdrCalls.test.js b/tests/voiceReport.groupCdrCalls.test.js new file mode 100644 index 0000000..8e79f24 --- /dev/null +++ b/tests/voiceReport.groupCdrCalls.test.js @@ -0,0 +1,97 @@ +// tests/voiceReport.groupCdrCalls.test.js + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + groupCdrIntoCalls, + summarizeCalls, + classifyCallDirection, +} from '../services/voiceReport/groupCdrCalls.js'; + +const SAMPLE_LEGS = [ + { + 'Answer time': '2026-07-23T13:06:24.325Z', + Answered: 'true', + Direction: 'TERMINATING', + 'Start time': '2026-07-23T13:06:24.316Z', + 'Call type': 'SIP_INBOUND', + 'Correlation ID': '3c7f5c7c-6f48-40f2-925c-133f6a84dcdb', + Duration: 184, + 'Report ID': 'fd1debbf-d2b4-331c-846a-2f2a79de9b08', + 'User type': 'AutomatedAttendantVideo', + User: 'Store 2477', + 'Called number': '+12122194600', + 'Calling number': '+17189864017', + 'Call outcome': 'Success', + 'Call outcome reason': 'Normal', + 'Answer indicator': 'Yes', + 'Auto Attendant Key Pressed': '1', + }, + { + 'Answer time': '2026-07-23T13:07:14.337Z', + Answered: 'true', + Direction: 'ORIGINATING', + 'Start time': '2026-07-23T13:06:26.570Z', + 'Call type': 'SIP_ENTERPRISE', + 'Correlation ID': '3c7f5c7c-6f48-40f2-925c-133f6a84dcdb', + Duration: 134, + 'Report ID': '9541d7ea-8ced-3d39-a8ef-c2fc5e341700', + 'User type': 'AutomatedAttendantVideo', + 'Called number': '52477', + 'Calling number': '+17189864017', + 'Call outcome': 'Success', + 'Call outcome reason': 'Normal', + 'Answer indicator': 'Yes', + }, + { + 'Answer time': '2026-07-23T13:07:14.337Z', + Answered: 'true', + Direction: 'TERMINATING', + 'Start time': '2026-07-23T13:06:26.739Z', + 'Call type': 'SIP_ENTERPRISE', + 'Client type': 'WXC_DEVICE', + 'Correlation ID': '3c7f5c7c-6f48-40f2-925c-133f6a84dcdb', + Duration: 134, + 'Report ID': '829fcf5b-975c-3f5d-9150-af4743232bd3', + 'User type': 'User', + User: 'Store 02477', + Model: 'DBS-210-3PC', + 'Called number': '52477', + 'Calling number': '+17189864017', + 'Call outcome': 'Success', + 'Call outcome reason': 'Normal', + 'Answer indicator': 'Yes', + 'Release time': '2026-07-23T13:09:28.784Z', + }, +]; + +test('classifyCallDirection detects inbound from SIP_INBOUND leg', () => { + assert.equal(classifyCallDirection(SAMPLE_LEGS), 'inbound'); +}); + +test('groupCdrIntoCalls collapses legs with same correlation ID', () => { + const calls = groupCdrIntoCalls(SAMPLE_LEGS); + assert.equal(calls.length, 1); + assert.equal(calls[0].legCount, 3); + assert.equal(calls[0].direction, 'inbound'); + assert.equal(calls[0].callingNumber, '+17189864017'); + assert.equal(calls[0].reachedPhone, true); + assert.equal(calls[0].endpointUser, 'Store 02477'); + assert.equal(calls[0].aaKeyPress, '1'); + assert.equal(calls[0].normalOutcome, true); +}); + +test('summarizeCalls aggregates inbound reach stats', () => { + const calls = groupCdrIntoCalls(SAMPLE_LEGS); + const summary = summarizeCalls(calls); + assert.equal(summary.total, 1); + assert.equal(summary.inbound, 1); + assert.equal(summary.inboundReachedPhone, 1); + assert.equal(summary.abnormal, 0); +}); + +test('legs without correlation ID stay separate', () => { + const a = { ...SAMPLE_LEGS[0], 'Correlation ID': 'a', 'Report ID': 'r1' }; + const b = { ...SAMPLE_LEGS[0], 'Correlation ID': 'b', 'Report ID': 'r2' }; + assert.equal(groupCdrIntoCalls([a, b]).length, 2); +}); diff --git a/tests/voiceReport.join.test.js b/tests/voiceReport.join.test.js new file mode 100644 index 0000000..6997e6e --- /dev/null +++ b/tests/voiceReport.join.test.js @@ -0,0 +1,87 @@ +// tests/voiceReport.join.test.js + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + joinCallQuality, + matchMediaQualityToCall, + worstPrismaBucketForCall, +} from '../services/voiceReport/joinCallQuality.js'; + +const window = { + startTime: '2026-07-23T13:00:00.000Z', + endTime: '2026-07-24T01:00:00.000Z', +}; + +test('matchMediaQualityToCall joins on correlation id', () => { + const call = { + start: '2026-07-23T14:00:00.000Z', + duration: 60, + callingNumber: '+15551234567', + calledNumber: '+17247795574', + correlationId: 'abc-123', + }; + const mq = [{ + 'Correlation ID': 'abc-123', + 'Start Time': '2026-07-23T14:00:01.000Z', + _norm: { correlationid: 'abc-123', starttime: '2026-07-23T14:00:01.000Z', mos: '4.2' }, + }]; + const hit = matchMediaQualityToCall(call, mq); + assert.ok(hit); + assert.equal(hit.mos, 4.2); +}); + +test('joinCallQuality groups legs into correlated calls', () => { + const joined = joinCallQuality({ + cdrItems: [ + { + direction: 'INBOUND', + startTime: '2026-07-23T14:00:00.000Z', + durationSeconds: 30, + status: 'SUCCESS', + callingNumber: '+1', + calledNumber: '+2', + 'Correlation ID': 'corr-1', + 'Report ID': 'r1', + 'Call type': 'SIP_INBOUND', + 'Call outcome': 'Success', + 'Call outcome reason': 'Normal', + }, + { + Direction: 'Outbound', + 'Start time': '2026-07-23T15:00:00.000Z', + Duration: 10, + 'Answer indicator': 'No', + 'Report ID': 'r2', + 'Call type': 'SIP_OUTBOUND', + 'Call outcome': 'Failed', + 'Call outcome reason': 'Busy', + }, + ], + mqRows: [], + appAudio: null, + window, + }); + assert.equal(joined.summary.total, 2); + assert.equal(joined.calls.length, 2); + assert.equal(joined.summary.inbound, 1); + assert.equal(joined.summary.outbound, 1); + assert.equal(joined.summary.abnormal, 1); +}); + +test('worstPrismaBucketForCall picks low MOS bucket', () => { + const leg = { + start: '2026-07-23T13:05:00.000Z', + duration: 600, + }; + const appAudio = { + mos: { values: [4.4, 4.4, 3.1, 4.4, 4.4] }, + loss: { values: [0, 0, 2, 0, 0] }, + jitter: { values: [5, 5, 5, 5, 5] }, + }; + const w = worstPrismaBucketForCall(leg, appAudio, window); + assert.ok(w); + assert.equal(w.mos, 3.1); + assert.equal(w.siteLevelApprox, true); +}); diff --git a/tests/voiceReport.renderer.test.js b/tests/voiceReport.renderer.test.js new file mode 100644 index 0000000..332380b --- /dev/null +++ b/tests/voiceReport.renderer.test.js @@ -0,0 +1,70 @@ +// tests/voiceReport.renderer.test.js + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { renderVoiceReportMarkdown } from '../services/renderers/voiceReportRenderer.js'; + +test('renderVoiceReportMarkdown summary', () => { + const md = renderVoiceReportMarkdown({ + ok: true, + store: { storeNum: '782', locationName: 'Store 0782', dialNumber: '+17247795574' }, + window: { + label: '2026-07-23', + mode: 'fullDay', + timeZone: 'America/New_York', + startTime: '2026-07-23T13:00:00.000Z', + endTime: '2026-07-24T01:00:00.000Z', + }, + cdr: { available: true, rawCount: 10 }, + mediaQuality: { available: false, reason: 'skipped in test' }, + prisma: { available: false, reason: 'not configured' }, + joined: { + summary: { + total: 5, + inbound: 3, + outbound: 2, + inboundReachedPhone: 2, + outboundConnected: 2, + abnormal: 1, + outcomes: { 'Success / Normal': 4, 'Failed / Busy': 1 }, + }, + calls: [], + }, + elapsedMs: 42, + }); + assert.match(md, /Voice report/); + assert.match(md, /Store 0782/); + assert.match(md, /Total calls.*5/); + assert.match(md, /Inbound reached a phone/); +}); + +test('renderVoiceReportMarkdown detail truncates', () => { + const calls = Array.from({ length: 30 }, (_, i) => ({ + start: `2026-07-23T14:${String(i).padStart(2, '0')}:00.000Z`, + direction: 'inbound', + duration: 30, + outcome: 'Success', + outcomeReason: 'Normal', + callingNumber: '+1', + calledNumber: '+2', + legCount: 2, + reachedPhone: true, + normalOutcome: true, + })); + const md = renderVoiceReportMarkdown({ + ok: true, + store: { storeNum: '782', locationName: 'Store 0782' }, + window: { label: '2026-07-23', mode: 'fullDay', timeZone: 'America/New_York', startTime: 'x', endTime: 'y' }, + cdr: { available: true }, + mediaQuality: { available: false }, + prisma: { available: false }, + joined: { + summary: { total: 30, inbound: 30, outbound: 0, outcomes: {} }, + calls, + }, + elapsedMs: 1, + }, { detail: true }); + assert.match(md, /Call detail/); + assert.match(md, /more calls/); +}); diff --git a/tests/webex.reportsClient.test.js b/tests/webex.reportsClient.test.js new file mode 100644 index 0000000..92552f4 --- /dev/null +++ b/tests/webex.reportsClient.test.js @@ -0,0 +1,51 @@ +// tests/webex.reportsClient.test.js + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + parseMediaQualityCsv, + filterMediaQualityRows, + reportDateRangeFromWindow, +} from '../integrations/webex/reportsCsv.js'; + +test('parseMediaQualityCsv parses header rows', () => { + const csv = 'Start Time,Location,MOS\n2026-07-23T14:00:00Z,Store 0782,4.1\n'; + const { headers, rows } = parseMediaQualityCsv(csv); + assert.equal(headers.length, 3); + assert.equal(rows.length, 1); + assert.equal(rows[0].MOS, '4.1'); +}); + +test('filterMediaQualityRows filters by location and window', () => { + const window = { + startTime: '2026-07-23T13:00:00.000Z', + endTime: '2026-07-23T18:00:00.000Z', + }; + const rows = [ + { + Location: 'Warrendale - LGW', + 'Start Time': '2026-07-23T14:00:00.000Z', + _norm: { location: 'warrendale - lgw', starttime: '2026-07-23T14:00:00.000Z' }, + }, + { + Location: 'Other', + 'Start Time': '2026-07-23T14:00:00.000Z', + _norm: { location: 'other', starttime: '2026-07-23T14:00:00.000Z' }, + }, + ]; + const filtered = filterMediaQualityRows(rows, { + locationName: 'Warrendale - LGW', + window, + }); + assert.equal(filtered.length, 1); +}); + +test('reportDateRangeFromWindow uses ISO date slice', () => { + const r = reportDateRangeFromWindow({ + startTime: '2026-07-23T13:00:00.000Z', + endTime: '2026-07-23T23:00:00.000Z', + }); + assert.equal(r.startDate, '2026-07-23'); + assert.equal(r.endDate, '2026-07-23'); +});