// src/services/voiceDiag/voiceDiagService.js // // Orchestrator for the /voicediag command. Two responsibilities: // // 1. buildContext(storeNum) — resolves the store's canonical Webex // user (currently 1:1 with a store number via the ae@ae.com // convention) and pre-fetches shared data that most checks want // (device list, telephony profile, phone status). Doing this // once here means individual check modules don't each re-do the // lookup. // // 2. runVoiceDiag(storeNum, opts) — iterates the check registry, // filters by `requires` capabilities present in the context and // an optional `only` allow-list, executes each check in parallel // via Promise.allSettled so one 500 doesn't take the run down, // and normalises any thrown error into a `status: 'skipped'` or // `status: 'error'` CheckResult. The returned array is in // registry order — the renderer relies on that for stable output. // // Design principles: // - Checks never see raw axios errors — the runner turns 401/403/404 // into `skipped: scope missing` and everything else into // `error: `. Individual checks decide their own OK/WARN // when their fetch succeeds. // - Context is intentionally minimal. If a future check needs an // extra pre-fetch, add it here rather than duplicating the fetch // per check. // - Never throws — every failure surfaces as a CheckResult so the // command handler can render *something* to the user. import { logger } from '../../utils/logger.js'; import { CHECKS, getCheckById } from './checks/index.js'; // The WebexClient singleton + phoneService helpers are imported LAZILY // inside buildContext(). Eager top-level imports would run the // WebexClient constructor at module load time, which requires // WEBEX_CLIENT_ID / WEBEX_CLIENT_SECRET in the environment. That's // fine at runtime but breaks unit tests that only exercise // runVoiceDiag / buildRemediationRegistry against a stub ctx (see // tests/voiceDiag.checks.test.js). /** * Build the shared execution context for a /voicediag run. Pre-fetches * the person + telephony profile + phone-status snapshot up-front so * each check has cheap random access to them. Missing pieces are set * to null so `requires` gating in runVoiceDiag can short-circuit. * * @param {string} storeNum 2-4 digit store id * @returns {Promise<{ * storeNum: string, * email: string, * personId: string | null, * person: object | null, * telephonyProfile: object, * phoneStatus: object | null, * personLabel: string, * }>} */ export async function buildContext(storeNum, opts = {}) { const email = `ae${String(storeNum).padStart(5, '0')}@ae.com`; const windowMinutes = opts.windowMinutes; logger( 'voicediag', `Building context for store ${storeNum} → ${email}` + (windowMinutes ? ` (WAN window: ${windowMinutes}m)` : ''), 'debug', ); const { default: webex } = await import('../../integrations/webex/WebexClient.js'); const { getPersonIdByEmail, getPersonDetails, getTelephonyProfile, collectPhoneStatus, } = await import('../phoneService.js'); // Prisma SD-WAN enrichment is lazy for the same reason WebexClient // is: it reads process.env at import time (auth mode selection) // and unit tests should be able to run without provisioning any // Prisma creds. Fetch failures are already absorbed inside the // composer, so the wrapping try/catch here is a belt-and-braces // guard against unexpected import-time surprises only. let collectSdwanForStore; try { ({ collectSdwanForStore } = await import('../enrichment/sdwanEnrichment.js')); } catch (err) { logger('voicediag', `Prisma enrichment unavailable: ${err.message}`, 'warn'); collectSdwanForStore = null; } const personId = await getPersonIdByEmail(email); // If we have no person, skip the expensive phoneStatus fetch — the // renderer will show every check as `skipped: no user`. This keeps // the "unknown store" case cheap. We still fetch SD-WAN data — a // store with no Webex user can absolutely still have a WAN we care // about (dark store investigation, e.g. the /findEmptyLocations // follow-up). const [personRes, telProfRes, phoneStatusRes, sdwanRes] = await Promise.allSettled([ personId ? getPersonDetails(personId) : Promise.resolve(null), personId ? getTelephonyProfile(personId) : Promise.resolve({}), personId ? collectPhoneStatus(storeNum).catch((err) => { // collectPhoneStatus can throw noisily on network hiccups. // We tolerate this: the phoneOnline check will read // ctx.phoneStatus and show 'skipped' if it's null. logger('voicediag', `collectPhoneStatus soft-failed: ${err.message}`, 'warn'); return null; }) : Promise.resolve(null), collectSdwanForStore ? collectSdwanForStore(storeNum, { windowMinutes }).catch((err) => { logger('voicediag', `collectSdwanForStore soft-failed: ${err.message}`, 'warn'); return null; }) : Promise.resolve(null), ]); const person = personRes.status === 'fulfilled' ? personRes.value : null; const telephonyProfile = telProfRes.status === 'fulfilled' ? (telProfRes.value || {}) : {}; const phoneStatus = phoneStatusRes.status === 'fulfilled' ? phoneStatusRes.value : null; const sdwanData = sdwanRes.status === 'fulfilled' ? sdwanRes.value : null; const personLabel = person?.displayName || email; return { storeNum: String(storeNum), email, personId, person, telephonyProfile, phoneStatus, personLabel, // Bare reference to the WebexClient singleton (lazy-loaded above) // so checks can make their own targeted calls // (`people/{id}/features/*`) without re-importing from every // check module. Keeps mocking easy in tests — a test can pass a // stub context with a fake webex; no ctx passes through the real // client unless it came from buildContext(). webex, // Prisma SD-WAN data. sdwanData is the full composer output; // sdwanSite is the shortcut checks use to gate `requires: // 'sdwanSite'`. Both null when this store isn't Prisma-managed // (or when the Prisma integration hasn't been configured). sdwanData, sdwanSite: sdwanData?.site || null, }; } /** * Options for runVoiceDiag. * * @typedef {Object} RunVoiceDiagOptions * @property {string[]} [only] * Restrict execution to the given check ids. Unknown ids are logged * at 'warn' and skipped silently in the results (no CheckResult for * them). Empty array runs everything (same as omitted). */ /** * Execute all registered checks against the given context. Never * throws. Returns one CheckResult per check that actually ran; checks * whose `requires` aren't satisfied by the context appear as * `skipped: missing ` so the renderer can still show them. * * @param {ReturnType extends Promise ? T : never} ctx * @param {RunVoiceDiagOptions} [opts] * @returns {Promise} */ export async function runVoiceDiag(ctx, opts = {}) { const onlyIds = Array.isArray(opts.only) && opts.only.length > 0 ? new Set(opts.only.map((s) => String(s).toLowerCase().trim())) : null; if (onlyIds) { for (const id of onlyIds) { if (!getCheckById(id)) { logger('voicediag', `--only referenced unknown check id: ${id}`, 'warn'); } } } // Both sides lowercased so `--only DND` matches check.id === 'dnd' // and `--only callforwarding` matches check.id === 'callForwarding'. const toRun = CHECKS.filter((c) => !onlyIds || onlyIds.has(String(c.id).toLowerCase())); const results = await Promise.all( toRun.map(async (check) => { const missing = missingRequirements(check, ctx); if (missing.length > 0) { return skipped( check, `not applicable — missing ${missing.join(', ')} for this store`, ); } try { const result = await check.run(ctx); return normalizeResult(check, result); } catch (err) { return errorToResult(check, err); } }), ); return results; } function missingRequirements(check, ctx) { const reqs = Array.isArray(check.requires) ? check.requires : []; const missing = []; for (const req of reqs) { if (req === 'personId' && !ctx.personId) missing.push('personId'); else if (req === 'phoneStatus' && !ctx.phoneStatus) missing.push('phoneStatus'); else if (req === 'telephonyProfile' && (!ctx.telephonyProfile || Object.keys(ctx.telephonyProfile).length === 0)) { missing.push('telephonyProfile'); } // WAN bucket vocabulary. `sdwanSite` gates on "is this a Prisma- // managed store at all"; `sdwanData` gates on "did the composer // produce anything usable" (i.e. discriminate a network-failure // day from a non-Prisma store). else if (req === 'sdwanSite' && !ctx.sdwanSite) missing.push('sdwanSite'); else if (req === 'sdwanData' && !ctx.sdwanData) missing.push('sdwanData'); } return missing; } function normalizeResult(check, result) { if (!result || typeof result !== 'object') { return { id: check.id, label: check.label, status: 'error', message: `Check "${check.id}" returned no result`, details: null, remediation: null, }; } return { id: check.id, label: check.label, status: result.status || 'ok', message: result.message || '', details: result.details ?? null, remediation: result.remediation ?? null, }; } function errorToResult(check, err) { const status = err?.response?.status; const apiMsg = err?.response?.data?.message || err?.response?.data?.errors?.[0]?.description || err?.message || String(err); // 401/403 → the service-app token doesn't have the scope. Surface // as `skipped: scope missing` and keep the run going against the // other checks. The scope is uniformly `spark-admin:people_read` // for the `/v1/people/{id}/features/*` surface, so if this fires // on ANY check it will fire on all of them — but we still emit // one skipped row per check so the operator can see the pattern. if (status === 401 || status === 403) { return { id: check.id, label: check.label, status: 'skipped', message: `${status} on ${check.label} — the service app token is missing ` + `\`${check.scope || 'spark-admin:people_read'}\`. ` + `Re-authorize the app and re-bootstrap ` + `\`tokens/webex-service-tokens.json\`.`, details: { httpStatus: status }, remediation: null, }; } // 404 on `/v1/people/{id}/features/*` most commonly means the // person isn't a Webex Calling user (no calling license, no // location assigned, or a service line) — the same admin URL // returns 200 for a properly-provisioned calling user. If the // path itself moved (see Webex changelog for scheduled migrations // to `/v1/telephony/config/people/{id}/...`), you'll see the // "no static resource" message quoted below, which is a routing // 404 rather than a "not applicable" 404 — the response body // distinguishes them. if (status === 404) { const routingMiss = /no static resource/i.test(apiMsg); return { id: check.id, label: check.label, status: 'skipped', message: routingMiss ? `404 (routing) on ${check.label} — the URL for this feature has moved. ` + `Update the ENDPOINT constant in the check module. Details: ${apiMsg}` : `404 on ${check.label} — this user has no Webex Calling license or the ` + `feature isn't applicable to their line type.`, details: { httpStatus: 404, routingMiss, apiMsg }, remediation: null, }; } logger('voicediag', `Check ${check.id} failed: ${apiMsg}`, 'warn'); return { id: check.id, label: check.label, status: 'error', message: `Error running ${check.label}: ${apiMsg}${status ? ` (HTTP ${status})` : ''}`, details: { httpStatus: status || null }, remediation: null, }; } function skipped(check, message) { return { id: check.id, label: check.label, status: 'skipped', message, details: null, remediation: null, }; } /** * Build a lookup { remediationId -> handler } across every check. * Used by commands/voiceDiag.js's `applyVoiceDiagRemediation` to * dispatch a `confirm_voicediag` attachmentAction to the right * check's remediation function. * * @returns {Map} */ export function buildRemediationRegistry() { const map = new Map(); for (const check of CHECKS) { const rems = check.remediations; if (!rems || typeof rems !== 'object') continue; for (const [actionId, handler] of Object.entries(rems)) { if (typeof handler !== 'function') continue; if (map.has(actionId)) { // Duplicate remediation ids across two checks would silently // swallow one. Fail loud so the mistake is obvious in tests. throw new Error( `Duplicate voicediag remediation id "${actionId}" — check "${check.id}" ` + `collides with an already-registered handler`, ); } map.set(actionId, { check, handler }); } } return map; } // Re-export CHECKS so tests / list-checks handlers don't need a // second import from ./checks/index.js. export { CHECKS };