Rename /voicereport to /callreport with scoped user and phone targets.
Add store/email/phone filtering, richer call-line formatting, CDR feed pagination and queueing, and split Jira poller enrichment into testable modules. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
1eaabbcee1
commit
a25fc08fe2
45 changed files with 3179 additions and 863 deletions
22
.env.example
22
.env.example
|
|
@ -56,8 +56,8 @@ WEBEX_BOT_TOKEN=your-bot-token-here
|
||||||
# - spark-admin:licenses_read (webexhost, findEmptyLocations)
|
# - spark-admin:licenses_read (webexhost, findEmptyLocations)
|
||||||
# - identity:tokens_read (offboarduser: list a user's authorizations)
|
# - identity:tokens_read (offboarduser: list a user's authorizations)
|
||||||
# - identity:tokens_write (offboarduser: revoke a user's authorizations)
|
# - identity:tokens_write (offboarduser: revoke a user's authorizations)
|
||||||
# - spark-admin:calling_cdr_read (/voicereport + /calltest CDR via cdr_feed)
|
# - spark-admin:calling_cdr_read (/callreport + /calltest CDR via cdr_feed)
|
||||||
# - analytics:read_all (/voicereport Webex Reports API — Pro Pack)
|
# - analytics:read_all (/callreport Webex Reports API — Pro Pack)
|
||||||
# The authorizing admin must also hold Full / User / Device Admin role for the
|
# The authorizing admin must also hold Full / User / Device Admin role for the
|
||||||
# token-management calls to succeed.
|
# token-management calls to succeed.
|
||||||
#
|
#
|
||||||
|
|
@ -91,7 +91,8 @@ WEBEX_TOKENS_PATH=./config/webex-service-tokens.json
|
||||||
# CDR feed (/cdr_feed) uses analytics-calling.webexapis.com — a different host than
|
# CDR feed (/cdr_feed) uses analytics-calling.webexapis.com — a different host than
|
||||||
# webexapis.com. In Docker, HTTP(S)_PROXY may route analytics subdomains through a
|
# webexapis.com. In Docker, HTTP(S)_PROXY may route analytics subdomains through a
|
||||||
# broken proxy while webexapis.com is in NO_PROXY. CDR requests bypass proxy by default.
|
# broken proxy while webexapis.com is in NO_PROXY. CDR requests bypass proxy by default.
|
||||||
# WEBEX_CDR_USE_PROXY=true # honor HTTP(S)_PROXY for cdr_feed (default: false)
|
# CDR feed rate limit queue (cdr_feed allows ~1 request/minute per token)
|
||||||
|
# CDR_FEED_COOLDOWN_MS=65000
|
||||||
# WEBEX_CDR_ANALYTICS_BASES=https://analytics-calling.webexapis.com/v1,https://analytics.webexapis.com/v1
|
# WEBEX_CDR_ANALYTICS_BASES=https://analytics-calling.webexapis.com/v1,https://analytics.webexapis.com/v1
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
@ -515,14 +516,15 @@ DECT_RELAY_AGENT_TOKEN=
|
||||||
#
|
#
|
||||||
# Per-store entry overrides: config/calltest-stores.json
|
# Per-store entry overrides: config/calltest-stores.json
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# /voicereport — daily voice digest (CDR + Media Quality + Prisma WAN)
|
# /callreport — daily call digest (CDR + Media Quality + Prisma WAN)
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# VOICEREPORT_BUSINESS_START_HOUR=9
|
# CALLREPORT_BUSINESS_START_HOUR=9
|
||||||
# VOICEREPORT_BUSINESS_END_HOUR=21
|
# CALLREPORT_BUSINESS_END_HOUR=21
|
||||||
# VOICEREPORT_API_LAG_MS=300000
|
# CALLREPORT_API_LAG_MS=300000
|
||||||
# VOICEREPORT_MAX_DETAIL_ROWS=25
|
# CALLREPORT_MAX_DETAIL_ROWS=25
|
||||||
# VOICEREPORT_REPORT_POLL_MS=5000
|
# CALLREPORT_REPORT_POLL_MS=5000
|
||||||
# VOICEREPORT_REPORT_POLL_MAX_MS=180000
|
# CALLREPORT_REPORT_POLL_MAX_MS=180000
|
||||||
|
# (VOICEREPORT_* env vars still accepted for backward compatibility)
|
||||||
# WEBEX_REPORT_TEMPLATE_MEDIA_QUALITY=
|
# WEBEX_REPORT_TEMPLATE_MEDIA_QUALITY=
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Optional. Per-base collect() RPC timeout. Corporate proxies can make
|
# Optional. Per-base collect() RPC timeout. Corporate proxies can make
|
||||||
|
|
|
||||||
107
commands/callReport.js
Normal file
107
commands/callReport.js
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
// commands/callReport.js
|
||||||
|
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
import { collectCallReport } from '../services/callReport/callReportService.js';
|
||||||
|
import { renderCallReportMarkdown } from '../services/renderers/callReportRenderer.js';
|
||||||
|
import { parseCallReportTarget } from '../services/callReport/parseTarget.js';
|
||||||
|
|
||||||
|
function pickDateArg(args) {
|
||||||
|
const targetParsed = parseCallReportTarget(args[0]);
|
||||||
|
for (let i = 1; i < args.length; i++) {
|
||||||
|
const a = String(args[i] || '').trim();
|
||||||
|
if (!a || a.startsWith('--')) continue;
|
||||||
|
if (i === 1 && targetParsed?.kind === 'store' && /^\d{2,4}$/.test(a)) continue;
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
const flag = args.find((a) => String(a).startsWith('--date='));
|
||||||
|
if (flag) return flag.split('=').slice(1).join('=');
|
||||||
|
const idx = args.indexOf('--date');
|
||||||
|
if (idx >= 0 && args[idx + 1]) return args[idx + 1];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDetailFlag(args, query) {
|
||||||
|
return args.some((a) => ['detail', 'detailed', '--detail', '--detailed'].includes(String(a).toLowerCase()))
|
||||||
|
|| query.detail === 'true'
|
||||||
|
|| query.detailed === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatQueuedRunAt(runAt) {
|
||||||
|
return DateTime.fromJSDate(runAt).toFormat('h:mm:ss a');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickTargetArg(args, query) {
|
||||||
|
return args[0]?.trim()
|
||||||
|
|| query.target
|
||||||
|
|| query.storeNum
|
||||||
|
|| query.store
|
||||||
|
|| query.email
|
||||||
|
|| query.number
|
||||||
|
|| query.s
|
||||||
|
|| null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usageMarkdown() {
|
||||||
|
return (
|
||||||
|
'**Call report usage**\n\n' +
|
||||||
|
'`/callreport <store>` — yesterday 9am–9pm local (default)\n' +
|
||||||
|
'`/callreport <email>` — calls for that user at their location\n' +
|
||||||
|
'`/callreport <phone>` — calls for that assigned number\n' +
|
||||||
|
'`/callreport <target> today` — today 9am → 5 min ago\n' +
|
||||||
|
'`/callreport <target> 2026-07-22` — specific day\n' +
|
||||||
|
'`/callreport <target> --detail` — include per-call table\n\n' +
|
||||||
|
'HTTP: `?target=782&date=yesterday&detail=true` (also `storeNum`, `email`, `number`)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ackLabel(target) {
|
||||||
|
if (!target) return 'target';
|
||||||
|
if (target.kind === 'store') return `store **${target.storeNum}**`;
|
||||||
|
if (target.locationName) return `**${target.label}** (${target.locationName})`;
|
||||||
|
return `**${target.label}**`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCallReport(bot, trigger) {
|
||||||
|
logger('callreport', 'Handler entered', 'debug');
|
||||||
|
|
||||||
|
const query = trigger.query || {};
|
||||||
|
const args = trigger.args || [];
|
||||||
|
|
||||||
|
const targetToken = pickTargetArg(args, query);
|
||||||
|
const dateArg = pickDateArg(args) || query.date || null;
|
||||||
|
const detail = hasDetailFlag(args, query);
|
||||||
|
|
||||||
|
if (!targetToken || !parseCallReportTarget(targetToken)) {
|
||||||
|
await bot.say('markdown', usageMarkdown());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`**Call report** — generating for ${ackLabel({ label: targetToken })} (${dateArg || 'yesterday'})…`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let cdrQueuedNoticeSent = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const report = await collectCallReport(targetToken, {
|
||||||
|
dateArg,
|
||||||
|
onCdrQueued: async ({ runAt, waitMs }) => {
|
||||||
|
if (cdrQueuedNoticeSent) return;
|
||||||
|
cdrQueuedNoticeSent = true;
|
||||||
|
const secs = Math.max(1, Math.ceil(waitMs / 1000));
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`⏳ **Call report** — CDR query queued. Webex allows one \`cdr_feed\` request per ~90 seconds. ` +
|
||||||
|
`Your request will run at **${formatQueuedRunAt(runAt)}** (~${secs}s).`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const md = renderCallReportMarkdown(report, { detail });
|
||||||
|
await bot.say('markdown', md);
|
||||||
|
} catch (err) {
|
||||||
|
logger('callreport', `Failed for target ${targetToken}: ${err.message}`, 'error');
|
||||||
|
await bot.say('markdown', `❌ Call report failed for **${targetToken}**: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,7 +21,7 @@ const SHORT_HELP = {
|
||||||
phonestatus: 'DECT + IP phone status for a store (plus 7d WAN follow-up)',
|
phonestatus: 'DECT + IP phone status for a store (plus 7d WAN follow-up)',
|
||||||
dectstatus: 'Full DECT basestation dump via relay (reboot / factory-reset cards)',
|
dectstatus: 'Full DECT basestation dump via relay (reboot / factory-reset cards)',
|
||||||
voicediag: 'Deep voice diagnostic: Webex Calling features + SD-WAN quality with fix cards',
|
voicediag: 'Deep voice diagnostic: Webex Calling features + SD-WAN quality with fix cards',
|
||||||
voicereport: 'Daily voice digest: CDR + Media Quality + Prisma WAN for a store',
|
callreport: 'Daily call digest: correlated CDR calls + per-call Prisma WAN (store, email, or phone)',
|
||||||
calltest: 'Twilio voice path test (store AA or direct dial, 60s listen)',
|
calltest: 'Twilio voice path test (store AA or direct dial, 60s listen)',
|
||||||
|
|
||||||
// Jira
|
// Jira
|
||||||
|
|
@ -151,26 +151,34 @@ const LONG_HELP = {
|
||||||
'PCAPs land in the System Log bundle downloadable from Control Hub diagnostics.',
|
'PCAPs land in the System Log bundle downloadable from Control Hub diagnostics.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
voicereport: {
|
callreport: {
|
||||||
title: '/voicereport',
|
title: '/callreport',
|
||||||
usage: [
|
usage: [
|
||||||
'/voicereport <store>',
|
'/callreport <store>',
|
||||||
'/voicereport <store> today',
|
'/callreport <email>',
|
||||||
'/voicereport <store> YYYY-MM-DD',
|
'/callreport <phone>',
|
||||||
'/voicereport <store> --detail',
|
'/callreport <target> today',
|
||||||
|
'/callreport <target> YYYY-MM-DD',
|
||||||
|
'/callreport <target> --detail',
|
||||||
],
|
],
|
||||||
examples: [
|
examples: [
|
||||||
'/voicereport 782',
|
'/callreport 782',
|
||||||
'/voicereport 782 today',
|
'/callreport mcqueenj@ae.com',
|
||||||
'/voicereport 782 2026-07-22',
|
'/callreport 7247795574',
|
||||||
'/voicereport 782 --detail',
|
'/callreport mcqueenj@ae.com today',
|
||||||
|
'/callreport 782 2026-07-22',
|
||||||
|
'/callreport 782 --detail',
|
||||||
],
|
],
|
||||||
notes: [
|
notes: [
|
||||||
'Store-scoped daily voice digest for **9am–9pm local** (default: **yesterday**). `today` uses 9am → 5 minutes ago.',
|
'**Store** (`782`): full location 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.',
|
'**Email** or **phone number**: resolves the user/line location via Webex, fetches location CDR, then filters to that person/number only.',
|
||||||
'Requires `spark-admin:calling_cdr_read` + Control Hub role **Webex Calling Detailed Call History API access** for CDR.',
|
'Groups CDR legs by **Correlation ID** into calls; lists inbound reach, auto-attendant, outbound, and abnormal outcomes.',
|
||||||
'Report generation may take 1–3 minutes; an ack message posts first.',
|
'Answered/connected calls include **Prisma Webex_Calling_RTP** MOS/jitter/loss for the call window when configured.',
|
||||||
'HTTP: `?storeNum=782&date=yesterday&detail=true`.',
|
'Requires `spark-admin:calling_cdr_read` + Control Hub role **Webex Calling Detailed Call History API access**.',
|
||||||
|
'CDR queries are serialized with a **65s cooldown** — a second request in that window is queued and you get a wait notice.',
|
||||||
|
'`--detail` adds correlation ID and raises per-section row cap.',
|
||||||
|
'HTTP: `?target=782&date=yesterday&detail=true` (also `storeNum`, `email`, `number`).',
|
||||||
|
'Alias: `/voicereport` (legacy).',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
calltest: {
|
calltest: {
|
||||||
|
|
@ -240,6 +248,7 @@ const LONG_HELP = {
|
||||||
examples: ['/jirapoll', '/jirapoll prime'],
|
examples: ['/jirapoll', '/jirapoll prime'],
|
||||||
notes: [
|
notes: [
|
||||||
'Triggers the same Jira poller that normally runs at the top of every hour. Enriches any unlabeled matching tickets with a phone/av snapshot comment and labels them `bot-enriched`. Tickets the AI classifier decides are out-of-scope get labeled `bot-skipped` so they aren\'t re-classified every hour.',
|
'Triggers the same Jira poller that normally runs at the top of every hour. Enriches any unlabeled matching tickets with a phone/av snapshot comment and labels them `bot-enriched`. Tickets the AI classifier decides are out-of-scope get labeled `bot-skipped` so they aren\'t re-classified every hour.',
|
||||||
|
'**Communication Services** tickets mentioning call quality (garbled, static, can\'t connect, …) get **phonestatus + callreport (today)**. Spam/robocall tickets get **callreport** only. See `services/jiraPoller/README.md` for adding more rules.',
|
||||||
'Idempotent — labels + JQL prevent double-processing, so running multiple times in a row is safe.',
|
'Idempotent — labels + JQL prevent double-processing, so running multiple times in a row is safe.',
|
||||||
'`/jirapoll prime` labels every matching ticket without enriching or notifying. Use once after adopting the poller to skip enriching the existing backlog. Same as `JIRA_POLLER_PRIME_ON_START=true` at startup.',
|
'`/jirapoll prime` labels every matching ticket without enriching or notifying. Use once after adopting the poller to skip enriching the existing backlog. Same as `JIRA_POLLER_PRIME_ON_START=true` at startup.',
|
||||||
'A summary of enriched tickets goes to the configured `JIRA_POLLER_ROOM_ID`. The invoking chat also gets a compact result line.',
|
'A summary of enriched tickets goes to the configured `JIRA_POLLER_ROOM_ID`. The invoking chat also gets a compact result line.',
|
||||||
|
|
@ -301,7 +310,7 @@ const LONG_HELP = {
|
||||||
|
|
||||||
const GROUPS = [
|
const GROUPS = [
|
||||||
{ title: 'Work orders', keys: ['wohistory', 'wosummary', 'woattachments'] },
|
{ title: 'Work orders', keys: ['wohistory', 'wosummary', 'woattachments'] },
|
||||||
{ title: 'AV & phones', keys: ['avstatus', 'phonestatus', 'dectstatus', 'voicediag', 'voicereport', 'calltest'] },
|
{ title: 'AV & phones', keys: ['avstatus', 'phonestatus', 'dectstatus', 'voicediag', 'callreport', 'calltest'] },
|
||||||
{ title: 'Jira', keys: ['jirahistory', 'jiraticket', 'jirapoll'] },
|
{ title: 'Jira', keys: ['jirahistory', 'jiraticket', 'jirapoll'] },
|
||||||
{ title: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] },
|
{ title: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] },
|
||||||
{ title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] },
|
{ title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] },
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ import { handleBulkAvStatusCSV } from './bulkAvStatusCSV.js';
|
||||||
import { handleBulkAvSwitchCSV } from './bulkAvSwitchCSV.js';
|
import { handleBulkAvSwitchCSV } from './bulkAvSwitchCSV.js';
|
||||||
import { handleTestDevicesByModel } from './testDevicesByModel.js';
|
import { handleTestDevicesByModel } from './testDevicesByModel.js';
|
||||||
import { handleCallTest } from './callTest.js';
|
import { handleCallTest } from './callTest.js';
|
||||||
import { handleVoiceReport } from './voiceReport.js';
|
import { handleCallReport } from './callReport.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Each entry:
|
* Each entry:
|
||||||
|
|
@ -58,7 +58,7 @@ export const commands = [
|
||||||
// (mutating: true). See commands/voiceDiag.js + services/voiceDiag/
|
// (mutating: true). See commands/voiceDiag.js + services/voiceDiag/
|
||||||
// for the full behavior contract + required scopes.
|
// for the full behavior contract + required scopes.
|
||||||
{ name: 'voicediag', handler: handleVoiceDiag, mutating: true },
|
{ name: 'voicediag', handler: handleVoiceDiag, mutating: true },
|
||||||
{ name: 'voicereport', handler: handleVoiceReport, mutating: false },
|
{ name: 'callreport', aliases: ['voicereport'], handler: handleCallReport, mutating: false },
|
||||||
{ name: 'wohistory', handler: handleWoHistory, mutating: false },
|
{ name: 'wohistory', handler: handleWoHistory, mutating: false },
|
||||||
{ name: 'wosummary', handler: handleWoSummary, mutating: false },
|
{ name: 'wosummary', handler: handleWoSummary, mutating: false },
|
||||||
{ name: 'woattachments', handler: handleWoAttachments, mutating: false },
|
{ name: 'woattachments', handler: handleWoAttachments, mutating: false },
|
||||||
|
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
// commands/voiceReport.js
|
|
||||||
|
|
||||||
import { logger } from '../utils/logger.js';
|
|
||||||
import { collectVoiceReport } from '../services/voiceReport/voiceReportService.js';
|
|
||||||
import { renderVoiceReportMarkdown } from '../services/renderers/voiceReportRenderer.js';
|
|
||||||
|
|
||||||
function pickDateArg(args) {
|
|
||||||
for (let i = 1; i < args.length; i++) {
|
|
||||||
const a = String(args[i] || '').trim();
|
|
||||||
if (!a || a.startsWith('--')) continue;
|
|
||||||
if (/^\d{2,4}$/.test(a) && i === 1) continue;
|
|
||||||
return a;
|
|
||||||
}
|
|
||||||
const flag = args.find((a) => String(a).startsWith('--date='));
|
|
||||||
if (flag) return flag.split('=').slice(1).join('=');
|
|
||||||
const idx = args.indexOf('--date');
|
|
||||||
if (idx >= 0 && args[idx + 1]) return args[idx + 1];
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasDetailFlag(args, query) {
|
|
||||||
return args.some((a) => ['detail', 'detailed', '--detail', '--detailed'].includes(String(a).toLowerCase()))
|
|
||||||
|| query.detail === 'true'
|
|
||||||
|| query.detailed === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleVoiceReport(bot, trigger) {
|
|
||||||
logger('voicereport', 'Handler entered', 'debug');
|
|
||||||
|
|
||||||
const query = trigger.query || {};
|
|
||||||
const args = trigger.args || [];
|
|
||||||
|
|
||||||
let storeNum = args[0]?.trim() || query.storeNum || query.store || query.s;
|
|
||||||
const dateArg = pickDateArg(args) || query.date || null;
|
|
||||||
const detail = hasDetailFlag(args, query);
|
|
||||||
|
|
||||||
if (!storeNum || !/^\d{2,4}$/.test(storeNum)) {
|
|
||||||
await bot.say(
|
|
||||||
'markdown',
|
|
||||||
'**Voice report usage**\n\n' +
|
|
||||||
'`/voicereport <store>` — yesterday 9am–9pm local (default)\n' +
|
|
||||||
'`/voicereport <store> today` — today 9am → 5 min ago\n' +
|
|
||||||
'`/voicereport <store> 2026-07-22` — specific day\n' +
|
|
||||||
'`/voicereport <store> --detail` — include per-call table\n\n' +
|
|
||||||
'HTTP: `?storeNum=782&date=yesterday&detail=true`',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await bot.say(
|
|
||||||
'markdown',
|
|
||||||
`**Voice report** — generating for store **${storeNum}** ` +
|
|
||||||
`(${dateArg || 'yesterday'})… This may take 1–3 minutes while Webex builds the Media Quality report.`,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const report = await collectVoiceReport(storeNum, { dateArg });
|
|
||||||
const md = renderVoiceReportMarkdown(report, { detail });
|
|
||||||
await bot.say('markdown', md);
|
|
||||||
} catch (err) {
|
|
||||||
logger('voicereport', `Failed for store ${storeNum}: ${err.message}`, 'error');
|
|
||||||
await bot.say('markdown', `❌ Voice report failed for store ${storeNum}: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -13,23 +13,20 @@ import {
|
||||||
|
|
||||||
export { filterMediaQualityRows, parseMediaQualityCsv, reportDateRangeFromWindow } from './reportsCsv.js';
|
export { filterMediaQualityRows, parseMediaQualityCsv, reportDateRangeFromWindow } from './reportsCsv.js';
|
||||||
|
|
||||||
|
import { callReportEnvInt } from '../../services/callReport/env.js';
|
||||||
|
|
||||||
const LOG_SCOPE = 'webex:reports';
|
const LOG_SCOPE = 'webex:reports';
|
||||||
const TEMPLATE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
const TEMPLATE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
let _templateCache = null;
|
let _templateCache = null;
|
||||||
let _templateCacheAt = 0;
|
let _templateCacheAt = 0;
|
||||||
|
|
||||||
function envInt(name, fallback) {
|
|
||||||
const v = Number(process.env[name]);
|
|
||||||
return Number.isFinite(v) ? v : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pollIntervalMs() {
|
function pollIntervalMs() {
|
||||||
return envInt('VOICEREPORT_REPORT_POLL_MS', 5000);
|
return callReportEnvInt('REPORT_POLL_MS', 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pollMaxMs() {
|
function pollMaxMs() {
|
||||||
return envInt('VOICEREPORT_REPORT_POLL_MAX_MS', 180_000);
|
return callReportEnvInt('REPORT_POLL_MAX_MS', 180_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function _clearReportTemplateCacheForTests() {
|
export function _clearReportTemplateCacheForTests() {
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,17 @@
|
||||||
// services/voiceReport/businessWindow.js
|
// services/callReport/businessWindow.js
|
||||||
// Store-local business-hour windows for /voicereport (default 9am–9pm).
|
// Store-local business-hour windows for /callreport (default 9am–9pm).
|
||||||
|
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { DISPLAY_TIMEZONE } from '../../utils/time.js';
|
import { DISPLAY_TIMEZONE } from '../../utils/time.js';
|
||||||
|
import { callReportEnvInt } from './env.js';
|
||||||
|
|
||||||
const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000;
|
const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000;
|
||||||
|
|
||||||
function envInt(name, fallback) {
|
|
||||||
const v = Number(process.env[name]);
|
|
||||||
return Number.isFinite(v) ? v : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBusinessHourConfig() {
|
export function getBusinessHourConfig() {
|
||||||
return {
|
return {
|
||||||
startHour: envInt('VOICEREPORT_BUSINESS_START_HOUR', 9),
|
startHour: callReportEnvInt('BUSINESS_START_HOUR', 9),
|
||||||
endHour: envInt('VOICEREPORT_BUSINESS_END_HOUR', 21),
|
endHour: callReportEnvInt('BUSINESS_END_HOUR', 21),
|
||||||
apiLagMs: envInt('VOICEREPORT_API_LAG_MS', 5 * 60 * 1000),
|
apiLagMs: callReportEnvInt('API_LAG_MS', 5 * 60 * 1000),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1,35 +1,37 @@
|
||||||
// services/voiceReport/voiceReportService.js
|
// services/callReport/callReportService.js
|
||||||
// Compose CDR + Media Quality + Prisma WAN for /voicereport.
|
// Compose CDR + Media Quality + Prisma WAN for /callreport.
|
||||||
|
|
||||||
import { logger } from '../../utils/logger.js';
|
import { logger } from '../../utils/logger.js';
|
||||||
import { getHistoricalCallActivity } from '../phoneService.js';
|
import { getHistoricalCallActivity } from '../phoneService.js';
|
||||||
import { findSdwanSiteForStore } from '../../integrations/paloalto/sites.js';
|
import { findSdwanSiteForStore } from '../../integrations/paloalto/sites.js';
|
||||||
import { getAppMetric } from '../../integrations/paloalto/metrics.js';
|
import { getAppMetric } from '../../integrations/paloalto/metrics.js';
|
||||||
import { buildAppDetailsUrl } from '../../integrations/paloalto/urls.js';
|
import { buildAppDetailsUrl } from '../../integrations/paloalto/urls.js';
|
||||||
import { fetchMediaQualityReport } from '../../integrations/webex/reportsClient.js';
|
|
||||||
import {
|
import {
|
||||||
resolveVoiceAppConfig,
|
resolveVoiceAppConfig,
|
||||||
summarizeAppSeries,
|
summarizeAppSeries,
|
||||||
} from '../enrichment/sdwanEnrichment.js';
|
} from '../enrichment/sdwanEnrichment.js';
|
||||||
import { computeBusinessWindow } from './businessWindow.js';
|
import { computeBusinessWindow } from './businessWindow.js';
|
||||||
import { resolveStoreForVoiceReport } from './storeContext.js';
|
import { resolveCallReportTarget } from './resolveTarget.js';
|
||||||
import { filterCdrToWindow, joinCallQuality } from './joinCallQuality.js';
|
import { filterCdrToWindow, joinCallQuality } from './joinCallQuality.js';
|
||||||
import { cdrStartTime } from '../cdrFeedParser.js';
|
import { cdrStartTime } from '../cdrFeedParser.js';
|
||||||
|
import { callReportEnvFlag } from './env.js';
|
||||||
|
|
||||||
const LOG_SCOPE = 'voicereport:service';
|
const LOG_SCOPE = 'callreport:service';
|
||||||
|
const MQ_DISABLED = !callReportEnvFlag('ENABLE_WEBEX_MQ');
|
||||||
|
|
||||||
async function fetchCdrForWindow(store, window) {
|
async function fetchCdrForWindow(target, window, { onCdrQueued } = {}) {
|
||||||
const cdr = await getHistoricalCallActivity(store.personId, 12, {
|
const cdr = await getHistoricalCallActivity(target.personId, 12, {
|
||||||
locationName: store.locationName,
|
locationName: target.locationName,
|
||||||
startTime: window.startTime,
|
startTime: window.startTime,
|
||||||
endTime: window.endTime,
|
endTime: window.endTime,
|
||||||
returnRawItems: true,
|
returnRawItems: true,
|
||||||
skipPersonFilter: true,
|
skipPersonFilter: true,
|
||||||
|
onQueued: onCdrQueued,
|
||||||
});
|
});
|
||||||
if (!cdr.available) {
|
if (!cdr.available) {
|
||||||
logger(
|
logger(
|
||||||
LOG_SCOPE,
|
LOG_SCOPE,
|
||||||
`CDR unavailable for store=${store.storeNum} loc=${JSON.stringify(store.locationName)}: ${cdr.reason}`,
|
`CDR unavailable for target=${target.label} loc=${JSON.stringify(target.locationName)}: ${cdr.reason}`,
|
||||||
'warn',
|
'warn',
|
||||||
);
|
);
|
||||||
return { available: false, reason: cdr.reason, items: [], fetchErrors: cdr.fetchErrors };
|
return { available: false, reason: cdr.reason, items: [], fetchErrors: cdr.fetchErrors };
|
||||||
|
|
@ -41,14 +43,14 @@ async function fetchCdrForWindow(store, window) {
|
||||||
const sample = (cdr.rawItems || []).slice(0, 2).map((r) => cdrStartTime(r) || 'no-start-field');
|
const sample = (cdr.rawItems || []).slice(0, 2).map((r) => cdrStartTime(r) || 'no-start-field');
|
||||||
logger(
|
logger(
|
||||||
LOG_SCOPE,
|
LOG_SCOPE,
|
||||||
`CDR window filter dropped all ${rawCount} record(s) for store=${store.storeNum} ` +
|
`CDR window filter dropped all ${rawCount} record(s) for target=${target.label} ` +
|
||||||
`(window ${window.startTime}..${window.endTime}); sample start fields: ${sample.join(', ')}`,
|
`(window ${window.startTime}..${window.endTime}); sample start fields: ${sample.join(', ')}`,
|
||||||
'warn',
|
'warn',
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
logger(
|
logger(
|
||||||
LOG_SCOPE,
|
LOG_SCOPE,
|
||||||
`CDR store=${store.storeNum} loc=${JSON.stringify(store.locationName)} ` +
|
`CDR target=${target.label} loc=${JSON.stringify(target.locationName)} ` +
|
||||||
`api=${rawCount} afterWindowFilter=${items.length}`,
|
`api=${rawCount} afterWindowFilter=${items.length}`,
|
||||||
'info',
|
'info',
|
||||||
);
|
);
|
||||||
|
|
@ -67,6 +69,10 @@ async function fetchPrismaAppAudio(storeNum, window) {
|
||||||
return { available: false, reason: 'PRISMA_APP_ID_VOICE not configured' };
|
return { available: false, reason: 'PRISMA_APP_ID_VOICE not configured' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!storeNum) {
|
||||||
|
return { available: false, reason: 'store number not resolved from location' };
|
||||||
|
}
|
||||||
|
|
||||||
const site = await findSdwanSiteForStore(storeNum);
|
const site = await findSdwanSiteForStore(storeNum);
|
||||||
if (!site?.id) {
|
if (!site?.id) {
|
||||||
return { available: false, reason: 'not a Prisma-managed store' };
|
return { available: false, reason: 'not a Prisma-managed store' };
|
||||||
|
|
@ -92,16 +98,16 @@ async function fetchPrismaAppAudio(storeNum, window) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} storeNum
|
* @param {string} targetToken store number, email, or phone number
|
||||||
* @param {object} opts
|
* @param {object} opts
|
||||||
* @param {string} [opts.dateArg] yesterday | today | YYYY-MM-DD
|
* @param {string} [opts.dateArg] yesterday | today | YYYY-MM-DD
|
||||||
* @param {Date} [opts.now]
|
* @param {Date} [opts.now]
|
||||||
*/
|
*/
|
||||||
export async function collectVoiceReport(storeNum, opts = {}) {
|
export async function collectCallReport(targetToken, opts = {}) {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const store = await resolveStoreForVoiceReport(storeNum);
|
const target = await resolveCallReportTarget(targetToken);
|
||||||
const window = computeBusinessWindow({
|
const window = computeBusinessWindow({
|
||||||
timeZone: store.timeZone,
|
timeZone: target.timeZone,
|
||||||
dateArg: opts.dateArg,
|
dateArg: opts.dateArg,
|
||||||
now: opts.now,
|
now: opts.now,
|
||||||
});
|
});
|
||||||
|
|
@ -109,7 +115,8 @@ export async function collectVoiceReport(storeNum, opts = {}) {
|
||||||
if (!window.ready) {
|
if (!window.ready) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
store,
|
target,
|
||||||
|
store: target,
|
||||||
window,
|
window,
|
||||||
reason: window.reason,
|
reason: window.reason,
|
||||||
};
|
};
|
||||||
|
|
@ -117,37 +124,35 @@ export async function collectVoiceReport(storeNum, opts = {}) {
|
||||||
|
|
||||||
logger(
|
logger(
|
||||||
LOG_SCOPE,
|
LOG_SCOPE,
|
||||||
`Collecting voice report store=${storeNum} loc=${store.locationName} ` +
|
`Collecting call report target=${target.label} kind=${target.kind} loc=${target.locationName} ` +
|
||||||
`window=${window.startTime}..${window.endTime}`,
|
`window=${window.startTime}..${window.endTime}`,
|
||||||
'info',
|
'info',
|
||||||
);
|
);
|
||||||
|
|
||||||
const [cdrRes, mqRes, prismaRes] = await Promise.allSettled([
|
const [cdrRes, prismaRes] = await Promise.allSettled([
|
||||||
fetchCdrForWindow(store, window),
|
fetchCdrForWindow(target, window, { onCdrQueued: opts.onCdrQueued }),
|
||||||
fetchMediaQualityReport({ window, locationName: store.locationName }),
|
fetchPrismaAppAudio(target.storeNum, window),
|
||||||
fetchPrismaAppAudio(storeNum, window),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const cdr = cdrRes.status === 'fulfilled'
|
const cdr = cdrRes.status === 'fulfilled'
|
||||||
? cdrRes.value
|
? cdrRes.value
|
||||||
: { available: false, reason: cdrRes.reason?.message, items: [] };
|
: { available: false, reason: cdrRes.reason?.message, items: [] };
|
||||||
const mediaQuality = mqRes.status === 'fulfilled'
|
const mediaQuality = { available: false, reason: MQ_DISABLED ? 'disabled' : 'not fetched', rows: [] };
|
||||||
? mqRes.value
|
|
||||||
: { available: false, reason: mqRes.reason?.message, rows: [] };
|
|
||||||
const prisma = prismaRes.status === 'fulfilled'
|
const prisma = prismaRes.status === 'fulfilled'
|
||||||
? prismaRes.value
|
? prismaRes.value
|
||||||
: { available: false, reason: prismaRes.reason?.message };
|
: { available: false, reason: prismaRes.reason?.message };
|
||||||
|
|
||||||
const joined = joinCallQuality({
|
const joined = joinCallQuality({
|
||||||
cdrItems: cdr.items || [],
|
cdrItems: cdr.items || [],
|
||||||
mqRows: mediaQuality.rows || [],
|
|
||||||
appAudio: prisma.appAudio || null,
|
appAudio: prisma.appAudio || null,
|
||||||
window,
|
window,
|
||||||
|
filter: target.filter || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
store,
|
target,
|
||||||
|
store: target,
|
||||||
window,
|
window,
|
||||||
cdr,
|
cdr,
|
||||||
mediaQuality,
|
mediaQuality,
|
||||||
57
services/callReport/collapseDisplayCalls.js
Normal file
57
services/callReport/collapseDisplayCalls.js
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
// services/callReport/collapseDisplayCalls.js
|
||||||
|
// Collapse repetitive AA-only bursts for readable /callreport output.
|
||||||
|
|
||||||
|
import { formatLocalTime } from './formatCallLine.js';
|
||||||
|
|
||||||
|
function callerKey(call) {
|
||||||
|
return [
|
||||||
|
call.callingLineId || call.leftParty?.lineId || '',
|
||||||
|
call.callingNumber || call.leftParty?.number || '',
|
||||||
|
call.aaKeyPress || '',
|
||||||
|
].join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse 3+ AA-only calls from the same caller into a single summary row.
|
||||||
|
* @param {object[]} calls
|
||||||
|
* @param {{ timeZone?: string, minBurst?: number }} opts
|
||||||
|
* @returns {Array<object|{ kind: 'burst', calls: object[], summary: string }>}
|
||||||
|
*/
|
||||||
|
export function collapseAaBursts(calls, opts = {}) {
|
||||||
|
const minBurst = opts.minBurst ?? 3;
|
||||||
|
const timeZone = opts.timeZone;
|
||||||
|
const out = [];
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < (calls || []).length) {
|
||||||
|
const call = calls[i];
|
||||||
|
const key = callerKey(call);
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < calls.length && callerKey(calls[j]) === key) j += 1;
|
||||||
|
const group = calls.slice(i, j);
|
||||||
|
|
||||||
|
if (group.length >= minBurst) {
|
||||||
|
const first = group[0];
|
||||||
|
const last = group[group.length - 1];
|
||||||
|
const left = first.callingLineId && String(first.callingLineId).toUpperCase() !== 'NA'
|
||||||
|
? `${first.callingLineId} (${first.callingNumber || ''})`
|
||||||
|
: (first.callingNumber || '?');
|
||||||
|
const main = first.storeMainNumber || first.calledNumber || '?';
|
||||||
|
const start = formatLocalTime(first.start, timeZone);
|
||||||
|
const end = formatLocalTime(last.start, timeZone);
|
||||||
|
const keyLabel = first.aaKeyPress ? ` (AA key ${first.aaKeyPress})` : '';
|
||||||
|
out.push({
|
||||||
|
kind: 'burst',
|
||||||
|
calls: group,
|
||||||
|
summary:
|
||||||
|
`⚠️ ${start}–${end}: ${left} → ${main}${keyLabel} — ` +
|
||||||
|
`${group.length} calls stopped at auto-attendant`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
out.push(...group);
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
15
services/callReport/env.js
Normal file
15
services/callReport/env.js
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// services/callReport/env.js
|
||||||
|
// CALLREPORT_* env vars (VOICEREPORT_* accepted for backward compatibility).
|
||||||
|
|
||||||
|
export function callReportEnvInt(suffix, fallback) {
|
||||||
|
const v = Number(process.env[`CALLREPORT_${suffix}`] ?? process.env[`VOICEREPORT_${suffix}`]);
|
||||||
|
return Number.isFinite(v) ? v : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function callReportEnvFlag(suffix, defaultValue = false) {
|
||||||
|
const raw = String(
|
||||||
|
process.env[`CALLREPORT_${suffix}`] ?? process.env[`VOICEREPORT_${suffix}`] ?? '',
|
||||||
|
).toLowerCase();
|
||||||
|
if (!raw) return defaultValue;
|
||||||
|
return raw === 'true' || raw === '1';
|
||||||
|
}
|
||||||
210
services/callReport/filterCallsByTarget.js
Normal file
210
services/callReport/filterCallsByTarget.js
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
// services/callReport/filterCallsByTarget.js
|
||||||
|
// Post-group CDR filtering for scoped /callreport targets.
|
||||||
|
|
||||||
|
import {
|
||||||
|
cdrCalledNumber,
|
||||||
|
cdrCallingNumber,
|
||||||
|
cdrDialedDigits,
|
||||||
|
cdrField,
|
||||||
|
cdrUserName,
|
||||||
|
cdrUserNumber,
|
||||||
|
} from '../cdrFeedParser.js';
|
||||||
|
import {
|
||||||
|
filterAbnormalCalls,
|
||||||
|
groupCallsByBucket,
|
||||||
|
summarizeCalls,
|
||||||
|
} from './groupCdrCalls.js';
|
||||||
|
|
||||||
|
function digitsOnly(value) {
|
||||||
|
return String(value || '').replace(/\D/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} value
|
||||||
|
* @returns {{ full?: string, last10?: string, extension?: string }|null}
|
||||||
|
*/
|
||||||
|
export function normalizePhone(value) {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
const d = digitsOnly(raw);
|
||||||
|
if (!d) return null;
|
||||||
|
|
||||||
|
if (d.length === 10) return { full: `1${d}`, last10: d };
|
||||||
|
if (d.length === 11 && d.startsWith('1')) return { full: d, last10: d.slice(1) };
|
||||||
|
if (d.length > 11) return { full: d, last10: d.slice(-10) };
|
||||||
|
if (d.length <= 6) return { extension: d };
|
||||||
|
return { full: d, last10: d.slice(-10) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function phonesEqual(a, b) {
|
||||||
|
const left = typeof a === 'object' ? a : normalizePhone(a);
|
||||||
|
const right = typeof b === 'object' ? b : normalizePhone(b);
|
||||||
|
if (!left || !right) return false;
|
||||||
|
if (left.full && right.full && left.full === right.full) return true;
|
||||||
|
if (left.last10 && right.last10 && left.last10 === right.last10) return true;
|
||||||
|
if (left.extension && right.extension && left.extension === right.extension) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPhoneToFilter(filter, value) {
|
||||||
|
const n = normalizePhone(value);
|
||||||
|
if (!n) return;
|
||||||
|
if (n.full) filter.phones.add(n.full);
|
||||||
|
if (n.last10) filter.phones10.add(n.last10);
|
||||||
|
if (n.extension) filter.extensions.add(n.extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a strict scoped filter for user/number /callreport targets.
|
||||||
|
*/
|
||||||
|
export function buildScopedCallFilter({
|
||||||
|
kind,
|
||||||
|
email = null,
|
||||||
|
phoneNumber = null,
|
||||||
|
personId = null,
|
||||||
|
person = null,
|
||||||
|
ownedNumbers = [],
|
||||||
|
locationMain = null,
|
||||||
|
extension = null,
|
||||||
|
}) {
|
||||||
|
const filter = {
|
||||||
|
kind,
|
||||||
|
email,
|
||||||
|
phoneNumber,
|
||||||
|
personId: personId || null,
|
||||||
|
phones: new Set(),
|
||||||
|
phones10: new Set(),
|
||||||
|
extensions: new Set(),
|
||||||
|
emails: new Set(),
|
||||||
|
displayNames: new Set(),
|
||||||
|
excludeMain: locationMain ? normalizePhone(locationMain) : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (phoneNumber) addPhoneToFilter(filter, phoneNumber);
|
||||||
|
if (extension) filter.extensions.add(String(extension).trim());
|
||||||
|
|
||||||
|
for (const rec of ownedNumbers) {
|
||||||
|
addPhoneToFilter(filter, rec.phoneNumber);
|
||||||
|
if (rec.extension) filter.extensions.add(String(rec.extension).trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (person) {
|
||||||
|
const name = person.displayName && String(person.displayName).trim();
|
||||||
|
if (name) filter.displayNames.add(name.toLowerCase());
|
||||||
|
(person.emails || []).forEach((e) => {
|
||||||
|
const v = String(e?.value || e || '').trim().toLowerCase();
|
||||||
|
if (v) filter.emails.add(v);
|
||||||
|
});
|
||||||
|
(person.phoneNumbers || []).forEach((p) => addPhoneToFilter(filter, p?.value || p));
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function phoneMatchesFilter(filter, value) {
|
||||||
|
const n = normalizePhone(value);
|
||||||
|
if (!n) return false;
|
||||||
|
if (n.full && filter.phones.has(n.full)) return true;
|
||||||
|
if (n.last10 && filter.phones10.has(n.last10)) return true;
|
||||||
|
if (n.extension && filter.extensions.has(n.extension)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textMatchesFilter(filter, value) {
|
||||||
|
if (!value) return false;
|
||||||
|
const low = String(value).trim().toLowerCase();
|
||||||
|
if (!low) return false;
|
||||||
|
return filter.displayNames.has(low) || filter.emails.has(low);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExcludedMain(filter, value) {
|
||||||
|
if (!filter.excludeMain || !value) return false;
|
||||||
|
return phonesEqual(filter.excludeMain, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldMatchesFilter(filter, value, { skipMain = false } = {}) {
|
||||||
|
if (!value) return false;
|
||||||
|
if (skipMain && isExcludedMain(filter, value)) return false;
|
||||||
|
return phoneMatchesFilter(filter, value) || textMatchesFilter(filter, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function legHasPersonId(leg, personId) {
|
||||||
|
const uid = cdrField(leg, 'userId', 'personId', 'ownerId') || leg.userId || leg.personId || leg.ownerId;
|
||||||
|
if (uid && String(uid) === String(personId)) return true;
|
||||||
|
const userObj = leg.user;
|
||||||
|
return Boolean(userObj?.id && String(userObj.id) === String(personId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function legMatchesFilter(leg, filter) {
|
||||||
|
if (!leg || !filter) return false;
|
||||||
|
if (filter.personId && legHasPersonId(leg, filter.personId)) return true;
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
cdrUserNumber(leg),
|
||||||
|
cdrUserName(leg),
|
||||||
|
cdrDialedDigits(leg),
|
||||||
|
cdrCallingNumber(leg),
|
||||||
|
cdrCalledNumber(leg),
|
||||||
|
cdrField(leg, 'redirectingNumber', 'callingParty', 'calledParty', 'remoteParty', 'partyNumber'),
|
||||||
|
cdrField(leg, 'userName', 'user', 'originator', 'terminator'),
|
||||||
|
cdrField(leg, 'callingName', 'calledName'),
|
||||||
|
];
|
||||||
|
|
||||||
|
return candidates.some((v) => fieldMatchesFilter(filter, v, { skipMain: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} call grouped call
|
||||||
|
* @param {object|null} filter
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function callMatchesFilter(call, filter) {
|
||||||
|
if (!filter) return true;
|
||||||
|
|
||||||
|
if (filter.personId && (call.legs || []).some((leg) => legHasPersonId(leg, filter.personId))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldMatchesFilter(filter, call.finalNumber)) return true;
|
||||||
|
if (textMatchesFilter(filter, call.endpointUser)) return true;
|
||||||
|
if (fieldMatchesFilter(filter, call.leftParty?.number, { skipMain: call.direction === 'outbound' })) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (textMatchesFilter(filter, call.leftParty?.user)) return true;
|
||||||
|
if (fieldMatchesFilter(filter, call.rightParty?.final)) return true;
|
||||||
|
if (fieldMatchesFilter(filter, call.rightParty?.number, { skipMain: true })) return true;
|
||||||
|
|
||||||
|
if (call.direction === 'inbound' && fieldMatchesFilter(filter, call.callingNumber)) return true;
|
||||||
|
if (call.direction === 'outbound' && fieldMatchesFilter(filter, call.callingNumber, { skipMain: true })) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldMatchesFilter(filter, call.calledNumber, { skipMain: true })) return true;
|
||||||
|
|
||||||
|
return (call.legs || []).some((leg) => legMatchesFilter(leg, filter));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object[]} calls
|
||||||
|
* @param {object|null} filter
|
||||||
|
* @returns {object[]}
|
||||||
|
*/
|
||||||
|
export function filterCallsByTarget(calls, filter) {
|
||||||
|
if (!filter) return calls || [];
|
||||||
|
return (calls || []).filter((call) => callMatchesFilter(call, filter));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuild buckets + summary after scoped filtering.
|
||||||
|
* @param {object[]} calls
|
||||||
|
*/
|
||||||
|
export function rebucketCalls(calls) {
|
||||||
|
const list = calls || [];
|
||||||
|
return {
|
||||||
|
calls: list,
|
||||||
|
legs: list,
|
||||||
|
buckets: groupCallsByBucket(list),
|
||||||
|
abnormalCalls: filterAbnormalCalls(list),
|
||||||
|
summary: summarizeCalls(list),
|
||||||
|
};
|
||||||
|
}
|
||||||
224
services/callReport/formatCallLine.js
Normal file
224
services/callReport/formatCallLine.js
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
// services/callReport/formatCallLine.js
|
||||||
|
// Readable per-call lines for /callreport.
|
||||||
|
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
export const WAN_THRESHOLDS = {
|
||||||
|
mosFair: 4,
|
||||||
|
mosUnusable: 3.5,
|
||||||
|
jitterMs: 40,
|
||||||
|
lossPct: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatDurationHuman(seconds) {
|
||||||
|
const s = Math.max(0, Math.round(Number(seconds) || 0));
|
||||||
|
if (s < 60) return `${s}s`;
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
const rem = s % 60;
|
||||||
|
return rem ? `${m}m ${rem}s` : `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLocalTime(iso, timeZone) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
try {
|
||||||
|
return DateTime.fromISO(String(iso), { zone: 'utc' })
|
||||||
|
.setZone(timeZone || 'utc')
|
||||||
|
.toFormat('h:mma')
|
||||||
|
.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return String(iso);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPhone(num) {
|
||||||
|
if (!num) return null;
|
||||||
|
const s = String(num).trim();
|
||||||
|
if (!s) return null;
|
||||||
|
if (s.startsWith('+')) return s;
|
||||||
|
const d = s.replace(/\D/g, '');
|
||||||
|
if (d.length === 10) return `+1${d}`;
|
||||||
|
if (d.length === 11 && d.startsWith('1')) return `+${d}`;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function partyLabel(lineId, number) {
|
||||||
|
const phone = fmtPhone(number);
|
||||||
|
const lid = lineId && String(lineId).toUpperCase() !== 'NA' ? String(lineId).trim() : null;
|
||||||
|
if (lid && phone) return `${lid} (${phone})`;
|
||||||
|
if (lid) return lid;
|
||||||
|
if (phone) return phone;
|
||||||
|
return '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeUserLabel(user, number) {
|
||||||
|
const phone = fmtPhone(number);
|
||||||
|
const u = user && String(user).trim();
|
||||||
|
if (u && phone) return `${u} (${phone})`;
|
||||||
|
if (u) return u;
|
||||||
|
if (phone) return phone;
|
||||||
|
return '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
function outboundOriginNumber(number) {
|
||||||
|
const numRaw = number && String(number).trim();
|
||||||
|
if (!numRaw) return null;
|
||||||
|
if (numRaw.length <= 5 && !numRaw.startsWith('+')) return numRaw;
|
||||||
|
return fmtPhone(number) || numRaw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOutboundOrigin(call) {
|
||||||
|
const user = call.endpointUser || call.leftParty?.user;
|
||||||
|
const number = call.leftParty?.number || call.callingNumber;
|
||||||
|
const model = call.model || call.endpointModel || call.leftParty?.model;
|
||||||
|
const u = user && String(user).trim();
|
||||||
|
const num = outboundOriginNumber(number);
|
||||||
|
const inner = [num, model && String(model).trim()].filter(Boolean).join(', ');
|
||||||
|
|
||||||
|
if (u && inner) return `${u} (${inner})`;
|
||||||
|
if (u) return u;
|
||||||
|
if (inner) return inner;
|
||||||
|
return '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isShortDial(num) {
|
||||||
|
const s = String(num || '').trim();
|
||||||
|
if (!s) return false;
|
||||||
|
if (/^[#*]?\d{1,4}$/.test(s)) return true;
|
||||||
|
const d = s.replace(/\D/g, '');
|
||||||
|
return d.length > 0 && d.length <= 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRemoteParty(call) {
|
||||||
|
const num = call.calledNumber || call.rightParty?.number;
|
||||||
|
if (call.direction === 'outbound' && isShortDial(num)) {
|
||||||
|
return `dialed ${num}`;
|
||||||
|
}
|
||||||
|
return fmtPhone(num) || '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAaOnly(call) {
|
||||||
|
return Boolean(call.reachedAttendant && !call.reachedPhone);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rightSuffix(call) {
|
||||||
|
const aaOnly = isAaOnly(call);
|
||||||
|
const parts = [];
|
||||||
|
if (!aaOnly && call.finalNumber && call.finalNumber !== call.calledNumber && call.finalNumber !== call.storeMainNumber) {
|
||||||
|
parts.push(`final ${call.finalNumber}`);
|
||||||
|
}
|
||||||
|
if (!aaOnly && call.model) parts.push(call.model);
|
||||||
|
if (aaOnly && call.aaKeyPress) {
|
||||||
|
parts.push(`AA key ${call.aaKeyPress}`);
|
||||||
|
}
|
||||||
|
if (!parts.length) return '';
|
||||||
|
return ` (${parts.join(', ')})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundMetric(value, decimals = 1) {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
||||||
|
const factor = 10 ** decimals;
|
||||||
|
return Math.round(value * factor) / factor;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCleanSuccess(call) {
|
||||||
|
if (call.abnormal || !call.normalOutcome) return false;
|
||||||
|
const disposition = String(call.disposition || call.outcome || '').trim();
|
||||||
|
return disposition.toLowerCase() === 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leading icon for a call line.
|
||||||
|
*/
|
||||||
|
export function formatCallPrefix(call) {
|
||||||
|
if (isAaOnly(call)) return '⚠️ ';
|
||||||
|
if (call.abnormal || !call.normalOutcome) return '⚠️ ';
|
||||||
|
if (isCleanSuccess(call)) return '✅ ';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trailing disposition text (omits redundant "Success" when prefixed with ✅).
|
||||||
|
*/
|
||||||
|
export function formatDispositionSuffix(call) {
|
||||||
|
if (isAaOnly(call)) return '';
|
||||||
|
if (isCleanSuccess(call)) return '';
|
||||||
|
const disposition = call.disposition || call.outcome || 'unknown';
|
||||||
|
if (String(disposition).trim().toLowerCase() === 'success') return '';
|
||||||
|
return ` ${disposition}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wanMetricLabel(label, value, unit, warnIcon, criticalIcon) {
|
||||||
|
let icon = '';
|
||||||
|
if (criticalIcon) icon = ` ${criticalIcon}`;
|
||||||
|
else if (warnIcon) icon = ` ${warnIcon}`;
|
||||||
|
return `${label}: ${value}${unit}${icon}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} wan
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
export function formatWanLine(wan) {
|
||||||
|
if (!wan) return null;
|
||||||
|
const bits = [];
|
||||||
|
const mos = roundMetric(wan.mos, 1);
|
||||||
|
const jitter = roundMetric(wan.jitter, 1);
|
||||||
|
const loss = roundMetric(wan.loss, 1);
|
||||||
|
|
||||||
|
if (mos != null) {
|
||||||
|
let warn = null;
|
||||||
|
let critical = null;
|
||||||
|
if (mos < WAN_THRESHOLDS.mosUnusable) critical = '‼️';
|
||||||
|
else if (mos < WAN_THRESHOLDS.mosFair) warn = '⚠️';
|
||||||
|
bits.push(wanMetricLabel('MOS', mos, '', warn, critical));
|
||||||
|
}
|
||||||
|
if (jitter != null) {
|
||||||
|
const warn = jitter > WAN_THRESHOLDS.jitterMs ? '⚠️' : null;
|
||||||
|
bits.push(wanMetricLabel('Jitter', jitter, 'ms', warn, null));
|
||||||
|
}
|
||||||
|
if (loss != null) {
|
||||||
|
const warn = loss > WAN_THRESHOLDS.lossPct ? '⚠️' : null;
|
||||||
|
bits.push(wanMetricLabel('Loss', loss, '%', warn, null));
|
||||||
|
}
|
||||||
|
return bits.length ? bits.join(' ') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} call grouped call from groupCdrCalls
|
||||||
|
* @param {{ timeZone?: string, includeWan?: boolean, detail?: boolean }} opts
|
||||||
|
* @returns {{ line1: string, line2: string|null }}
|
||||||
|
*/
|
||||||
|
export function formatCallBlock(call, opts = {}) {
|
||||||
|
const { timeZone, includeWan = true, detail = false } = opts;
|
||||||
|
const time = formatLocalTime(call.start, timeZone);
|
||||||
|
const dur = formatDurationHuman(call.duration);
|
||||||
|
const prefix = formatCallPrefix(call);
|
||||||
|
|
||||||
|
let left;
|
||||||
|
let right;
|
||||||
|
if (call.direction === 'inbound') {
|
||||||
|
left = partyLabel(call.callingLineId || call.leftParty?.lineId, call.callingNumber || call.leftParty?.number);
|
||||||
|
const main = fmtPhone(call.storeMainNumber || call.calledNumber || call.rightParty?.main) || '?';
|
||||||
|
right = `${main}${rightSuffix(call)}`;
|
||||||
|
} else if (call.direction === 'outbound') {
|
||||||
|
left = formatOutboundOrigin(call);
|
||||||
|
right = formatRemoteParty(call);
|
||||||
|
} else {
|
||||||
|
left = partyLabel(call.callingLineId, call.callingNumber);
|
||||||
|
right = fmtPhone(call.calledNumber) || '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffix = formatDispositionSuffix(call);
|
||||||
|
let line1 = `${prefix}${time} (${dur}): ${left} → ${right}${suffix}`;
|
||||||
|
if (detail && call.correlationId) {
|
||||||
|
line1 += ` _(${call.legCount} leg(s), ${call.correlationId.slice(0, 8)}…)_`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let line2 = null;
|
||||||
|
if (includeWan && call.wan) {
|
||||||
|
const wanText = formatWanLine(call.wan);
|
||||||
|
if (wanText) line2 = ` ${wanText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { line1, line2 };
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,32 @@
|
||||||
// services/voiceReport/groupCdrCalls.js
|
// services/callReport/groupCdrCalls.js
|
||||||
// Group cdr_feed legs by Correlation ID into logical calls.
|
// Group cdr_feed legs by Correlation ID into logical calls.
|
||||||
|
|
||||||
import {
|
import {
|
||||||
cdrCalledNumber,
|
cdrCalledNumber,
|
||||||
|
cdrCallerIdNumber,
|
||||||
|
cdrCallingLineId,
|
||||||
cdrCallingNumber,
|
cdrCallingNumber,
|
||||||
|
cdrDialedDigits,
|
||||||
cdrDirectionValue,
|
cdrDirectionValue,
|
||||||
cdrDurationSeconds,
|
cdrDurationSeconds,
|
||||||
cdrField,
|
cdrField,
|
||||||
|
cdrModel,
|
||||||
cdrRecordId,
|
cdrRecordId,
|
||||||
|
cdrSiteMainNumber,
|
||||||
cdrStartTime,
|
cdrStartTime,
|
||||||
cdrDedupKey,
|
cdrDedupKey,
|
||||||
|
cdrUserName,
|
||||||
|
cdrUserNumber,
|
||||||
|
formatCallDisposition,
|
||||||
} from '../cdrFeedParser.js';
|
} from '../cdrFeedParser.js';
|
||||||
|
|
||||||
|
export const CALL_BUCKETS = {
|
||||||
|
inboundReachedPhone: 'inboundReachedPhone',
|
||||||
|
inboundAaOnly: 'inboundAaOnly',
|
||||||
|
outboundConnected: 'outboundConnected',
|
||||||
|
other: 'other',
|
||||||
|
};
|
||||||
|
|
||||||
export function cdrCorrelationId(item) {
|
export function cdrCorrelationId(item) {
|
||||||
return cdrField(item, 'correlationId', 'Correlation ID', 'correlationID');
|
return cdrField(item, 'correlationId', 'Correlation ID', 'correlationID');
|
||||||
}
|
}
|
||||||
|
|
@ -70,11 +85,57 @@ export function isNormalCallOutcome(item) {
|
||||||
return !reason || reason === 'normal';
|
return !reason || reason === 'normal';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findInboundEntryLeg(legs) {
|
||||||
|
return legs.find((l) => callTypeValue(l).includes('INBOUND')) || legs[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function findUserPhoneLeg(legs) {
|
||||||
|
return legs.find((l) => isUserPhoneLeg(l) && isTruthyAnswered(l))
|
||||||
|
|| legs.find(isUserPhoneLeg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findOutboundOriginatingLeg(legs) {
|
||||||
|
return legs.find((l) => cdrDirectionValue(l).includes('ORIGINAT') && isUserPhoneLeg(l))
|
||||||
|
|| legs.find((l) => cdrDirectionValue(l).includes('ORIGINAT'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function digitsOnly(value) {
|
||||||
|
return String(value || '').replace(/\D/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFinalNumber(legs, mainNumber, phoneLeg) {
|
||||||
|
const candidates = [
|
||||||
|
phoneLeg ? cdrCalledNumber(phoneLeg) : null,
|
||||||
|
phoneLeg ? cdrUserNumber(phoneLeg) : null,
|
||||||
|
phoneLeg ? cdrDialedDigits(phoneLeg) : null,
|
||||||
|
...legs.map(cdrDialedDigits),
|
||||||
|
...legs.filter(isUserPhoneLeg).map(cdrCalledNumber),
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const mainDigits = digitsOnly(mainNumber);
|
||||||
|
for (const c of candidates) {
|
||||||
|
const d = digitsOnly(c);
|
||||||
|
if (!d) continue;
|
||||||
|
if (mainDigits && d === mainDigits) continue;
|
||||||
|
if (c !== mainNumber) return c;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickCallingLineId(legs, direction) {
|
||||||
|
if (direction === 'inbound') {
|
||||||
|
const entry = findInboundEntryLeg(legs);
|
||||||
|
const clid = cdrCallingLineId(entry);
|
||||||
|
if (clid && String(clid).toUpperCase() !== 'NA') return clid;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function pickExternalNumber(legs, direction) {
|
function pickExternalNumber(legs, direction) {
|
||||||
for (const leg of legs) {
|
for (const leg of legs) {
|
||||||
const ct = callTypeValue(leg);
|
const ct = callTypeValue(leg);
|
||||||
if (direction === 'inbound' && ct.includes('INBOUND')) {
|
if (direction === 'inbound' && ct.includes('INBOUND')) {
|
||||||
return cdrCallingNumber(leg) || cdrField(leg, 'Caller ID number', 'caller id number');
|
return cdrCallingNumber(leg) || cdrCallerIdNumber(leg);
|
||||||
}
|
}
|
||||||
if (direction === 'outbound' && ct.includes('OUTBOUND')) {
|
if (direction === 'outbound' && ct.includes('OUTBOUND')) {
|
||||||
return cdrCalledNumber(leg);
|
return cdrCalledNumber(leg);
|
||||||
|
|
@ -83,13 +144,24 @@ function pickExternalNumber(legs, direction) {
|
||||||
const first = legs[0];
|
const first = legs[0];
|
||||||
if (!first) return null;
|
if (!first) return null;
|
||||||
if (direction === 'inbound') {
|
if (direction === 'inbound') {
|
||||||
return cdrCallingNumber(first) || cdrField(first, 'Caller ID number', 'caller id number');
|
return cdrCallingNumber(first) || cdrCallerIdNumber(first);
|
||||||
}
|
}
|
||||||
return cdrCalledNumber(first);
|
return cdrCalledNumber(first);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickStoreMainNumber(legs) {
|
||||||
|
for (const leg of legs) {
|
||||||
|
const main = cdrSiteMainNumber(leg);
|
||||||
|
if (main) return main;
|
||||||
|
}
|
||||||
|
const entry = findInboundEntryLeg(legs);
|
||||||
|
if (entry && classifyCallDirection(legs) === 'inbound') {
|
||||||
|
return cdrCalledNumber(entry);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function pickTerminalOutcome(legs) {
|
function pickTerminalOutcome(legs) {
|
||||||
// Prefer the user-phone leg, then any leg with explicit outcome.
|
|
||||||
const phoneLegs = legs.filter(isUserPhoneLeg);
|
const phoneLegs = legs.filter(isUserPhoneLeg);
|
||||||
const candidates = phoneLegs.length ? phoneLegs : legs;
|
const candidates = phoneLegs.length ? phoneLegs : legs;
|
||||||
let chosen = candidates[candidates.length - 1];
|
let chosen = candidates[candidates.length - 1];
|
||||||
|
|
@ -98,9 +170,12 @@ function pickTerminalOutcome(legs) {
|
||||||
chosen = leg;
|
chosen = leg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const outcome = cdrField(chosen, 'Call outcome', 'call outcome') || 'unknown';
|
||||||
|
const reason = cdrField(chosen, 'Call outcome reason', 'call outcome reason') || '';
|
||||||
return {
|
return {
|
||||||
outcome: cdrField(chosen, 'Call outcome', 'call outcome') || 'unknown',
|
outcome,
|
||||||
reason: cdrField(chosen, 'Call outcome reason', 'call outcome reason') || '',
|
reason,
|
||||||
|
disposition: formatCallDisposition(outcome, reason),
|
||||||
normal: isNormalCallOutcome(chosen),
|
normal: isNormalCallOutcome(chosen),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -115,32 +190,67 @@ function summarizeReach(legs, direction) {
|
||||||
return {
|
return {
|
||||||
reachedPhone: Boolean(answeredPhone),
|
reachedPhone: Boolean(answeredPhone),
|
||||||
reachedAttendant: Boolean(answeredAa),
|
reachedAttendant: Boolean(answeredAa),
|
||||||
endpointUser: answeredPhone
|
endpointUser: answeredPhone ? cdrUserName(answeredPhone) : null,
|
||||||
? cdrField(answeredPhone, 'User', 'user')
|
endpointModel: answeredPhone ? cdrModel(answeredPhone) : null,
|
||||||
: null,
|
|
||||||
endpointModel: answeredPhone
|
|
||||||
? cdrField(answeredPhone, 'Model', 'model')
|
|
||||||
: null,
|
|
||||||
aaKeyPress: aaLegs.map((l) => cdrField(l, 'Auto Attendant Key Pressed', 'auto attendant key pressed'))
|
aaKeyPress: aaLegs.map((l) => cdrField(l, 'Auto Attendant Key Pressed', 'auto attendant key pressed'))
|
||||||
.find((v) => v && v !== 'NA') || null,
|
.find((v) => v && v !== 'NA') || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Outbound: originating user/device leg answered, remote side picked up on terminating leg.
|
const originating = findOutboundOriginatingLeg(legs);
|
||||||
const originating = legs.find((l) => cdrDirectionValue(l).includes('ORIGINAT') && isUserPhoneLeg(l));
|
|
||||||
const remoteAnswered = legs.some((l) =>
|
const remoteAnswered = legs.some((l) =>
|
||||||
cdrDirectionValue(l).includes('TERMINAT') && isTruthyAnswered(l) && !isAutomatedAttendantLeg(l),
|
cdrDirectionValue(l).includes('TERMINAT') && isTruthyAnswered(l) && !isAutomatedAttendantLeg(l),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
reachedPhone: Boolean(originating && isTruthyAnswered(originating)),
|
reachedPhone: Boolean(originating && isTruthyAnswered(originating)),
|
||||||
connected: remoteAnswered || (originating && isTruthyAnswered(originating)),
|
connected: remoteAnswered || Boolean(originating && isTruthyAnswered(originating)),
|
||||||
endpointUser: originating ? cdrField(originating, 'User', 'user') : null,
|
endpointUser: originating ? cdrUserName(originating) : null,
|
||||||
endpointModel: originating ? cdrField(originating, 'Model', 'model') : null,
|
endpointModel: originating ? cdrModel(originating) : null,
|
||||||
reachedAttendant: false,
|
reachedAttendant: false,
|
||||||
aaKeyPress: null,
|
aaKeyPress: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} call
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function assignCallBucket(call) {
|
||||||
|
if (call.direction === 'inbound') {
|
||||||
|
if (call.reachedPhone) return CALL_BUCKETS.inboundReachedPhone;
|
||||||
|
if (call.reachedAttendant) return CALL_BUCKETS.inboundAaOnly;
|
||||||
|
return CALL_BUCKETS.other;
|
||||||
|
}
|
||||||
|
if (call.direction === 'outbound') {
|
||||||
|
if (call.connected || call.reachedPhone) return CALL_BUCKETS.outboundConnected;
|
||||||
|
return CALL_BUCKETS.other;
|
||||||
|
}
|
||||||
|
return CALL_BUCKETS.other;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object[]} calls
|
||||||
|
*/
|
||||||
|
export function groupCallsByBucket(calls) {
|
||||||
|
const buckets = {
|
||||||
|
[CALL_BUCKETS.inboundReachedPhone]: [],
|
||||||
|
[CALL_BUCKETS.inboundAaOnly]: [],
|
||||||
|
[CALL_BUCKETS.outboundConnected]: [],
|
||||||
|
[CALL_BUCKETS.other]: [],
|
||||||
|
};
|
||||||
|
for (const call of calls || []) {
|
||||||
|
buckets[assignCallBucket(call)].push(call);
|
||||||
|
}
|
||||||
|
return buckets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object[]} calls
|
||||||
|
*/
|
||||||
|
export function filterAbnormalCalls(calls) {
|
||||||
|
return (calls || []).filter((c) => c.abnormal || !c.normalOutcome);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {object[]} rawLegs
|
* @param {object[]} rawLegs
|
||||||
* @returns {object}
|
* @returns {object}
|
||||||
|
|
@ -152,11 +262,14 @@ export function summarizeCallGroup(correlationId, rawLegs) {
|
||||||
return ta - tb;
|
return ta - tb;
|
||||||
});
|
});
|
||||||
const direction = classifyCallDirection(legs);
|
const direction = classifyCallDirection(legs);
|
||||||
|
const entryLeg = findInboundEntryLeg(legs);
|
||||||
|
const phoneLeg = findUserPhoneLeg(legs);
|
||||||
|
const outboundOrig = findOutboundOriginatingLeg(legs);
|
||||||
|
const storeMain = pickStoreMainNumber(legs);
|
||||||
|
|
||||||
const starts = legs.map((l) => cdrStartTime(l)).filter(Boolean);
|
const starts = legs.map((l) => cdrStartTime(l)).filter(Boolean);
|
||||||
const start = starts.length ? starts.sort()[0] : null;
|
const start = starts.length ? starts.sort()[0] : null;
|
||||||
const releaseTimes = legs
|
const releaseTimes = legs.map((l) => cdrField(l, 'Release time', 'release time')).filter(Boolean);
|
||||||
.map((l) => cdrField(l, 'Release time', 'release time'))
|
|
||||||
.filter(Boolean);
|
|
||||||
const end = releaseTimes.length ? releaseTimes.sort().reverse()[0] : null;
|
const end = releaseTimes.length ? releaseTimes.sort().reverse()[0] : null;
|
||||||
const duration = (() => {
|
const duration = (() => {
|
||||||
if (start && end) {
|
if (start && end) {
|
||||||
|
|
@ -168,6 +281,35 @@ export function summarizeCallGroup(correlationId, rawLegs) {
|
||||||
const outcome = pickTerminalOutcome(legs);
|
const outcome = pickTerminalOutcome(legs);
|
||||||
const reach = summarizeReach(legs, direction);
|
const reach = summarizeReach(legs, direction);
|
||||||
const abnormal = legs.some((l) => !isNormalCallOutcome(l));
|
const abnormal = legs.some((l) => !isNormalCallOutcome(l));
|
||||||
|
const finalNumber = pickFinalNumber(legs, storeMain, phoneLeg);
|
||||||
|
const callingLineId = pickCallingLineId(legs, direction);
|
||||||
|
|
||||||
|
let leftParty;
|
||||||
|
let rightParty;
|
||||||
|
if (direction === 'inbound') {
|
||||||
|
leftParty = {
|
||||||
|
lineId: callingLineId,
|
||||||
|
number: pickExternalNumber(legs, direction) || cdrCallingNumber(entryLeg),
|
||||||
|
};
|
||||||
|
rightParty = {
|
||||||
|
main: storeMain || cdrCalledNumber(entryLeg),
|
||||||
|
final: finalNumber,
|
||||||
|
model: reach.endpointModel,
|
||||||
|
user: reach.endpointUser,
|
||||||
|
};
|
||||||
|
} else if (direction === 'outbound') {
|
||||||
|
leftParty = {
|
||||||
|
user: reach.endpointUser || cdrUserName(outboundOrig),
|
||||||
|
number: cdrUserNumber(outboundOrig) || cdrCallingNumber(outboundOrig),
|
||||||
|
model: reach.endpointModel || (outboundOrig ? cdrModel(outboundOrig) : null),
|
||||||
|
};
|
||||||
|
rightParty = {
|
||||||
|
number: pickExternalNumber(legs, direction) || cdrCalledNumber(legs[legs.length - 1]),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
leftParty = { number: cdrCallingNumber(legs[0]) };
|
||||||
|
rightParty = { number: cdrCalledNumber(legs[legs.length - 1]) };
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
correlationId,
|
correlationId,
|
||||||
|
|
@ -175,13 +317,23 @@ export function summarizeCallGroup(correlationId, rawLegs) {
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
duration,
|
duration,
|
||||||
callingNumber: pickExternalNumber(legs, direction) || cdrCallingNumber(legs[0]),
|
callingLineId,
|
||||||
calledNumber: cdrCalledNumber(legs[legs.length - 1]) || cdrCalledNumber(legs[0]),
|
callingNumber: leftParty.number || pickExternalNumber(legs, direction),
|
||||||
|
calledNumber: direction === 'inbound'
|
||||||
|
? (storeMain || cdrCalledNumber(entryLeg))
|
||||||
|
: (rightParty.number || cdrCalledNumber(legs[legs.length - 1])),
|
||||||
|
storeMainNumber: storeMain,
|
||||||
|
finalNumber,
|
||||||
|
model: reach.endpointModel || (phoneLeg ? cdrModel(phoneLeg) : null),
|
||||||
|
disposition: outcome.disposition,
|
||||||
outcome: outcome.outcome,
|
outcome: outcome.outcome,
|
||||||
outcomeReason: outcome.reason,
|
outcomeReason: outcome.reason,
|
||||||
normalOutcome: outcome.normal && !abnormal,
|
normalOutcome: outcome.normal && !abnormal,
|
||||||
abnormal,
|
abnormal,
|
||||||
legCount: legs.length,
|
legCount: legs.length,
|
||||||
|
leftParty,
|
||||||
|
rightParty,
|
||||||
|
bucket: null,
|
||||||
...reach,
|
...reach,
|
||||||
legs,
|
legs,
|
||||||
};
|
};
|
||||||
|
|
@ -203,7 +355,11 @@ export function groupCdrIntoCalls(rawItems) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...groups.values()]
|
return [...groups.values()]
|
||||||
.map((g) => summarizeCallGroup(g.correlationId, g.legs))
|
.map((g) => {
|
||||||
|
const call = summarizeCallGroup(g.correlationId, g.legs);
|
||||||
|
call.bucket = assignCallBucket(call);
|
||||||
|
return call;
|
||||||
|
})
|
||||||
.sort((a, b) => new Date(a.start || 0).getTime() - new Date(b.start || 0).getTime());
|
.sort((a, b) => new Date(a.start || 0).getTime() - new Date(b.start || 0).getTime());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -232,7 +388,7 @@ export function summarizeCalls(calls) {
|
||||||
|
|
||||||
const outcomes = {};
|
const outcomes = {};
|
||||||
for (const call of calls) {
|
for (const call of calls) {
|
||||||
const key = [call.outcome, call.outcomeReason].filter(Boolean).join(' / ') || 'unknown';
|
const key = call.disposition || [call.outcome, call.outcomeReason].filter(Boolean).join(' / ') || 'unknown';
|
||||||
outcomes[key] = (outcomes[key] || 0) + 1;
|
outcomes[key] = (outcomes[key] || 0) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
143
services/callReport/joinCallQuality.js
Normal file
143
services/callReport/joinCallQuality.js
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
// services/callReport/joinCallQuality.js
|
||||||
|
// Join grouped CDR calls with Prisma WAN buckets.
|
||||||
|
|
||||||
|
import { isWithinWindow } from './businessWindow.js';
|
||||||
|
import { cdrStartTime } from '../cdrFeedParser.js';
|
||||||
|
import {
|
||||||
|
filterAbnormalCalls,
|
||||||
|
groupCallsByBucket,
|
||||||
|
groupCdrIntoCalls,
|
||||||
|
summarizeCalls,
|
||||||
|
} from './groupCdrCalls.js';
|
||||||
|
import { filterCallsByTarget, rebucketCalls } from './filterCallsByTarget.js';
|
||||||
|
|
||||||
|
const DEFAULT_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
function shouldAttachWan(call) {
|
||||||
|
if (call.direction === 'inbound') return Boolean(call.reachedPhone);
|
||||||
|
if (call.direction === 'outbound') return Boolean(call.connected || call.reachedPhone);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function intervalMsFromSeries(summary) {
|
||||||
|
if (summary?.interval === '5min') return DEFAULT_INTERVAL_MS;
|
||||||
|
return DEFAULT_INTERVAL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueAtTime(series, time) {
|
||||||
|
if (!series || time == null) return null;
|
||||||
|
if (series.timedPoints?.length) {
|
||||||
|
const hit = series.timedPoints.find((p) => p.time === time);
|
||||||
|
return hit?.value ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlappingBucketTimes(series, startMs, endMs, intervalMs, winStartMs) {
|
||||||
|
const times = new Set();
|
||||||
|
if (series?.timedPoints?.length) {
|
||||||
|
for (const p of series.timedPoints) {
|
||||||
|
if (p.value == null || !p.time) continue;
|
||||||
|
const t = new Date(p.time).getTime();
|
||||||
|
if (!Number.isFinite(t)) continue;
|
||||||
|
if (t < endMs && (t + intervalMs) > startMs) times.add(p.time);
|
||||||
|
}
|
||||||
|
return times;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for tests / legacy summaries without timedPoints.
|
||||||
|
const mosVals = series?.values || [];
|
||||||
|
if (!mosVals.length || !Number.isFinite(winStartMs)) return times;
|
||||||
|
const idx0 = Math.max(0, Math.floor((startMs - winStartMs) / intervalMs));
|
||||||
|
const idx1 = Math.max(idx0, Math.ceil((endMs - winStartMs) / intervalMs));
|
||||||
|
for (let i = idx0; i <= idx1 && i < mosVals.length; i++) {
|
||||||
|
if (mosVals[i] != null) times.add(`__idx:${i}`);
|
||||||
|
}
|
||||||
|
return times;
|
||||||
|
}
|
||||||
|
|
||||||
|
function worstFromTimedOverlap(appAudio, startMs, endMs, winStartMs) {
|
||||||
|
const mosSeries = appAudio?.mos;
|
||||||
|
if (!mosSeries) return null;
|
||||||
|
|
||||||
|
const intervalMs = intervalMsFromSeries(mosSeries);
|
||||||
|
const bucketTimes = overlappingBucketTimes(mosSeries, startMs, endMs, intervalMs, winStartMs);
|
||||||
|
if (!bucketTimes.size) return null;
|
||||||
|
|
||||||
|
let worstMos = null;
|
||||||
|
let worstLoss = null;
|
||||||
|
let worstJitter = null;
|
||||||
|
|
||||||
|
for (const time of bucketTimes) {
|
||||||
|
let m;
|
||||||
|
let l;
|
||||||
|
let j;
|
||||||
|
if (time.startsWith('__idx:')) {
|
||||||
|
const i = Number(time.slice(6));
|
||||||
|
m = mosSeries.values?.[i];
|
||||||
|
l = appAudio.loss?.values?.[i];
|
||||||
|
j = appAudio.jitter?.values?.[i];
|
||||||
|
} else {
|
||||||
|
m = valueAtTime(mosSeries, time);
|
||||||
|
l = valueAtTime(appAudio.loss, time);
|
||||||
|
j = valueAtTime(appAudio.jitter, time);
|
||||||
|
}
|
||||||
|
if (typeof m === 'number' && (worstMos == null || m < worstMos)) worstMos = m;
|
||||||
|
if (typeof l === 'number' && (worstLoss == null || l > worstLoss)) worstLoss = l;
|
||||||
|
if (typeof j === 'number' && (worstJitter == null || j > worstJitter)) worstJitter = j;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (worstMos == null && worstLoss == null && worstJitter == null) return null;
|
||||||
|
return {
|
||||||
|
siteLevelApprox: true,
|
||||||
|
mos: worstMos,
|
||||||
|
loss: worstLoss,
|
||||||
|
jitter: worstJitter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find worst Prisma 5-min bucket overlapping [start, end].
|
||||||
|
*/
|
||||||
|
export function worstPrismaBucketForCall(call, appAudio, window) {
|
||||||
|
if (!appAudio?.mos?.values?.length && !appAudio?.mos?.timedPoints?.length) return null;
|
||||||
|
const startMs = call.start ? new Date(call.start).getTime() : NaN;
|
||||||
|
const endMs = Number.isFinite(startMs)
|
||||||
|
? startMs + (call.duration || 0) * 1000
|
||||||
|
: NaN;
|
||||||
|
if (!Number.isFinite(startMs)) return null;
|
||||||
|
|
||||||
|
const winStartMs = window?.startTime ? new Date(window.startTime).getTime() : startMs;
|
||||||
|
return worstFromTimedOverlap(appAudio, startMs, endMs, winStartMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function joinCallQuality({ cdrItems, appAudio, window, filter = null }) {
|
||||||
|
const calls = groupCdrIntoCalls(cdrItems);
|
||||||
|
const joined = calls.map((call) => {
|
||||||
|
const wan = shouldAttachWan(call)
|
||||||
|
? worstPrismaBucketForCall(call, appAudio, window)
|
||||||
|
: null;
|
||||||
|
return { ...call, wan };
|
||||||
|
});
|
||||||
|
|
||||||
|
const scoped = filter ? filterCallsByTarget(joined, filter) : joined;
|
||||||
|
if (filter) {
|
||||||
|
return rebucketCalls(scoped);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
calls: joined,
|
||||||
|
legs: joined,
|
||||||
|
buckets: groupCallsByBucket(joined),
|
||||||
|
abnormalCalls: filterAbnormalCalls(joined),
|
||||||
|
summary: summarizeCalls(joined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterCdrToWindow(items, window) {
|
||||||
|
return (items || []).filter((item) => {
|
||||||
|
const start = cdrStartTime(item);
|
||||||
|
if (!start) return true;
|
||||||
|
return isWithinWindow(start, window);
|
||||||
|
});
|
||||||
|
}
|
||||||
38
services/callReport/parseTarget.js
Normal file
38
services/callReport/parseTarget.js
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
// services/callReport/parseTarget.js
|
||||||
|
// Token parsing for /callreport targets (no Webex dependencies).
|
||||||
|
|
||||||
|
import { normalizeE164 } from '../callTest/config.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} token
|
||||||
|
* @returns {{ kind: 'store', storeNum: string } | { kind: 'user', email: string } | { kind: 'number', phoneNumber: string } | null}
|
||||||
|
*/
|
||||||
|
export function parseCallReportTarget(token) {
|
||||||
|
const t = String(token || '').trim();
|
||||||
|
if (!t) return null;
|
||||||
|
|
||||||
|
if (t.includes('@')) {
|
||||||
|
return { kind: 'user', email: t.toLowerCase() };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\d{2,4}$/.test(t)) {
|
||||||
|
return { kind: 'store', storeNum: t };
|
||||||
|
}
|
||||||
|
|
||||||
|
const e164 = normalizeE164(t);
|
||||||
|
if (e164) {
|
||||||
|
return { kind: 'number', phoneNumber: e164 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse store number from location name like "Store 0782".
|
||||||
|
* @param {string} locationName
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
export function parseStoreNumFromLocationName(locationName) {
|
||||||
|
const m = String(locationName || '').match(/store\s*0*(\d{2,4})\b/i);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
207
services/callReport/resolveTarget.js
Normal file
207
services/callReport/resolveTarget.js
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
// services/callReport/resolveTarget.js
|
||||||
|
// Parse and resolve /callreport targets: store, user email, or phone number.
|
||||||
|
|
||||||
|
import webex from '../../integrations/webex/WebexClient.js';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
import { DISPLAY_TIMEZONE } from '../../utils/time.js';
|
||||||
|
import {
|
||||||
|
resolveNumberAssignment,
|
||||||
|
resolvePersonLocation,
|
||||||
|
} from '../callTest/locationResolver.js';
|
||||||
|
import { buildScopedCallFilter } from './filterCallsByTarget.js';
|
||||||
|
import {
|
||||||
|
getDectNetworksForPerson,
|
||||||
|
getPersonDetails,
|
||||||
|
getPersonIdByEmail,
|
||||||
|
} from '../phoneService.js';
|
||||||
|
import { resolveStoreForCallReport } from './storeContext.js';
|
||||||
|
import { parseCallReportTarget, parseStoreNumFromLocationName } from './parseTarget.js';
|
||||||
|
|
||||||
|
const LOG_SCOPE = 'callreport:target';
|
||||||
|
|
||||||
|
async function resolveLocationDetails(locationId) {
|
||||||
|
if (!locationId) return null;
|
||||||
|
return webex.request('GET', `telephony/config/locations/${locationId}`).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveTimezone({ personId, locationId, locationDetails }) {
|
||||||
|
let timeZone = DISPLAY_TIMEZONE;
|
||||||
|
try {
|
||||||
|
const profile = personId
|
||||||
|
? await webex.request('GET', `telephony/config/people/${personId}`).catch(() => null)
|
||||||
|
: null;
|
||||||
|
const location = locationDetails || (locationId
|
||||||
|
? await resolveLocationDetails(locationId)
|
||||||
|
: null);
|
||||||
|
timeZone = profile?.timeZone || location?.timeZone || location?.timezone || timeZone;
|
||||||
|
} catch (err) {
|
||||||
|
logger(LOG_SCOPE, `Timezone lookup failed: ${err.message}`, 'debug');
|
||||||
|
}
|
||||||
|
return timeZone;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStoreContextFromLocation({
|
||||||
|
kind,
|
||||||
|
label,
|
||||||
|
storeNum,
|
||||||
|
personId,
|
||||||
|
locationId,
|
||||||
|
locationName,
|
||||||
|
dialNumber,
|
||||||
|
timeZone,
|
||||||
|
email,
|
||||||
|
filter,
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
label,
|
||||||
|
storeNum,
|
||||||
|
personId: personId || null,
|
||||||
|
locationId,
|
||||||
|
locationName,
|
||||||
|
dialNumber,
|
||||||
|
timeZone,
|
||||||
|
email: email || null,
|
||||||
|
filter: filter || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveStoreTarget(parsed) {
|
||||||
|
const store = await resolveStoreForCallReport(parsed.storeNum);
|
||||||
|
return buildStoreContextFromLocation({
|
||||||
|
kind: 'store',
|
||||||
|
label: `Store ${store.storeNum}`,
|
||||||
|
storeNum: store.storeNum,
|
||||||
|
personId: store.personId,
|
||||||
|
locationId: store.locationId,
|
||||||
|
locationName: store.locationName,
|
||||||
|
dialNumber: store.dialNumber,
|
||||||
|
timeZone: store.timeZone,
|
||||||
|
email: store.email,
|
||||||
|
filter: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveNumberTarget(parsed) {
|
||||||
|
const assignment = await resolveNumberAssignment(parsed.phoneNumber);
|
||||||
|
if (!assignment?.locationId) {
|
||||||
|
throw new Error(`No Webex location found for number ${parsed.phoneNumber}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationDetails = await resolveLocationDetails(assignment.locationId);
|
||||||
|
const dialNumber = locationDetails?.callingLineId?.phoneNumber
|
||||||
|
|| locationDetails?.phoneNumber
|
||||||
|
|| null;
|
||||||
|
const storeNum = parseStoreNumFromLocationName(assignment.locationName);
|
||||||
|
const timeZone = await resolveTimezone({
|
||||||
|
personId: assignment.owner?.id,
|
||||||
|
locationId: assignment.locationId,
|
||||||
|
locationDetails,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filter = buildScopedCallFilter({
|
||||||
|
kind: 'number',
|
||||||
|
phoneNumber: parsed.phoneNumber,
|
||||||
|
personId: assignment.owner?.id || null,
|
||||||
|
extension: assignment.extension,
|
||||||
|
locationMain: dialNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
return buildStoreContextFromLocation({
|
||||||
|
kind: 'number',
|
||||||
|
label: parsed.phoneNumber,
|
||||||
|
storeNum,
|
||||||
|
personId: assignment.owner?.id || null,
|
||||||
|
locationId: assignment.locationId,
|
||||||
|
locationName: assignment.locationName,
|
||||||
|
dialNumber,
|
||||||
|
timeZone,
|
||||||
|
email: null,
|
||||||
|
filter,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveUserTarget(parsed) {
|
||||||
|
const personId = await getPersonIdByEmail(parsed.email);
|
||||||
|
if (!personId) {
|
||||||
|
throw new Error(`No Webex person found for ${parsed.email}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const person = await getPersonDetails(personId);
|
||||||
|
let locationId = null;
|
||||||
|
let locationName = null;
|
||||||
|
let ownedNumbers = [];
|
||||||
|
|
||||||
|
const personLoc = await resolvePersonLocation(personId);
|
||||||
|
if (personLoc) {
|
||||||
|
locationId = personLoc.locationId;
|
||||||
|
locationName = personLoc.locationName;
|
||||||
|
ownedNumbers = personLoc.ownedNumbers;
|
||||||
|
} else {
|
||||||
|
const dectNets = await getDectNetworksForPerson(personId);
|
||||||
|
if (dectNets.length > 0) {
|
||||||
|
locationId = dectNets[0].locationId && dectNets[0].locationId !== '—'
|
||||||
|
? dectNets[0].locationId
|
||||||
|
: null;
|
||||||
|
locationName = dectNets[0].locationName && dectNets[0].locationName !== '—'
|
||||||
|
? dectNets[0].locationName
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!locationId || !locationName) {
|
||||||
|
throw new Error(`No Webex Calling location found for ${parsed.email}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationDetails = await resolveLocationDetails(locationId);
|
||||||
|
const dialNumber = locationDetails?.callingLineId?.phoneNumber
|
||||||
|
|| locationDetails?.phoneNumber
|
||||||
|
|| null;
|
||||||
|
const storeNum = parseStoreNumFromLocationName(locationName);
|
||||||
|
const timeZone = await resolveTimezone({ personId, locationId, locationDetails });
|
||||||
|
|
||||||
|
const filter = buildScopedCallFilter({
|
||||||
|
kind: 'user',
|
||||||
|
email: parsed.email,
|
||||||
|
personId,
|
||||||
|
person,
|
||||||
|
ownedNumbers,
|
||||||
|
locationMain: dialNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ownedNumbers.length > 1) {
|
||||||
|
logger(
|
||||||
|
LOG_SCOPE,
|
||||||
|
`User ${parsed.email} has numbers at multiple locations; using ${locationName}`,
|
||||||
|
'warn',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildStoreContextFromLocation({
|
||||||
|
kind: 'user',
|
||||||
|
label: parsed.email,
|
||||||
|
storeNum,
|
||||||
|
personId,
|
||||||
|
locationId,
|
||||||
|
locationName,
|
||||||
|
dialNumber,
|
||||||
|
timeZone,
|
||||||
|
email: parsed.email,
|
||||||
|
filter,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} token raw first argument
|
||||||
|
* @returns {Promise<object>} unified call report target context
|
||||||
|
*/
|
||||||
|
export async function resolveCallReportTarget(token) {
|
||||||
|
const parsed = parseCallReportTarget(token);
|
||||||
|
if (!parsed) {
|
||||||
|
throw new Error('Invalid target — use a store number (2–4 digits), email, or phone number');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.kind === 'store') return resolveStoreTarget(parsed);
|
||||||
|
if (parsed.kind === 'number') return resolveNumberTarget(parsed);
|
||||||
|
return resolveUserTarget(parsed);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// services/voiceReport/storeContext.js
|
// services/callReport/storeContext.js
|
||||||
// Resolve Webex location + timezone for a store number.
|
// Resolve Webex location + timezone for a store number.
|
||||||
|
|
||||||
import webex from '../../integrations/webex/WebexClient.js';
|
import webex from '../../integrations/webex/WebexClient.js';
|
||||||
|
|
@ -6,13 +6,13 @@ import { logger } from '../../utils/logger.js';
|
||||||
import { DISPLAY_TIMEZONE } from '../../utils/time.js';
|
import { DISPLAY_TIMEZONE } from '../../utils/time.js';
|
||||||
import { resolveStoreMainNumber } from '../callTest/storeResolver.js';
|
import { resolveStoreMainNumber } from '../callTest/storeResolver.js';
|
||||||
|
|
||||||
const LOG_SCOPE = 'voicereport:store';
|
const LOG_SCOPE = 'callreport:store';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} storeNum
|
* @param {string} storeNum
|
||||||
* @returns {Promise<{ storeNum, personId, locationId, locationName, dialNumber, timeZone, email }>}
|
* @returns {Promise<{ storeNum, personId, locationId, locationName, dialNumber, timeZone, email }>}
|
||||||
*/
|
*/
|
||||||
export async function resolveStoreForVoiceReport(storeNum) {
|
export async function resolveStoreForCallReport(storeNum) {
|
||||||
const base = await resolveStoreMainNumber(storeNum);
|
const base = await resolveStoreMainNumber(storeNum);
|
||||||
let timeZone = DISPLAY_TIMEZONE;
|
let timeZone = DISPLAY_TIMEZONE;
|
||||||
|
|
||||||
|
|
@ -152,6 +152,7 @@ export async function fetchMatchedCdrForSession(session) {
|
||||||
endTime,
|
endTime,
|
||||||
returnRawItems: true,
|
returnRawItems: true,
|
||||||
skipPersonFilter: !ctx.personId,
|
skipPersonFilter: !ctx.personId,
|
||||||
|
onQueued: session.onCdrQueued,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!cdr.available) {
|
if (!cdr.available) {
|
||||||
|
|
@ -187,7 +188,17 @@ export function scheduleCdrMatchEnrichment(session, { notifyRoom, renderMarkdown
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger(LOG_SCOPE, `CDR match fetch for testId=${session.testId}`, 'info');
|
logger(LOG_SCOPE, `CDR match fetch for testId=${session.testId}`, 'info');
|
||||||
const result = await fetchMatchedCdrForSession(cur);
|
const result = await fetchMatchedCdrForSession({
|
||||||
|
...cur,
|
||||||
|
onCdrQueued: async ({ runAt, waitMs }) => {
|
||||||
|
const secs = Math.max(1, Math.ceil(waitMs / 1000));
|
||||||
|
await notifyRoom(
|
||||||
|
session.roomId,
|
||||||
|
`⏳ **Call test CDR** — query queued for \`${session.testId}\`. ` +
|
||||||
|
`Runs at **${runAt.toLocaleTimeString()}** (~${secs}s).`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
updateSession(session.testId, {
|
updateSession(session.testId, {
|
||||||
enrichment: {
|
enrichment: {
|
||||||
...(cur.enrichment || {}),
|
...(cur.enrichment || {}),
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ import { normalizeE164 } from './config.js';
|
||||||
const LOG_SCOPE = 'calltest:location';
|
const LOG_SCOPE = 'calltest:location';
|
||||||
const CACHE_TTL_MS = 60 * 60 * 1000;
|
const CACHE_TTL_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
let _phoneIndex = null;
|
let _numbersCache = null;
|
||||||
let _phoneIndexAt = 0;
|
let _numbersCacheAt = 0;
|
||||||
|
|
||||||
async function getWebex() {
|
async function getWebex() {
|
||||||
const mod = await import('../../integrations/webex/WebexClient.js');
|
const mod = await import('../../integrations/webex/WebexClient.js');
|
||||||
|
|
@ -24,13 +24,27 @@ function parseLinkNext(linkHeader) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function phoneDigits(value) {
|
export function phoneDigits(value) {
|
||||||
const d = String(value || '').replace(/\D/g, '');
|
const d = String(value || '').replace(/\D/g, '');
|
||||||
if (d.length === 10) return `1${d}`;
|
if (d.length === 10) return `1${d}`;
|
||||||
if (d.length === 11 && d.startsWith('1')) return d;
|
if (d.length === 11 && d.startsWith('1')) return d;
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function numberRecordFromApi(n) {
|
||||||
|
const raw = n.phoneNumber || n.number || n.value;
|
||||||
|
const loc = n.location;
|
||||||
|
if (!raw || !loc?.id) return null;
|
||||||
|
return {
|
||||||
|
locationId: loc.id,
|
||||||
|
locationName: loc.name || null,
|
||||||
|
phoneNumber: raw,
|
||||||
|
extension: n.extension || n.primaryExtension || null,
|
||||||
|
owner: n.owner || null,
|
||||||
|
raw: n,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchAllNumbers() {
|
async function fetchAllNumbers() {
|
||||||
const webex = await getWebex();
|
const webex = await getWebex();
|
||||||
const all = [];
|
const all = [];
|
||||||
|
|
@ -52,37 +66,92 @@ async function fetchAllNumbers() {
|
||||||
return all;
|
return all;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildPhoneIndex() {
|
async function buildNumbersCache() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (_phoneIndex && now - _phoneIndexAt < CACHE_TTL_MS) {
|
if (_numbersCache && now - _numbersCacheAt < CACHE_TTL_MS) {
|
||||||
return _phoneIndex;
|
return _numbersCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger(LOG_SCOPE, 'Building location phone index from telephony/config/numbers', 'debug');
|
logger(LOG_SCOPE, 'Building location phone index from telephony/config/numbers', 'debug');
|
||||||
const numbers = await fetchAllNumbers();
|
const numbers = await fetchAllNumbers();
|
||||||
const index = new Map();
|
const byPhoneDigits = new Map();
|
||||||
|
const byPersonId = new Map();
|
||||||
|
const records = [];
|
||||||
|
|
||||||
for (const n of numbers) {
|
for (const n of numbers) {
|
||||||
const raw = n.phoneNumber || n.number || n.value;
|
const entry = numberRecordFromApi(n);
|
||||||
const loc = n.location;
|
if (!entry) continue;
|
||||||
if (!raw || !loc?.id) continue;
|
records.push(entry);
|
||||||
|
|
||||||
const entry = {
|
const keys = new Set([phoneDigits(entry.phoneNumber), phoneDigits(normalizeE164(entry.phoneNumber) || entry.phoneNumber)]);
|
||||||
locationId: loc.id,
|
|
||||||
locationName: loc.name || null,
|
|
||||||
phoneNumber: raw,
|
|
||||||
};
|
|
||||||
|
|
||||||
const keys = new Set([phoneDigits(raw), phoneDigits(normalizeE164(raw) || raw)]);
|
|
||||||
for (const k of keys) {
|
for (const k of keys) {
|
||||||
if (k) index.set(k, entry);
|
if (k) byPhoneDigits.set(k, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownerId = entry.owner?.id;
|
||||||
|
if (ownerId) {
|
||||||
|
if (!byPersonId.has(ownerId)) byPersonId.set(ownerId, []);
|
||||||
|
byPersonId.get(ownerId).push(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_phoneIndex = index;
|
_numbersCache = { byPhoneDigits, byPersonId, records };
|
||||||
_phoneIndexAt = now;
|
_numbersCacheAt = now;
|
||||||
logger(LOG_SCOPE, `Phone index built: ${index.size} keys from ${numbers.length} numbers`, 'debug');
|
logger(
|
||||||
return index;
|
LOG_SCOPE,
|
||||||
|
`Phone index built: ${byPhoneDigits.size} phone keys, ${byPersonId.size} owners from ${numbers.length} numbers`,
|
||||||
|
'debug',
|
||||||
|
);
|
||||||
|
return _numbersCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMatchKey(set, value) {
|
||||||
|
if (value == null || value === '') return;
|
||||||
|
const s = String(value).trim();
|
||||||
|
if (!s) return;
|
||||||
|
set.add(s.toLowerCase());
|
||||||
|
const digits = s.replace(/\D/g, '');
|
||||||
|
if (digits) {
|
||||||
|
set.add(digits);
|
||||||
|
if (digits.length >= 4) set.add(digits.slice(-4));
|
||||||
|
if (digits.length >= 5) set.add(digits.slice(-5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build CDR match keys for a provisioned phone number record.
|
||||||
|
* @param {object} record from numberRecordFromApi
|
||||||
|
* @returns {Set<string>}
|
||||||
|
*/
|
||||||
|
export function buildMatchKeysForNumber(record) {
|
||||||
|
const keys = new Set();
|
||||||
|
if (!record) return keys;
|
||||||
|
addMatchKey(keys, record.phoneNumber);
|
||||||
|
addMatchKey(keys, normalizeE164(record.phoneNumber));
|
||||||
|
addMatchKey(keys, record.extension);
|
||||||
|
if (record.owner?.name) addMatchKey(keys, record.owner.name);
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build CDR match keys for a Webex person + their owned numbers.
|
||||||
|
* @param {object} person
|
||||||
|
* @param {object[]} ownedNumbers
|
||||||
|
* @returns {Set<string>}
|
||||||
|
*/
|
||||||
|
export function buildMatchKeysForPerson(person, ownedNumbers = []) {
|
||||||
|
const keys = new Set();
|
||||||
|
if (!person) return keys;
|
||||||
|
|
||||||
|
addMatchKey(keys, person.displayName);
|
||||||
|
(person.emails || []).forEach((e) => addMatchKey(keys, e));
|
||||||
|
(person.phoneNumbers || []).forEach((p) => addMatchKey(keys, p?.value || p));
|
||||||
|
|
||||||
|
for (const record of ownedNumbers) {
|
||||||
|
for (const k of buildMatchKeysForNumber(record)) keys.add(k);
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -90,27 +159,66 @@ async function buildPhoneIndex() {
|
||||||
* @returns {Promise<{locationId: string, locationName: string, phoneNumber: string}|null>}
|
* @returns {Promise<{locationId: string, locationName: string, phoneNumber: string}|null>}
|
||||||
*/
|
*/
|
||||||
export async function resolveLocationForDialNumber(dialNumber) {
|
export async function resolveLocationForDialNumber(dialNumber) {
|
||||||
|
const assignment = await resolveNumberAssignment(dialNumber);
|
||||||
|
if (!assignment?.locationName) return null;
|
||||||
|
return {
|
||||||
|
locationId: assignment.locationId,
|
||||||
|
locationName: assignment.locationName,
|
||||||
|
phoneNumber: assignment.phoneNumber,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full number assignment details for a dialed E.164.
|
||||||
|
* @returns {Promise<object|null>}
|
||||||
|
*/
|
||||||
|
export async function resolveNumberAssignment(dialNumber) {
|
||||||
const e164 = normalizeE164(dialNumber);
|
const e164 = normalizeE164(dialNumber);
|
||||||
if (!e164) return null;
|
if (!e164) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const index = await buildPhoneIndex();
|
const cache = await buildNumbersCache();
|
||||||
const key = phoneDigits(e164);
|
const key = phoneDigits(e164);
|
||||||
const hit = index.get(key);
|
return cache.byPhoneDigits.get(key) || null;
|
||||||
if (!hit?.locationName) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
locationId: hit.locationId,
|
|
||||||
locationName: hit.locationName,
|
|
||||||
phoneNumber: hit.phoneNumber,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger(LOG_SCOPE, `resolveLocationForDialNumber failed: ${err.message}`, 'warn');
|
logger(LOG_SCOPE, `resolveNumberAssignment failed: ${err.message}`, 'warn');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function _clearLocationCacheForTests() {
|
/**
|
||||||
_phoneIndex = null;
|
* Resolve location from numbers owned by a person.
|
||||||
_phoneIndexAt = 0;
|
* @returns {Promise<{ locationId, locationName, ownedNumbers }|null>}
|
||||||
|
*/
|
||||||
|
export async function resolvePersonLocation(personId) {
|
||||||
|
if (!personId) return null;
|
||||||
|
try {
|
||||||
|
const cache = await buildNumbersCache();
|
||||||
|
const ownedNumbers = cache.byPersonId.get(personId) || [];
|
||||||
|
if (ownedNumbers.length > 0) {
|
||||||
|
const first = ownedNumbers[0];
|
||||||
|
return {
|
||||||
|
locationId: first.locationId,
|
||||||
|
locationName: first.locationName,
|
||||||
|
ownedNumbers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
logger(LOG_SCOPE, `resolvePersonLocation failed: ${err.message}`, 'warn');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use buildNumbersCache internally; kept for tests.
|
||||||
|
*/
|
||||||
|
export async function buildPhoneIndex() {
|
||||||
|
const cache = await buildNumbersCache();
|
||||||
|
return cache.byPhoneDigits;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _clearLocationCacheForTests() {
|
||||||
|
_numbersCache = null;
|
||||||
|
_numbersCacheAt = 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,54 @@ export function cdrCalledNumber(item) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function cdrCallingLineId(item) {
|
||||||
|
return cdrField(item, 'callingLineId', 'Calling line ID', 'calling line id');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cdrCalledLineId(item) {
|
||||||
|
return cdrField(item, 'calledLineId', 'Called line ID', 'called line id');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cdrDialedDigits(item) {
|
||||||
|
return cdrField(item, 'dialedDigits', 'Dialed digits', 'dialed digits');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cdrCallOutcome(item) {
|
||||||
|
return cdrField(item, 'Call outcome', 'call outcome');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cdrCallOutcomeReason(item) {
|
||||||
|
return cdrField(item, 'Call outcome reason', 'call outcome reason');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cdrCallerIdNumber(item) {
|
||||||
|
return cdrField(item, 'Caller ID number', 'caller id number', 'callerIdNumber');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cdrUserName(item) {
|
||||||
|
return cdrField(item, 'User', 'user');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cdrUserNumber(item) {
|
||||||
|
return cdrField(item, 'User number', 'user number');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cdrModel(item) {
|
||||||
|
return cdrField(item, 'Model', 'model');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cdrSiteMainNumber(item) {
|
||||||
|
return cdrField(item, 'Site main number', 'site main number');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCallDisposition(outcome, reason) {
|
||||||
|
const o = String(outcome || '').trim();
|
||||||
|
const r = String(reason || '').trim();
|
||||||
|
if (!o) return 'unknown';
|
||||||
|
if (!r || r.toLowerCase() === 'normal') return o;
|
||||||
|
return `${o} / ${r}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function cdrDedupKey(item) {
|
export function cdrDedupKey(item) {
|
||||||
const id = cdrRecordId(item);
|
const id = cdrRecordId(item);
|
||||||
if (id) return `id:${id}`;
|
if (id) return `id:${id}`;
|
||||||
|
|
@ -130,3 +178,33 @@ export function describeCdrFeedPayload(data) {
|
||||||
}
|
}
|
||||||
return parts.join(' ');
|
return parts.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Webex RFC5988 Link header and return the `rel="next"` URL.
|
||||||
|
* @param {string|undefined} linkHeader
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
export function parseCdrFeedLinkNext(linkHeader) {
|
||||||
|
if (!linkHeader || typeof linkHeader !== 'string') return null;
|
||||||
|
for (const part of linkHeader.split(',')) {
|
||||||
|
const m = part.match(/<([^>]+)>\s*;\s*rel\s*=\s*"?next"?/i);
|
||||||
|
if (m) return m[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the next cdr_feed page from response body and/or Link header.
|
||||||
|
* @returns {{ url: string, params: object|null }|null}
|
||||||
|
*/
|
||||||
|
export function resolveCdrFeedNextPage(data, headers, baseUrl, baseParams) {
|
||||||
|
const linkNext = parseCdrFeedLinkNext(headers?.link || headers?.Link);
|
||||||
|
if (linkNext) return { url: linkNext, params: null };
|
||||||
|
|
||||||
|
const token = data?.next || data?.['next'] || data?.metadata?.next || null;
|
||||||
|
if (!token) return null;
|
||||||
|
if (typeof token === 'string' && token.startsWith('http')) {
|
||||||
|
return { url: token, params: null };
|
||||||
|
}
|
||||||
|
return { url: baseUrl, params: { ...baseParams, next: token } };
|
||||||
|
}
|
||||||
|
|
|
||||||
92
services/cdrFeedQueue.js
Normal file
92
services/cdrFeedQueue.js
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
// services/cdrFeedQueue.js
|
||||||
|
// Serialize cdr_feed HTTP calls — Webex allows ~1 request/minute per token.
|
||||||
|
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
const LOG_SCOPE = 'cdr:queue';
|
||||||
|
|
||||||
|
let testCooldownOverride = null;
|
||||||
|
|
||||||
|
function cooldownMs() {
|
||||||
|
if (testCooldownOverride != null) return testCooldownOverride;
|
||||||
|
const v = Number(process.env.CDR_FEED_COOLDOWN_MS);
|
||||||
|
return Number.isFinite(v) && v > 0 ? v : 65_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CDR_FEED_COOLDOWN_MS = 65_000;
|
||||||
|
|
||||||
|
let chain = Promise.resolve();
|
||||||
|
let lastStartMs = 0;
|
||||||
|
let pendingCount = 0;
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateRunAtMs(position) {
|
||||||
|
const now = Date.now();
|
||||||
|
const firstSlot = lastStartMs > 0
|
||||||
|
? Math.max(now, lastStartMs + cooldownMs())
|
||||||
|
: now;
|
||||||
|
if (position <= 0) return firstSlot;
|
||||||
|
return firstSlot + position * cooldownMs();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a cdr_feed fetch when the global cooldown allows. Queues concurrent callers.
|
||||||
|
*
|
||||||
|
* @template T
|
||||||
|
* @param {() => Promise<T>} fn
|
||||||
|
* @param {{ onQueued?: (info: { runAt: Date, waitMs: number, position: number }) => void|Promise<void>, label?: string }} [opts]
|
||||||
|
* @returns {Promise<T>}
|
||||||
|
*/
|
||||||
|
export function enqueueCdrFeedFetch(fn, opts = {}) {
|
||||||
|
const { onQueued, label } = opts;
|
||||||
|
const now = Date.now();
|
||||||
|
const position = pendingCount;
|
||||||
|
pendingCount += 1;
|
||||||
|
|
||||||
|
const inCooldown = lastStartMs > 0 && now < lastStartMs + cooldownMs();
|
||||||
|
if ((position > 0 || inCooldown) && onQueued) {
|
||||||
|
const runAtMs = estimateRunAtMs(position);
|
||||||
|
Promise.resolve(onQueued({
|
||||||
|
runAt: new Date(runAtMs),
|
||||||
|
waitMs: Math.max(0, runAtMs - now),
|
||||||
|
position: position + 1,
|
||||||
|
})).catch((err) => {
|
||||||
|
logger(LOG_SCOPE, `onQueued callback failed${label ? ` (${label})` : ''}: ${err.message}`, 'warn');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = chain.then(async () => {
|
||||||
|
const waitMs = Math.max(0, (lastStartMs > 0 ? lastStartMs + cooldownMs() : 0) - Date.now());
|
||||||
|
if (waitMs > 0) {
|
||||||
|
logger(
|
||||||
|
LOG_SCOPE,
|
||||||
|
`Waiting ${Math.ceil(waitMs / 1000)}s for cdr_feed cooldown${label ? ` (${label})` : ''}`,
|
||||||
|
'info',
|
||||||
|
);
|
||||||
|
await sleep(waitMs);
|
||||||
|
}
|
||||||
|
lastStartMs = Date.now();
|
||||||
|
return fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
chain = job.catch(() => {});
|
||||||
|
return job.finally(() => {
|
||||||
|
pendingCount = Math.max(0, pendingCount - 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal test helper */
|
||||||
|
export function _resetCdrFeedQueueForTests() {
|
||||||
|
chain = Promise.resolve();
|
||||||
|
lastStartMs = 0;
|
||||||
|
pendingCount = 0;
|
||||||
|
testCooldownOverride = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal test helper */
|
||||||
|
export function _setCdrFeedCooldownForTests(ms) {
|
||||||
|
testCooldownOverride = ms;
|
||||||
|
}
|
||||||
|
|
@ -339,8 +339,13 @@ export function summarizeAppSeries(raw, expectedName) {
|
||||||
const datapoints = series.data?.[0]?.datapoints;
|
const datapoints = series.data?.[0]?.datapoints;
|
||||||
if (!Array.isArray(datapoints)) return null;
|
if (!Array.isArray(datapoints)) return null;
|
||||||
|
|
||||||
const values = datapoints
|
const timedPoints = datapoints.map((p) => ({
|
||||||
.map((p) => (typeof p?.value === 'number' && Number.isFinite(p.value) ? p.value : null))
|
time: p?.time || null,
|
||||||
|
value: (typeof p?.value === 'number' && Number.isFinite(p.value) ? p.value : null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const values = timedPoints
|
||||||
|
.map((p) => p.value)
|
||||||
.filter((v) => v !== null);
|
.filter((v) => v !== null);
|
||||||
|
|
||||||
const summary = {
|
const summary = {
|
||||||
|
|
@ -355,6 +360,8 @@ export function summarizeAppSeries(raw, expectedName) {
|
||||||
// to re-parse the raw response. Small (<= 288 numbers per metric)
|
// to re-parse the raw response. Small (<= 288 numbers per metric)
|
||||||
// so no memory concern.
|
// so no memory concern.
|
||||||
values,
|
values,
|
||||||
|
// Timestamp-aligned series for per-call WAN overlap (callreport).
|
||||||
|
timedPoints,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (values.length === 0) return summary;
|
if (values.length === 0) return summary;
|
||||||
|
|
|
||||||
67
services/jiraPoller/README.md
Normal file
67
services/jiraPoller/README.md
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
# Jira poller enrichment
|
||||||
|
|
||||||
|
Hourly automation that enriches unassigned Jira tickets with CollabFinder
|
||||||
|
command output posted as ADF comments. Orchestrated by
|
||||||
|
[`services/jiraPollerService.js`](../jiraPollerService.js); scheduled in
|
||||||
|
`index.js` when `JIRA_POLLER_ROOM_ID` is set.
|
||||||
|
|
||||||
|
## Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
JQL search → classifyTicket (AI) → resolveEnrichmentPlan (rules)
|
||||||
|
→ runEnrichmentChecks (collectors + renderers) → Jira ADF comment
|
||||||
|
→ label bot-enriched → Webex summary
|
||||||
|
```
|
||||||
|
|
||||||
|
Idempotency uses Jira labels (`bot-enriched`, `bot-skipped`), not local
|
||||||
|
state. Transient failures leave the ticket unlabeled for retry next hour.
|
||||||
|
|
||||||
|
## Communication Services symptom rules
|
||||||
|
|
||||||
|
For tickets with component **Communication Services** and a resolvable store:
|
||||||
|
|
||||||
|
| Symptom keywords | Checks run |
|
||||||
|
|------------------|------------|
|
||||||
|
| Call quality / connectivity (garbled, static, can't connect, …) | `phonestatus` + `callreport` |
|
||||||
|
| Spam / robocall / nuisance | `callreport` only |
|
||||||
|
| Neither | Default: `phone` → phonestatus, `av` → avstatus |
|
||||||
|
|
||||||
|
`callreport` uses the **today** business window (9am local → now minus 5 min).
|
||||||
|
|
||||||
|
Store number comes from the AI classifier; if missing but a symptom rule
|
||||||
|
matched, the planner falls back to `Store NNNN` in the summary.
|
||||||
|
|
||||||
|
## Adding a new symptom rule
|
||||||
|
|
||||||
|
1. Open [`enrichmentRules.js`](enrichmentRules.js).
|
||||||
|
2. Add regex patterns to `CALL_QUALITY_PATTERNS`, `SPAM_PATTERNS`, or a new
|
||||||
|
pattern list.
|
||||||
|
3. Extend `resolveEnrichmentPlan()` to push a rule id into `matchedRules`
|
||||||
|
and append check ids to `checks`.
|
||||||
|
4. Add tests in `tests/jiraPoller.enrichmentRules.test.js`.
|
||||||
|
|
||||||
|
## Adding a new enrichment check
|
||||||
|
|
||||||
|
1. Register the check in `CHECK_IDS` and `CHECK_RUNNERS` in
|
||||||
|
[`runEnrichment.js`](runEnrichment.js) (collector + renderer, same as the
|
||||||
|
chat command).
|
||||||
|
2. Reference the new check id from a rule in `enrichmentRules.js`.
|
||||||
|
3. Add tests in `tests/jiraPoller.runEnrichment.test.js`.
|
||||||
|
|
||||||
|
## Manual trigger
|
||||||
|
|
||||||
|
```
|
||||||
|
/jirapoll run poller now
|
||||||
|
/jirapoll prime label backlog without enriching (one-time adoption)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `JIRA_POLLER_ROOM_ID` | Webex room for hourly summary + enables cron |
|
||||||
|
| `JIRA_STORE_FIELD_ID` | Pin Store Number custom field id |
|
||||||
|
| `JIRA_POLLER_MODEL` | Override X.AI model for classification |
|
||||||
|
| `CDR_FEED_COOLDOWN_MS` | cdr_feed rate limit (default 65000) for callreport |
|
||||||
|
|
||||||
|
See `.env.example` for full Jira poller section.
|
||||||
154
services/jiraPoller/enrichmentRules.js
Normal file
154
services/jiraPoller/enrichmentRules.js
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
// services/jiraPoller/enrichmentRules.js
|
||||||
|
// Rule-based enrichment planner for the hourly Jira poller.
|
||||||
|
|
||||||
|
export const COMM_SERVICES_COMPONENT = 'Communication Services';
|
||||||
|
|
||||||
|
export const CHECK_IDS = {
|
||||||
|
phonestatus: 'phonestatus',
|
||||||
|
callreport: 'callreport',
|
||||||
|
avstatus: 'avstatus',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CALL_QUALITY_PATTERNS = [
|
||||||
|
/\bgarbled\b/i,
|
||||||
|
/\bstatic(?:y|s)?\b/i,
|
||||||
|
/\bchoppy\b/i,
|
||||||
|
/\bmuffled\b/i,
|
||||||
|
/\bcan(?:'t| ?not) connect\b/i,
|
||||||
|
/\bnot connecting\b/i,
|
||||||
|
/\bcan(?:'t| ?not) call\b/i,
|
||||||
|
/\bno dial tone\b/i,
|
||||||
|
/\bone[- ]way\b/i,
|
||||||
|
/\bdropped calls?\b/i,
|
||||||
|
/\bcall quality\b/i,
|
||||||
|
/\bbad audio\b/i,
|
||||||
|
/\bpoor audio\b/i,
|
||||||
|
/\bvoice quality\b/i,
|
||||||
|
/\bno audio\b/i,
|
||||||
|
/\bdead air\b/i,
|
||||||
|
/\bintermittent\b/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
const SPAM_PATTERNS = [
|
||||||
|
/\bspam\b/i,
|
||||||
|
/\brobocall/i,
|
||||||
|
/\brobo[- ]?call/i,
|
||||||
|
/\bnuisance calls?\b/i,
|
||||||
|
/\bunwanted calls?\b/i,
|
||||||
|
/\bauto[- ]?dial/i,
|
||||||
|
/\btelemarketer/i,
|
||||||
|
/\bprank calls?\b/i,
|
||||||
|
/\bharassing calls?\b/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
function hasComponent(components, name) {
|
||||||
|
return (components || []).some((c) => c?.name === name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ticketText(ticket) {
|
||||||
|
const summary = ticket?.summary || '';
|
||||||
|
const description = ticket?.description || '';
|
||||||
|
return `${summary}\n${description}`.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesAny(text, patterns) {
|
||||||
|
return patterns.some((re) => re.test(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract store number from summary text (e.g. "Store 2477 - ...").
|
||||||
|
* @param {string} summary
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
export function extractStoreFromSummary(summary) {
|
||||||
|
if (!summary) return null;
|
||||||
|
const m = String(summary).match(/\bstore\s+(\d{2,6})\b/i);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} ticket
|
||||||
|
* @param {string} ticket.summary
|
||||||
|
* @param {string} [ticket.description]
|
||||||
|
* @param {Array<{name?: string}>} [ticket.components]
|
||||||
|
* @param {object} classification from classifyTicket()
|
||||||
|
* @param {string|null} classification.storeNum
|
||||||
|
* @param {'phone'|'av'|'skip'} classification.kind
|
||||||
|
* @returns {{
|
||||||
|
* checks: string[],
|
||||||
|
* matchedRules: string[],
|
||||||
|
* storeNum: string|null,
|
||||||
|
* skip: boolean,
|
||||||
|
* skipReason?: string,
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
|
export function resolveEnrichmentPlan(ticket, classification) {
|
||||||
|
const text = ticketText(ticket);
|
||||||
|
const isCommServices = hasComponent(ticket?.components, COMM_SERVICES_COMPONENT);
|
||||||
|
const callQuality = isCommServices && matchesAny(text, CALL_QUALITY_PATTERNS);
|
||||||
|
const spam = isCommServices && matchesAny(text, SPAM_PATTERNS);
|
||||||
|
|
||||||
|
let storeNum = classification?.storeNum || null;
|
||||||
|
if (!storeNum && (callQuality || spam)) {
|
||||||
|
storeNum = extractStoreFromSummary(ticket?.summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedRules = [];
|
||||||
|
if (callQuality) matchedRules.push('comm-call-quality');
|
||||||
|
if (spam) matchedRules.push('comm-spam');
|
||||||
|
|
||||||
|
if (callQuality || spam) {
|
||||||
|
if (!storeNum) {
|
||||||
|
return {
|
||||||
|
checks: [],
|
||||||
|
matchedRules,
|
||||||
|
storeNum: null,
|
||||||
|
skip: true,
|
||||||
|
skipReason: 'Communication Services symptom matched but no store number found',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const checks = [];
|
||||||
|
if (callQuality) {
|
||||||
|
checks.push(CHECK_IDS.phonestatus, CHECK_IDS.callreport);
|
||||||
|
} else if (spam) {
|
||||||
|
checks.push(CHECK_IDS.callreport);
|
||||||
|
}
|
||||||
|
return { checks: [...new Set(checks)], matchedRules, storeNum, skip: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (classification?.kind === 'skip' || !storeNum) {
|
||||||
|
return {
|
||||||
|
checks: [],
|
||||||
|
matchedRules: [],
|
||||||
|
storeNum: null,
|
||||||
|
skip: true,
|
||||||
|
skipReason: classification?.reason || 'no store number',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (classification.kind === 'phone') {
|
||||||
|
return {
|
||||||
|
checks: [CHECK_IDS.phonestatus],
|
||||||
|
matchedRules: ['default-phone'],
|
||||||
|
storeNum,
|
||||||
|
skip: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (classification.kind === 'av') {
|
||||||
|
return {
|
||||||
|
checks: [CHECK_IDS.avstatus],
|
||||||
|
matchedRules: ['default-av'],
|
||||||
|
storeNum,
|
||||||
|
skip: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
checks: [],
|
||||||
|
matchedRules: [],
|
||||||
|
storeNum,
|
||||||
|
skip: true,
|
||||||
|
skipReason: `unsupported kind: ${classification?.kind}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
11
services/jiraPoller/formatBody.js
Normal file
11
services/jiraPoller/formatBody.js
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
// services/jiraPoller/formatBody.js
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array<{title: string, markdown: string}>} sections
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatEnrichmentBody(sections) {
|
||||||
|
return (sections || [])
|
||||||
|
.map((s) => `## ${s.title}\n\n${s.markdown}`)
|
||||||
|
.join('\n\n');
|
||||||
|
}
|
||||||
77
services/jiraPoller/runEnrichment.js
Normal file
77
services/jiraPoller/runEnrichment.js
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
// services/jiraPoller/runEnrichment.js
|
||||||
|
// Run poller enrichment checks and merge markdown sections.
|
||||||
|
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
import { collectPhoneStatus } from '../phoneService.js';
|
||||||
|
import { collectDeviceStatus } from '../deviceService.js';
|
||||||
|
import { collectCallReport } from '../callReport/callReportService.js';
|
||||||
|
import { renderPhoneStatusMarkdown } from '../renderers/phoneStatusRenderer.js';
|
||||||
|
import { renderAvStatusMarkdown } from '../renderers/avStatusRenderer.js';
|
||||||
|
import { renderCallReportMarkdown } from '../renderers/callReportRenderer.js';
|
||||||
|
import { CHECK_IDS } from './enrichmentRules.js';
|
||||||
|
import { formatEnrichmentBody } from './formatBody.js';
|
||||||
|
|
||||||
|
const LOG_SCOPE = 'jira:poller:enrich';
|
||||||
|
|
||||||
|
const CHECK_RUNNERS = {
|
||||||
|
[CHECK_IDS.phonestatus]: async (storeNum) => {
|
||||||
|
const data = await collectPhoneStatus(storeNum);
|
||||||
|
return {
|
||||||
|
title: 'Phone status',
|
||||||
|
markdown: renderPhoneStatusMarkdown(data, {
|
||||||
|
storeNum,
|
||||||
|
detailed: true,
|
||||||
|
footer: false,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[CHECK_IDS.avstatus]: async (storeNum) => {
|
||||||
|
const data = await collectDeviceStatus(storeNum);
|
||||||
|
return {
|
||||||
|
title: 'AV status',
|
||||||
|
markdown: renderAvStatusMarkdown(data, {
|
||||||
|
storeNum,
|
||||||
|
detailed: true,
|
||||||
|
footer: false,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[CHECK_IDS.callreport]: async (storeNum, opts = {}) => {
|
||||||
|
const report = await collectCallReport(storeNum, {
|
||||||
|
dateArg: 'today',
|
||||||
|
onCdrQueued: ({ runAt, waitMs }) => {
|
||||||
|
logger(
|
||||||
|
LOG_SCOPE,
|
||||||
|
`CDR queued for store ${storeNum} — runs at ${runAt.toISOString()} (~${Math.ceil(waitMs / 1000)}s)`,
|
||||||
|
'info',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
title: 'Call report (today)',
|
||||||
|
markdown: renderCallReportMarkdown(report, { detail: false }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} storeNum
|
||||||
|
* @param {string[]} checks ordered check ids
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @returns {Promise<{ sections: Array<{title: string, markdown: string}>, bodyMarkdown: string }>}
|
||||||
|
*/
|
||||||
|
export async function runEnrichmentChecks(storeNum, checks, opts = {}) {
|
||||||
|
const sections = [];
|
||||||
|
for (const checkId of checks) {
|
||||||
|
const runner = CHECK_RUNNERS[checkId];
|
||||||
|
if (!runner) {
|
||||||
|
throw new Error(`Unknown enrichment check: ${checkId}`);
|
||||||
|
}
|
||||||
|
const section = await runner(storeNum, opts);
|
||||||
|
sections.push(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyMarkdown = formatEnrichmentBody(sections);
|
||||||
|
|
||||||
|
return { sections, bodyMarkdown };
|
||||||
|
}
|
||||||
|
|
@ -47,8 +47,8 @@ import { classifyTicket, TicketClassifierError } from './ticketClassifier.js';
|
||||||
import { adfToPlainText } from '../utils/adfToPlainText.js';
|
import { adfToPlainText } from '../utils/adfToPlainText.js';
|
||||||
import { markdownToAdfContent } from '../utils/markdownToAdf.js';
|
import { markdownToAdfContent } from '../utils/markdownToAdf.js';
|
||||||
import { buildAdfComment } from '../utils/adfComment.js';
|
import { buildAdfComment } from '../utils/adfComment.js';
|
||||||
import { renderPhoneStatusMarkdown } from './renderers/phoneStatusRenderer.js';
|
import { resolveEnrichmentPlan } from './jiraPoller/enrichmentRules.js';
|
||||||
import { renderAvStatusMarkdown } from './renderers/avStatusRenderer.js';
|
import { runEnrichmentChecks } from './jiraPoller/runEnrichment.js';
|
||||||
|
|
||||||
const BOT_LABEL = 'bot-enriched';
|
const BOT_LABEL = 'bot-enriched';
|
||||||
// Applied when the AI classifier decides a ticket is out-of-scope for
|
// Applied when the AI classifier decides a ticket is out-of-scope for
|
||||||
|
|
@ -69,17 +69,8 @@ const STORE_FIELD_NAME = 'Store Number';
|
||||||
// drains at MAX_TICKETS_PER_POLL/hour once it exists.
|
// drains at MAX_TICKETS_PER_POLL/hour once it exists.
|
||||||
const MAX_TICKETS_PER_POLL = 50;
|
const MAX_TICKETS_PER_POLL = 50;
|
||||||
|
|
||||||
// Maps classifier's `kind` to the enrichment collector. Replaces the
|
// Legacy map — COMPONENT_ROUTES is the component-based source of truth.
|
||||||
// component-name -> collector map that used to be the source of truth
|
// Enrichment execution uses jiraPoller/runEnrichment.js.
|
||||||
// pre-AI. The classifier's output space is closed (phone|av|skip), so
|
|
||||||
// this map only needs the two enrichable kinds — 'skip' short-circuits
|
|
||||||
// before we get here.
|
|
||||||
const KIND_TO_COLLECTOR = {
|
|
||||||
phone: collectPhoneStatus,
|
|
||||||
av: collectDeviceStatus,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Component name -> enrichment collector. Strict mapping per plan; a
|
|
||||||
// ticket whose components don't match any key here is skipped (though
|
// ticket whose components don't match any key here is skipped (though
|
||||||
// the poller's JQL should ensure this is never actually hit).
|
// the poller's JQL should ensure this is never actually hit).
|
||||||
export const COMPONENT_ROUTES = {
|
export const COMPONENT_ROUTES = {
|
||||||
|
|
@ -339,57 +330,33 @@ export async function pollNewTickets({ prime = false } = {}) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (classification.kind === 'skip' || !classification.storeNum) {
|
const plan = resolveEnrichmentPlan(ticketPayload, classification);
|
||||||
logger('jira:poller', `${key}: SKIP — AI: ${classification.reason}`);
|
|
||||||
// Label AI-determined skips so we don't burn tokens re-classifying
|
|
||||||
// the same ticket every hour. If labeling fails (transient Jira
|
|
||||||
// 5xx, scope drop, whatever) we log and continue — the ticket will
|
|
||||||
// simply be re-classified next hour; the cost of one duplicate AI
|
|
||||||
// call is far cheaper than the cost of dropping a poll entirely.
|
|
||||||
await tagSkipped(key);
|
|
||||||
skipped.push({ key, reason: classification.reason });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const collect = KIND_TO_COLLECTOR[classification.kind];
|
if (plan.skip || !plan.storeNum || !plan.checks.length) {
|
||||||
if (!collect) {
|
const reason = plan.skipReason || classification.reason;
|
||||||
// Belt-and-suspenders: parseAndValidate already gates kind to
|
logger('jira:poller', `${key}: SKIP — ${reason}`);
|
||||||
// phone|av|skip, but if the schema ever loosens we don't want to
|
|
||||||
// silently no-op. Label as skipped for the same "don't retry"
|
|
||||||
// reasoning as the AI-skip branch above.
|
|
||||||
logger('jira:poller', `${key}: SKIP — no collector for kind '${classification.kind}'`, 'warn');
|
|
||||||
await tagSkipped(key);
|
await tagSkipped(key);
|
||||||
skipped.push({ key, reason: `unsupported kind: ${classification.kind}` });
|
skipped.push({ key, reason });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger('jira:poller', `${key}: enriching (${classification.kind}, store ${classification.storeNum}) — AI: ${classification.reason}`);
|
logger(
|
||||||
const data = await collect(classification.storeNum);
|
'jira:poller',
|
||||||
|
`${key}: enriching (store ${plan.storeNum}, checks=${plan.checks.join('+')}) — ` +
|
||||||
// Same markdown the chat commands emit. `footer: false` strips
|
`rules: ${plan.matchedRules.join(', ')} · AI: ${classification.reason}`,
|
||||||
// the "*Last checked: HH:MM*" line — a Jira comment already has
|
);
|
||||||
// an authoritative timestamp in the header paragraph below and
|
const { bodyMarkdown } = await runEnrichmentChecks(plan.storeNum, plan.checks);
|
||||||
// Jira's own `created` field. Detailed mode always on for Jira
|
|
||||||
// so triagers get the richest possible per-device info.
|
|
||||||
const markdown = classification.kind === 'phone'
|
|
||||||
? renderPhoneStatusMarkdown(data, {
|
|
||||||
storeNum: classification.storeNum,
|
|
||||||
detailed: true,
|
|
||||||
footer: false,
|
|
||||||
})
|
|
||||||
: renderAvStatusMarkdown(data, {
|
|
||||||
storeNum: classification.storeNum,
|
|
||||||
detailed: true,
|
|
||||||
footer: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const rulesLabel = plan.matchedRules.length
|
||||||
|
? plan.matchedRules.join(', ')
|
||||||
|
: classification.kind;
|
||||||
const headerLine =
|
const headerLine =
|
||||||
`Auto-enriched by CollabFinder — ${classification.kind} snapshot for store ${classification.storeNum} ` +
|
`Auto-enriched by CollabFinder — store ${plan.storeNum} ` +
|
||||||
`at ${new Date().toISOString()} · AI: ${classification.reason}`;
|
`at ${new Date().toISOString()} · rules: ${rulesLabel} · AI: ${classification.reason}`;
|
||||||
const adf = buildAdfComment({
|
const adf = buildAdfComment({
|
||||||
headerLine,
|
headerLine,
|
||||||
bodyNodes: markdownToAdfContent(markdown),
|
bodyNodes: markdownToAdfContent(bodyMarkdown),
|
||||||
});
|
});
|
||||||
|
|
||||||
await jira.addComment(key, adf);
|
await jira.addComment(key, adf);
|
||||||
|
|
@ -398,12 +365,11 @@ export async function pollNewTickets({ prime = false } = {}) {
|
||||||
enriched.push({
|
enriched.push({
|
||||||
key,
|
key,
|
||||||
summary,
|
summary,
|
||||||
storeNum: classification.storeNum,
|
storeNum: plan.storeNum,
|
||||||
kind: classification.kind,
|
kind: classification.kind,
|
||||||
reason: classification.reason,
|
reason: classification.reason,
|
||||||
// Jira components drive the Webex bullet icon (Mobility vs Comm
|
checks: plan.checks,
|
||||||
// vs AV). Capture at enrich time so the summary post doesn't
|
matchedRules: plan.matchedRules,
|
||||||
// re-walk the issue object.
|
|
||||||
components: f.components || [],
|
components: f.components || [],
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { attachMerakiClientWithPorts } from './enrichment/merakiEnrichment.js';
|
||||||
import {
|
import {
|
||||||
extractCdrFeedList,
|
extractCdrFeedList,
|
||||||
describeCdrFeedPayload,
|
describeCdrFeedPayload,
|
||||||
|
resolveCdrFeedNextPage,
|
||||||
cdrCalledNumber,
|
cdrCalledNumber,
|
||||||
cdrCallingNumber,
|
cdrCallingNumber,
|
||||||
cdrDedupKey,
|
cdrDedupKey,
|
||||||
|
|
@ -24,6 +25,7 @@ import {
|
||||||
cdrDurationSeconds,
|
cdrDurationSeconds,
|
||||||
cdrStartTime,
|
cdrStartTime,
|
||||||
} from './cdrFeedParser.js';
|
} from './cdrFeedParser.js';
|
||||||
|
import { enqueueCdrFeedFetch } from './cdrFeedQueue.js';
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
// Main public function
|
// Main public function
|
||||||
|
|
@ -611,6 +613,108 @@ function tokenFingerprint(token) {
|
||||||
return `…${token.slice(-6)}`;
|
return `…${token.slice(-6)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function executeCdrFeedHttpFetch({
|
||||||
|
locationName,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
tokenHint,
|
||||||
|
analyticsBases,
|
||||||
|
cdrPath,
|
||||||
|
maxPerPage,
|
||||||
|
}) {
|
||||||
|
const allRawItems = [];
|
||||||
|
const fetchErrors = [];
|
||||||
|
|
||||||
|
let usedBase = null;
|
||||||
|
let firstRespData = null;
|
||||||
|
let lastHttpStatus = null;
|
||||||
|
const maxPages = Number(process.env.CDR_FEED_MAX_PAGES || 10);
|
||||||
|
|
||||||
|
for (const base of analyticsBases) {
|
||||||
|
const url = `${base}${cdrPath}`;
|
||||||
|
const params = { startTime, endTime, locations: locationName, max: maxPerPage };
|
||||||
|
const queryStr = new URLSearchParams(params).toString();
|
||||||
|
const fullUrl = `${url}?${queryStr}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let pageUrl = url;
|
||||||
|
let pageParams = params;
|
||||||
|
let page = 0;
|
||||||
|
let truncated = false;
|
||||||
|
|
||||||
|
while (page < maxPages) {
|
||||||
|
page += 1;
|
||||||
|
const pageQuery = pageParams ? new URLSearchParams(pageParams).toString() : '';
|
||||||
|
const pageFull = pageParams ? `${pageUrl}?${pageQuery}` : pageUrl;
|
||||||
|
if (page === 1) {
|
||||||
|
logger('phone:service', `cdr_feed GET ${pageFull}`, 'info');
|
||||||
|
} else {
|
||||||
|
logger('phone:service', `cdr_feed pagination page ${page} GET ${pageFull}`, 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await webex.analyticsRequestRaw('GET', pageUrl, {
|
||||||
|
params: pageParams || undefined,
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
lastHttpStatus = resp.status;
|
||||||
|
const d = resp.data || {};
|
||||||
|
if (page === 1) firstRespData = d;
|
||||||
|
|
||||||
|
const list = extractCdrFeedList(d);
|
||||||
|
allRawItems.push(...list);
|
||||||
|
usedBase = base;
|
||||||
|
logger(
|
||||||
|
'phone:service',
|
||||||
|
`cdr_feed ${base} page ${page} → HTTP ${resp.status}, parsed ${list.length} record(s) ` +
|
||||||
|
`(running total ${allRawItems.length}; ${describeCdrFeedPayload(d)})`,
|
||||||
|
'info',
|
||||||
|
);
|
||||||
|
if (page === 1 && list.length === 0 && d && Object.keys(d).length > 0) {
|
||||||
|
logger(
|
||||||
|
'phone:service',
|
||||||
|
`cdr_feed HTTP ${resp.status} but 0 parseable records — payload shape may have changed (${describeCdrFeedPayload(d)})`,
|
||||||
|
'warn',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = resolveCdrFeedNextPage(d, resp.headers, url, params);
|
||||||
|
if (!next) break;
|
||||||
|
|
||||||
|
if (page >= maxPages) {
|
||||||
|
truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
pageUrl = next.url;
|
||||||
|
pageParams = next.params;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (truncated) {
|
||||||
|
logger(
|
||||||
|
'phone:service',
|
||||||
|
`cdr_feed pagination stopped at CDR_FEED_MAX_PAGES=${maxPages} ` +
|
||||||
|
`(${allRawItems.length} records collected; more may exist)`,
|
||||||
|
'warn',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
const st = e.response?.status;
|
||||||
|
const errMsg = formatCdrFetchError(e);
|
||||||
|
const bd = e.response?.data || errMsg;
|
||||||
|
logger(
|
||||||
|
'phone:service',
|
||||||
|
`cdr_feed ERROR ${fullUrl} status=${st || 'n/a'}: ${typeof bd === 'string' ? bd : JSON.stringify(bd).slice(0, 300)}`,
|
||||||
|
'warn',
|
||||||
|
);
|
||||||
|
fetchErrors.push({ base, startTime, endTime, status: st, body: bd, message: errMsg });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allRawItems, fetchErrors, usedBase, firstRespData, lastHttpStatus };
|
||||||
|
}
|
||||||
|
|
||||||
function getCdrAnalyticsBases() {
|
function getCdrAnalyticsBases() {
|
||||||
const env = process.env.WEBEX_CDR_ANALYTICS_BASES;
|
const env = process.env.WEBEX_CDR_ANALYTICS_BASES;
|
||||||
if (env) {
|
if (env) {
|
||||||
|
|
@ -642,6 +746,7 @@ export async function getHistoricalCallActivity(personId, hours = 24, options =
|
||||||
endTime: optEndTime,
|
endTime: optEndTime,
|
||||||
returnRawItems = false,
|
returnRawItems = false,
|
||||||
skipPersonFilter = false,
|
skipPersonFilter = false,
|
||||||
|
onQueued,
|
||||||
} = options || {};
|
} = options || {};
|
||||||
|
|
||||||
if (!personId && !providedLocationName) {
|
if (!personId && !providedLocationName) {
|
||||||
|
|
@ -651,7 +756,7 @@ export async function getHistoricalCallActivity(personId, hours = 24, options =
|
||||||
// Prefer analytics-calling (user-confirmed working base for cdr_feed). Fall back to the other if needed.
|
// Prefer analytics-calling (user-confirmed working base for cdr_feed). Fall back to the other if needed.
|
||||||
const ANALYTICS_BASES = getCdrAnalyticsBases();
|
const ANALYTICS_BASES = getCdrAnalyticsBases();
|
||||||
const CDR_PATH = '/cdr_feed';
|
const CDR_PATH = '/cdr_feed';
|
||||||
const MAX_PER_PAGE = 1000;
|
const MAX_PER_PAGE = Number(process.env.CDR_FEED_MAX_PER_PAGE || 5000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const person = personId ? await webex.request('GET', `people/${personId}`) : null;
|
const person = personId ? await webex.request('GET', `people/${personId}`) : null;
|
||||||
|
|
@ -760,86 +865,23 @@ export async function getHistoricalCallActivity(personId, hours = 24, options =
|
||||||
'info',
|
'info',
|
||||||
);
|
);
|
||||||
|
|
||||||
const allRawItems = [];
|
const {
|
||||||
const fetchErrors = [];
|
allRawItems,
|
||||||
|
fetchErrors,
|
||||||
let usedBase = null;
|
usedBase,
|
||||||
let firstRespData = null;
|
lastHttpStatus,
|
||||||
let lastHttpStatus = null;
|
} = await enqueueCdrFeedFetch(
|
||||||
|
() => executeCdrFeedHttpFetch({
|
||||||
for (const base of ANALYTICS_BASES) {
|
locationName,
|
||||||
const url = `${base}${CDR_PATH}`;
|
startTime,
|
||||||
const params = { startTime, endTime, locations: locationName, max: MAX_PER_PAGE };
|
endTime,
|
||||||
const queryStr = new URLSearchParams(params).toString();
|
tokenHint,
|
||||||
const fullUrl = `${url}?${queryStr}`;
|
analyticsBases: ANALYTICS_BASES,
|
||||||
logger('phone:service', `cdr_feed GET ${fullUrl}`, 'info');
|
cdrPath: CDR_PATH,
|
||||||
|
maxPerPage: MAX_PER_PAGE,
|
||||||
try {
|
}),
|
||||||
const resp = await webex.analyticsRequestRaw('GET', url, { params, timeout: 15000 });
|
{ onQueued, label: locationName },
|
||||||
lastHttpStatus = resp.status;
|
);
|
||||||
const d = resp.data || {};
|
|
||||||
firstRespData = d;
|
|
||||||
|
|
||||||
const list = extractCdrFeedList(d);
|
|
||||||
allRawItems.push(...list);
|
|
||||||
usedBase = base;
|
|
||||||
logger(
|
|
||||||
'phone:service',
|
|
||||||
`cdr_feed ${base} → HTTP ${resp.status}, parsed ${list.length} record(s) (${describeCdrFeedPayload(d)})`,
|
|
||||||
'info',
|
|
||||||
);
|
|
||||||
if (list.length === 0 && d && Object.keys(d).length > 0) {
|
|
||||||
logger(
|
|
||||||
'phone:service',
|
|
||||||
`cdr_feed HTTP ${resp.status} but 0 parseable records — payload shape may have changed (${describeCdrFeedPayload(d)})`,
|
|
||||||
'warn',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pagination support (up to 10 additional pages per rate limit)
|
|
||||||
let nextToken = d.next || d['next'] || (d.metadata && d.metadata.next) || null;
|
|
||||||
let page = 1;
|
|
||||||
const MAX_PAGES = 10;
|
|
||||||
while (nextToken && page < MAX_PAGES) {
|
|
||||||
page++;
|
|
||||||
let pageUrl = url;
|
|
||||||
let pageParams = { ...params, next: nextToken };
|
|
||||||
if (typeof nextToken === 'string' && nextToken.startsWith('http')) {
|
|
||||||
pageUrl = nextToken;
|
|
||||||
pageParams = null;
|
|
||||||
}
|
|
||||||
const pageQuery = pageParams ? new URLSearchParams(pageParams).toString() : '';
|
|
||||||
const pageFull = pageParams ? `${pageUrl}?${pageQuery}` : pageUrl;
|
|
||||||
logger('phone:service', `cdr_feed pagination page ${page} URL: ${pageFull}`, 'debug');
|
|
||||||
try {
|
|
||||||
const pResp = await webex.analyticsRequestRaw('GET', pageUrl, {
|
|
||||||
params: pageParams || undefined,
|
|
||||||
timeout: 15000,
|
|
||||||
});
|
|
||||||
logger('phone:service', `cdr_feed pagination status=${pResp.status}`, 'debug');
|
|
||||||
const pd = pResp.data || {};
|
|
||||||
const pList = extractCdrFeedList(pd);
|
|
||||||
allRawItems.push(...pList);
|
|
||||||
nextToken = pd.next || pd['next'] || (pd.metadata && pd.metadata.next) || null;
|
|
||||||
} catch (pe) {
|
|
||||||
const pst = pe.response?.status;
|
|
||||||
logger('phone:service', `cdr_feed pagination ERROR status=${pst || 'n/a'}: ${pe.message}`, 'warn');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break; // first successful HTTP response (even if 0 items = empty window is valid)
|
|
||||||
} catch (e) {
|
|
||||||
const st = e.response?.status;
|
|
||||||
const errMsg = formatCdrFetchError(e);
|
|
||||||
const bd = e.response?.data || errMsg;
|
|
||||||
logger(
|
|
||||||
'phone:service',
|
|
||||||
`cdr_feed ERROR ${fullUrl} status=${st || 'n/a'}: ${typeof bd === 'string' ? bd : JSON.stringify(bd).slice(0, 300)}`,
|
|
||||||
'warn',
|
|
||||||
);
|
|
||||||
fetchErrors.push({ base, startTime, endTime, status: st, body: bd, message: errMsg });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allRawItems.length === 0 && fetchErrors.length > 0) {
|
if (allRawItems.length === 0 && fetchErrors.length > 0) {
|
||||||
const errSummary = fetchErrors
|
const errSummary = fetchErrors
|
||||||
|
|
|
||||||
164
services/renderers/callReportRenderer.js
Normal file
164
services/renderers/callReportRenderer.js
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
// services/renderers/callReportRenderer.js
|
||||||
|
|
||||||
|
import { CALL_BUCKETS } from '../callReport/groupCdrCalls.js';
|
||||||
|
import { formatCallBlock } from '../callReport/formatCallLine.js';
|
||||||
|
import { collapseAaBursts } from '../callReport/collapseDisplayCalls.js';
|
||||||
|
import { callReportEnvInt } from '../callReport/env.js';
|
||||||
|
|
||||||
|
function fmtTime(iso) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
try {
|
||||||
|
return new Date(iso).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC');
|
||||||
|
} catch {
|
||||||
|
return String(iso);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOutcomeBreakdown(outcomes) {
|
||||||
|
const entries = Object.entries(outcomes || {});
|
||||||
|
if (!entries.length) return '_No outcome data._';
|
||||||
|
return entries
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([k, v]) => `- **${k}:** ${v}`)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReachSummary(summary) {
|
||||||
|
const lines = [];
|
||||||
|
if (summary.inbound > 0) {
|
||||||
|
lines.push(`- **Inbound reached a phone:** ${summary.inboundReachedPhone} of ${summary.inbound}`);
|
||||||
|
if (summary.inboundAaOnly > 0) {
|
||||||
|
lines.push(`- **Inbound stopped at auto-attendant:** ${summary.inboundAaOnly}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (summary.outbound > 0) {
|
||||||
|
lines.push(`- **Outbound connected:** ${summary.outboundConnected} of ${summary.outbound}`);
|
||||||
|
}
|
||||||
|
if (summary.abnormal > 0) {
|
||||||
|
lines.push(`- **Abnormal outcomes:** ${summary.abnormal}`);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCallSection(title, calls, { timeZone, includeWan, maxRows, detail, collapseAa = false }) {
|
||||||
|
if (!calls?.length) return '';
|
||||||
|
const displayCalls = collapseAa ? collapseAaBursts(calls, { timeZone }) : calls;
|
||||||
|
const show = displayCalls.slice(0, maxRows);
|
||||||
|
let md = `\n### ${title} (${calls.length})\n`;
|
||||||
|
for (const item of show) {
|
||||||
|
if (item.kind === 'burst') {
|
||||||
|
md += `${item.summary}\n\n`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const { line1, line2 } = formatCallBlock(item, { timeZone, includeWan, detail });
|
||||||
|
md += `${line1}\n`;
|
||||||
|
if (line2) md += `${line2}\n`;
|
||||||
|
md += '\n';
|
||||||
|
}
|
||||||
|
if (displayCalls.length > maxRows) {
|
||||||
|
md += `_${displayCalls.length - maxRows} more in this section — use Control Hub for full export._\n`;
|
||||||
|
}
|
||||||
|
return md;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReportTitle(target) {
|
||||||
|
if (!target) return 'Call report';
|
||||||
|
if (target.kind === 'store') return `Call report — Store ${target.storeNum}`;
|
||||||
|
return `Call report — ${target.label}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScopeLine(target) {
|
||||||
|
if (!target || target.kind === 'store') return null;
|
||||||
|
if (target.kind === 'user') return `- **Scope:** user ${target.email || target.label}`;
|
||||||
|
if (target.kind === 'number') return `- **Scope:** number ${target.label}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} report from collectCallReport
|
||||||
|
* @param {{ detail?: boolean }} opts
|
||||||
|
*/
|
||||||
|
export function renderCallReportMarkdown(report, opts = {}) {
|
||||||
|
if (!report?.ok) {
|
||||||
|
const target = report?.target || report?.store;
|
||||||
|
let md = `**Call report** — ${target?.label || target?.storeNum || '?'}\n\n`;
|
||||||
|
md += `_${report?.reason || report?.window?.reason || 'Unable to build report.'}_\n`;
|
||||||
|
return md.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { store, target: reportTarget, window, cdr, prisma, joined } = report;
|
||||||
|
const target = reportTarget || store;
|
||||||
|
const summary = joined.summary || {};
|
||||||
|
const buckets = joined.buckets || {};
|
||||||
|
const maxRows = callReportEnvInt('MAX_DETAIL_ROWS', 25);
|
||||||
|
const detail = Boolean(opts.detail);
|
||||||
|
const timeZone = window.timeZone;
|
||||||
|
|
||||||
|
let md = `**${renderReportTitle(target)}**\n\n`;
|
||||||
|
md += `- **Date:** ${window.label} (${window.mode})\n`;
|
||||||
|
md += `- **Timezone:** ${window.timeZone}\n`;
|
||||||
|
md += `- **Window:** ${fmtTime(window.startTime)} → ${fmtTime(window.endTime)}\n`;
|
||||||
|
md += `- **Location:** ${store.locationName}\n`;
|
||||||
|
if (store.dialNumber) md += `- **Main:** ${store.dialNumber}\n`;
|
||||||
|
const scopeLine = renderScopeLine(target);
|
||||||
|
if (scopeLine) md += `${scopeLine}\n`;
|
||||||
|
|
||||||
|
md += '\n**Call volume (CDR)**\n';
|
||||||
|
if (!cdr.available) {
|
||||||
|
md += `_CDR unavailable:_ ${cdr.reason || 'unknown'}\n`;
|
||||||
|
} else {
|
||||||
|
md += `- **Total calls:** ${summary.total ?? 0}\n`;
|
||||||
|
md += `- **Inbound:** ${summary.inbound ?? 0} | **Outbound:** ${summary.outbound ?? 0}\n`;
|
||||||
|
md += `- **CDR legs fetched:** ${cdr.rawCount ?? '—'} → **${summary.total ?? 0}** correlated call(s)\n`;
|
||||||
|
const reach = renderReachSummary(summary);
|
||||||
|
if (reach) md += `${reach}\n`;
|
||||||
|
if (target?.filter && summary.total === 0) {
|
||||||
|
md += '_No calls matched scope in this window._\n';
|
||||||
|
}
|
||||||
|
md += '\n**Call outcomes**\n';
|
||||||
|
md += `${renderOutcomeBreakdown(summary.outcomes)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
md += '\n**WAN voice (Prisma DPI)**\n';
|
||||||
|
if (!prisma.available) {
|
||||||
|
md += `_Unavailable:_ ${prisma.reason || 'not configured'}\n`;
|
||||||
|
} else {
|
||||||
|
const m = prisma.appAudio?.mos;
|
||||||
|
md += `- **App:** ${prisma.appAudio.appName}\n`;
|
||||||
|
if (m?.min != null && m?.max != null) {
|
||||||
|
md += `- **Worst MOS in window:** ${m.min} | **Best MOS:** ${m.max}\n`;
|
||||||
|
} else if (m?.min != null) {
|
||||||
|
md += `- **Worst MOS in window:** ${m.min}\n`;
|
||||||
|
}
|
||||||
|
if (prisma.appAudio?.detailsUrl) {
|
||||||
|
md += `- [View in Prisma SCM](${prisma.appAudio.detailsUrl})\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cdr.available) {
|
||||||
|
const sectionOpts = { timeZone, includeWan: true, maxRows, detail };
|
||||||
|
md += renderCallSection(
|
||||||
|
'Inbound — reached a phone',
|
||||||
|
buckets[CALL_BUCKETS.inboundReachedPhone],
|
||||||
|
sectionOpts,
|
||||||
|
);
|
||||||
|
md += renderCallSection(
|
||||||
|
'Inbound — stopped at auto-attendant',
|
||||||
|
buckets[CALL_BUCKETS.inboundAaOnly],
|
||||||
|
{ ...sectionOpts, collapseAa: true },
|
||||||
|
);
|
||||||
|
md += renderCallSection(
|
||||||
|
'Outbound — connected',
|
||||||
|
buckets[CALL_BUCKETS.outboundConnected],
|
||||||
|
sectionOpts,
|
||||||
|
);
|
||||||
|
md += renderCallSection(
|
||||||
|
'Other / unanswered',
|
||||||
|
buckets[CALL_BUCKETS.other],
|
||||||
|
sectionOpts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
md += `\n*Generated in ${report.elapsedMs}ms*`;
|
||||||
|
return md.trim();
|
||||||
|
}
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
// services/renderers/voiceReportRenderer.js
|
|
||||||
|
|
||||||
function envInt(name, fallback) {
|
|
||||||
const v = Number(process.env[name]);
|
|
||||||
return Number.isFinite(v) ? v : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtTime(iso) {
|
|
||||||
if (!iso) return '—';
|
|
||||||
try {
|
|
||||||
return new Date(iso).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC');
|
|
||||||
} catch {
|
|
||||||
return String(iso);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function countPoorQuality(calls) {
|
|
||||||
const mosWarn = Number(process.env.WAN_STANDARD_APP_MOS_WARN || 4.0);
|
|
||||||
const lossWarn = Number(process.env.WAN_STANDARD_APP_LOSS_WARN_PCT || 5);
|
|
||||||
const jitterWarn = Number(process.env.WAN_STANDARD_APP_JITTER_WARN_MS || 30);
|
|
||||||
let poor = 0;
|
|
||||||
for (const call of calls) {
|
|
||||||
const q = call.media || call.wan || {};
|
|
||||||
if (q.mos != null && q.mos < mosWarn) poor++;
|
|
||||||
else if (q.loss != null && q.loss > lossWarn) poor++;
|
|
||||||
else if (q.jitter != null && q.jitter > jitterWarn) poor++;
|
|
||||||
}
|
|
||||||
return poor;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderOutcomeBreakdown(outcomes) {
|
|
||||||
const entries = Object.entries(outcomes || {});
|
|
||||||
if (!entries.length) return '_No outcome data._';
|
|
||||||
return entries
|
|
||||||
.sort((a, b) => b[1] - a[1])
|
|
||||||
.map(([k, v]) => `- **${k}:** ${v}`)
|
|
||||||
.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderReachSummary(summary) {
|
|
||||||
const lines = [];
|
|
||||||
if (summary.inbound > 0) {
|
|
||||||
lines.push(
|
|
||||||
`- **Inbound reached a phone:** ${summary.inboundReachedPhone} of ${summary.inbound}`,
|
|
||||||
);
|
|
||||||
if (summary.inboundAaOnly > 0) {
|
|
||||||
lines.push(`- **Inbound stopped at auto-attendant:** ${summary.inboundAaOnly}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (summary.outbound > 0) {
|
|
||||||
lines.push(
|
|
||||||
`- **Outbound connected:** ${summary.outboundConnected} of ${summary.outbound}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (summary.abnormal > 0) {
|
|
||||||
lines.push(`- **Abnormal outcomes:** ${summary.abnormal}`);
|
|
||||||
}
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCallReachLine(call) {
|
|
||||||
if (call.direction === 'inbound') {
|
|
||||||
if (call.reachedPhone) {
|
|
||||||
const who = [call.endpointUser, call.endpointModel].filter(Boolean).join(' / ');
|
|
||||||
return `reached ${who || 'phone'}`;
|
|
||||||
}
|
|
||||||
if (call.reachedAttendant) {
|
|
||||||
const key = call.aaKeyPress ? ` (AA key ${call.aaKeyPress})` : '';
|
|
||||||
return `auto-attendant only${key}`;
|
|
||||||
}
|
|
||||||
return 'not answered';
|
|
||||||
}
|
|
||||||
if (call.direction === 'outbound') {
|
|
||||||
return call.connected || call.reachedPhone ? 'connected' : 'not connected';
|
|
||||||
}
|
|
||||||
return call.outcome || '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {object} report from collectVoiceReport
|
|
||||||
* @param {{ detail?: boolean }} opts
|
|
||||||
*/
|
|
||||||
export function renderVoiceReportMarkdown(report, opts = {}) {
|
|
||||||
if (!report?.ok) {
|
|
||||||
let md = `**Voice report** — Store ${report?.store?.storeNum || '?'}\n\n`;
|
|
||||||
md += `_${report?.reason || report?.window?.reason || 'Unable to build report.'}_\n`;
|
|
||||||
return md.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { store, window, cdr, mediaQuality, prisma, joined } = report;
|
|
||||||
const calls = joined.calls || joined.legs || [];
|
|
||||||
const summary = joined.summary || {};
|
|
||||||
const maxRows = envInt('VOICEREPORT_MAX_DETAIL_ROWS', 25);
|
|
||||||
const detail = Boolean(opts.detail);
|
|
||||||
|
|
||||||
let md = `**Voice report — Store ${store.storeNum}**\n\n`;
|
|
||||||
md += `- **Date:** ${window.label} (${window.mode})\n`;
|
|
||||||
md += `- **Timezone:** ${window.timeZone}\n`;
|
|
||||||
md += `- **Window:** ${fmtTime(window.startTime)} → ${fmtTime(window.endTime)}\n`;
|
|
||||||
md += `- **Location:** ${store.locationName}\n`;
|
|
||||||
if (store.dialNumber) md += `- **Main:** ${store.dialNumber}\n`;
|
|
||||||
|
|
||||||
md += '\n**Call volume (CDR)**\n';
|
|
||||||
if (!cdr.available) {
|
|
||||||
md += `_CDR unavailable:_ ${cdr.reason || 'unknown'}\n`;
|
|
||||||
} else {
|
|
||||||
md += `- **Total calls:** ${summary.total ?? calls.length}\n`;
|
|
||||||
md += `- **Inbound:** ${summary.inbound ?? 0} | **Outbound:** ${summary.outbound ?? 0}\n`;
|
|
||||||
md += `- **CDR legs fetched:** ${cdr.rawCount ?? '—'} → **${summary.total ?? calls.length}** correlated call(s)\n`;
|
|
||||||
const reach = renderReachSummary(summary);
|
|
||||||
if (reach) md += `${reach}\n`;
|
|
||||||
md += '\n**Call outcomes**\n';
|
|
||||||
md += `${renderOutcomeBreakdown(summary.outcomes)}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
md += '\n**Media quality (Webex report)**\n';
|
|
||||||
if (!mediaQuality.available) {
|
|
||||||
md += `_Unavailable:_ ${mediaQuality.reason || 'not generated'}\n`;
|
|
||||||
} else {
|
|
||||||
md += `- **Rows in window:** ${mediaQuality.rows?.length ?? 0} (from ${mediaQuality.rawCount ?? '?'} downloaded)\n`;
|
|
||||||
const poor = countPoorQuality(calls.filter((c) => c.media));
|
|
||||||
md += `- **Calls with quality flags:** ${poor}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
md += '\n**WAN voice (Prisma DPI)**\n';
|
|
||||||
if (!prisma.available) {
|
|
||||||
md += `_Unavailable:_ ${prisma.reason || 'not configured'}\n`;
|
|
||||||
} else {
|
|
||||||
const m = prisma.appAudio?.mos;
|
|
||||||
md += `- **App:** ${prisma.appAudio.appName}\n`;
|
|
||||||
if (m?.min != null) md += `- **Worst MOS in window:** ${m.min}\n`;
|
|
||||||
if (m?.max != null) md += `- **Best MOS in window:** ${m.max}\n`;
|
|
||||||
if (prisma.appAudio?.detailsUrl) {
|
|
||||||
md += `- [View in Prisma SCM](${prisma.appAudio.detailsUrl})\n`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detail && calls.length) {
|
|
||||||
md += '\n**Call detail**\n';
|
|
||||||
const show = calls.slice(0, maxRows);
|
|
||||||
for (const call of show) {
|
|
||||||
const dir = String(call.direction || 'unknown').toUpperCase();
|
|
||||||
const outcome = [call.outcome, call.outcomeReason].filter(Boolean).join(' / ');
|
|
||||||
const flag = call.abnormal || !call.normalOutcome ? ' ⚠️' : '';
|
|
||||||
md += `\n- **${fmtTime(call.start)}** ${dir} ${call.duration || 0}s — ${outcome || '—'}${flag}\n`;
|
|
||||||
md += ` ${call.callingNumber || '?'} → ${call.calledNumber || '?'} (${call.legCount} leg(s), ${renderCallReachLine(call)})\n`;
|
|
||||||
if (call.media) {
|
|
||||||
md += ` Media: MOS ${call.media.mos ?? '—'}, jitter ${call.media.jitter ?? '—'}, loss ${call.media.loss ?? '—'}\n`;
|
|
||||||
}
|
|
||||||
if (call.wan) {
|
|
||||||
md += ` WAN≈: MOS ${call.wan.mos ?? '—'}, jitter ${call.wan.jitter ?? '—'}, loss ${call.wan.loss ?? '—'}\n`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (calls.length > maxRows) {
|
|
||||||
md += `\n_${calls.length - maxRows} more calls — use Control Hub Detailed Call History for full export._\n`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
md += `\n*Generated in ${report.elapsedMs}ms*`;
|
|
||||||
return md.trim();
|
|
||||||
}
|
|
||||||
|
|
@ -1,199 +0,0 @@
|
||||||
// services/voiceReport/joinCallQuality.js
|
|
||||||
// Join CDR legs, Media Quality rows, and Prisma WAN buckets.
|
|
||||||
|
|
||||||
import { isWithinWindow } from './businessWindow.js';
|
|
||||||
import {
|
|
||||||
cdrCalledNumber,
|
|
||||||
cdrCallingNumber,
|
|
||||||
cdrDirectionValue,
|
|
||||||
cdrDispositionValue,
|
|
||||||
cdrDurationSeconds,
|
|
||||||
cdrField,
|
|
||||||
cdrStartTime,
|
|
||||||
} from '../cdrFeedParser.js';
|
|
||||||
import { groupCdrIntoCalls, summarizeCalls } from './groupCdrCalls.js';
|
|
||||||
|
|
||||||
export function phoneDigits(value) {
|
|
||||||
const d = String(value || '').replace(/\D/g, '');
|
|
||||||
if (d.length === 10) return `1${d}`;
|
|
||||||
if (d.length === 11 && d.startsWith('1')) return d;
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cdrDisposition(item) {
|
|
||||||
return cdrDispositionValue(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cdrDirection(item) {
|
|
||||||
return cdrDirectionValue(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cdrStart(item) {
|
|
||||||
return cdrStartTime(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cdrDuration(item) {
|
|
||||||
return cdrDurationSeconds(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCdrLeg(item) {
|
|
||||||
return {
|
|
||||||
start: cdrStart(item),
|
|
||||||
direction: cdrDirection(item),
|
|
||||||
duration: cdrDuration(item),
|
|
||||||
disposition: cdrDisposition(item),
|
|
||||||
callingNumber: cdrCallingNumber(item),
|
|
||||||
calledNumber: cdrCalledNumber(item),
|
|
||||||
correlationId: cdrField(item, 'correlationId', 'Correlation ID', 'correlationID'),
|
|
||||||
raw: item,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mqCorrelation(row) {
|
|
||||||
const n = row._norm || {};
|
|
||||||
return n.correlationid || n.finalcorrelationid || row['Correlation ID'] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mqStart(row) {
|
|
||||||
const n = row._norm || {};
|
|
||||||
return n.starttime || n.callstarttime || row['Start Time'] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mqNumbers(row) {
|
|
||||||
const n = row._norm || {};
|
|
||||||
return [
|
|
||||||
n.callingnumber, n.callednumber, n.remoteparty,
|
|
||||||
row['Calling Number'], row['Called Number'],
|
|
||||||
].filter(Boolean).map(phoneDigits);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mqQuality(row) {
|
|
||||||
const n = row._norm || {};
|
|
||||||
const mos = Number(n.mos || n.meanopinionscore || n.audiomos || NaN);
|
|
||||||
const jitter = Number(n.jitter || n.jitterms || NaN);
|
|
||||||
const loss = Number(n.packetloss || n.packetlosspct || n.loss || NaN);
|
|
||||||
return {
|
|
||||||
mos: Number.isFinite(mos) ? mos : null,
|
|
||||||
jitter: Number.isFinite(jitter) ? jitter : null,
|
|
||||||
loss: Number.isFinite(loss) ? loss : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function scoreMqMatch(call, row) {
|
|
||||||
const callCorr = call.correlationId ? String(call.correlationId).toLowerCase() : '';
|
|
||||||
const rowCorr = mqCorrelation(row);
|
|
||||||
if (callCorr && rowCorr && String(rowCorr).toLowerCase() === callCorr) return 100;
|
|
||||||
|
|
||||||
const legStart = call.start ? new Date(call.start).getTime() : NaN;
|
|
||||||
const rowStart = mqStart(row) ? new Date(mqStart(row)).getTime() : NaN;
|
|
||||||
if (!Number.isFinite(legStart) || !Number.isFinite(rowStart)) return 0;
|
|
||||||
|
|
||||||
const delta = Math.abs(legStart - rowStart);
|
|
||||||
if (delta > 120_000) return 0;
|
|
||||||
|
|
||||||
let score = 50;
|
|
||||||
if (delta < 15_000) score += 30;
|
|
||||||
else if (delta < 60_000) score += 10;
|
|
||||||
|
|
||||||
const legNums = new Set([
|
|
||||||
phoneDigits(call.callingNumber),
|
|
||||||
phoneDigits(call.calledNumber),
|
|
||||||
].filter(Boolean));
|
|
||||||
const rowNums = mqNumbers(row);
|
|
||||||
if (rowNums.some((n) => legNums.has(n))) score += 20;
|
|
||||||
return score;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function matchMediaQualityToCall(call, mqRows) {
|
|
||||||
let best = null;
|
|
||||||
let bestScore = 0;
|
|
||||||
for (const row of mqRows || []) {
|
|
||||||
const s = scoreMqMatch(call, row);
|
|
||||||
if (s > bestScore) {
|
|
||||||
bestScore = s;
|
|
||||||
best = row;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!best || bestScore < 50) return null;
|
|
||||||
return { ...mqQuality(best), score: bestScore };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @deprecated use matchMediaQualityToCall */
|
|
||||||
export function matchMediaQualityToLeg(leg, mqRows) {
|
|
||||||
return matchMediaQualityToCall(leg, mqRows);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find worst Prisma 5-min bucket overlapping [start, end].
|
|
||||||
*/
|
|
||||||
export function worstPrismaBucketForCall(call, appAudio, window) {
|
|
||||||
if (!appAudio?.mos?.values?.length) return null;
|
|
||||||
const startMs = call.start ? new Date(call.start).getTime() : NaN;
|
|
||||||
const endMs = Number.isFinite(startMs)
|
|
||||||
? startMs + (call.duration || 0) * 1000
|
|
||||||
: NaN;
|
|
||||||
if (!Number.isFinite(startMs)) return null;
|
|
||||||
|
|
||||||
const intervalMs = 5 * 60 * 1000;
|
|
||||||
const winStart = new Date(window.startTime).getTime();
|
|
||||||
const idx0 = Math.max(0, Math.floor((startMs - winStart) / intervalMs));
|
|
||||||
const idx1 = Math.max(idx0, Math.ceil((endMs - winStart) / intervalMs));
|
|
||||||
|
|
||||||
const mosVals = appAudio.mos.values;
|
|
||||||
const lossVals = appAudio.loss?.values || [];
|
|
||||||
const jitterVals = appAudio.jitter?.values || [];
|
|
||||||
|
|
||||||
let worstMos = null;
|
|
||||||
let worstLoss = null;
|
|
||||||
let worstJitter = null;
|
|
||||||
for (let i = idx0; i <= idx1 && i < mosVals.length; i++) {
|
|
||||||
const m = mosVals[i];
|
|
||||||
if (typeof m === 'number' && (worstMos == null || m < worstMos)) worstMos = m;
|
|
||||||
const l = lossVals[i];
|
|
||||||
if (typeof l === 'number' && (worstLoss == null || l > worstLoss)) worstLoss = l;
|
|
||||||
const j = jitterVals[i];
|
|
||||||
if (typeof j === 'number' && (worstJitter == null || j > worstJitter)) worstJitter = j;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (worstMos == null && worstLoss == null && worstJitter == null) return null;
|
|
||||||
return {
|
|
||||||
siteLevelApprox: true,
|
|
||||||
mos: worstMos,
|
|
||||||
loss: worstLoss,
|
|
||||||
jitter: worstJitter,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function summarizeDispositions(legs) {
|
|
||||||
const counts = {};
|
|
||||||
for (const leg of legs) {
|
|
||||||
const key = String(leg.disposition || 'unknown').toLowerCase();
|
|
||||||
counts[key] = (counts[key] || 0) + 1;
|
|
||||||
}
|
|
||||||
return counts;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function joinCallQuality({ cdrItems, mqRows, appAudio, window }) {
|
|
||||||
const calls = groupCdrIntoCalls(cdrItems);
|
|
||||||
const joined = calls.map((call) => {
|
|
||||||
const media = matchMediaQualityToCall(call, mqRows);
|
|
||||||
const wan = worstPrismaBucketForCall(call, appAudio, window);
|
|
||||||
return { ...call, media, wan };
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
calls: joined,
|
|
||||||
legs: joined, // backward compat for callers expecting .legs
|
|
||||||
summary: summarizeCalls(joined),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function filterCdrToWindow(items, window) {
|
|
||||||
return (items || []).filter((item) => {
|
|
||||||
const start = cdrStartTime(item);
|
|
||||||
// cdr_feed query params already scope by start time; keep rows when the
|
|
||||||
// report column label differs from our camelCase guesses.
|
|
||||||
if (!start) return true;
|
|
||||||
return isWithinWindow(start, window);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// tests/voiceReport.businessWindow.test.js
|
// tests/callReport.businessWindow.test.js
|
||||||
|
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
@ -7,7 +7,7 @@ import { DateTime } from 'luxon';
|
||||||
import {
|
import {
|
||||||
computeBusinessWindow,
|
computeBusinessWindow,
|
||||||
parseReportDateArg,
|
parseReportDateArg,
|
||||||
} from '../services/voiceReport/businessWindow.js';
|
} from '../services/callReport/businessWindow.js';
|
||||||
|
|
||||||
test('parseReportDateArg defaults to yesterday', () => {
|
test('parseReportDateArg defaults to yesterday', () => {
|
||||||
const now = DateTime.fromISO('2026-07-24T15:00:00', { zone: 'America/New_York' }).toJSDate();
|
const now = DateTime.fromISO('2026-07-24T15:00:00', { zone: 'America/New_York' }).toJSDate();
|
||||||
61
tests/callReport.collapseDisplayCalls.test.js
Normal file
61
tests/callReport.collapseDisplayCalls.test.js
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
// tests/callReport.collapseDisplayCalls.test.js
|
||||||
|
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { collapseAaBursts } from '../services/callReport/collapseDisplayCalls.js';
|
||||||
|
import { formatCallBlock } from '../services/callReport/formatCallLine.js';
|
||||||
|
|
||||||
|
test('collapseAaBursts groups 3+ same-caller AA calls', () => {
|
||||||
|
const mk = (i) => ({
|
||||||
|
start: `2026-07-23T14:${String(10 + i).padStart(2, '0')}:00.000Z`,
|
||||||
|
callingLineId: 'CLASE AVA',
|
||||||
|
callingNumber: '+17188408304',
|
||||||
|
storeMainNumber: '+12122194600',
|
||||||
|
calledNumber: '+12122194600',
|
||||||
|
aaKeyPress: '1',
|
||||||
|
reachedAttendant: true,
|
||||||
|
reachedPhone: false,
|
||||||
|
direction: 'inbound',
|
||||||
|
duration: 12,
|
||||||
|
disposition: 'Success',
|
||||||
|
});
|
||||||
|
const collapsed = collapseAaBursts([mk(0), mk(1), mk(2), mk(3)], { timeZone: 'America/New_York' });
|
||||||
|
assert.equal(collapsed.length, 1);
|
||||||
|
assert.equal(collapsed[0].kind, 'burst');
|
||||||
|
assert.match(collapsed[0].summary, /4 calls stopped at auto-attendant/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatCallBlock outbound short dial shows dialed', () => {
|
||||||
|
const call = {
|
||||||
|
direction: 'outbound',
|
||||||
|
start: '2026-07-23T16:27:00.000Z',
|
||||||
|
duration: 22,
|
||||||
|
endpointUser: 'Store 02477',
|
||||||
|
leftParty: { user: 'Store 02477', number: '52477' },
|
||||||
|
calledNumber: '7',
|
||||||
|
disposition: 'Refusal / UnassignedNumber',
|
||||||
|
abnormal: true,
|
||||||
|
normalOutcome: false,
|
||||||
|
};
|
||||||
|
const { line1 } = formatCallBlock(call, { timeZone: 'America/New_York', includeWan: false });
|
||||||
|
assert.match(line1, /→ dialed 7/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatCallBlock outbound connected shows device model', () => {
|
||||||
|
const call = {
|
||||||
|
direction: 'outbound',
|
||||||
|
start: '2026-07-24T14:17:00.000Z',
|
||||||
|
duration: 312,
|
||||||
|
endpointUser: 'Store 00482',
|
||||||
|
leftParty: { user: 'Store 00482', number: '50482', model: 'DBS-210-3PC' },
|
||||||
|
model: 'DBS-210-3PC',
|
||||||
|
calledNumber: '+17247795678',
|
||||||
|
disposition: 'Success',
|
||||||
|
normalOutcome: true,
|
||||||
|
abnormal: false,
|
||||||
|
};
|
||||||
|
const { line1 } = formatCallBlock(call, { timeZone: 'America/New_York', includeWan: false });
|
||||||
|
assert.match(line1, /Store 00482 \(50482, DBS-210-3PC\) → \+17247795678/);
|
||||||
|
assert.match(line1, /^✅ /);
|
||||||
|
});
|
||||||
135
tests/callReport.filterCallsByTarget.test.js
Normal file
135
tests/callReport.filterCallsByTarget.test.js
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
// tests/callReport.filterCallsByTarget.test.js
|
||||||
|
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildScopedCallFilter,
|
||||||
|
callMatchesFilter,
|
||||||
|
filterCallsByTarget,
|
||||||
|
rebucketCalls,
|
||||||
|
} from '../services/callReport/filterCallsByTarget.js';
|
||||||
|
import { CALL_BUCKETS } from '../services/callReport/groupCdrCalls.js';
|
||||||
|
|
||||||
|
const inboundToExtension = {
|
||||||
|
direction: 'inbound',
|
||||||
|
finalNumber: '7841',
|
||||||
|
endpointUser: 'Store 00782',
|
||||||
|
callingNumber: '+14125551234',
|
||||||
|
calledNumber: '+14123694426',
|
||||||
|
storeMainNumber: '+14123694426',
|
||||||
|
reachedPhone: true,
|
||||||
|
normalOutcome: true,
|
||||||
|
abnormal: false,
|
||||||
|
disposition: 'Success',
|
||||||
|
legs: [{
|
||||||
|
'Called number': '7841',
|
||||||
|
'User type': 'User',
|
||||||
|
User: 'Store 00782',
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
const inboundToOtherExtension = {
|
||||||
|
direction: 'inbound',
|
||||||
|
finalNumber: '52477',
|
||||||
|
endpointUser: 'Store 00782',
|
||||||
|
callingNumber: '+14125559999',
|
||||||
|
calledNumber: '+14123694426',
|
||||||
|
storeMainNumber: '+14123694426',
|
||||||
|
reachedPhone: true,
|
||||||
|
normalOutcome: true,
|
||||||
|
abnormal: false,
|
||||||
|
disposition: 'Success',
|
||||||
|
legs: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const outboundFromUser = {
|
||||||
|
direction: 'outbound',
|
||||||
|
endpointUser: 'Store 00782',
|
||||||
|
callingNumber: '50782',
|
||||||
|
calledNumber: '52027',
|
||||||
|
connected: true,
|
||||||
|
reachedPhone: true,
|
||||||
|
normalOutcome: true,
|
||||||
|
abnormal: false,
|
||||||
|
disposition: 'Success',
|
||||||
|
legs: [{
|
||||||
|
'User number': '50782',
|
||||||
|
User: 'Store 00782',
|
||||||
|
'Called number': '52027',
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
const johnInbound = {
|
||||||
|
direction: 'inbound',
|
||||||
|
callingNumber: '+17247795251',
|
||||||
|
calledNumber: '+17247799200',
|
||||||
|
storeMainNumber: '+17247799200',
|
||||||
|
finalNumber: '+19362978712',
|
||||||
|
endpointUser: 'John Skaggs',
|
||||||
|
legs: [{ 'Calling number': '+17247795251', 'Called number': '+17247799200', User: 'John Skaggs' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const userInbound = {
|
||||||
|
direction: 'inbound',
|
||||||
|
callingNumber: '+14125551234',
|
||||||
|
calledNumber: '+17247799200',
|
||||||
|
storeMainNumber: '+17247799200',
|
||||||
|
finalNumber: '+17247795574',
|
||||||
|
endpointUser: 'McQueen',
|
||||||
|
legs: [{ 'Called number': '5574', User: 'McQueen', 'User number': '+17247795574' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const sharedOutbound = {
|
||||||
|
direction: 'outbound',
|
||||||
|
endpointUser: 'WAR_1_LGW',
|
||||||
|
callingNumber: '+17247799200',
|
||||||
|
calledNumber: '12399',
|
||||||
|
storeMainNumber: '+17247799200',
|
||||||
|
leftParty: { user: 'WAR_1_LGW', number: '+17247799200' },
|
||||||
|
legs: [{ User: 'WAR_1_LGW', 'Calling number': '+17247799200', 'Called number': '12399' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
test('callMatchesFilter matches inbound final extension', () => {
|
||||||
|
const filter = buildScopedCallFilter({ kind: 'number', extension: '7841' });
|
||||||
|
assert.equal(callMatchesFilter(inboundToExtension, filter), true);
|
||||||
|
assert.equal(callMatchesFilter(inboundToOtherExtension, filter), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callMatchesFilter matches outbound user extension', () => {
|
||||||
|
const filter = buildScopedCallFilter({ kind: 'number', extension: '50782' });
|
||||||
|
assert.equal(callMatchesFilter(outboundFromUser, filter), true);
|
||||||
|
assert.equal(callMatchesFilter(inboundToExtension, filter), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callMatchesFilter excludes other users at same corporate location', () => {
|
||||||
|
const filter = buildScopedCallFilter({
|
||||||
|
kind: 'number',
|
||||||
|
phoneNumber: '+17247795574',
|
||||||
|
locationMain: '+17247799200',
|
||||||
|
});
|
||||||
|
assert.equal(callMatchesFilter(userInbound, filter), true);
|
||||||
|
assert.equal(callMatchesFilter(johnInbound, filter), false);
|
||||||
|
assert.equal(callMatchesFilter(sharedOutbound, filter), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filterCallsByTarget and rebucketCalls reduce summary counts', () => {
|
||||||
|
const calls = [inboundToExtension, inboundToOtherExtension, outboundFromUser];
|
||||||
|
const filter = buildScopedCallFilter({ kind: 'number', extension: '7841' });
|
||||||
|
filter.extensions.add('50782');
|
||||||
|
|
||||||
|
const filtered = filterCallsByTarget(calls, filter);
|
||||||
|
assert.equal(filtered.length, 2);
|
||||||
|
|
||||||
|
const rebucketed = rebucketCalls(filtered);
|
||||||
|
assert.equal(rebucketed.summary.total, 2);
|
||||||
|
assert.equal(rebucketed.summary.inbound, 1);
|
||||||
|
assert.equal(rebucketed.summary.outbound, 1);
|
||||||
|
assert.equal(rebucketed.buckets[CALL_BUCKETS.inboundReachedPhone].length, 1);
|
||||||
|
assert.equal(rebucketed.buckets[CALL_BUCKETS.outboundConnected].length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filterCallsByTarget is no-op without filter', () => {
|
||||||
|
const calls = [inboundToExtension, outboundFromUser];
|
||||||
|
assert.deepEqual(filterCallsByTarget(calls, null), calls);
|
||||||
|
});
|
||||||
122
tests/callReport.formatCallLine.test.js
Normal file
122
tests/callReport.formatCallLine.test.js
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
// tests/callReport.formatCallLine.test.js
|
||||||
|
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { groupCdrIntoCalls } from '../services/callReport/groupCdrCalls.js';
|
||||||
|
import { formatCallBlock, formatDurationHuman, formatLocalTime, formatWanLine } from '../services/callReport/formatCallLine.js';
|
||||||
|
|
||||||
|
const SAMPLE_LEGS = [
|
||||||
|
{
|
||||||
|
'Answer time': '2026-07-23T13:06:24.325Z',
|
||||||
|
Answered: 'true',
|
||||||
|
Direction: 'TERMINATING',
|
||||||
|
'Calling line ID': 'WIRELESS CALLER',
|
||||||
|
'Start time': '2026-07-23T13:06:24.316Z',
|
||||||
|
'Call type': 'SIP_INBOUND',
|
||||||
|
'Correlation ID': '3c7f5c7c-6f48-40f2-925c-133f6a84dcdb',
|
||||||
|
Duration: 184,
|
||||||
|
'Report ID': 'fd1debbf-d2b4-331c-846a-2f2a79de9b08',
|
||||||
|
'Site main number': '+12122194600',
|
||||||
|
'User type': 'AutomatedAttendantVideo',
|
||||||
|
User: 'Store 2477',
|
||||||
|
'Called number': '+12122194600',
|
||||||
|
'Calling number': '+17189864017',
|
||||||
|
'Call outcome': 'Success',
|
||||||
|
'Call outcome reason': 'Normal',
|
||||||
|
'Answer indicator': 'Yes',
|
||||||
|
'Auto Attendant Key Pressed': '1',
|
||||||
|
'Release time': '2026-07-23T13:09:28.784Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'Start time': '2026-07-23T13:06:26.570Z',
|
||||||
|
'Call type': 'SIP_ENTERPRISE',
|
||||||
|
'Correlation ID': '3c7f5c7c-6f48-40f2-925c-133f6a84dcdb',
|
||||||
|
'Report ID': '9541d7ea-8ced-3d39-a8ef-c2fc5e341700',
|
||||||
|
'Called number': '52477',
|
||||||
|
'Calling number': '+17189864017',
|
||||||
|
Duration: 134,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'Start time': '2026-07-23T13:06:26.739Z',
|
||||||
|
'Call type': 'SIP_ENTERPRISE',
|
||||||
|
'Client type': 'WXC_DEVICE',
|
||||||
|
'Correlation ID': '3c7f5c7c-6f48-40f2-925c-133f6a84dcdb',
|
||||||
|
'Report ID': '829fcf5b-975c-3f5d-9150-af4743232bd3',
|
||||||
|
'User type': 'User',
|
||||||
|
User: 'Store 02477',
|
||||||
|
Model: 'DBS-210-3PC',
|
||||||
|
'Called number': '52477',
|
||||||
|
'Calling number': '+17189864017',
|
||||||
|
'Call outcome': 'Success',
|
||||||
|
'Call outcome reason': 'Normal',
|
||||||
|
'Answer indicator': 'Yes',
|
||||||
|
Answered: 'true',
|
||||||
|
Duration: 134,
|
||||||
|
'Release time': '2026-07-23T13:09:28.784Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test('formatDurationHuman', () => {
|
||||||
|
assert.equal(formatDurationHuman(45), '45s');
|
||||||
|
assert.equal(formatDurationHuman(184), '3m 4s');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatLocalTime uses store timezone', () => {
|
||||||
|
const t = formatLocalTime('2026-07-23T13:06:24.316Z', 'America/New_York');
|
||||||
|
assert.match(t, /9:06am/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatCallBlock inbound with CLID arrow and final', () => {
|
||||||
|
const [call] = groupCdrIntoCalls(SAMPLE_LEGS);
|
||||||
|
call.wan = { mos: 4.1, jitter: 3.4, loss: 2.4 };
|
||||||
|
const { line1, line2 } = formatCallBlock(call, { timeZone: 'America/New_York', includeWan: true });
|
||||||
|
assert.match(line1, /9:06am \(3m 4s\):/);
|
||||||
|
assert.match(line1, /WIRELESS CALLER \(\+17189864017\)/);
|
||||||
|
assert.match(line1, /→ \+12122194600 \(final 52477, DBS-210-3PC\)/);
|
||||||
|
assert.match(line1, /^✅ /);
|
||||||
|
assert.doesNotMatch(line1, /Success$/);
|
||||||
|
assert.match(line2, /MOS: 4\.1/);
|
||||||
|
assert.match(line2, /Jitter: 3\.4ms/);
|
||||||
|
assert.match(line2, /Loss: 2\.4%/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatCallBlock abnormal shows warning icon and disposition', () => {
|
||||||
|
const [call] = groupCdrIntoCalls(SAMPLE_LEGS);
|
||||||
|
call.abnormal = true;
|
||||||
|
call.normalOutcome = false;
|
||||||
|
call.disposition = 'Failed / Busy';
|
||||||
|
call.wan = { mos: 4.1, jitter: 3.4, loss: 2.4 };
|
||||||
|
const { line1, line2 } = formatCallBlock(call, { timeZone: 'America/New_York', includeWan: false });
|
||||||
|
assert.match(line1, /^⚠️ /);
|
||||||
|
assert.match(line1, /Failed \/ Busy/);
|
||||||
|
assert.equal(line2, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatCallBlock AA-only uses warning icon without Success', () => {
|
||||||
|
const call = {
|
||||||
|
direction: 'inbound',
|
||||||
|
start: '2026-07-23T13:59:00.000Z',
|
||||||
|
duration: 12,
|
||||||
|
callingLineId: 'PITTSBURGH PA',
|
||||||
|
callingNumber: '+14122020183',
|
||||||
|
storeMainNumber: '+14123694426',
|
||||||
|
calledNumber: '+14123694426',
|
||||||
|
reachedAttendant: true,
|
||||||
|
reachedPhone: false,
|
||||||
|
aaKeyPress: 'No Selection',
|
||||||
|
disposition: 'Success',
|
||||||
|
normalOutcome: true,
|
||||||
|
abnormal: false,
|
||||||
|
};
|
||||||
|
const { line1 } = formatCallBlock(call, { timeZone: 'America/New_York', includeWan: false });
|
||||||
|
assert.match(line1, /^⚠️ /);
|
||||||
|
assert.match(line1, /AA key No Selection/);
|
||||||
|
assert.doesNotMatch(line1, /Success/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatWanLine flags MOS and loss thresholds', () => {
|
||||||
|
assert.match(formatWanLine({ mos: 3.6, jitter: 0, loss: 9.8 }), /MOS: 3\.6 ⚠️/);
|
||||||
|
assert.match(formatWanLine({ mos: 3.4, jitter: 0, loss: 0 }), /MOS: 3\.4 ‼️/);
|
||||||
|
assert.match(formatWanLine({ mos: 4.4, jitter: 45, loss: 2 }), /Jitter: 45ms ⚠️/);
|
||||||
|
assert.match(formatWanLine({ mos: 4.4, jitter: 0, loss: 6 }), /Loss: 6% ⚠️/);
|
||||||
|
});
|
||||||
|
|
@ -1,18 +1,21 @@
|
||||||
// tests/voiceReport.groupCdrCalls.test.js
|
// tests/callReport.groupCdrCalls.test.js
|
||||||
|
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
|
assignCallBucket,
|
||||||
groupCdrIntoCalls,
|
groupCdrIntoCalls,
|
||||||
summarizeCalls,
|
summarizeCalls,
|
||||||
classifyCallDirection,
|
classifyCallDirection,
|
||||||
} from '../services/voiceReport/groupCdrCalls.js';
|
CALL_BUCKETS,
|
||||||
|
} from '../services/callReport/groupCdrCalls.js';
|
||||||
|
|
||||||
const SAMPLE_LEGS = [
|
const SAMPLE_LEGS = [
|
||||||
{
|
{
|
||||||
'Answer time': '2026-07-23T13:06:24.325Z',
|
'Answer time': '2026-07-23T13:06:24.325Z',
|
||||||
Answered: 'true',
|
Answered: 'true',
|
||||||
Direction: 'TERMINATING',
|
Direction: 'TERMINATING',
|
||||||
|
'Calling line ID': 'WIRELESS CALLER',
|
||||||
'Start time': '2026-07-23T13:06:24.316Z',
|
'Start time': '2026-07-23T13:06:24.316Z',
|
||||||
'Call type': 'SIP_INBOUND',
|
'Call type': 'SIP_INBOUND',
|
||||||
'Correlation ID': '3c7f5c7c-6f48-40f2-925c-133f6a84dcdb',
|
'Correlation ID': '3c7f5c7c-6f48-40f2-925c-133f6a84dcdb',
|
||||||
|
|
@ -21,6 +24,7 @@ const SAMPLE_LEGS = [
|
||||||
'User type': 'AutomatedAttendantVideo',
|
'User type': 'AutomatedAttendantVideo',
|
||||||
User: 'Store 2477',
|
User: 'Store 2477',
|
||||||
'Called number': '+12122194600',
|
'Called number': '+12122194600',
|
||||||
|
'Site main number': '+12122194600',
|
||||||
'Calling number': '+17189864017',
|
'Calling number': '+17189864017',
|
||||||
'Call outcome': 'Success',
|
'Call outcome': 'Success',
|
||||||
'Call outcome reason': 'Normal',
|
'Call outcome reason': 'Normal',
|
||||||
|
|
@ -78,6 +82,11 @@ test('groupCdrIntoCalls collapses legs with same correlation ID', () => {
|
||||||
assert.equal(calls[0].reachedPhone, true);
|
assert.equal(calls[0].reachedPhone, true);
|
||||||
assert.equal(calls[0].endpointUser, 'Store 02477');
|
assert.equal(calls[0].endpointUser, 'Store 02477');
|
||||||
assert.equal(calls[0].aaKeyPress, '1');
|
assert.equal(calls[0].aaKeyPress, '1');
|
||||||
|
assert.equal(calls[0].callingLineId, 'WIRELESS CALLER');
|
||||||
|
assert.equal(calls[0].finalNumber, '52477');
|
||||||
|
assert.equal(calls[0].model, 'DBS-210-3PC');
|
||||||
|
assert.equal(calls[0].storeMainNumber, '+12122194600');
|
||||||
|
assert.equal(assignCallBucket(calls[0]), CALL_BUCKETS.inboundReachedPhone);
|
||||||
assert.equal(calls[0].normalOutcome, true);
|
assert.equal(calls[0].normalOutcome, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
184
tests/callReport.join.test.js
Normal file
184
tests/callReport.join.test.js
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
// tests/callReport.join.test.js
|
||||||
|
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { buildScopedCallFilter } from '../services/callReport/filterCallsByTarget.js';
|
||||||
|
import { joinCallQuality, worstPrismaBucketForCall } from '../services/callReport/joinCallQuality.js';
|
||||||
|
import { CALL_BUCKETS } from '../services/callReport/groupCdrCalls.js';
|
||||||
|
|
||||||
|
const window = {
|
||||||
|
startTime: '2026-07-23T13:00:00.000Z',
|
||||||
|
endTime: '2026-07-24T01:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
const INBOUND_PHONE_LEGS = [
|
||||||
|
{
|
||||||
|
'Start time': '2026-07-23T13:00:00.000Z',
|
||||||
|
'Release time': '2026-07-23T13:05:00.000Z',
|
||||||
|
'Call type': 'SIP_INBOUND',
|
||||||
|
'Correlation ID': 'corr-1',
|
||||||
|
'Report ID': 'r1',
|
||||||
|
'Calling number': '+15551234567',
|
||||||
|
'Called number': '+12122194600',
|
||||||
|
'Site main number': '+12122194600',
|
||||||
|
'Call outcome': 'Success',
|
||||||
|
'Call outcome reason': 'Normal',
|
||||||
|
'Answer indicator': 'Yes',
|
||||||
|
Answered: 'true',
|
||||||
|
Duration: 300,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'Start time': '2026-07-23T13:00:30.000Z',
|
||||||
|
'Call type': 'SIP_ENTERPRISE',
|
||||||
|
'Correlation ID': 'corr-1',
|
||||||
|
'Report ID': 'r2',
|
||||||
|
'User type': 'User',
|
||||||
|
User: 'Store 02477',
|
||||||
|
Model: 'DBS-210-3PC',
|
||||||
|
'Called number': '52477',
|
||||||
|
'Calling number': '+15551234567',
|
||||||
|
'Call outcome': 'Success',
|
||||||
|
'Call outcome reason': 'Normal',
|
||||||
|
'Answer indicator': 'Yes',
|
||||||
|
Answered: 'true',
|
||||||
|
Duration: 270,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test('joinCallQuality groups legs into correlated calls', () => {
|
||||||
|
const joined = joinCallQuality({
|
||||||
|
cdrItems: [
|
||||||
|
...INBOUND_PHONE_LEGS,
|
||||||
|
{
|
||||||
|
Direction: 'Outbound',
|
||||||
|
'Start time': '2026-07-23T15:00:00.000Z',
|
||||||
|
Duration: 10,
|
||||||
|
'Answer indicator': 'No',
|
||||||
|
'Report ID': 'r3',
|
||||||
|
'Call type': 'SIP_OUTBOUND',
|
||||||
|
'Call outcome': 'Failed',
|
||||||
|
'Call outcome reason': 'Busy',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
appAudio: null,
|
||||||
|
window,
|
||||||
|
});
|
||||||
|
assert.equal(joined.summary.total, 2);
|
||||||
|
assert.equal(joined.summary.inbound, 1);
|
||||||
|
assert.equal(joined.summary.outbound, 1);
|
||||||
|
assert.equal(joined.summary.abnormal, 1);
|
||||||
|
assert.equal(joined.abnormalCalls.length, 1);
|
||||||
|
assert.equal(joined.buckets[CALL_BUCKETS.inboundReachedPhone].length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('joinCallQuality attaches WAN only for answered inbound', () => {
|
||||||
|
const appAudio = {
|
||||||
|
mos: { values: [4.4, 4.4, 3.1, 4.4, 4.4] },
|
||||||
|
loss: { values: [0, 0, 2, 0, 0] },
|
||||||
|
jitter: { values: [5, 5, 5, 5, 5] },
|
||||||
|
};
|
||||||
|
const joined = joinCallQuality({
|
||||||
|
cdrItems: INBOUND_PHONE_LEGS,
|
||||||
|
appAudio,
|
||||||
|
window,
|
||||||
|
});
|
||||||
|
assert.ok(joined.calls[0].wan);
|
||||||
|
assert.equal(joined.calls[0].wan.mos, 4.4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('joinCallQuality skips WAN for unanswered outbound', () => {
|
||||||
|
const appAudio = {
|
||||||
|
mos: { values: [4.0, 4.0, 4.0] },
|
||||||
|
loss: { values: [0, 0, 0] },
|
||||||
|
jitter: { values: [5, 5, 5] },
|
||||||
|
};
|
||||||
|
const joined = joinCallQuality({
|
||||||
|
cdrItems: [{
|
||||||
|
'Start time': '2026-07-23T15:00:00.000Z',
|
||||||
|
'Report ID': 'r4',
|
||||||
|
'Call type': 'SIP_OUTBOUND',
|
||||||
|
'Call outcome': 'Failed',
|
||||||
|
'Call outcome reason': 'Busy',
|
||||||
|
'Answer indicator': 'No',
|
||||||
|
}],
|
||||||
|
appAudio,
|
||||||
|
window,
|
||||||
|
});
|
||||||
|
assert.equal(joined.calls[0].wan, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('worstPrismaBucketForCall picks low MOS bucket', () => {
|
||||||
|
const call = {
|
||||||
|
start: '2026-07-23T13:05:00.000Z',
|
||||||
|
duration: 600,
|
||||||
|
};
|
||||||
|
const appAudio = {
|
||||||
|
mos: { values: [4.4, 4.4, 3.1, 4.4, 4.4] },
|
||||||
|
loss: { values: [0, 0, 2, 0, 0] },
|
||||||
|
jitter: { values: [5, 5, 5, 5, 5] },
|
||||||
|
};
|
||||||
|
const w = worstPrismaBucketForCall(call, appAudio, window);
|
||||||
|
assert.ok(w);
|
||||||
|
assert.equal(w.mos, 3.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('worstPrismaBucketForCall matches Prisma datapoint timestamps', () => {
|
||||||
|
const call = {
|
||||||
|
start: '2026-07-23T15:18:00.000Z',
|
||||||
|
duration: 455,
|
||||||
|
};
|
||||||
|
const appAudio = {
|
||||||
|
mos: {
|
||||||
|
interval: '5min',
|
||||||
|
values: [4.4, 4.3, 3.1],
|
||||||
|
timedPoints: [
|
||||||
|
{ time: '2026-07-23T13:00:00.000Z', value: 4.4 },
|
||||||
|
{ time: '2026-07-23T13:05:00.000Z', value: null },
|
||||||
|
{ time: '2026-07-23T15:15:00.000Z', value: 4.3 },
|
||||||
|
{ time: '2026-07-23T15:20:00.000Z', value: 3.1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
loss: {
|
||||||
|
timedPoints: [
|
||||||
|
{ time: '2026-07-23T15:15:00.000Z', value: 0 },
|
||||||
|
{ time: '2026-07-23T15:20:00.000Z', value: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
jitter: {
|
||||||
|
timedPoints: [
|
||||||
|
{ time: '2026-07-23T15:15:00.000Z', value: 1.2 },
|
||||||
|
{ time: '2026-07-23T15:20:00.000Z', value: 3.4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const w = worstPrismaBucketForCall(call, appAudio, window);
|
||||||
|
assert.ok(w);
|
||||||
|
assert.equal(w.mos, 3.1);
|
||||||
|
assert.equal(w.loss, 2);
|
||||||
|
assert.equal(w.jitter, 3.4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('joinCallQuality filters scoped calls when filter provided', () => {
|
||||||
|
const joined = joinCallQuality({
|
||||||
|
cdrItems: [
|
||||||
|
...INBOUND_PHONE_LEGS,
|
||||||
|
{
|
||||||
|
Direction: 'Outbound',
|
||||||
|
'Start time': '2026-07-23T15:00:00.000Z',
|
||||||
|
Duration: 10,
|
||||||
|
'Answer indicator': 'No',
|
||||||
|
'Report ID': 'r3',
|
||||||
|
'Call type': 'SIP_OUTBOUND',
|
||||||
|
'Call outcome': 'Failed',
|
||||||
|
'Call outcome reason': 'Busy',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
appAudio: null,
|
||||||
|
window,
|
||||||
|
filter: buildScopedCallFilter({ kind: 'number', extension: '52477' }),
|
||||||
|
});
|
||||||
|
assert.equal(joined.summary.total, 1);
|
||||||
|
assert.equal(joined.summary.inbound, 1);
|
||||||
|
assert.equal(joined.summary.outbound, 0);
|
||||||
|
});
|
||||||
154
tests/callReport.renderer.test.js
Normal file
154
tests/callReport.renderer.test.js
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
// tests/callReport.renderer.test.js
|
||||||
|
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { renderCallReportMarkdown } from '../services/renderers/callReportRenderer.js';
|
||||||
|
import { CALL_BUCKETS } from '../services/callReport/groupCdrCalls.js';
|
||||||
|
|
||||||
|
const sampleCall = {
|
||||||
|
start: '2026-07-23T13:06:24.316Z',
|
||||||
|
duration: 184,
|
||||||
|
direction: 'inbound',
|
||||||
|
callingLineId: 'WIRELESS CALLER',
|
||||||
|
callingNumber: '+17189864017',
|
||||||
|
calledNumber: '+12122194600',
|
||||||
|
storeMainNumber: '+12122194600',
|
||||||
|
finalNumber: '52477',
|
||||||
|
model: 'DBS-210-3PC',
|
||||||
|
disposition: 'Success',
|
||||||
|
outcome: 'Success',
|
||||||
|
reachedPhone: true,
|
||||||
|
normalOutcome: true,
|
||||||
|
abnormal: false,
|
||||||
|
legCount: 3,
|
||||||
|
wan: { mos: 4.1, jitter: 3.4, loss: 2.4 },
|
||||||
|
};
|
||||||
|
|
||||||
|
test('renderCallReportMarkdown summary and sections', () => {
|
||||||
|
const md = renderCallReportMarkdown({
|
||||||
|
ok: true,
|
||||||
|
store: { storeNum: '782', locationName: 'Store 0782', dialNumber: '+17247795574', kind: 'store' },
|
||||||
|
window: {
|
||||||
|
label: '2026-07-23',
|
||||||
|
mode: 'fullDay',
|
||||||
|
timeZone: 'America/New_York',
|
||||||
|
startTime: '2026-07-23T13:00:00.000Z',
|
||||||
|
endTime: '2026-07-24T01:00:00.000Z',
|
||||||
|
},
|
||||||
|
cdr: { available: true, rawCount: 10 },
|
||||||
|
prisma: {
|
||||||
|
available: true,
|
||||||
|
appAudio: { appName: 'Webex_Calling_RTP', mos: { min: 2.7, max: 4.41 }, detailsUrl: 'https://example.com' },
|
||||||
|
},
|
||||||
|
joined: {
|
||||||
|
summary: {
|
||||||
|
total: 2,
|
||||||
|
inbound: 2,
|
||||||
|
outbound: 0,
|
||||||
|
inboundReachedPhone: 1,
|
||||||
|
inboundAaOnly: 0,
|
||||||
|
outboundConnected: 0,
|
||||||
|
abnormal: 1,
|
||||||
|
outcomes: { Success: 1, 'Failed / Busy': 1 },
|
||||||
|
},
|
||||||
|
buckets: {
|
||||||
|
[CALL_BUCKETS.inboundReachedPhone]: [sampleCall],
|
||||||
|
[CALL_BUCKETS.inboundAaOnly]: [],
|
||||||
|
[CALL_BUCKETS.outboundConnected]: [],
|
||||||
|
[CALL_BUCKETS.other]: [],
|
||||||
|
},
|
||||||
|
abnormalCalls: [{
|
||||||
|
...sampleCall,
|
||||||
|
abnormal: true,
|
||||||
|
normalOutcome: false,
|
||||||
|
disposition: 'Failed / Busy',
|
||||||
|
wan: { mos: 4.1, jitter: 3.4, loss: 2.4 },
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
elapsedMs: 42,
|
||||||
|
});
|
||||||
|
assert.match(md, /Call report — Store 782/);
|
||||||
|
assert.match(md, /Total calls.*2/);
|
||||||
|
assert.match(md, /Inbound — reached a phone/);
|
||||||
|
assert.match(md, /\*\*Abnormal outcomes:\*\* 1/);
|
||||||
|
assert.doesNotMatch(md, /### Abnormal outcomes/);
|
||||||
|
assert.doesNotMatch(md, /Compact index/);
|
||||||
|
assert.doesNotMatch(md, /\*\*Scope:\*\*/);
|
||||||
|
assert.match(md, /✅ 9:06am/);
|
||||||
|
assert.match(md, /WIRELESS CALLER/);
|
||||||
|
assert.match(md, /\*\*Worst MOS in window:\*\* 2\.7/);
|
||||||
|
assert.match(md, /\*\*Best MOS:\*\* 4\.41/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderCallReportMarkdown shows scoped user header', () => {
|
||||||
|
const md = renderCallReportMarkdown({
|
||||||
|
ok: true,
|
||||||
|
target: {
|
||||||
|
kind: 'user',
|
||||||
|
label: 'mcqueenj@ae.com',
|
||||||
|
email: 'mcqueenj@ae.com',
|
||||||
|
storeNum: '782',
|
||||||
|
locationName: 'Store 0782',
|
||||||
|
dialNumber: '+14123694426',
|
||||||
|
filter: { kind: 'user', email: 'mcqueenj@ae.com' },
|
||||||
|
},
|
||||||
|
store: {
|
||||||
|
kind: 'user',
|
||||||
|
label: 'mcqueenj@ae.com',
|
||||||
|
email: 'mcqueenj@ae.com',
|
||||||
|
storeNum: '782',
|
||||||
|
locationName: 'Store 0782',
|
||||||
|
dialNumber: '+14123694426',
|
||||||
|
},
|
||||||
|
window: {
|
||||||
|
label: '2026-07-23',
|
||||||
|
mode: 'fullDay',
|
||||||
|
timeZone: 'America/New_York',
|
||||||
|
startTime: '2026-07-23T13:00:00.000Z',
|
||||||
|
endTime: '2026-07-24T01:00:00.000Z',
|
||||||
|
},
|
||||||
|
cdr: { available: true, rawCount: 5 },
|
||||||
|
prisma: { available: false },
|
||||||
|
joined: {
|
||||||
|
summary: { total: 0, inbound: 0, outbound: 0, outcomes: {} },
|
||||||
|
buckets: {
|
||||||
|
[CALL_BUCKETS.inboundReachedPhone]: [],
|
||||||
|
[CALL_BUCKETS.inboundAaOnly]: [],
|
||||||
|
[CALL_BUCKETS.outboundConnected]: [],
|
||||||
|
[CALL_BUCKETS.other]: [],
|
||||||
|
},
|
||||||
|
abnormalCalls: [],
|
||||||
|
},
|
||||||
|
elapsedMs: 12,
|
||||||
|
});
|
||||||
|
assert.match(md, /Call report — mcqueenj@ae\.com/);
|
||||||
|
assert.match(md, /\*\*Scope:\*\* user mcqueenj@ae\.com/);
|
||||||
|
assert.match(md, /No calls matched scope/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderCallReportMarkdown truncates per section', () => {
|
||||||
|
const calls = Array.from({ length: 30 }, (_, i) => ({
|
||||||
|
...sampleCall,
|
||||||
|
start: `2026-07-23T14:${String(i).padStart(2, '0')}:00.000Z`,
|
||||||
|
}));
|
||||||
|
const md = renderCallReportMarkdown({
|
||||||
|
ok: true,
|
||||||
|
store: { storeNum: '782', locationName: 'Store 0782' },
|
||||||
|
window: { label: '2026-07-23', mode: 'fullDay', timeZone: 'America/New_York', startTime: 'x', endTime: 'y' },
|
||||||
|
cdr: { available: true },
|
||||||
|
prisma: { available: false },
|
||||||
|
joined: {
|
||||||
|
summary: { total: 30, inbound: 30, outbound: 0, outcomes: {} },
|
||||||
|
buckets: {
|
||||||
|
[CALL_BUCKETS.inboundReachedPhone]: calls,
|
||||||
|
[CALL_BUCKETS.inboundAaOnly]: [],
|
||||||
|
[CALL_BUCKETS.outboundConnected]: [],
|
||||||
|
[CALL_BUCKETS.other]: [],
|
||||||
|
},
|
||||||
|
abnormalCalls: [],
|
||||||
|
},
|
||||||
|
elapsedMs: 1,
|
||||||
|
});
|
||||||
|
assert.match(md, /more in this section/);
|
||||||
|
});
|
||||||
74
tests/callReport.resolveTarget.test.js
Normal file
74
tests/callReport.resolveTarget.test.js
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
// tests/callReport.resolveTarget.test.js
|
||||||
|
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import {
|
||||||
|
parseCallReportTarget,
|
||||||
|
parseStoreNumFromLocationName,
|
||||||
|
} from '../services/callReport/parseTarget.js';
|
||||||
|
import {
|
||||||
|
buildMatchKeysForNumber,
|
||||||
|
buildMatchKeysForPerson,
|
||||||
|
} from '../services/callTest/locationResolver.js';
|
||||||
|
|
||||||
|
test('parseCallReportTarget recognizes store numbers', () => {
|
||||||
|
assert.deepEqual(parseCallReportTarget('782'), { kind: 'store', storeNum: '782' });
|
||||||
|
assert.deepEqual(parseCallReportTarget('24'), { kind: 'store', storeNum: '24' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseCallReportTarget recognizes email addresses', () => {
|
||||||
|
assert.deepEqual(parseCallReportTarget('mcqueenj@ae.com'), {
|
||||||
|
kind: 'user',
|
||||||
|
email: 'mcqueenj@ae.com',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseCallReportTarget recognizes phone numbers', () => {
|
||||||
|
assert.deepEqual(parseCallReportTarget('7247795574'), {
|
||||||
|
kind: 'number',
|
||||||
|
phoneNumber: '+17247795574',
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseCallReportTarget('+17247795574'), {
|
||||||
|
kind: 'number',
|
||||||
|
phoneNumber: '+17247795574',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseCallReportTarget rejects invalid tokens', () => {
|
||||||
|
assert.equal(parseCallReportTarget(''), null);
|
||||||
|
assert.equal(parseCallReportTarget('50782'), null);
|
||||||
|
assert.equal(parseCallReportTarget('not-a-target'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseStoreNumFromLocationName extracts store digits', () => {
|
||||||
|
assert.equal(parseStoreNumFromLocationName('Store 0782'), '782');
|
||||||
|
assert.equal(parseStoreNumFromLocationName('store 2477'), '2477');
|
||||||
|
assert.equal(parseStoreNumFromLocationName('HQ'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildMatchKeysForNumber includes phone and extension', () => {
|
||||||
|
const keys = buildMatchKeysForNumber({
|
||||||
|
phoneNumber: '+17247795574',
|
||||||
|
extension: '7841',
|
||||||
|
owner: { name: 'Jane Doe' },
|
||||||
|
});
|
||||||
|
assert.ok(keys.has('17247795574'));
|
||||||
|
assert.ok(keys.has('7841'));
|
||||||
|
assert.ok(keys.has('jane doe'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildMatchKeysForPerson includes email and owned numbers', () => {
|
||||||
|
const keys = buildMatchKeysForPerson(
|
||||||
|
{
|
||||||
|
displayName: 'Jane Doe',
|
||||||
|
emails: ['jane@ae.com'],
|
||||||
|
phoneNumbers: [{ value: '+15551234567' }],
|
||||||
|
},
|
||||||
|
[{ phoneNumber: '+15559876543', extension: '50782' }],
|
||||||
|
);
|
||||||
|
assert.ok(keys.has('jane@ae.com'));
|
||||||
|
assert.ok(keys.has('jane doe'));
|
||||||
|
assert.ok(keys.has('50782'));
|
||||||
|
assert.ok(keys.has('15551234567'));
|
||||||
|
});
|
||||||
46
tests/cdrFeedQueue.test.js
Normal file
46
tests/cdrFeedQueue.test.js
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
// tests/cdrFeedQueue.test.js
|
||||||
|
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import {
|
||||||
|
enqueueCdrFeedFetch,
|
||||||
|
_resetCdrFeedQueueForTests,
|
||||||
|
_setCdrFeedCooldownForTests,
|
||||||
|
} from '../services/cdrFeedQueue.js';
|
||||||
|
|
||||||
|
test.afterEach(() => {
|
||||||
|
_resetCdrFeedQueueForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('enqueueCdrFeedFetch runs immediately when idle', async () => {
|
||||||
|
let ran = false;
|
||||||
|
await enqueueCdrFeedFetch(async () => { ran = true; return 1; });
|
||||||
|
assert.equal(ran, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('enqueueCdrFeedFetch queues second call and invokes onQueued', async () => {
|
||||||
|
_setCdrFeedCooldownForTests(150);
|
||||||
|
|
||||||
|
let releaseFirst;
|
||||||
|
const firstGate = new Promise((resolve) => { releaseFirst = resolve; });
|
||||||
|
|
||||||
|
const first = enqueueCdrFeedFetch(async () => {
|
||||||
|
await firstGate;
|
||||||
|
return 'first';
|
||||||
|
});
|
||||||
|
|
||||||
|
let queuedInfo = null;
|
||||||
|
const second = enqueueCdrFeedFetch(async () => 'second', {
|
||||||
|
onQueued: (info) => { queuedInfo = info; },
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
assert.ok(queuedInfo, 'onQueued should fire for second caller');
|
||||||
|
assert.ok(queuedInfo.waitMs >= 100, `expected meaningful wait, got ${queuedInfo.waitMs}`);
|
||||||
|
assert.equal(queuedInfo.position, 2);
|
||||||
|
|
||||||
|
releaseFirst();
|
||||||
|
assert.equal(await first, 'first');
|
||||||
|
assert.equal(await second, 'second');
|
||||||
|
});
|
||||||
117
tests/jiraPoller.enrichmentRules.test.js
Normal file
117
tests/jiraPoller.enrichmentRules.test.js
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
// tests/jiraPoller.enrichmentRules.test.js
|
||||||
|
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolveEnrichmentPlan,
|
||||||
|
extractStoreFromSummary,
|
||||||
|
CHECK_IDS,
|
||||||
|
COMM_SERVICES_COMPONENT,
|
||||||
|
} from '../services/jiraPoller/enrichmentRules.js';
|
||||||
|
|
||||||
|
const comm = [{ name: COMM_SERVICES_COMPONENT }];
|
||||||
|
const mobility = [{ name: 'Mobility' }];
|
||||||
|
|
||||||
|
test('extractStoreFromSummary parses Store NNNN', () => {
|
||||||
|
assert.equal(extractStoreFromSummary('Store 2477 - garbled calls'), '2477');
|
||||||
|
assert.equal(extractStoreFromSummary('no store here'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('comm call quality runs phonestatus and callreport', () => {
|
||||||
|
const plan = resolveEnrichmentPlan(
|
||||||
|
{
|
||||||
|
summary: 'Store 2477 - garbled voice on inbound calls',
|
||||||
|
description: '',
|
||||||
|
components: comm,
|
||||||
|
},
|
||||||
|
{ kind: 'phone', storeNum: '2477', reason: 'desk phone issue' },
|
||||||
|
);
|
||||||
|
assert.equal(plan.skip, false);
|
||||||
|
assert.deepEqual(plan.checks, [CHECK_IDS.phonestatus, CHECK_IDS.callreport]);
|
||||||
|
assert.ok(plan.matchedRules.includes('comm-call-quality'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('comm spam runs callreport only', () => {
|
||||||
|
const plan = resolveEnrichmentPlan(
|
||||||
|
{
|
||||||
|
summary: 'Store 782 - spam robocalls to main number',
|
||||||
|
description: '',
|
||||||
|
components: comm,
|
||||||
|
},
|
||||||
|
{ kind: 'phone', storeNum: '782', reason: 'spam calls' },
|
||||||
|
);
|
||||||
|
assert.deepEqual(plan.checks, [CHECK_IDS.callreport]);
|
||||||
|
assert.ok(plan.matchedRules.includes('comm-spam'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('comm spam with both symptoms runs phonestatus and callreport', () => {
|
||||||
|
const plan = resolveEnrichmentPlan(
|
||||||
|
{
|
||||||
|
summary: 'Store 100 - spam and garbled audio',
|
||||||
|
components: comm,
|
||||||
|
},
|
||||||
|
{ kind: 'phone', storeNum: '100', reason: 'x' },
|
||||||
|
);
|
||||||
|
assert.deepEqual(plan.checks, [CHECK_IDS.phonestatus, CHECK_IDS.callreport]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobility spam does not trigger comm spam rule', () => {
|
||||||
|
const plan = resolveEnrichmentPlan(
|
||||||
|
{
|
||||||
|
summary: 'Store 500 - spam calls',
|
||||||
|
components: mobility,
|
||||||
|
},
|
||||||
|
{ kind: 'phone', storeNum: '500', reason: 'phone' },
|
||||||
|
);
|
||||||
|
assert.deepEqual(plan.checks, [CHECK_IDS.phonestatus]);
|
||||||
|
assert.deepEqual(plan.matchedRules, ['default-phone']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default phone kind runs phonestatus only', () => {
|
||||||
|
const plan = resolveEnrichmentPlan(
|
||||||
|
{
|
||||||
|
summary: 'Store 300 - phone not registering',
|
||||||
|
components: comm,
|
||||||
|
},
|
||||||
|
{ kind: 'phone', storeNum: '300', reason: 'registration' },
|
||||||
|
);
|
||||||
|
assert.deepEqual(plan.checks, [CHECK_IDS.phonestatus]);
|
||||||
|
assert.deepEqual(plan.matchedRules, ['default-phone']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default av kind runs avstatus', () => {
|
||||||
|
const plan = resolveEnrichmentPlan(
|
||||||
|
{
|
||||||
|
summary: 'Store 400 - signage offline',
|
||||||
|
components: [{ name: 'Audio Visual' }],
|
||||||
|
},
|
||||||
|
{ kind: 'av', storeNum: '400', reason: 'signage' },
|
||||||
|
);
|
||||||
|
assert.deepEqual(plan.checks, [CHECK_IDS.avstatus]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('comm symptom with store from summary when AI has no store', () => {
|
||||||
|
const plan = resolveEnrichmentPlan(
|
||||||
|
{
|
||||||
|
summary: 'Store 2477 - static on all calls',
|
||||||
|
components: comm,
|
||||||
|
},
|
||||||
|
{ kind: 'skip', storeNum: null, reason: 'unclear' },
|
||||||
|
);
|
||||||
|
assert.equal(plan.storeNum, '2477');
|
||||||
|
assert.equal(plan.skip, false);
|
||||||
|
assert.ok(plan.checks.includes(CHECK_IDS.callreport));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('comm symptom without store skips', () => {
|
||||||
|
const plan = resolveEnrichmentPlan(
|
||||||
|
{
|
||||||
|
summary: 'Garbled calls - no store listed',
|
||||||
|
components: comm,
|
||||||
|
},
|
||||||
|
{ kind: 'skip', storeNum: null, reason: 'no store' },
|
||||||
|
);
|
||||||
|
assert.equal(plan.skip, true);
|
||||||
|
assert.equal(plan.checks.length, 0);
|
||||||
|
});
|
||||||
22
tests/jiraPoller.runEnrichment.test.js
Normal file
22
tests/jiraPoller.runEnrichment.test.js
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
// tests/jiraPoller.runEnrichment.test.js
|
||||||
|
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { formatEnrichmentBody } from '../services/jiraPoller/formatBody.js';
|
||||||
|
import { CHECK_IDS } from '../services/jiraPoller/enrichmentRules.js';
|
||||||
|
|
||||||
|
test('formatEnrichmentBody merges sections with headings', () => {
|
||||||
|
const body = formatEnrichmentBody([
|
||||||
|
{ title: 'Phone status', markdown: '**Phones OK**' },
|
||||||
|
{ title: 'Call report (today)', markdown: '**Calls: 5**' },
|
||||||
|
]);
|
||||||
|
assert.match(body, /## Phone status\n\n\*\*Phones OK\*\*/);
|
||||||
|
assert.match(body, /## Call report \(today\)\n\n\*\*Calls: 5\*\*/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CHECK_IDS covers expected checks', () => {
|
||||||
|
assert.equal(CHECK_IDS.phonestatus, 'phonestatus');
|
||||||
|
assert.equal(CHECK_IDS.callreport, 'callreport');
|
||||||
|
assert.equal(CHECK_IDS.avstatus, 'avstatus');
|
||||||
|
});
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { extractCdrFeedList } from '../services/cdrFeedParser.js';
|
import { extractCdrFeedList, parseCdrFeedLinkNext, resolveCdrFeedNextPage } from '../services/cdrFeedParser.js';
|
||||||
import {
|
import {
|
||||||
cdrDedupKey,
|
cdrDedupKey,
|
||||||
cdrStartTime,
|
cdrStartTime,
|
||||||
cdrCallingNumber,
|
cdrCallingNumber,
|
||||||
} from '../services/cdrFeedParser.js';
|
} from '../services/cdrFeedParser.js';
|
||||||
import { filterCdrToWindow } from '../services/voiceReport/joinCallQuality.js';
|
import { filterCdrToWindow } from '../services/callReport/joinCallQuality.js';
|
||||||
|
|
||||||
test('extractCdrFeedList handles items array', () => {
|
test('extractCdrFeedList handles items array', () => {
|
||||||
const rows = [{ id: 1 }, { id: 2 }];
|
const rows = [{ id: 1 }, { id: 2 }];
|
||||||
|
|
@ -59,3 +59,33 @@ test('filterCdrToWindow keeps API rows when start time is report-labeled', () =>
|
||||||
];
|
];
|
||||||
assert.equal(filterCdrToWindow(rows, window).length, 2);
|
assert.equal(filterCdrToWindow(rows, window).length, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseCdrFeedLinkNext reads rel=next from Link header', () => {
|
||||||
|
const link = '<https://analytics-calling.webexapis.com/v1/cdr_feed?startTimeForNextFetch=2025-08-15T09:30:00.000Z&max=5000>; rel="next"';
|
||||||
|
assert.equal(
|
||||||
|
parseCdrFeedLinkNext(link),
|
||||||
|
'https://analytics-calling.webexapis.com/v1/cdr_feed?startTimeForNextFetch=2025-08-15T09:30:00.000Z&max=5000',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveCdrFeedNextPage prefers Link header over body token', () => {
|
||||||
|
const baseUrl = 'https://analytics-calling.webexapis.com/v1/cdr_feed';
|
||||||
|
const baseParams = { startTime: 'a', endTime: 'b', locations: 'Store 0782', max: 5000 };
|
||||||
|
const next = resolveCdrFeedNextPage(
|
||||||
|
{ next: 'cursor-token' },
|
||||||
|
{ link: '<https://example.com/page2>; rel="next"' },
|
||||||
|
baseUrl,
|
||||||
|
baseParams,
|
||||||
|
);
|
||||||
|
assert.deepEqual(next, { url: 'https://example.com/page2', params: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveCdrFeedNextPage falls back to body next cursor', () => {
|
||||||
|
const baseUrl = 'https://analytics-calling.webexapis.com/v1/cdr_feed';
|
||||||
|
const baseParams = { startTime: 'a', endTime: 'b', locations: 'Store 0782', max: 5000 };
|
||||||
|
const next = resolveCdrFeedNextPage({ next: 'cursor-token' }, {}, baseUrl, baseParams);
|
||||||
|
assert.deepEqual(next, {
|
||||||
|
url: baseUrl,
|
||||||
|
params: { ...baseParams, next: 'cursor-token' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
// tests/voiceReport.join.test.js
|
|
||||||
|
|
||||||
import test from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
|
|
||||||
import {
|
|
||||||
joinCallQuality,
|
|
||||||
matchMediaQualityToCall,
|
|
||||||
worstPrismaBucketForCall,
|
|
||||||
} from '../services/voiceReport/joinCallQuality.js';
|
|
||||||
|
|
||||||
const window = {
|
|
||||||
startTime: '2026-07-23T13:00:00.000Z',
|
|
||||||
endTime: '2026-07-24T01:00:00.000Z',
|
|
||||||
};
|
|
||||||
|
|
||||||
test('matchMediaQualityToCall joins on correlation id', () => {
|
|
||||||
const call = {
|
|
||||||
start: '2026-07-23T14:00:00.000Z',
|
|
||||||
duration: 60,
|
|
||||||
callingNumber: '+15551234567',
|
|
||||||
calledNumber: '+17247795574',
|
|
||||||
correlationId: 'abc-123',
|
|
||||||
};
|
|
||||||
const mq = [{
|
|
||||||
'Correlation ID': 'abc-123',
|
|
||||||
'Start Time': '2026-07-23T14:00:01.000Z',
|
|
||||||
_norm: { correlationid: 'abc-123', starttime: '2026-07-23T14:00:01.000Z', mos: '4.2' },
|
|
||||||
}];
|
|
||||||
const hit = matchMediaQualityToCall(call, mq);
|
|
||||||
assert.ok(hit);
|
|
||||||
assert.equal(hit.mos, 4.2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('joinCallQuality groups legs into correlated calls', () => {
|
|
||||||
const joined = joinCallQuality({
|
|
||||||
cdrItems: [
|
|
||||||
{
|
|
||||||
direction: 'INBOUND',
|
|
||||||
startTime: '2026-07-23T14:00:00.000Z',
|
|
||||||
durationSeconds: 30,
|
|
||||||
status: 'SUCCESS',
|
|
||||||
callingNumber: '+1',
|
|
||||||
calledNumber: '+2',
|
|
||||||
'Correlation ID': 'corr-1',
|
|
||||||
'Report ID': 'r1',
|
|
||||||
'Call type': 'SIP_INBOUND',
|
|
||||||
'Call outcome': 'Success',
|
|
||||||
'Call outcome reason': 'Normal',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Direction: 'Outbound',
|
|
||||||
'Start time': '2026-07-23T15:00:00.000Z',
|
|
||||||
Duration: 10,
|
|
||||||
'Answer indicator': 'No',
|
|
||||||
'Report ID': 'r2',
|
|
||||||
'Call type': 'SIP_OUTBOUND',
|
|
||||||
'Call outcome': 'Failed',
|
|
||||||
'Call outcome reason': 'Busy',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
mqRows: [],
|
|
||||||
appAudio: null,
|
|
||||||
window,
|
|
||||||
});
|
|
||||||
assert.equal(joined.summary.total, 2);
|
|
||||||
assert.equal(joined.calls.length, 2);
|
|
||||||
assert.equal(joined.summary.inbound, 1);
|
|
||||||
assert.equal(joined.summary.outbound, 1);
|
|
||||||
assert.equal(joined.summary.abnormal, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('worstPrismaBucketForCall picks low MOS bucket', () => {
|
|
||||||
const leg = {
|
|
||||||
start: '2026-07-23T13:05:00.000Z',
|
|
||||||
duration: 600,
|
|
||||||
};
|
|
||||||
const appAudio = {
|
|
||||||
mos: { values: [4.4, 4.4, 3.1, 4.4, 4.4] },
|
|
||||||
loss: { values: [0, 0, 2, 0, 0] },
|
|
||||||
jitter: { values: [5, 5, 5, 5, 5] },
|
|
||||||
};
|
|
||||||
const w = worstPrismaBucketForCall(leg, appAudio, window);
|
|
||||||
assert.ok(w);
|
|
||||||
assert.equal(w.mos, 3.1);
|
|
||||||
assert.equal(w.siteLevelApprox, true);
|
|
||||||
});
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
// tests/voiceReport.renderer.test.js
|
|
||||||
|
|
||||||
import test from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
|
|
||||||
import { renderVoiceReportMarkdown } from '../services/renderers/voiceReportRenderer.js';
|
|
||||||
|
|
||||||
test('renderVoiceReportMarkdown summary', () => {
|
|
||||||
const md = renderVoiceReportMarkdown({
|
|
||||||
ok: true,
|
|
||||||
store: { storeNum: '782', locationName: 'Store 0782', dialNumber: '+17247795574' },
|
|
||||||
window: {
|
|
||||||
label: '2026-07-23',
|
|
||||||
mode: 'fullDay',
|
|
||||||
timeZone: 'America/New_York',
|
|
||||||
startTime: '2026-07-23T13:00:00.000Z',
|
|
||||||
endTime: '2026-07-24T01:00:00.000Z',
|
|
||||||
},
|
|
||||||
cdr: { available: true, rawCount: 10 },
|
|
||||||
mediaQuality: { available: false, reason: 'skipped in test' },
|
|
||||||
prisma: { available: false, reason: 'not configured' },
|
|
||||||
joined: {
|
|
||||||
summary: {
|
|
||||||
total: 5,
|
|
||||||
inbound: 3,
|
|
||||||
outbound: 2,
|
|
||||||
inboundReachedPhone: 2,
|
|
||||||
outboundConnected: 2,
|
|
||||||
abnormal: 1,
|
|
||||||
outcomes: { 'Success / Normal': 4, 'Failed / Busy': 1 },
|
|
||||||
},
|
|
||||||
calls: [],
|
|
||||||
},
|
|
||||||
elapsedMs: 42,
|
|
||||||
});
|
|
||||||
assert.match(md, /Voice report/);
|
|
||||||
assert.match(md, /Store 0782/);
|
|
||||||
assert.match(md, /Total calls.*5/);
|
|
||||||
assert.match(md, /Inbound reached a phone/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renderVoiceReportMarkdown detail truncates', () => {
|
|
||||||
const calls = Array.from({ length: 30 }, (_, i) => ({
|
|
||||||
start: `2026-07-23T14:${String(i).padStart(2, '0')}:00.000Z`,
|
|
||||||
direction: 'inbound',
|
|
||||||
duration: 30,
|
|
||||||
outcome: 'Success',
|
|
||||||
outcomeReason: 'Normal',
|
|
||||||
callingNumber: '+1',
|
|
||||||
calledNumber: '+2',
|
|
||||||
legCount: 2,
|
|
||||||
reachedPhone: true,
|
|
||||||
normalOutcome: true,
|
|
||||||
}));
|
|
||||||
const md = renderVoiceReportMarkdown({
|
|
||||||
ok: true,
|
|
||||||
store: { storeNum: '782', locationName: 'Store 0782' },
|
|
||||||
window: { label: '2026-07-23', mode: 'fullDay', timeZone: 'America/New_York', startTime: 'x', endTime: 'y' },
|
|
||||||
cdr: { available: true },
|
|
||||||
mediaQuality: { available: false },
|
|
||||||
prisma: { available: false },
|
|
||||||
joined: {
|
|
||||||
summary: { total: 30, inbound: 30, outbound: 0, outcomes: {} },
|
|
||||||
calls,
|
|
||||||
},
|
|
||||||
elapsedMs: 1,
|
|
||||||
}, { detail: true });
|
|
||||||
assert.match(md, /Call detail/);
|
|
||||||
assert.match(md, /more calls/);
|
|
||||||
});
|
|
||||||
Loading…
Reference in a new issue