collabSupport/integrations/webex/reportsClient.js
jmcqueen a25fc08fe2 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>
2026-07-27 10:10:03 -04:00

174 lines
5.5 KiB
JavaScript

// integrations/webex/reportsClient.js
// Webex Control Hub Reports API (Pro Pack) — Calling Media Quality.
import axios from 'axios';
import AdmZip from 'adm-zip';
import webex from './WebexClient.js';
import { logger } from '../../utils/logger.js';
import {
filterMediaQualityRows,
parseMediaQualityCsv,
reportDateRangeFromWindow,
} from './reportsCsv.js';
export { filterMediaQualityRows, parseMediaQualityCsv, reportDateRangeFromWindow } from './reportsCsv.js';
import { callReportEnvInt } from '../../services/callReport/env.js';
const LOG_SCOPE = 'webex:reports';
const TEMPLATE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
let _templateCache = null;
let _templateCacheAt = 0;
function pollIntervalMs() {
return callReportEnvInt('REPORT_POLL_MS', 5000);
}
function pollMaxMs() {
return callReportEnvInt('REPORT_POLL_MAX_MS', 180_000);
}
export function _clearReportTemplateCacheForTests() {
_templateCache = null;
_templateCacheAt = 0;
}
export async function listReportTemplates() {
const now = Date.now();
if (_templateCache && now - _templateCacheAt < TEMPLATE_CACHE_TTL_MS) {
return _templateCache;
}
const data = await webex.request('GET', 'reports/templates');
const items = data?.items || data?.reportTemplates || [];
_templateCache = items;
_templateCacheAt = now;
return items;
}
/**
* Resolve template id by env override or fuzzy name match.
*/
export async function resolveMediaQualityTemplateId() {
const override = process.env.WEBEX_REPORT_TEMPLATE_MEDIA_QUALITY;
if (override && String(override).trim()) {
return Number(override);
}
const templates = await listReportTemplates();
const hit = templates.find((t) => {
const name = String(t.name || t.title || '').toLowerCase();
return name.includes('media quality') || name.includes('calling media');
});
return hit ? Number(hit.id ?? hit.templateId) : null;
}
export async function createReport(templateId, { startDate, endDate, siteList } = {}) {
if (!templateId || !Number.isFinite(templateId)) {
return { ok: false, reason: 'no Media Quality report template id (set WEBEX_REPORT_TEMPLATE_MEDIA_QUALITY)' };
}
const body = {
templateId: Number(templateId),
};
if (startDate) body.startDate = startDate;
if (endDate) body.endDate = endDate;
if (siteList) body.siteList = siteList;
try {
const data = await webex.request('POST', 'reports', body);
return { ok: true, report: data };
} catch (err) {
const msg = err.response?.data?.message || err.message;
logger(LOG_SCOPE, `createReport failed: ${msg}`, 'warn');
return { ok: false, reason: msg };
}
}
export async function getReport(reportId) {
return webex.request('GET', `reports/${reportId}`);
}
export async function pollReportUntilDone(reportId) {
const deadline = Date.now() + pollMaxMs();
while (Date.now() < deadline) {
const report = await getReport(reportId);
const status = String(report?.status || '').toLowerCase();
if (status === 'done' || status === 'complete' || status === 'completed') {
return { ok: true, report };
}
if (status === 'failed' || status === 'error') {
return { ok: false, reason: report?.statusMessage || 'report generation failed', report };
}
await new Promise((r) => setTimeout(r, pollIntervalMs()));
}
return { ok: false, reason: 'report poll timed out' };
}
/**
* Download report payload (CSV text or first CSV inside ZIP).
*/
export async function downloadReportText(downloadURL) {
if (!downloadURL) return { ok: false, reason: 'no downloadURL' };
const token = await webex.auth.getAccessToken();
const resp = await axios.get(downloadURL, {
headers: { Authorization: `Bearer ${token}` },
responseType: 'arraybuffer',
timeout: 60_000,
maxContentLength: 50 * 1024 * 1024,
});
const buf = Buffer.from(resp.data);
if (buf[0] === 0x50 && buf[1] === 0x4b) {
return extractCsvFromZip(buf);
}
return { ok: true, text: buf.toString('utf8') };
}
function extractCsvFromZip(buf) {
try {
const zip = new AdmZip(buf);
const entry = zip.getEntries().find((e) => /\.csv$/i.test(e.entryName));
if (!entry) return { ok: false, reason: 'no CSV inside report ZIP' };
return { ok: true, text: entry.getData().toString('utf8') };
} catch (err) {
return { ok: false, reason: `ZIP extract failed: ${err.message}` };
}
}
export async function fetchMediaQualityReport({ window, locationName }) {
const templateId = await resolveMediaQualityTemplateId();
if (!templateId) {
return { available: false, reason: 'Media Quality template not found' };
}
const { startDate, endDate } = reportDateRangeFromWindow(window);
const created = await createReport(templateId, { startDate, endDate });
if (!created.ok) {
return { available: false, reason: created.reason };
}
const reportId = created.report?.id;
if (!reportId) {
return { available: false, reason: 'create report returned no id' };
}
const polled = await pollReportUntilDone(reportId);
if (!polled.ok) {
return { available: false, reason: polled.reason };
}
const downloadURL = polled.report?.downloadURL || polled.report?.downloadUrl;
const dl = await downloadReportText(downloadURL);
if (!dl.ok) {
return { available: false, reason: dl.reason };
}
const parsed = parseMediaQualityCsv(dl.text);
const filtered = filterMediaQualityRows(parsed.rows, { locationName, window });
return {
available: true,
reportId,
rawCount: parsed.rows.length,
rows: filtered,
headers: parsed.headers,
};
}