// 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, }; }