collabSupport/services/voiceReport/voiceReportService.js
jmcqueen 1eaabbcee1 Add /voicereport store digest and harden Webex CDR feed handling.
Groups cdr_feed legs by Correlation ID for call-level summaries, fixes report-column field parsing and Docker proxy routing, and adds /calltest post-call Twilio and CDR enrichment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 13:43:11 -04:00

159 lines
5 KiB
JavaScript

// services/voiceReport/voiceReportService.js
// Compose CDR + Media Quality + Prisma WAN for /voicereport.
import { logger } from '../../utils/logger.js';
import { getHistoricalCallActivity } from '../phoneService.js';
import { findSdwanSiteForStore } from '../../integrations/paloalto/sites.js';
import { getAppMetric } from '../../integrations/paloalto/metrics.js';
import { buildAppDetailsUrl } from '../../integrations/paloalto/urls.js';
import { fetchMediaQualityReport } from '../../integrations/webex/reportsClient.js';
import {
resolveVoiceAppConfig,
summarizeAppSeries,
} from '../enrichment/sdwanEnrichment.js';
import { computeBusinessWindow } from './businessWindow.js';
import { resolveStoreForVoiceReport } from './storeContext.js';
import { filterCdrToWindow, joinCallQuality } from './joinCallQuality.js';
import { cdrStartTime } from '../cdrFeedParser.js';
const LOG_SCOPE = 'voicereport:service';
async function fetchCdrForWindow(store, window) {
const cdr = await getHistoricalCallActivity(store.personId, 12, {
locationName: store.locationName,
startTime: window.startTime,
endTime: window.endTime,
returnRawItems: true,
skipPersonFilter: true,
});
if (!cdr.available) {
logger(
LOG_SCOPE,
`CDR unavailable for store=${store.storeNum} loc=${JSON.stringify(store.locationName)}: ${cdr.reason}`,
'warn',
);
return { available: false, reason: cdr.reason, items: [], fetchErrors: cdr.fetchErrors };
}
const beforeFilter = cdr.rawItems?.length || 0;
const items = filterCdrToWindow(cdr.rawItems || [], window);
const rawCount = cdr.rawCount ?? beforeFilter;
if (rawCount > 0 && items.length === 0) {
const sample = (cdr.rawItems || []).slice(0, 2).map((r) => cdrStartTime(r) || 'no-start-field');
logger(
LOG_SCOPE,
`CDR window filter dropped all ${rawCount} record(s) for store=${store.storeNum} ` +
`(window ${window.startTime}..${window.endTime}); sample start fields: ${sample.join(', ')}`,
'warn',
);
} else {
logger(
LOG_SCOPE,
`CDR store=${store.storeNum} loc=${JSON.stringify(store.locationName)} ` +
`api=${rawCount} afterWindowFilter=${items.length}`,
'info',
);
}
return {
available: true,
items,
rawCount,
afterFilterCount: items.length,
};
}
async function fetchPrismaAppAudio(storeNum, window) {
const voiceCfg = resolveVoiceAppConfig();
if (!voiceCfg.appId) {
return { available: false, reason: 'PRISMA_APP_ID_VOICE not configured' };
}
const site = await findSdwanSiteForStore(storeNum);
if (!site?.id) {
return { available: false, reason: 'not a Prisma-managed store' };
}
const opts = { startTime: window.startTime, endTime: window.endTime };
const [mosRes, lossRes, jitterRes] = await Promise.all([
getAppMetric(site.id, voiceCfg.appId, 'mos', opts),
getAppMetric(site.id, voiceCfg.appId, 'loss', opts),
getAppMetric(site.id, voiceCfg.appId, 'jitter', opts),
]);
const appAudio = {
appId: voiceCfg.appId,
appName: voiceCfg.appName,
detailsUrl: buildAppDetailsUrl(site.id, voiceCfg.appId),
mos: summarizeAppSeries(mosRes, 'AppAudioMos'),
loss: summarizeAppSeries(lossRes, 'AppPerfUDPAudioPacketLoss'),
jitter: summarizeAppSeries(jitterRes, 'AppPerfUDPAudioJitter'),
};
return { available: true, site, appAudio };
}
/**
* @param {string} storeNum
* @param {object} opts
* @param {string} [opts.dateArg] yesterday | today | YYYY-MM-DD
* @param {Date} [opts.now]
*/
export async function collectVoiceReport(storeNum, opts = {}) {
const startedAt = Date.now();
const store = await resolveStoreForVoiceReport(storeNum);
const window = computeBusinessWindow({
timeZone: store.timeZone,
dateArg: opts.dateArg,
now: opts.now,
});
if (!window.ready) {
return {
ok: false,
store,
window,
reason: window.reason,
};
}
logger(
LOG_SCOPE,
`Collecting voice report store=${storeNum} loc=${store.locationName} ` +
`window=${window.startTime}..${window.endTime}`,
'info',
);
const [cdrRes, mqRes, prismaRes] = await Promise.allSettled([
fetchCdrForWindow(store, window),
fetchMediaQualityReport({ window, locationName: store.locationName }),
fetchPrismaAppAudio(storeNum, window),
]);
const cdr = cdrRes.status === 'fulfilled'
? cdrRes.value
: { available: false, reason: cdrRes.reason?.message, items: [] };
const mediaQuality = mqRes.status === 'fulfilled'
? mqRes.value
: { available: false, reason: mqRes.reason?.message, rows: [] };
const prisma = prismaRes.status === 'fulfilled'
? prismaRes.value
: { available: false, reason: prismaRes.reason?.message };
const joined = joinCallQuality({
cdrItems: cdr.items || [],
mqRows: mediaQuality.rows || [],
appAudio: prisma.appAudio || null,
window,
});
return {
ok: true,
store,
window,
cdr,
mediaQuality,
prisma,
joined,
fetchedAt: new Date().toISOString(),
elapsedMs: Date.now() - startedAt,
};
}