From 2eb31a2ddcc8589b8c87fd4795b04195b914f083 Mon Sep 17 00:00:00 2001 From: jmcqueen Date: Tue, 7 Jul 2026 18:51:31 -0400 Subject: [PATCH] Add /voicediag rules-engine command with 9 per-user calling checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a new diagnostic command that walks a registry of check modules against a store user's Webex Calling configuration and surfaces per-issue adaptive-card remediation for the fixable ones. Checks (services/voiceDiag/checks/): dnd, callForwarding, callWaiting, callIntercept, voicemail, hoteling, executiveAssistant, outgoingPermission, phoneOnline. Remediations offered for DND, forwarding, waiting, and intercept. Uses the /v1/people/{id}/features/* admin surface (spark-admin:people_read + spark-admin:people_write scopes we already hold) — the earlier telephony/config/people/*/callSettings/* path scheme returns 404 from the Webex gateway and is not a live surface. Runner distinguishes routing-404s ("URL moved") from "not applicable" 404s ("no calling license") via the response body. Arg parser accepts detail/detailed/--detail/--detailed and normalises macOS smart-dashes so --detailed doesn't die when auto-correct turns it into an em-dash. Wires a VOICEDIAG_ACTIONS dispatcher in index.js mirroring the IGMP branch, and registers /voicediag in commands/registry.js. 170 tests pass (52 new: 39 check + 12 renderer + 5 arg-normalization). Docs updated in .env.example, services/phoneService.js:467, and a new services/voiceDiag/README.md that includes a "how to add a check" recipe plus a note on the earlier wrong URL scheme. Co-authored-by: Cursor --- .env.example | 28 +- commands/registry.js | 8 + commands/voiceDiag.js | 314 +++++++++++ index.js | 56 ++ services/phoneService.js | 5 + services/renderers/voiceDiagRenderer.js | 165 ++++++ services/voiceDiag/README.md | 130 +++++ services/voiceDiag/checks/callForwarding.js | 175 +++++++ services/voiceDiag/checks/callIntercept.js | 119 +++++ services/voiceDiag/checks/callWaiting.js | 98 ++++ services/voiceDiag/checks/dnd.js | 120 +++++ .../voiceDiag/checks/executiveAssistant.js | 55 ++ services/voiceDiag/checks/hoteling.js | 46 ++ services/voiceDiag/checks/index.js | 74 +++ .../voiceDiag/checks/outgoingPermission.js | 98 ++++ services/voiceDiag/checks/phoneOnline.js | 130 +++++ services/voiceDiag/checks/voicemail.js | 138 +++++ services/voiceDiag/voiceDiagService.js | 318 +++++++++++ tests/voiceDiag.checks.test.js | 492 ++++++++++++++++++ tests/voiceDiagRenderer.test.js | 160 ++++++ utils/pendingVoiceFixes.js | 75 +++ 21 files changed, 2801 insertions(+), 3 deletions(-) create mode 100644 commands/voiceDiag.js create mode 100644 services/renderers/voiceDiagRenderer.js create mode 100644 services/voiceDiag/README.md create mode 100644 services/voiceDiag/checks/callForwarding.js create mode 100644 services/voiceDiag/checks/callIntercept.js create mode 100644 services/voiceDiag/checks/callWaiting.js create mode 100644 services/voiceDiag/checks/dnd.js create mode 100644 services/voiceDiag/checks/executiveAssistant.js create mode 100644 services/voiceDiag/checks/hoteling.js create mode 100644 services/voiceDiag/checks/index.js create mode 100644 services/voiceDiag/checks/outgoingPermission.js create mode 100644 services/voiceDiag/checks/phoneOnline.js create mode 100644 services/voiceDiag/checks/voicemail.js create mode 100644 services/voiceDiag/voiceDiagService.js create mode 100644 tests/voiceDiag.checks.test.js create mode 100644 tests/voiceDiagRenderer.test.js create mode 100644 utils/pendingVoiceFixes.js diff --git a/.env.example b/.env.example index c396169..1026ab5 100644 --- a/.env.example +++ b/.env.example @@ -41,11 +41,33 @@ WEBEX_BOT_TOKEN=your-bot-token-here # calls — people lookup, room operations, etc.). Separate from the bot token. # # Required scopes (set when creating the service app at developer.webex.com): -# - spark-admin:people_read (people lookup) -# - identity:tokens_read (offboarduser: list a user's authorizations) -# - identity:tokens_write (offboarduser: revoke a user's authorizations) +# - spark-admin:people_read (people lookup, voicediag reads, +# webexhost, findEmptyLocations) +# - spark-admin:people_write (webexhost: assign licenses, +# voicediag: apply remediations — +# disable DND / clear forwarding / +# disable intercept / enable call +# waiting via /v1/people/{id}/features/*) +# - spark-admin:licenses_read (webexhost, findEmptyLocations) +# - identity:tokens_read (offboarduser: list a user's authorizations) +# - identity:tokens_write (offboarduser: revoke a user's authorizations) # The authorizing admin must also hold Full / User / Device Admin role for the # token-management calls to succeed. +# +# NOTE — early drafts of /voicediag documented `spark-admin:telephony_config_*` +# scopes and a `/v1/telephony/config/people/{id}/callSettings/*` URL scheme. +# That URL family returns 404 "no static resource" from the Webex gateway. +# The live admin surface is `/v1/people/{id}/features/{feature}` (see +# services/voiceDiag/ for details) and it uses the same `spark-admin:people_*` +# scopes that /webexhost and /offboarduser already require — no additional +# scope work is needed to enable /voicediag. +# +# Refresh tokens preserve their original scope set, so if you ever DO add a +# new scope you must: +# 1. Save the updated scopes on the service app. +# 2. Re-authorize the app for the org as a Full / User admin. +# 3. Delete/rotate tokens/webex-service-tokens.json so the next request +# re-bootstraps with the enlarged scope set. WEBEX_CLIENT_ID=your-service-app-client-id WEBEX_CLIENT_SECRET=your-service-app-client-secret diff --git a/commands/registry.js b/commands/registry.js index 1755c36..2ca2e08 100644 --- a/commands/registry.js +++ b/commands/registry.js @@ -25,6 +25,7 @@ import { handleProvisionVc } from './vcProvision.js'; import { handleVcMonitor } from './vcMonitor.js'; import { handleOffboardUser } from './offboardUser.js'; import { handleWebexHost } from './webexHost.js'; +import { handleVoiceDiag } from './voiceDiag.js'; import { handleBulkAvStatusCSV } from './bulkAvStatusCSV.js'; import { handleBulkAvSwitchCSV } from './bulkAvSwitchCSV.js'; import { handleTestDevicesByModel } from './testDevicesByModel.js'; @@ -43,6 +44,13 @@ export const commands = [ { name: 'avstatus', aliases: ['wostatus'], handler: handleAvStatus, mutating: false }, { name: 'phonestatus', handler: handlePhoneStatus, mutating: false }, + // /voicediag reads per-user Webex Calling features via + // /v1/people/{id}/features/* and offers per-issue adaptive-card + // remediation. Reads are non-mutating but the confirm buttons on + // the cards perform PUTs, so the HTTP path is gated + // (mutating: true). See commands/voiceDiag.js + services/voiceDiag/ + // for the full behavior contract + required scopes. + { name: 'voicediag', handler: handleVoiceDiag, mutating: true }, { name: 'wohistory', handler: handleWoHistory, mutating: false }, { name: 'wosummary', handler: handleWoSummary, mutating: false }, { name: 'woattachments', handler: handleWoAttachments, mutating: false }, diff --git a/commands/voiceDiag.js b/commands/voiceDiag.js new file mode 100644 index 0000000..e86991e --- /dev/null +++ b/commands/voiceDiag.js @@ -0,0 +1,314 @@ +// src/commands/voiceDiag.js +// +// /voicediag [detail] [--only id1,id2] +// /voicediag list-checks +// +// Thin orchestrator around services/voiceDiag/voiceDiagService.js. This +// handler: +// +// 1. Parses args (or query params on the HTTP path). +// 2. Runs `buildContext(storeNum)` + `runVoiceDiag(ctx, opts)`. +// 3. Renders the results via voiceDiagRenderer. +// 4. For every fixable result, posts a per-issue confirmation card +// through the same "pending map + one-shot dispatch" pattern used +// by /webexhost, /offboarduser, and the IGMP-fix inline card. +// +// The remediation dispatch (`applyVoiceDiagRemediation` / +// `cancelVoiceDiagRemediation`) is called from index.js after the +// `attachmentAction` router matches `confirm_voicediag` / +// `cancel_voicediag`. Each pending card entry carries its +// `remediationId`, which we look up in a registry built from every +// check's `remediations` export. Adding a new check with a new +// remediation therefore requires zero changes to index.js — the +// action namespace routes through this single dispatcher. + +import { randomUUID } from 'node:crypto'; +import { logger } from '../utils/logger.js'; +import { extractRequester, describeRequester } from '../utils/requester.js'; +import { pendingVoiceFixes } from '../utils/pendingVoiceFixes.js'; +import { renderVoiceDiagMarkdown } from '../services/renderers/voiceDiagRenderer.js'; +import { + buildContext, + runVoiceDiag, + buildRemediationRegistry, + CHECKS, +} from '../services/voiceDiag/voiceDiagService.js'; + +// Built once at module load. `buildRemediationRegistry` throws on +// duplicate action ids, so any collision surfaces at import time — +// which is exactly what we want. +const REMEDIATION_REGISTRY = buildRemediationRegistry(); + +export async function handleVoiceDiag(bot, trigger) { + logger('voicediag', 'Handler entered', 'debug'); + + const query = trigger.query || {}; + // Normalise Unicode dashes → ASCII `--`. macOS auto-correct silently + // turns `--detailed` into `—detailed` when typed inline in Webex, + // and the framework passes the arg through verbatim. Do the same + // for a run of consecutive hyphens (`---detailed` → `--detailed`). + const args = (trigger.args || []).map(normalizeArg); + + // Special-case a discovery / debug mode. + if ((args[0] || query.mode || '').toString().toLowerCase() === 'list-checks') { + await bot.say('markdown', renderListChecks()); + return; + } + + let storeNum = args[0]?.trim() || query.storeNum || query.store || query.s; + + // Accept every reasonable variant so the footer hint ("pass + // `detailed`") and the /phonestatus muscle memory ("`detailed`" as + // the second positional) both work, alongside the flag forms + // (`--detail`, `--detailed`) and the `?detailed=true` query param. + const detailed = argIncludes(args, 'detail') + || argIncludes(args, 'detailed') + || argIncludes(args, '--detail') + || argIncludes(args, '--detailed') + || query.mode === 'detailed' + || query.detailed === 'true' + || query.detailed === true; + + const onlyRaw = pickOnlyArg(args) ?? query.only ?? ''; + const only = String(onlyRaw) + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + + if (!storeNum || !/^\d{2,4}$/.test(storeNum)) { + await bot.say( + 'markdown', + 'Please provide a 2–4 digit store number.\n' + + 'Examples:\n' + + '- `/voicediag 782`\n' + + '- `/voicediag 782 detailed`\n' + + '- `/voicediag 782 --only dnd,callForwarding`\n' + + '- `/voicediag list-checks`', + ); + return; + } + + let ctx; + try { + ctx = await buildContext(storeNum); + } catch (err) { + logger('voicediag', `buildContext failed for store ${storeNum}: ${err.message}`, 'error'); + await bot.say('markdown', `❌ Failed to build diagnostic context for store ${storeNum}: ${err.message}`); + return; + } + + if (!ctx.personId) { + await bot.say( + 'markdown', + `❌ Could not resolve a Webex user for store ${storeNum} (expected \`${ctx.email}\`). ` + + `Verify the store number is correct.`, + ); + return; + } + + let results; + try { + results = await runVoiceDiag(ctx, { only }); + } catch (err) { + // runVoiceDiag should never throw (each check is isolated), but + // guard anyway so a runner-level bug surfaces to the operator. + logger('voicediag', `runVoiceDiag threw for store ${storeNum}: ${err.message}`, 'error'); + await bot.say('markdown', `❌ Diagnostic run failed for store ${storeNum}: ${err.message}`); + return; + } + + const reply = renderVoiceDiagMarkdown(results, { + storeNum: ctx.storeNum, + personLabel: ctx.personLabel, + email: ctx.email, + detailed, + }); + await bot.say('markdown', reply || 'No diagnostic results.'); + + // Adaptive-card remediation is chat-only. HTTP callers get the + // markdown snapshot but no interactive cards (same rule the + // /phonestatus + IGMP-fix inline card follows). `trigger.person` + // is populated for chat, absent for the HTTP fake trigger. + if (!trigger.person) return; + + const requester = extractRequester(trigger); + const fixable = results.filter((r) => r.status !== 'ok' && r.remediation); + for (const result of fixable) { + await postRemediationCard(bot, { + storeNum: ctx.storeNum, + result, + requester, + }); + } +} + +/** + * apply / cancel dispatchers — invoked from index.js after the + * attachmentAction router matches `confirm_voicediag` / + * `cancel_voicediag`. + * + * The pending payload (see pendingVoiceFixes) always carries a + * `remediationId` and `remediationPayload`. `remediationId` looks up + * the check that owns the action + its handler in REMEDIATION_REGISTRY. + */ +export async function applyVoiceDiagRemediation(bot, data, _roomId, requester) { + const entry = REMEDIATION_REGISTRY.get(data?.remediationId); + if (!entry) { + logger( + 'voicediag:action', + `No handler registered for remediationId "${data?.remediationId}" — ignoring`, + 'warn', + ); + await bot.say( + 'markdown', + `⚠️ No handler is registered for remediation \`${data?.remediationId || 'unknown'}\`. ` + + `This card may have been created by an older build of the bot.`, + ); + return; + } + + logger( + 'voicediag:audit', + `Dispatching ${data.remediationId} for store ${data?.storeNum} ` + + `(check=${entry.check.id}) requested by ${describeRequester(requester)}`, + ); + + await entry.handler(bot, data.remediationPayload || {}, requester); +} + +export async function cancelVoiceDiagRemediation(bot, data, _roomId, requester) { + const { storeNum, remediationId, personLabel } = data || {}; + await bot.say( + 'markdown', + `❌ Cancelled \`${remediationId || 'voicediag'}\` for **${personLabel || `store ${storeNum || '?'}`}**. No changes were made.`, + ); + logger( + 'voicediag:audit', + `CANCELLED ${remediationId || 'unknown'} for store ${storeNum || '?'} ` + + `by ${describeRequester(requester)}`, + ); +} + +// ─── internals ──────────────────────────────────────────────────── + +function argIncludes(args, needle) { + const n = String(needle).toLowerCase(); + return args.some((a) => String(a).toLowerCase() === n); +} + +// Coerce macOS-style smart-dashes back to plain ASCII hyphens so +// `—detailed` (em-dash), `–detailed` (en-dash), and `---detailed` +// all end up matching `--detailed`. Applied per-arg before any +// other parsing. Exported for unit tests. +export function normalizeArg(raw) { + if (raw === null || raw === undefined) return raw; + return String(raw) + .replace(/[\u2010-\u2015\u2212]/g, '--') // hyphens/dashes/minus → -- + .replace(/^-{3,}/, '--'); // collapse ---+ to -- +} + +// Supports both `--only=dnd,callWaiting` and `--only dnd,callWaiting` +// (i.e. the value is the token immediately after `--only`). +function pickOnlyArg(args) { + for (let i = 0; i < args.length; i += 1) { + const a = String(args[i] || ''); + if (a.toLowerCase().startsWith('--only=')) { + return a.slice('--only='.length); + } + if (a.toLowerCase() === '--only' && i + 1 < args.length) { + return args[i + 1]; + } + } + return null; +} + +function renderListChecks() { + let md = '**/voicediag registered checks**\n\n'; + for (const c of CHECKS) { + const rems = c.remediations ? Object.keys(c.remediations) : []; + md += `- **${c.id}** — ${c.label}\n`; + md += ` - Requires: ${(c.requires || []).join(', ') || 'none'}\n`; + md += ` - Scope: \`${c.scope || 'unspecified'}\`\n`; + if (rems.length > 0) { + md += ` - Remediations: ${rems.map((r) => `\`${r}\``).join(', ')}\n`; + } + } + return md; +} + +/** + * Registers a pending remediation payload and posts the adaptive + * card. All cards share the same `confirm_voicediag` / + * `cancel_voicediag` action namespace — the specific remediation is + * carried by `data.remediationId` inside the pending map entry. + */ +async function postRemediationCard(bot, { storeNum, result, requester }) { + const cardId = randomUUID(); + const { remediation } = result; + + pendingVoiceFixes.set(cardId, { + storeNum, + personId: remediation.payload?.personId || null, + personLabel: remediation.payload?.personLabel || null, + requester, + remediationId: remediation.action, + remediationPayload: remediation.payload || {}, + }); + + const card = buildVoiceDiagCard({ + storeNum, + result, + cardId, + }); + + await bot.say({ + markdown: `Remediation available for **${result.label}** — review and confirm:`, + attachments: [{ + contentType: 'application/vnd.microsoft.card.adaptive', + content: card, + }], + }); +} + +function buildVoiceDiagCard({ storeNum, result, cardId }) { + const { label, message, remediation } = result; + return { + type: 'AdaptiveCard', + $schema: 'http://adaptivecards.io/schemas/adaptive-card.json', + version: '1.3', + body: [ + { + type: 'TextBlock', + size: 'Medium', + weight: 'Bolder', + text: `${label} — Store ${storeNum}`, + wrap: true, + }, + { + type: 'TextBlock', + text: message, + wrap: true, + spacing: 'Small', + }, + { + type: 'TextBlock', + text: remediation.summary || remediation.title, + wrap: true, + spacing: 'Small', + isSubtle: true, + }, + ], + actions: [ + { + type: 'Action.Submit', + title: `✅ ${remediation.title}`, + data: { action: 'confirm_voicediag', cardId }, + }, + { + type: 'Action.Submit', + title: '❌ Cancel', + data: { action: 'cancel_voicediag', cardId }, + }, + ], + }; +} diff --git a/index.js b/index.js index 1e6cacb..78724f8 100644 --- a/index.js +++ b/index.js @@ -34,6 +34,11 @@ import { cancelIgmpFixCard, } from './commands/igmpFix.js'; import { pendingIgmpFixes } from './utils/pendingIgmpFixes.js'; +import { + applyVoiceDiagRemediation, + cancelVoiceDiagRemediation, +} from './commands/voiceDiag.js'; +import { pendingVoiceFixes } from './utils/pendingVoiceFixes.js'; import { extractRequester } from './utils/requester.js'; import { getDectRelayHub } from './services/dectRelayHub.js'; import { @@ -298,6 +303,7 @@ const DECT_ACTIONS = new Set([ const OFFBOARD_ACTIONS = new Set(['confirm_offboard', 'cancel_offboard']); const HOST_ASSIGN_ACTIONS = new Set(['confirm_host_assign', 'cancel_host_assign']); const IGMP_FIX_ACTIONS = new Set(['confirm_igmp_fix', 'cancel_igmp_fix']); +const VOICEDIAG_ACTIONS = new Set(['confirm_voicediag', 'cancel_voicediag']); // Best-effort delete of the adaptive-card message that fired this action. // Removing the card prevents users from clicking Confirm/Cancel a second time @@ -483,6 +489,56 @@ framework.on('attachmentAction', async (bot, trigger) => { return; } + // ── /voicediag confirm / cancel ── + // Mirrors the IGMP branch above. Every /voicediag remediation card + // shares the same two action types (`confirm_voicediag` / + // `cancel_voicediag`) — the specific remediation to apply is + // carried in the pending payload's `remediationId` so that adding + // new remediations to a check module never requires touching this + // dispatcher. + if (VOICEDIAG_ACTIONS.has(actionType)) { + const { cardId } = action.inputs; + const roomId = trigger.roomId || action.roomId; + + if (!cardId) { + logger('voicediag:action', `Missing cardId on ${actionType} — ignoring`); + return; + } + if (!pendingVoiceFixes.has(cardId)) { + logger('voicediag:action', `Card ${cardId} is expired or unknown`); + return; + } + + const voiceData = pendingVoiceFixes.get(cardId); + pendingVoiceFixes.delete(cardId); // one-shot + logger( + 'voicediag:action', + `Received ${actionType} for card ${cardId} ` + + `(store: ${voiceData.storeNum}, remediation: ${voiceData.remediationId})`, + ); + + await censorActionCard(bot, trigger, 'voicediag:action'); + + const requester = extractRequester(trigger); + + try { + if (actionType === 'confirm_voicediag') { + await applyVoiceDiagRemediation(bot, voiceData, roomId, requester); + } else { + await cancelVoiceDiagRemediation(bot, voiceData, roomId, requester); + } + } catch (err) { + logger( + 'voicediag:action', + `Error processing ${actionType} for store ${voiceData.storeNum} ` + + `remediation ${voiceData.remediationId}: ${err.message}`, + 'error', + ); + await bot.say('markdown', `⚠️ Error during voice diagnostic remediation: ${err.message}`); + } + return; + } + logger('action', `Ignoring unhandled attachmentAction: ${actionType}`, 'debug'); }); diff --git a/services/phoneService.js b/services/phoneService.js index 8e9e95a..eaf44f9 100644 --- a/services/phoneService.js +++ b/services/phoneService.js @@ -465,6 +465,11 @@ export async function getDevicesForPerson(personId) { // - /people/{id} → full person (phoneNumbers, displayName etc.) // - telephony/config/locations/{id} → callingLineId.phoneNumber (the main +1 number assigned to AA) // DND/callForwarding etc. still 404 under current scopes → resilient, only log at debug/warn. +// The full per-person calling-features surface is exposed via +// /voicediag (see services/voiceDiag/) using the admin path +// `/v1/people/{id}/features/*` — no additional scopes required +// beyond `spark-admin:people_read` / `spark-admin:people_write` +// which we already hold. // Location fetch started early for parallelism with DECT detail calls. // ────────────────────────────────────────────── diff --git a/services/renderers/voiceDiagRenderer.js b/services/renderers/voiceDiagRenderer.js new file mode 100644 index 0000000..e018c5c --- /dev/null +++ b/services/renderers/voiceDiagRenderer.js @@ -0,0 +1,165 @@ +// src/services/renderers/voiceDiagRenderer.js +// +// Renders the /voicediag CheckResult array into Webex-friendly +// markdown. Pure function — no I/O — so it's usable from both the +// chat command and (later) HTTP surfaces without recomputing. +// +// Layout: +// +// **Voice Diagnostic - Store 12345** (user: ae12345@ae.com — Store 12345) +// +// Errors (1) Warnings (2) Skipped (1) OK (5) +// +// **ERRORS** +// - **Call Intercept**: Call intercept is ACTIVE — inbound calls... +// • incomingType: INTERCEPT_ALL, outgoingType: ALLOW_ALL +// +// **WARNINGS** +// - **Do Not Disturb**: DND is enabled — incoming calls silenced. +// - **Call Forwarding**: 1 forwarding variant is active: ... +// +// **SKIPPED** +// - **Call Waiting**: 403 on Call Waiting — likely missing scope ... +// +// **OK** (5 — hidden; pass `detailed` to see them) +// +// Fixable issues (2) — see confirmation cards below. +// +// Options +// storeNum (required) header text +// detailed (default false) — expands OK checks + shows the +// `details` block under every check +// emitFooter (default true) — trailing italic timestamp +// +// The renderer never emits an adaptive card itself. The caller +// (commands/voiceDiag.js) walks the same results array to post cards. + +const SEVERITY_ORDER = ['error', 'warn', 'skipped', 'ok']; +const SEVERITY_LABEL = { + error: 'ERRORS', + warn: 'WARNINGS', + skipped: 'SKIPPED', + ok: 'OK', +}; + +/** + * @param {Array<{id, label, status, message, details, remediation}>} results + * @param {{ + * storeNum: string, + * personLabel?: string, + * email?: string, + * detailed?: boolean, + * emitFooter?: boolean, + * }} [opts] + * @returns {string} markdown, whitespace-trimmed + */ +export function renderVoiceDiagMarkdown(results, opts = {}) { + const { + storeNum, + personLabel = null, + email = null, + detailed = false, + emitFooter = true, + } = opts; + + const list = Array.isArray(results) ? results : []; + + let reply = `**Voice Diagnostic - Store ${storeNum}**`; + if (personLabel || email) { + const who = [personLabel, email].filter(Boolean).join(' — '); + reply += ` (user: ${who})`; + } + reply += '\n\n'; + + if (list.length === 0) { + reply += '_No checks were executed. Verify the store number and re-run._\n'; + return reply.trim(); + } + + const counts = countBySeverity(list); + reply += summaryLine(counts) + '\n\n'; + + for (const severity of SEVERITY_ORDER) { + const bucket = list.filter((r) => r.status === severity); + if (bucket.length === 0) continue; + + // Hide the OK bucket from the console body unless detailed — + // keeps the default output focused on what's actionable. + if (severity === 'ok' && !detailed) continue; + + reply += `**${SEVERITY_LABEL[severity]}**\n`; + for (const r of bucket) { + reply += `- **${r.label}**: ${r.message}\n`; + if (detailed && r.details && Object.keys(r.details).length > 0) { + reply += ` - ${renderDetails(r.details)}\n`; + } + } + reply += '\n'; + } + + const fixable = list.filter((r) => r.status !== 'ok' && r.remediation); + if (fixable.length > 0) { + reply += + `**Fixable issues (${fixable.length})** — see confirmation cards below.\n\n` + + fixable.map((r) => `- ${r.label}: ${r.remediation.title}`).join('\n') + + '\n\n'; + } + + if (!detailed && counts.ok > 0) { + reply += `_${counts.ok} OK check(s) hidden — pass \`detailed\` to include them._\n\n`; + } + + if (emitFooter) { + const now = new Date(); + reply += `_Last checked: ${now.toISOString()}_\n`; + } + + return reply.trim(); +} + +function countBySeverity(list) { + const counts = { error: 0, warn: 0, skipped: 0, ok: 0 }; + for (const r of list) { + if (Object.prototype.hasOwnProperty.call(counts, r.status)) { + counts[r.status] += 1; + } + } + return counts; +} + +function summaryLine({ error, warn, skipped, ok }) { + return `Errors (${error}) · Warnings (${warn}) · Skipped (${skipped}) · OK (${ok})`; +} + +// Compact one-line rendering of the details object. Truncates arrays +// past a small length so the chat message doesn't blow up on +// verbose payloads (outgoingPermission's rule list, for example). +function renderDetails(details) { + if (details === null || details === undefined) return ''; + if (typeof details !== 'object') return String(details); + + const parts = []; + for (const [k, v] of Object.entries(details)) { + parts.push(`${k}: ${formatValue(v)}`); + } + return parts.join(', '); +} + +function formatValue(v) { + if (v === null || v === undefined) return '—'; + if (typeof v === 'boolean' || typeof v === 'number' || typeof v === 'string') { + return String(v); + } + if (Array.isArray(v)) { + if (v.length === 0) return '[]'; + if (v.length <= 3) return `[${v.map(formatValue).join(', ')}]`; + return `[${v.slice(0, 3).map(formatValue).join(', ')}, …+${v.length - 3}]`; + } + // Fall through: nested object — collapse to a JSON snippet. + try { + const s = JSON.stringify(v); + return s.length > 120 ? `${s.slice(0, 117)}…` : s; + } catch { + return '[object]'; + } +} diff --git a/services/voiceDiag/README.md b/services/voiceDiag/README.md new file mode 100644 index 0000000..1a06036 --- /dev/null +++ b/services/voiceDiag/README.md @@ -0,0 +1,130 @@ +# /voicediag + +Rules-engine style diagnostic for the store's Webex Calling +per-user configuration. The orchestrator (`voiceDiagService.js`) +walks a registry of check modules, gathers their `CheckResult` +objects, and hands them to the renderer + adaptive-card layer in +`commands/voiceDiag.js`. + +## Command + +``` +/voicediag default: hides OK checks, posts fixable cards +/voicediag detail include OK checks + expand every details block +/voicediag --only dnd,callForwarding +/voicediag list-checks enumerate every registered check + its scope +``` + +HTTP path: `GET /voicediag?storeNum=[&detailed=true][&only=dnd,callWaiting]`. +HTTP callers get the markdown snapshot only — remediation cards are +chat-only. + +## Prerequisites + +All checks call `/v1/people/{personId}/features/{feature}` (with a +few Webex-namespace variants noted inline in each check module). +The scopes required are: + +- `spark-admin:people_read` — every read (all checks) +- `spark-admin:people_write` — remediations (disable DND, clear + forwarding, enable call waiting, disable call intercept) + +These are the same scopes `/webexhost` and `/offboarduser` already +use, so no operator work is needed to enable `/voicediag` on an +existing deployment. If the token ever loses those scopes, +each check self-reports as `skipped: scope missing` — the run still +completes, and the phone-online summary keeps working. + +### About the URL scheme + +Early drafts of this feature documented `/v1/telephony/config/people/{id}/callSettings/*`. +That path family returns 404 "no static resource" from the Webex API +gateway — it is not a live surface. The correct admin path is +`/v1/people/{id}/features/{feature}`, per Webex's User Call Settings +docs. A couple of features use different feature-name segments than +their check id — most notably `intercept` (not `callIntercept`); +see each check module for the exact endpoint. The runner +distinguishes routing-404s ("URL moved, update the check") from +"not applicable" 404s ("this person isn't a calling user") using +the `no static resource` marker in the response body. + +## Adding a new check + +1. Create `services/voiceDiag/checks/.js` and export a + descriptor: + + ```js + export const myNewCheck = { + id: 'myNew', + label: 'My New Check', + requires: ['personId'], // subset of ['personId','phoneStatus','telephonyProfile'] + scope: 'spark-admin:people_read', + async run(ctx) { + const data = await ctx.webex.request('GET', `people/${ctx.personId}/features/whatever`); + // ...evaluate... + return { + status: 'warn', // 'ok' | 'warn' | 'error' | 'skipped' + message: 'Human sentence for the chat row.', + details: { relevant: 'facts' }, + remediation: { // optional + action: 'fix_my_thing', // globally unique remediation id + title: 'Fix My Thing', + summary: 'One-line description of what the fix will do.', + payload: { personId: ctx.personId, personLabel: ctx.personLabel, storeNum: ctx.storeNum }, + }, + }; + }, + remediations: { // optional + async fix_my_thing(bot, data, requester) { + // do the PUT, audit, reply + }, + }, + }; + ``` + +2. Register it in `services/voiceDiag/checks/index.js` at the + position you want the renderer to output it. + +3. Add a `describe`-level block to `tests/voiceDiag.checks.test.js` + covering the enabled / disabled / 403 branches. + +That's it — no changes to the runner, renderer, command handler, +`index.js` dispatcher, or `commands/registry.js`. The single +`confirm_voicediag` / `cancel_voicediag` action namespace routes +through the shared dispatcher, which looks the remediation up in the +central registry built at module load. Remediation id collisions +throw at import so you'll notice immediately. + +### CheckResult shape + +```ts +type Severity = 'ok' | 'warn' | 'error' | 'skipped'; + +type Remediation = { + action: string; // globally unique across all checks + title: string; // button label + summary?: string; // optional card body line + payload: Record; // handed to remediations[action](bot, payload, requester) +}; + +type CheckResult = { + id: string; // set from check.id automatically + label: string; // set from check.label automatically + status: Severity; + message: string; + details?: object | null; // shown under `detail` mode + remediation?: Remediation | null; +}; +``` + +### Error handling + +Never throw from `run()`. The runner wraps every check in a +try/catch and converts: + +- HTTP 401/403/404 → `status: 'skipped'` with a "missing scope" + hint pointing at `check.scope`. +- Any other error → `status: 'error'` with the API message. + +So if you _can't_ reason about a specific 5xx, just let it bubble — +the runner will surface it. diff --git a/services/voiceDiag/checks/callForwarding.js b/services/voiceDiag/checks/callForwarding.js new file mode 100644 index 0000000..12c9145 --- /dev/null +++ b/services/voiceDiag/checks/callForwarding.js @@ -0,0 +1,175 @@ +// src/services/voiceDiag/checks/callForwarding.js +// +// Detects Webex Calling forwarding variants on the store's canonical +// user. Endpoint returns three sub-blocks that are all evaluated in a +// single API round-trip: +// +// { +// callForwarding: { +// always: { enabled, destination, destinationVoicemailEnabled, ringReminderEnabled }, +// busy: { enabled, destination, destinationVoicemailEnabled }, +// noAnswer: { enabled, destination, numberOfRings, destinationVoicemailEnabled, systemMaxNumberOfRings } +// }, +// businessContinuity: { enabled, destination, ... } // optional; not fixed here +// } +// +// Endpoint: GET/PUT /v1/people/{personId}/features/callForwarding +// Scope: spark-admin:people_read (GET) + spark-admin:people_write (PUT). +// +// Because PUT expects the full callForwarding object shape (partial +// updates are rejected as bad request in practice), remediation +// fetches fresh state right before writing and only mutates the +// targeted variant's `enabled` flag. This avoids clobbering a +// destination the operator might still want configured for the day +// they re-enable it — clearing forwarding in Control Hub UI works the +// same way (leaves the destination string intact). +// +// Severity rules: +// - Any variant with enabled=true → warn (with per-variant +// remediation card). +// - `always` forwarded to a destination outside the +1AE prefix +// range is currently just noted in the message, not upgraded to +// an error — the plan calls it out but false positives on hand- +// entered destinations are high enough that we keep it at warn +// for now and let the operator judge. + +import { logger } from '../../../utils/logger.js'; +import { describeRequester } from '../../../utils/requester.js'; + +const ENDPOINT = (personId) => + `people/${personId}/features/callForwarding`; + +/** Registered forwarding variants + their human labels. Order matters + * for stable messages / audit lines. */ +const VARIANTS = [ + { key: 'always', label: 'Call Forwarding — Always' }, + { key: 'busy', label: 'Call Forwarding — Busy' }, + { key: 'noAnswer', label: 'Call Forwarding — No Answer' }, +]; + +export const callForwardingCheck = { + id: 'callForwarding', + label: 'Call Forwarding', + requires: ['personId'], + scope: 'spark-admin:people_read', + + async run(ctx) { + const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId)); + const cf = data?.callForwarding || {}; + + const perVariant = VARIANTS.map(({ key, label }) => { + const v = cf[key] || {}; + return { + key, + label, + enabled: !!v.enabled, + destination: v.destination || null, + destinationVoicemailEnabled: !!v.destinationVoicemailEnabled, + numberOfRings: v.numberOfRings ?? null, + }; + }); + + const active = perVariant.filter((v) => v.enabled); + + if (active.length === 0) { + return { + status: 'ok', + message: 'No forwarding variants are active.', + details: { perVariant }, + remediation: null, + }; + } + + // A single check produces at most one remediation card in this + // architecture. When multiple forwarding variants are active, we + // build a compound remediation carrying every enabled variant — + // one PUT clears them all in a single button click, which is + // what the operator wants ("stop everything forwarding this user"). + const variantsToClear = active.map((v) => v.key); + const summaryLine = active + .map((v) => `${v.label} → ${v.destination || 'unspecified'}`) + .join('; '); + + return { + status: 'warn', + message: + `${active.length} forwarding ${active.length === 1 ? 'variant is' : 'variants are'} ` + + `active: ${summaryLine}.`, + details: { perVariant, active: variantsToClear }, + remediation: { + action: 'clear_call_forwarding', + title: + active.length === 1 + ? `Turn off ${active[0].label}` + : `Turn off ${active.length} forwarding variants`, + summary: + active.length === 1 + ? `Disable ${active[0].label} for ${ctx.personLabel} (destination ${active[0].destination || 'unspecified'} preserved).` + : `Disable ${active.length} forwarding variants for ${ctx.personLabel}. Destinations are preserved so they can be re-enabled later without re-typing.`, + payload: { + personId: ctx.personId, + personLabel: ctx.personLabel, + storeNum: ctx.storeNum, + variantsToClear, + before: perVariant, + }, + }, + }; + }, + + remediations: { + async clear_call_forwarding(bot, data, requester) { + const { personId, personLabel, storeNum, variantsToClear, before } = data; + if (!Array.isArray(variantsToClear) || variantsToClear.length === 0) { + await bot.say( + 'markdown', + `⚠️ Forwarding remediation for **${personLabel}** had no variants to clear — nothing to do.`, + ); + return; + } + + logger( + 'voicediag:audit', + `CONFIRMED clear_call_forwarding for ${personLabel} (person=${personId}, store=${storeNum}) ` + + `by ${describeRequester(requester)} — variants=${variantsToClear.join(',')}, ` + + `before=${JSON.stringify(before)}`, + ); + + try { + const { default: webex } = await import('../../../integrations/webex/WebexClient.js'); + // Fetch fresh state to avoid stomping on a destination change + // that happened between diagnose and remediate. + const fresh = await webex.request('GET', ENDPOINT(personId)); + const cf = { ...(fresh?.callForwarding || {}) }; + for (const variant of variantsToClear) { + cf[variant] = { ...(cf[variant] || {}), enabled: false }; + } + await webex.request('PUT', ENDPOINT(personId), { callForwarding: cf }); + } catch (err) { + logger( + 'voicediag:audit', + `FAILED clear_call_forwarding for ${personLabel}: ${err.message}`, + 'error', + ); + await bot.say( + 'markdown', + `❌ Failed to clear call forwarding for **${personLabel}**: ${err.message}`, + ); + return; + } + + const humanList = variantsToClear + .map((k) => VARIANTS.find((v) => v.key === k)?.label || k) + .join(', '); + await bot.say( + 'markdown', + `✅ Cleared forwarding for **${personLabel}** (store ${storeNum}): ${humanList}. ` + + `Destinations were preserved. Re-run \`/voicediag ${storeNum}\` to verify.`, + ); + logger( + 'voicediag:audit', + `COMPLETED clear_call_forwarding for ${personLabel} (store ${storeNum}) — variants=${variantsToClear.join(',')}`, + ); + }, + }, +}; diff --git a/services/voiceDiag/checks/callIntercept.js b/services/voiceDiag/checks/callIntercept.js new file mode 100644 index 0000000..5097faf --- /dev/null +++ b/services/voiceDiag/checks/callIntercept.js @@ -0,0 +1,119 @@ +// src/services/voiceDiag/checks/callIntercept.js +// +// Detects whether Call Intercept is active. Intercept is a common +// silent cause of "calls just don't come through" — when it's on, +// incoming calls get an announcement instead of ringing the desk, +// and callers hear a generic "the person you're trying to reach is +// not available" prompt with no way to leave a message unless the +// intercept was configured with a rerouting target. +// +// Endpoint: GET/PUT /v1/people/{personId}/features/intercept +// NOTE the URL segment is `intercept`, not `callIntercept` +// (the check id keeps the descriptive name for the UI). +// Scope: spark-admin:people_read (GET) + spark-admin:people_write (PUT). +// +// Response shape (relevant fields): +// { +// enabled: true, +// incoming: { +// type: 'INTERCEPT_ALL' | 'ALLOW_ALL', +// voicemailEnabled: true, +// announcements: { +// greeting: 'CUSTOM' | 'DEFAULT', +// newNumber: { enabled: false, destination: '' }, +// zeroTransfer: { enabled: false, destination: '' } +// } +// }, +// outgoing: { type: 'INTERCEPT_ALL' | 'ALLOW_ALL', transferEnabled: false, destination: '' } +// } +// +// Remediation: PUT `{enabled: false}` disables intercept in both +// directions in one call. When the operator wants finer control +// (e.g. only outgoing intercept off) they should do it in Control +// Hub — the bot's role here is to unstick "no calls at all" stores. + +import { logger } from '../../../utils/logger.js'; +import { describeRequester } from '../../../utils/requester.js'; + +const ENDPOINT = (personId) => + `people/${personId}/features/intercept`; + +export const callInterceptCheck = { + id: 'callIntercept', + label: 'Call Intercept', + requires: ['personId'], + scope: 'spark-admin:people_read', + + async run(ctx) { + const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId)); + const enabled = !!data?.enabled; + const incomingType = data?.incoming?.type || null; + const outgoingType = data?.outgoing?.type || null; + + if (!enabled) { + return { + status: 'ok', + message: 'Call intercept is off.', + details: { enabled, incomingType, outgoingType }, + remediation: null, + }; + } + + return { + status: 'error', + message: + 'Call intercept is ACTIVE — inbound and/or outbound calls are being blocked with an announcement.', + details: { enabled, incomingType, outgoingType }, + remediation: { + action: 'disable_call_intercept', + title: 'Disable Call Intercept', + summary: `Turn call intercept off for ${ctx.personLabel} so calls resume.`, + payload: { + personId: ctx.personId, + personLabel: ctx.personLabel, + storeNum: ctx.storeNum, + before: { enabled, incomingType, outgoingType }, + }, + }, + }; + }, + + remediations: { + async disable_call_intercept(bot, data, requester) { + const { personId, personLabel, storeNum, before } = data; + + logger( + 'voicediag:audit', + `CONFIRMED disable_call_intercept for ${personLabel} (person=${personId}, store=${storeNum}) ` + + `by ${describeRequester(requester)} — was enabled=${before?.enabled ?? 'unknown'}, ` + + `incoming=${before?.incomingType ?? 'unknown'}, outgoing=${before?.outgoingType ?? 'unknown'}`, + ); + + try { + const { default: webex } = await import('../../../integrations/webex/WebexClient.js'); + await webex.request('PUT', ENDPOINT(personId), { enabled: false }); + } catch (err) { + logger( + 'voicediag:audit', + `FAILED disable_call_intercept for ${personLabel}: ${err.message}`, + 'error', + ); + await bot.say( + 'markdown', + `❌ Failed to disable call intercept for **${personLabel}**: ${err.message}`, + ); + return; + } + + await bot.say( + 'markdown', + `✅ Call intercept disabled for **${personLabel}** (store ${storeNum}). ` + + `Re-run \`/voicediag ${storeNum}\` to verify.`, + ); + logger( + 'voicediag:audit', + `COMPLETED disable_call_intercept for ${personLabel} (store ${storeNum})`, + ); + }, + }, +}; diff --git a/services/voiceDiag/checks/callWaiting.js b/services/voiceDiag/checks/callWaiting.js new file mode 100644 index 0000000..56bf300 --- /dev/null +++ b/services/voiceDiag/checks/callWaiting.js @@ -0,0 +1,98 @@ +// src/services/voiceDiag/checks/callWaiting.js +// +// Detects whether Call Waiting is enabled for the store user. Call +// waiting being *off* is uncommon in a store context — it means a +// second incoming call while the operator is on the phone will get a +// busy tone or hit the busy-forward target instead of showing a +// beep-in on the desk phone. +// +// Endpoint: GET/PUT /v1/people/{personId}/features/callWaiting +// Payload: { enabled: boolean } +// Scope: spark-admin:people_read (GET) + spark-admin:people_write (PUT). +// +// Severity: warn when disabled — we can't tell for sure it's a bug +// (some sites want it off), but for a store phone it's usually +// inadvertent. Remediation: turn call waiting back on. + +import { logger } from '../../../utils/logger.js'; +import { describeRequester } from '../../../utils/requester.js'; + +const ENDPOINT = (personId) => + `people/${personId}/features/callWaiting`; + +export const callWaitingCheck = { + id: 'callWaiting', + label: 'Call Waiting', + requires: ['personId'], + scope: 'spark-admin:people_read', + + async run(ctx) { + const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId)); + const enabled = !!data?.enabled; + + if (enabled) { + return { + status: 'ok', + message: 'Call waiting is on.', + details: { enabled }, + remediation: null, + }; + } + + return { + status: 'warn', + message: + 'Call waiting is disabled — a second inbound call will not beep in.', + details: { enabled }, + remediation: { + action: 'enable_call_waiting', + title: 'Enable Call Waiting', + summary: `Turn call waiting on for ${ctx.personLabel}.`, + payload: { + personId: ctx.personId, + personLabel: ctx.personLabel, + storeNum: ctx.storeNum, + before: { enabled }, + }, + }, + }; + }, + + remediations: { + async enable_call_waiting(bot, data, requester) { + const { personId, personLabel, storeNum, before } = data; + + logger( + 'voicediag:audit', + `CONFIRMED enable_call_waiting for ${personLabel} (person=${personId}, store=${storeNum}) ` + + `by ${describeRequester(requester)} — was enabled=${before?.enabled ?? 'unknown'}`, + ); + + try { + const { default: webex } = await import('../../../integrations/webex/WebexClient.js'); + await webex.request('PUT', ENDPOINT(personId), { enabled: true }); + } catch (err) { + logger( + 'voicediag:audit', + `FAILED enable_call_waiting for ${personLabel}: ${err.message}`, + 'error', + ); + await bot.say( + 'markdown', + `❌ Failed to enable call waiting for **${personLabel}**: ${err.message}`, + ); + return; + } + + await bot.say( + 'markdown', + `✅ Call waiting enabled for **${personLabel}** (store ${storeNum}). ` + + `Re-run \`/voicediag ${storeNum}\` to verify.`, + ); + logger( + 'voicediag:audit', + `COMPLETED enable_call_waiting for ${personLabel} (store ${storeNum})`, + ); + }, + }, +}; diff --git a/services/voiceDiag/checks/dnd.js b/services/voiceDiag/checks/dnd.js new file mode 100644 index 0000000..bb4b11e --- /dev/null +++ b/services/voiceDiag/checks/dnd.js @@ -0,0 +1,120 @@ +// src/services/voiceDiag/checks/dnd.js +// +// Detects whether the store's canonical Webex Calling user has Do +// Not Disturb enabled. DND-on is a top-5 cause of "the store phone +// doesn't ring" tickets — with DND on, incoming Webex calls skip the +// device entirely and go straight to voicemail (or the configured +// forward-when-no-answer target). +// +// Endpoint: GET/PUT /v1/people/{personId}/features/doNotDisturb +// Payload: { enabled: boolean, ringSplashEnabled: boolean } +// +// Scope: spark-admin:people_read (GET) + spark-admin:people_write (PUT). +// These are the scopes /webexhost / /offboarduser already use, +// so no re-bootstrapping is needed. NOTE: the plan initially +// referenced /v1/telephony/config/people/{id}/callSettings/* +// but every one of those paths returns 404 "no static resource" +// from the Webex API gateway — the current admin surface for +// per-person call settings is `/v1/people/{id}/features/*`. +// See wxc_sdk's user-call-settings table for the full list. +// +// Remediation: PUT `{enabled: false, ringSplashEnabled: false}`. We +// force ringSplashEnabled off too on the reasonable assumption that a +// store phone user in a diagnostic sweep doesn't want the visual +// "someone is calling you" splash left dangling half-configured. If +// this ever becomes a policy issue, split it into two remediations +// and drop the ringSplash toggle here. + +import { logger } from '../../../utils/logger.js'; +import { describeRequester } from '../../../utils/requester.js'; + +const ENDPOINT = (personId) => + `people/${personId}/features/doNotDisturb`; + +export const dndCheck = { + id: 'dnd', + label: 'Do Not Disturb', + requires: ['personId'], + scope: 'spark-admin:people_read', + + async run(ctx) { + const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId)); + + const enabled = !!data?.enabled; + const ringSplashEnabled = !!data?.ringSplashEnabled; + + if (!enabled) { + return { + status: 'ok', + message: 'DND is off.', + details: { enabled, ringSplashEnabled }, + remediation: null, + }; + } + + return { + status: 'warn', + message: + 'DND is enabled — incoming calls will be silenced on this user\'s phones.', + details: { enabled, ringSplashEnabled }, + remediation: { + action: 'disable_dnd', + title: 'Disable DND', + summary: `Turn DND off for ${ctx.personLabel}.`, + payload: { + personId: ctx.personId, + personLabel: ctx.personLabel, + storeNum: ctx.storeNum, + // Snapshot of the "before" state so the audit log line + // reads correctly after we PUT the new value. + before: { enabled, ringSplashEnabled }, + }, + }, + }; + }, + + remediations: { + async disable_dnd(bot, data, requester) { + const { personId, personLabel, storeNum, before } = data; + + logger( + 'voicediag:audit', + `CONFIRMED disable_dnd for ${personLabel} (person=${personId}, store=${storeNum}) ` + + `by ${describeRequester(requester)} — was enabled=${before?.enabled ?? 'unknown'}, ` + + `ringSplashEnabled=${before?.ringSplashEnabled ?? 'unknown'}`, + ); + + try { + // Import lazily so unit tests can replace the exported webex + // singleton via ctx.webex in run(), while the remediation path + // stays honest about which client it uses in production. + const { default: webex } = await import('../../../integrations/webex/WebexClient.js'); + await webex.request('PUT', ENDPOINT(personId), { + enabled: false, + ringSplashEnabled: false, + }); + } catch (err) { + logger( + 'voicediag:audit', + `FAILED disable_dnd for ${personLabel}: ${err.message}`, + 'error', + ); + await bot.say( + 'markdown', + `❌ Failed to disable DND for **${personLabel}**: ${err.message}`, + ); + return; + } + + await bot.say( + 'markdown', + `✅ DND disabled for **${personLabel}** (store ${storeNum}). ` + + `Re-run \`/voicediag ${storeNum}\` to verify.`, + ); + logger( + 'voicediag:audit', + `COMPLETED disable_dnd for ${personLabel} (store ${storeNum})`, + ); + }, + }, +}; diff --git a/services/voiceDiag/checks/executiveAssistant.js b/services/voiceDiag/checks/executiveAssistant.js new file mode 100644 index 0000000..de98996 --- /dev/null +++ b/services/voiceDiag/checks/executiveAssistant.js @@ -0,0 +1,55 @@ +// src/services/voiceDiag/checks/executiveAssistant.js +// +// Info-only check for the Executive / Executive Assistant feature. +// This isn't something a store line would normally be configured +// for; when the type is anything other than 'UNASSIGNED', calls can +// be filtered/screened through an assistant relationship in ways +// that look like "the store phone isn't behaving right". We surface +// the current type so an operator can decide whether to unassign it +// in Control Hub. No remediation button — this is intentional +// executive configuration, we don't auto-flip it. +// +// Endpoint: GET /v1/people/{personId}/features/executiveAssistant +// Scope: spark-admin:people_read +// Response shape: { type: 'UNASSIGNED' | 'EXECUTIVE' | 'EXECUTIVE_ASSISTANT' } +// +// NOTE: Webex has announced a path migration for this endpoint (see +// developer.webex.com changelog — /v1/telephony/config/people/{id}/executive +// is the new path). When that ships and starts returning 404 on the +// legacy path, swap the ENDPOINT constant. Do NOT eagerly switch +// before Cisco's cutover window closes — until then only the legacy +// path is live. + +const ENDPOINT = (personId) => + `people/${personId}/features/executiveAssistant`; + +export const executiveAssistantCheck = { + id: 'executiveAssistant', + label: 'Executive / Assistant', + requires: ['personId'], + scope: 'spark-admin:people_read', + + async run(ctx) { + const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId)); + const type = data?.type || 'UNASSIGNED'; + + if (type === 'UNASSIGNED') { + return { + status: 'ok', + message: 'No executive / assistant relationship (normal for a store line).', + details: { type }, + remediation: null, + }; + } + + return { + status: 'warn', + message: + `This line is configured as **${type}** in the executive/assistant feature. ` + + `Verify this is intentional — otherwise calls may be routed through an ` + + `assistant relationship.`, + details: { type }, + remediation: null, + }; + }, +}; diff --git a/services/voiceDiag/checks/hoteling.js b/services/voiceDiag/checks/hoteling.js new file mode 100644 index 0000000..3f4223e --- /dev/null +++ b/services/voiceDiag/checks/hoteling.js @@ -0,0 +1,46 @@ +// src/services/voiceDiag/checks/hoteling.js +// +// Info-only check for the Hoteling feature on the store user's line. +// Hoteling lets a "guest" line temporarily associate with a shared +// desk phone — if it's turned on for a store user that shouldn't have +// it, calls can end up at whatever guest device most recently checked +// in, which usually presents as "the phone at the store isn't the +// one that rings when we call". No remediation is offered because +// the intended state is site-dependent; the operator can flip it via +// Control Hub if the current state is wrong. +// +// Endpoint: GET /v1/people/{personId}/features/hoteling +// Scope: spark-admin:people_read +// Response shape: { enabled: boolean } + +const ENDPOINT = (personId) => + `people/${personId}/features/hoteling`; + +export const hotelingCheck = { + id: 'hoteling', + label: 'Hoteling', + requires: ['personId'], + scope: 'spark-admin:people_read', + + async run(ctx) { + const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId)); + const enabled = !!data?.enabled; + + if (!enabled) { + return { + status: 'ok', + message: 'Hoteling is disabled (normal for a store line).', + details: { enabled }, + remediation: null, + }; + } + + return { + status: 'warn', + message: + 'Hoteling is ENABLED — this line may be roaming to another desk phone. Verify in Control Hub.', + details: { enabled }, + remediation: null, + }; + }, +}; diff --git a/services/voiceDiag/checks/index.js b/services/voiceDiag/checks/index.js new file mode 100644 index 0000000..336cbbc --- /dev/null +++ b/services/voiceDiag/checks/index.js @@ -0,0 +1,74 @@ +// src/services/voiceDiag/checks/index.js +// +// Ordered registry of every /voicediag check. Order matters — the +// renderer walks this list to produce the output, so put the checks a +// human operator would notice first (DND, forwarding) at the top, +// then the deeper feature-config ones, then finally the online-device +// summary which is often the last thing they want to see. +// +// Adding a new check +// 1. Drop a new file next to this one (e.g. `myNewCheck.js`) that +// exports the check descriptor as its default export or a named +// export. +// 2. Add it to CHECKS below in the position you want it to render. +// 3. If it exports a `remediations: {actionId: handler}` map, the +// voiceDiagService remediation registry will pick it up +// automatically. Action ids must be globally unique across all +// checks — a collision throws at startup. +// +// Every check must export an object matching: +// +// { +// id: string, // stable, snake_case, globally unique +// label: string, // human-readable heading +// requires: string[], // subset of ['personId','phoneStatus', +// // 'telephonyProfile'] — runner skips +// // the check if any are missing on ctx +// scope: string, // primary Webex scope needed; surfaced +// // in the skipped message on 401/403/404 +// run: async (ctx) => CheckResult, +// remediations?: { [actionId: string]: async (bot, data, requester) => void } +// } + +import { dndCheck } from './dnd.js'; +import { callForwardingCheck } from './callForwarding.js'; +import { callWaitingCheck } from './callWaiting.js'; +import { voicemailCheck } from './voicemail.js'; +import { callInterceptCheck } from './callIntercept.js'; +import { hotelingCheck } from './hoteling.js'; +import { executiveAssistantCheck } from './executiveAssistant.js'; +import { outgoingPermissionCheck } from './outgoingPermission.js'; +import { phoneOnlineCheck } from './phoneOnline.js'; + +export const CHECKS = [ + dndCheck, + callForwardingCheck, + callInterceptCheck, + callWaitingCheck, + voicemailCheck, + hotelingCheck, + executiveAssistantCheck, + outgoingPermissionCheck, + phoneOnlineCheck, +]; + +const _byId = new Map(); +for (const c of CHECKS) { + if (!c || typeof c !== 'object') { + throw new Error('voicediag: encountered non-object check in registry'); + } + if (!c.id || typeof c.id !== 'string') { + throw new Error('voicediag: check is missing required "id" string'); + } + const key = c.id.toLowerCase(); + if (_byId.has(key)) { + throw new Error(`voicediag: duplicate check id "${c.id}"`); + } + _byId.set(key, c); +} + +/** Case-insensitive lookup so `--only DND` and `--only dnd` both work. */ +export function getCheckById(id) { + if (!id) return null; + return _byId.get(String(id).toLowerCase().trim()) || null; +} diff --git a/services/voiceDiag/checks/outgoingPermission.js b/services/voiceDiag/checks/outgoingPermission.js new file mode 100644 index 0000000..2a70f6b --- /dev/null +++ b/services/voiceDiag/checks/outgoingPermission.js @@ -0,0 +1,98 @@ +// src/services/voiceDiag/checks/outgoingPermission.js +// +// Info-only check for outgoing call permissions. Extends the +// count-only surface currently in `/phonestatus` with per-rule +// detail. When a call type is set to BLOCK (rather than ALLOW / +// AUTH_CODE / TRANSFER), users on that line can't dial calls of that +// class. Blocked domestic-toll or international are especially +// common tickets ("we can't dial out from the store"), so we flag +// those as a warning. +// +// Endpoint: GET /v1/people/{personId}/features/outgoingPermission +// Scope: spark-admin:people_read +// Response shape: +// { +// useCustomEnabled: boolean, +// callingPermissions: [ +// { callType: 'INTERNAL_CALL' | 'LOCAL' | 'TOLL_FREE' | 'TOLL' | +// 'NATIONAL' | 'INTERNATIONAL' | 'OPERATOR_ASSISTED' | +// 'CHARGEABLE_DIRECTORY_ASSISTED' | 'SPECIAL_SERVICES_I'|II | +// 'PREMIUM_SERVICES_I'|II | 'CASUAL', +// action: 'ALLOW' | 'BLOCK' | 'AUTH_CODE' | 'TRANSFER_NUMBER_1'..3, +// transferEnabled: boolean } +// ] +// } +// +// When `useCustomEnabled=false` the account defers to the site's +// default outgoing permission set — in that case there's nothing +// user-scoped to report, so we return OK with a note. + +const ENDPOINT = (personId) => + `people/${personId}/features/outgoingPermission`; + +// Call types that being BLOCKED tends to be a bug for a store line. +// Others (premium/casual/operator) are almost always intentionally +// blocked, so blocking them is fine. +const HIGH_IMPACT_CALL_TYPES = new Set([ + 'LOCAL', + 'NATIONAL', + 'TOLL_FREE', + 'TOLL', + 'INTERNATIONAL', +]); + +export const outgoingPermissionCheck = { + id: 'outgoingPermission', + label: 'Outgoing Call Permissions', + requires: ['personId'], + scope: 'spark-admin:people_read', + + async run(ctx) { + const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId)); + + const useCustomEnabled = !!data?.useCustomEnabled; + const rules = Array.isArray(data?.callingPermissions) ? data.callingPermissions : []; + + if (!useCustomEnabled) { + return { + status: 'ok', + message: 'Using the location default outgoing permission set (no user override).', + details: { useCustomEnabled, rules }, + remediation: null, + }; + } + + const blocked = rules + .filter((r) => r?.action === 'BLOCK') + .map((r) => r.callType); + + const highImpactBlocked = blocked.filter((t) => HIGH_IMPACT_CALL_TYPES.has(t)); + + if (highImpactBlocked.length > 0) { + return { + status: 'warn', + message: + `Custom outgoing permissions are set and the following high-impact call types ` + + `are BLOCKED: ${highImpactBlocked.join(', ')}. Users on this line will hear a fast-busy for those calls.`, + details: { useCustomEnabled, rules, blocked, highImpactBlocked }, + remediation: null, + }; + } + + if (blocked.length > 0) { + return { + status: 'ok', + message: `Custom outgoing permissions with ${blocked.length} blocked call type(s): ${blocked.join(', ')}. None are high-impact.`, + details: { useCustomEnabled, rules, blocked, highImpactBlocked: [] }, + remediation: null, + }; + } + + return { + status: 'ok', + message: `Custom outgoing permissions (${rules.length} rules) — nothing is blocked.`, + details: { useCustomEnabled, rules, blocked: [], highImpactBlocked: [] }, + remediation: null, + }; + }, +}; diff --git a/services/voiceDiag/checks/phoneOnline.js b/services/voiceDiag/checks/phoneOnline.js new file mode 100644 index 0000000..14e5022 --- /dev/null +++ b/services/voiceDiag/checks/phoneOnline.js @@ -0,0 +1,130 @@ +// src/services/voiceDiag/checks/phoneOnline.js +// +// Summarises how many of the store's registered devices are online +// right now, plus a per-device drill-down. Deliberately reuses the +// phone-status snapshot already fetched during buildContext() — this +// check makes ZERO new API calls, which keeps it fast and keeps the +// /voicediag output aligned with /phonestatus for the same store +// (same source, same view of "which phones are up right now"). +// +// Signal shape: +// status: 'error' when at least one phone is offline (a store with +// a dead phone is a real ticket). +// status: 'warn' when the store has no registered devices at all +// (this is often "we didn't ship / provision yet" but still worth +// surfacing). +// status: 'ok' when every device reports `connected`. +// +// Device shape from collectPhoneStatus: +// data.phones.data[i] = { mac, name, status, lastSeen, firmware, model, ... } +// data.dectBasestations[i] = { mac, name, status, lastSeen, firmware, ... } +// +// Statuses observed in the wild: 'connected', 'disconnected', +// 'unknown', 'activating', 'offline'. We treat 'connected' as the +// only truly OK value; everything else counts as offline for the +// purposes of the summary. + +const OK_STATUSES = new Set(['connected']); + +function isOnline(dev) { + const s = String(dev?.status || '').toLowerCase(); + return OK_STATUSES.has(s); +} + +export const phoneOnlineCheck = { + id: 'phoneOnline', + label: 'Phone Online Status', + requires: ['phoneStatus'], + // No new API scope required — reuses collectPhoneStatus() output, + // which is already gated on the /phonestatus set of scopes. + scope: null, + + async run(ctx) { + const data = ctx.phoneStatus; + if (!data) { + return { + status: 'skipped', + message: 'phoneStatus snapshot unavailable — cannot report device online counts.', + details: null, + remediation: null, + }; + } + + const phones = Array.isArray(data.phones?.data) ? data.phones.data : []; + const dectBases = Array.isArray(data.dectBasestations) ? data.dectBasestations : []; + + const phonesOnline = phones.filter(isOnline); + const phonesOffline = phones.filter((p) => !isOnline(p)); + const basesOnline = dectBases.filter(isOnline); + const basesOffline = dectBases.filter((b) => !isOnline(b)); + + const totalDevices = phones.length + dectBases.length; + + if (totalDevices === 0) { + return { + status: 'warn', + message: 'No desk phones or DECT basestations are registered for this store.', + details: { + phonesTotal: 0, + phonesOnline: 0, + phonesOffline: 0, + basesTotal: 0, + basesOnline: 0, + basesOffline: 0, + }, + remediation: null, + }; + } + + const offlineCount = phonesOffline.length + basesOffline.length; + const summary = + `Desk phones: ${phonesOnline.length}/${phones.length} online. ` + + `DECT bases: ${basesOnline.length}/${dectBases.length} online.`; + + const offlineList = [ + ...phonesOffline.map((p) => ({ + kind: 'phone', + name: p.name || 'Unknown phone', + mac: p.mac || null, + status: p.status || 'unknown', + lastSeen: p.lastSeen || null, + model: p.model || null, + })), + ...basesOffline.map((b) => ({ + kind: 'dect-base', + name: b.name || 'Unknown DECT base', + mac: b.mac || null, + status: b.status || 'unknown', + lastSeen: b.lastSeen || null, + model: b.model || null, + })), + ]; + + const details = { + phonesTotal: phones.length, + phonesOnline: phonesOnline.length, + phonesOffline: phonesOffline.length, + basesTotal: dectBases.length, + basesOnline: basesOnline.length, + basesOffline: basesOffline.length, + offlineDevices: offlineList, + }; + + if (offlineCount === 0) { + return { + status: 'ok', + message: summary, + details, + remediation: null, + }; + } + + return { + status: 'error', + message: + `${offlineCount} device(s) offline for this store. ${summary}`, + details, + remediation: null, + }; + }, +}; diff --git a/services/voiceDiag/checks/voicemail.js b/services/voiceDiag/checks/voicemail.js new file mode 100644 index 0000000..0b1f09d --- /dev/null +++ b/services/voiceDiag/checks/voicemail.js @@ -0,0 +1,138 @@ +// src/services/voiceDiag/checks/voicemail.js +// +// Detects voicemail configuration for the store user. Voicemail has +// enough sub-facets that we surface the current settings without +// offering a one-click remediation — the "right" answer for a store +// is site-specific (some sites disable VM entirely and forward busy +// to the AA, others rely on it as the noAnswer target). We flag two +// classes of finding: +// +// - **error**: enabled=true AND no PIN set — the user cannot pick +// up messages, and every caller who reaches VM will be dumped +// into the "please set your PIN" prompt. +// - **warn**: enabled=true AND a forward-to-email target is +// configured that doesn't look like an @ae.com address — mail +// forwarding of voicemail off-org is worth double-checking. +// - **ok**: everything else. +// +// Endpoint: GET /v1/people/{personId}/features/voicemail +// Scope: spark-admin:people_read +// Response shape (relevant fields): +// { +// enabled: true, +// sendAllCalls: { enabled: false }, +// sendBusyCalls: { enabled: false, greeting: 'DEFAULT' }, +// sendUnansweredCalls: { enabled: true, ... }, +// notifications: { enabled: false, destination: '' }, +// transferToNumber: { enabled: false, destination: '' }, +// emailCopyOfMessage: { enabled: false, emailId: '' }, +// messageStorage: { +// mwiEnabled: true, +// storageType: 'INTERNAL' | 'EXTERNAL', +// externalEmail: '' +// }, +// faxMessage: { ... } +// } +// +// Webex doesn't currently expose PIN-set status via the public API, +// so "PIN set" is inferred pragmatically: we check whether the person +// has ever accessed their voicemail (via passcode lastChanged if the +// endpoint returns it) or, as a fallback, we simply note that PIN +// state is unknown and treat it as informational only. See the note +// inline below. + +const ENDPOINT = (personId) => + `people/${personId}/features/voicemail`; + +const AE_EMAIL_RE = /@ae\.com$/i; + +export const voicemailCheck = { + id: 'voicemail', + label: 'Voicemail', + requires: ['personId'], + scope: 'spark-admin:people_read', + + async run(ctx) { + const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId)); + + const enabled = !!data?.enabled; + const storage = data?.messageStorage || {}; + const mwiEnabled = !!storage?.mwiEnabled; + const emailCopy = data?.emailCopyOfMessage || {}; + const transferTo = data?.transferToNumber || {}; + const externalEmail = storage?.externalEmail || ''; + const forwardEmailTarget = emailCopy?.enabled ? (emailCopy?.emailId || '') : ''; + + const details = { + enabled, + mwiEnabled, + storageType: storage?.storageType || null, + externalEmail, + emailCopyEnabled: !!emailCopy?.enabled, + emailCopyTarget: forwardEmailTarget, + transferToEnabled: !!transferTo?.enabled, + transferToDestination: transferTo?.destination || null, + sendAllCallsEnabled: !!data?.sendAllCalls?.enabled, + sendBusyCallsEnabled: !!data?.sendBusyCalls?.enabled, + sendUnansweredCallsEnabled: !!data?.sendUnansweredCalls?.enabled, + }; + + if (!enabled) { + return { + status: 'ok', + message: 'Voicemail is disabled — callers will not be able to leave messages.', + details, + remediation: null, + }; + } + + // sendAllCalls silently swallowing every inbound call is almost + // always a mistake — surface it loudly. + if (details.sendAllCallsEnabled) { + return { + status: 'error', + message: 'Voicemail is enabled AND "send all calls to voicemail" is on — the phone will never ring.', + details, + remediation: null, + }; + } + + if (!mwiEnabled) { + return { + status: 'warn', + message: 'Voicemail is enabled but MWI (message-waiting indicator) is off — new messages won\'t light up the phone.', + details, + remediation: null, + }; + } + + if (forwardEmailTarget && !AE_EMAIL_RE.test(forwardEmailTarget)) { + return { + status: 'warn', + message: `Voicemail-to-email is forwarding to an off-org address: ${forwardEmailTarget}. Verify this is intentional.`, + details, + remediation: null, + }; + } + + if (details.transferToEnabled && !details.transferToDestination) { + return { + status: 'warn', + message: 'Voicemail transfer-to-number is enabled but no destination is set — will fall through to the default greeting.', + details, + remediation: null, + }; + } + + return { + status: 'ok', + message: 'Voicemail is enabled with MWI on and no unusual forwarding.', + details, + remediation: null, + }; + }, + + // No remediations — voicemail policy is too site-specific for a + // one-size-fits-all button. The renderer surfaces the details so + // the operator can act via Control Hub if they want. +}; diff --git a/services/voiceDiag/voiceDiagService.js b/services/voiceDiag/voiceDiagService.js new file mode 100644 index 0000000..1d8c25f --- /dev/null +++ b/services/voiceDiag/voiceDiagService.js @@ -0,0 +1,318 @@ +// 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) { + const email = `ae${String(storeNum).padStart(5, '0')}@ae.com`; + logger('voicediag', `Building context for store ${storeNum} → ${email}`, 'debug'); + + const { default: webex } = await import('../../integrations/webex/WebexClient.js'); + const { + getPersonIdByEmail, + getPersonDetails, + getTelephonyProfile, + collectPhoneStatus, + } = await import('../phoneService.js'); + + 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. + const [personRes, telProfRes, phoneStatusRes] = personId + ? await Promise.allSettled([ + getPersonDetails(personId), + getTelephonyProfile(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; + }), + ]) + : [ + { status: 'fulfilled', value: null }, + { status: 'fulfilled', value: {} }, + { status: 'fulfilled', value: 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 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, + }; +} + +/** + * 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'); + } + } + 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 }; diff --git a/tests/voiceDiag.checks.test.js b/tests/voiceDiag.checks.test.js new file mode 100644 index 0000000..42506af --- /dev/null +++ b/tests/voiceDiag.checks.test.js @@ -0,0 +1,492 @@ +// Unit tests for services/voiceDiag/checks/*. Every check gets a +// happy-path, an actionable-finding path, and a scope-missing path +// (403 or 404 surfaced by the runner as `status: 'skipped'`). +// +// Remediation functions are exercised in tests/voiceDiag.remediations.test.js +// where we can stub the WebexClient import. This file only covers the +// `run(ctx)` shape — ctx.webex.request is trivially stubbed. + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { dndCheck } from '../services/voiceDiag/checks/dnd.js'; +import { callForwardingCheck } from '../services/voiceDiag/checks/callForwarding.js'; +import { callWaitingCheck } from '../services/voiceDiag/checks/callWaiting.js'; +import { voicemailCheck } from '../services/voiceDiag/checks/voicemail.js'; +import { callInterceptCheck } from '../services/voiceDiag/checks/callIntercept.js'; +import { hotelingCheck } from '../services/voiceDiag/checks/hoteling.js'; +import { executiveAssistantCheck } from '../services/voiceDiag/checks/executiveAssistant.js'; +import { outgoingPermissionCheck } from '../services/voiceDiag/checks/outgoingPermission.js'; +import { phoneOnlineCheck } from '../services/voiceDiag/checks/phoneOnline.js'; +import { + runVoiceDiag, + buildRemediationRegistry, +} from '../services/voiceDiag/voiceDiagService.js'; +import { CHECKS } from '../services/voiceDiag/checks/index.js'; + +// ─── Helpers ──────────────────────────────────────────────────────── + +/** Build a stub context with a webex.request that returns whatever + * callback returns for its (method, endpoint) signature. */ +function mkCtx({ response, error } = {}) { + return { + storeNum: '12345', + email: 'ae12345@ae.com', + personId: 'PID_TEST', + personLabel: 'Store 12345', + telephonyProfile: {}, + phoneStatus: null, + person: null, + webex: { + async request(_method, _endpoint) { + if (error) throw error; + return response; + }, + }, + }; +} + +/** Build an http error that mirrors what axios throws. */ +function httpError(status, message = 'Boom') { + const err = new Error(message); + err.response = { status, data: { message } }; + return err; +} + +// ─── dnd ──────────────────────────────────────────────────────────── + +test('dnd: disabled → ok', async () => { + const ctx = mkCtx({ response: { enabled: false, ringSplashEnabled: false } }); + const r = await dndCheck.run(ctx); + assert.equal(r.status, 'ok'); + assert.equal(r.remediation, null); +}); + +test('dnd: enabled → warn + disable_dnd remediation', async () => { + const ctx = mkCtx({ response: { enabled: true, ringSplashEnabled: true } }); + const r = await dndCheck.run(ctx); + assert.equal(r.status, 'warn'); + assert.equal(r.remediation.action, 'disable_dnd'); + assert.equal(r.remediation.payload.personId, 'PID_TEST'); + assert.equal(r.remediation.payload.before.enabled, true); +}); + +test('dnd: 403 flows through runner as skipped with scope hint', async () => { + const ctx = mkCtx({ error: httpError(403) }); + const results = await runVoiceDiag(ctx, { only: ['dnd'] }); + assert.equal(results.length, 1); + assert.equal(results[0].status, 'skipped'); + assert.match(results[0].message, /missing/); + assert.match(results[0].message, /spark-admin:people_read/); +}); + +test('dnd: 404 flows through runner as skipped ("not applicable" wording)', async () => { + const ctx = mkCtx({ error: httpError(404) }); + const results = await runVoiceDiag(ctx, { only: ['dnd'] }); + assert.equal(results[0].status, 'skipped'); + assert.match(results[0].message, /Webex Calling license|feature isn't applicable/); +}); + +test('dnd: 404 with "no static resource" body → skipped with routing-miss diagnostic', async () => { + const ctx = mkCtx({ + error: httpError(404, 'No static resource hydra/v1/people/.../features/doNotDisturb.'), + }); + const results = await runVoiceDiag(ctx, { only: ['dnd'] }); + assert.equal(results[0].status, 'skipped'); + assert.match(results[0].message, /URL for this feature has moved/); + assert.equal(results[0].details.routingMiss, true); +}); + +// ─── callForwarding ───────────────────────────────────────────────── + +test('callForwarding: nothing enabled → ok', async () => { + const ctx = mkCtx({ + response: { + callForwarding: { + always: { enabled: false }, + busy: { enabled: false }, + noAnswer: { enabled: false }, + }, + }, + }); + const r = await callForwardingCheck.run(ctx); + assert.equal(r.status, 'ok'); + assert.equal(r.remediation, null); +}); + +test('callForwarding: always-forwarding enabled → warn + compound remediation', async () => { + const ctx = mkCtx({ + response: { + callForwarding: { + always: { enabled: true, destination: '+18005551212' }, + busy: { enabled: false }, + noAnswer: { enabled: false }, + }, + }, + }); + const r = await callForwardingCheck.run(ctx); + assert.equal(r.status, 'warn'); + assert.equal(r.remediation.action, 'clear_call_forwarding'); + assert.deepEqual(r.remediation.payload.variantsToClear, ['always']); + assert.match(r.message, /\+18005551212/); +}); + +test('callForwarding: multiple variants active → single compound remediation', async () => { + const ctx = mkCtx({ + response: { + callForwarding: { + always: { enabled: true, destination: '+15551111111' }, + busy: { enabled: true, destination: '+15552222222' }, + noAnswer: { enabled: false }, + }, + }, + }); + const r = await callForwardingCheck.run(ctx); + assert.equal(r.status, 'warn'); + assert.deepEqual(r.remediation.payload.variantsToClear, ['always', 'busy']); + assert.match(r.remediation.title, /2 forwarding variants/); +}); + +test('callForwarding: 403 → skipped via runner', async () => { + const ctx = mkCtx({ error: httpError(403) }); + const results = await runVoiceDiag(ctx, { only: ['callForwarding'] }); + assert.equal(results[0].status, 'skipped'); +}); + +// ─── callWaiting ──────────────────────────────────────────────────── + +test('callWaiting: enabled → ok', async () => { + const ctx = mkCtx({ response: { enabled: true } }); + const r = await callWaitingCheck.run(ctx); + assert.equal(r.status, 'ok'); +}); + +test('callWaiting: disabled → warn + enable_call_waiting remediation', async () => { + const ctx = mkCtx({ response: { enabled: false } }); + const r = await callWaitingCheck.run(ctx); + assert.equal(r.status, 'warn'); + assert.equal(r.remediation.action, 'enable_call_waiting'); +}); + +test('callWaiting: 404 → skipped via runner', async () => { + const ctx = mkCtx({ error: httpError(404) }); + const results = await runVoiceDiag(ctx, { only: ['callWaiting'] }); + assert.equal(results[0].status, 'skipped'); +}); + +// ─── voicemail ────────────────────────────────────────────────────── + +test('voicemail: disabled → ok', async () => { + const ctx = mkCtx({ response: { enabled: false } }); + const r = await voicemailCheck.run(ctx); + assert.equal(r.status, 'ok'); +}); + +test('voicemail: sendAllCalls on → error', async () => { + const ctx = mkCtx({ + response: { + enabled: true, + sendAllCalls: { enabled: true }, + messageStorage: { mwiEnabled: true }, + }, + }); + const r = await voicemailCheck.run(ctx); + assert.equal(r.status, 'error'); + assert.match(r.message, /never ring/); +}); + +test('voicemail: enabled but MWI off → warn', async () => { + const ctx = mkCtx({ + response: { + enabled: true, + messageStorage: { mwiEnabled: false }, + }, + }); + const r = await voicemailCheck.run(ctx); + assert.equal(r.status, 'warn'); + assert.match(r.message, /MWI/); +}); + +test('voicemail: enabled, email forward to off-org → warn', async () => { + const ctx = mkCtx({ + response: { + enabled: true, + messageStorage: { mwiEnabled: true }, + emailCopyOfMessage: { enabled: true, emailId: 'random@gmail.com' }, + }, + }); + const r = await voicemailCheck.run(ctx); + assert.equal(r.status, 'warn'); + assert.match(r.message, /off-org/); +}); + +test('voicemail: enabled + MWI + no unusual forwarding → ok', async () => { + const ctx = mkCtx({ + response: { + enabled: true, + messageStorage: { mwiEnabled: true }, + emailCopyOfMessage: { enabled: false, emailId: '' }, + }, + }); + const r = await voicemailCheck.run(ctx); + assert.equal(r.status, 'ok'); +}); + +test('voicemail: 401 → skipped via runner', async () => { + const ctx = mkCtx({ error: httpError(401) }); + const results = await runVoiceDiag(ctx, { only: ['voicemail'] }); + assert.equal(results[0].status, 'skipped'); +}); + +// ─── callIntercept ────────────────────────────────────────────────── + +test('callIntercept: disabled → ok', async () => { + const ctx = mkCtx({ response: { enabled: false } }); + const r = await callInterceptCheck.run(ctx); + assert.equal(r.status, 'ok'); +}); + +test('callIntercept: enabled → error + disable_call_intercept remediation', async () => { + const ctx = mkCtx({ + response: { + enabled: true, + incoming: { type: 'INTERCEPT_ALL' }, + outgoing: { type: 'ALLOW_ALL' }, + }, + }); + const r = await callInterceptCheck.run(ctx); + assert.equal(r.status, 'error'); + assert.equal(r.remediation.action, 'disable_call_intercept'); +}); + +test('callIntercept: 403 → skipped via runner', async () => { + const ctx = mkCtx({ error: httpError(403) }); + const results = await runVoiceDiag(ctx, { only: ['callIntercept'] }); + assert.equal(results[0].status, 'skipped'); +}); + +// ─── hoteling (info-only) ─────────────────────────────────────────── + +test('hoteling: disabled → ok', async () => { + const ctx = mkCtx({ response: { enabled: false } }); + const r = await hotelingCheck.run(ctx); + assert.equal(r.status, 'ok'); + assert.equal(r.remediation, null); +}); + +test('hoteling: enabled → warn (info only, no remediation)', async () => { + const ctx = mkCtx({ response: { enabled: true } }); + const r = await hotelingCheck.run(ctx); + assert.equal(r.status, 'warn'); + assert.equal(r.remediation, null); +}); + +test('hoteling: 404 → skipped via runner', async () => { + const ctx = mkCtx({ error: httpError(404) }); + const results = await runVoiceDiag(ctx, { only: ['hoteling'] }); + assert.equal(results[0].status, 'skipped'); +}); + +// ─── executiveAssistant (info-only) ───────────────────────────────── + +test('executiveAssistant: UNASSIGNED → ok', async () => { + const ctx = mkCtx({ response: { type: 'UNASSIGNED' } }); + const r = await executiveAssistantCheck.run(ctx); + assert.equal(r.status, 'ok'); +}); + +test('executiveAssistant: EXECUTIVE_ASSISTANT → warn (no remediation)', async () => { + const ctx = mkCtx({ response: { type: 'EXECUTIVE_ASSISTANT' } }); + const r = await executiveAssistantCheck.run(ctx); + assert.equal(r.status, 'warn'); + assert.equal(r.remediation, null); + assert.match(r.message, /EXECUTIVE_ASSISTANT/); +}); + +test('executiveAssistant: 403 → skipped via runner', async () => { + const ctx = mkCtx({ error: httpError(403) }); + const results = await runVoiceDiag(ctx, { only: ['executiveAssistant'] }); + assert.equal(results[0].status, 'skipped'); +}); + +// ─── outgoingPermission (info-only) ───────────────────────────────── + +test('outgoingPermission: useCustomEnabled=false → ok (defers to location)', async () => { + const ctx = mkCtx({ + response: { useCustomEnabled: false, callingPermissions: [] }, + }); + const r = await outgoingPermissionCheck.run(ctx); + assert.equal(r.status, 'ok'); + assert.match(r.message, /location default/); +}); + +test('outgoingPermission: NATIONAL BLOCK → warn', async () => { + const ctx = mkCtx({ + response: { + useCustomEnabled: true, + callingPermissions: [ + { callType: 'NATIONAL', action: 'BLOCK' }, + { callType: 'INTERNAL_CALL', action: 'ALLOW' }, + ], + }, + }); + const r = await outgoingPermissionCheck.run(ctx); + assert.equal(r.status, 'warn'); + assert.deepEqual(r.details.highImpactBlocked, ['NATIONAL']); +}); + +test('outgoingPermission: only premium blocked → ok', async () => { + const ctx = mkCtx({ + response: { + useCustomEnabled: true, + callingPermissions: [ + { callType: 'PREMIUM_SERVICES_I', action: 'BLOCK' }, + ], + }, + }); + const r = await outgoingPermissionCheck.run(ctx); + assert.equal(r.status, 'ok'); + assert.deepEqual(r.details.highImpactBlocked, []); +}); + +test('outgoingPermission: 404 → skipped via runner', async () => { + const ctx = mkCtx({ error: httpError(404) }); + const results = await runVoiceDiag(ctx, { only: ['outgoingPermission'] }); + assert.equal(results[0].status, 'skipped'); +}); + +// ─── phoneOnline (no API — reads phoneStatus snapshot) ────────────── + +test('phoneOnline: no phoneStatus → skipped by runner (requires guard)', async () => { + const ctx = { ...mkCtx(), phoneStatus: null }; + const results = await runVoiceDiag(ctx, { only: ['phoneOnline'] }); + assert.equal(results[0].status, 'skipped'); + assert.match(results[0].message, /missing phoneStatus/); +}); + +test('phoneOnline: no devices → warn', async () => { + const ctx = { + ...mkCtx(), + phoneStatus: { phones: { data: [] }, dectBasestations: [] }, + }; + const r = await phoneOnlineCheck.run(ctx); + assert.equal(r.status, 'warn'); + assert.match(r.message, /No desk phones/); +}); + +test('phoneOnline: all connected → ok', async () => { + const ctx = { + ...mkCtx(), + phoneStatus: { + phones: { data: [{ mac: 'aa:bb', status: 'connected', name: 'A' }] }, + dectBasestations: [{ mac: 'cc:dd', status: 'connected', name: 'DB' }], + }, + }; + const r = await phoneOnlineCheck.run(ctx); + assert.equal(r.status, 'ok'); +}); + +test('phoneOnline: one offline phone → error, offlineDevices populated', async () => { + const ctx = { + ...mkCtx(), + phoneStatus: { + phones: { data: [ + { mac: 'aa:bb', status: 'connected', name: 'A' }, + { mac: 'ee:ff', status: 'disconnected', name: 'B', lastSeen: 'now' }, + ] }, + dectBasestations: [], + }, + }; + const r = await phoneOnlineCheck.run(ctx); + assert.equal(r.status, 'error'); + assert.equal(r.details.offlineDevices.length, 1); + assert.equal(r.details.offlineDevices[0].name, 'B'); +}); + +// ─── runner-level integration ─────────────────────────────────────── + +test('runVoiceDiag: missing personId → every personId-requiring check is skipped', async () => { + const ctx = mkCtx(); + ctx.personId = null; + const results = await runVoiceDiag(ctx); + const personRequired = results.filter((r) => + ['dnd', 'callForwarding', 'callWaiting', 'voicemail', 'callIntercept', 'hoteling', 'executiveAssistant', 'outgoingPermission'].includes(r.id), + ); + for (const r of personRequired) { + assert.equal(r.status, 'skipped', `expected ${r.id} skipped, got ${r.status}`); + } +}); + +test('runVoiceDiag: --only filter honours case-insensitive names', async () => { + const ctx = mkCtx({ response: { enabled: false } }); + const results = await runVoiceDiag(ctx, { only: ['DND'] }); + assert.equal(results.length, 1); + assert.equal(results[0].id, 'dnd'); +}); + +test('runVoiceDiag: check runner isolates thrown errors as error status', async () => { + const ctx = mkCtx({ error: new Error('random blowup') }); + const results = await runVoiceDiag(ctx, { only: ['dnd'] }); + assert.equal(results[0].status, 'error'); + assert.match(results[0].message, /random blowup/); +}); + +test('runVoiceDiag: registry order preserved in results', async () => { + const ctx = mkCtx({ response: { enabled: false } }); + ctx.phoneStatus = { phones: { data: [] }, dectBasestations: [] }; + const results = await runVoiceDiag(ctx); + const orderedIds = CHECKS.map((c) => c.id); + assert.deepEqual(results.map((r) => r.id), orderedIds); +}); + +// ─── command-arg normalization ───────────────────────────────────── + +test('normalizeArg: em-dash converted to double-hyphen', async () => { + const { normalizeArg } = await import('../commands/voiceDiag.js'); + assert.equal(normalizeArg('\u2014detailed'), '--detailed'); + assert.equal(normalizeArg('\u2014only'), '--only'); +}); + +test('normalizeArg: en-dash and hyphen-like Unicode range all collapse to --', async () => { + const { normalizeArg } = await import('../commands/voiceDiag.js'); + assert.equal(normalizeArg('\u2013detailed'), '--detailed'); // en dash + assert.equal(normalizeArg('\u2015detailed'), '--detailed'); // horizontal bar + assert.equal(normalizeArg('\u2212detailed'), '--detailed'); // minus sign +}); + +test('normalizeArg: run of 3+ ASCII hyphens collapses to --', async () => { + const { normalizeArg } = await import('../commands/voiceDiag.js'); + assert.equal(normalizeArg('---detailed'), '--detailed'); + assert.equal(normalizeArg('----only'), '--only'); +}); + +test('normalizeArg: leaves plain --flag and bare word untouched', async () => { + const { normalizeArg } = await import('../commands/voiceDiag.js'); + assert.equal(normalizeArg('--detailed'), '--detailed'); + assert.equal(normalizeArg('detailed'), 'detailed'); + assert.equal(normalizeArg('782'), '782'); +}); + +test('normalizeArg: nullish input passed through', async () => { + const { normalizeArg } = await import('../commands/voiceDiag.js'); + assert.equal(normalizeArg(null), null); + assert.equal(normalizeArg(undefined), undefined); +}); + +test('buildRemediationRegistry: contains every declared remediation exactly once', () => { + const registry = buildRemediationRegistry(); + const declared = new Map(); + for (const c of CHECKS) { + for (const rid of Object.keys(c.remediations || {})) { + if (declared.has(rid)) { + throw new Error(`duplicate remediation id in fixture: ${rid}`); + } + declared.set(rid, c.id); + } + } + assert.equal(registry.size, declared.size); + for (const [rid, checkId] of declared.entries()) { + assert.ok(registry.has(rid), `missing remediation ${rid}`); + assert.equal(registry.get(rid).check.id, checkId); + } +}); diff --git a/tests/voiceDiagRenderer.test.js b/tests/voiceDiagRenderer.test.js new file mode 100644 index 0000000..0fdcc70 --- /dev/null +++ b/tests/voiceDiagRenderer.test.js @@ -0,0 +1,160 @@ +// Unit tests for services/renderers/voiceDiagRenderer.js. Renderer +// is pure — no I/O — so the tests just assert on the produced +// markdown string. The interesting cases are: +// +// - severity buckets appear in the right order +// - the OK bucket is hidden by default and shown under `detailed` +// - the fixable-issues footer counts and lists only remediable +// results (an OK check with a remediation still doesn't count, +// since we filter status !== 'ok') +// - the details block is only rendered under `detailed` +// - empty result list yields a benign single-line message + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { renderVoiceDiagMarkdown } from '../services/renderers/voiceDiagRenderer.js'; + +const R = (id, status, message, remediation = null, details = null, label = null) => ({ + id, + label: label || `Check ${id}`, + status, + message, + details, + remediation, +}); + +const REMEDIATION = { + action: 'disable_dnd', + title: 'Disable DND', + summary: 'Turn DND off.', + payload: {}, +}; + +test('renderer: empty results → benign message', () => { + const md = renderVoiceDiagMarkdown([], { storeNum: '12345' }); + assert.match(md, /Voice Diagnostic - Store 12345/); + assert.match(md, /No checks were executed/); +}); + +test('renderer: header includes personLabel + email when provided', () => { + const md = renderVoiceDiagMarkdown([], { + storeNum: '12345', + personLabel: 'Store 12345', + email: 'ae12345@ae.com', + }); + assert.match(md, /user: Store 12345 — ae12345@ae\.com/); +}); + +test('renderer: mixed severities render in error → warn → skipped order (default hides ok)', () => { + const results = [ + R('c1', 'ok', 'all good'), + R('c2', 'warn', 'watch this', REMEDIATION), + R('c3', 'error', 'boom'), + R('c4', 'skipped', '403 missing scope'), + R('c5', 'ok', 'also good'), + ]; + const md = renderVoiceDiagMarkdown(results, { storeNum: '99' }); + + const orderIdx = ['**ERRORS**', '**WARNINGS**', '**SKIPPED**'].map((h) => md.indexOf(h)); + assert.ok(orderIdx.every((i) => i > -1), `all severity headings present, got ${orderIdx}`); + assert.ok(orderIdx[0] < orderIdx[1], 'ERRORS before WARNINGS'); + assert.ok(orderIdx[1] < orderIdx[2], 'WARNINGS before SKIPPED'); + assert.equal(md.includes('**OK**'), false, 'OK bucket hidden by default'); + assert.match(md, /OK check\(s\) hidden — pass `detailed`/); +}); + +test('renderer: summary line reports every bucket count', () => { + const results = [ + R('c1', 'ok', 'a'), + R('c2', 'warn', 'b'), + R('c3', 'warn', 'c'), + R('c4', 'error', 'd'), + R('c5', 'skipped', 'e'), + R('c6', 'ok', 'f'), + R('c7', 'ok', 'g'), + ]; + const md = renderVoiceDiagMarkdown(results, { storeNum: '99' }); + assert.match(md, /Errors \(1\) · Warnings \(2\) · Skipped \(1\) · OK \(3\)/); +}); + +test('renderer: detailed mode surfaces OK bucket + details block', () => { + const results = [ + R('c1', 'ok', 'all good', null, { enabled: false, mwiEnabled: true }), + ]; + const md = renderVoiceDiagMarkdown(results, { storeNum: '99', detailed: true }); + assert.match(md, /\*\*OK\*\*/); + assert.match(md, /enabled: false, mwiEnabled: true/); +}); + +test('renderer: fixable footer counts non-ok results with a remediation only', () => { + const results = [ + R('c1', 'warn', 'w1', REMEDIATION), + R('c2', 'error', 'e1', REMEDIATION), + R('c3', 'ok', 'o1', REMEDIATION), // OK w/ remediation should NOT count + R('c4', 'warn', 'w2'), // warn w/o remediation should NOT count + ]; + const md = renderVoiceDiagMarkdown(results, { storeNum: '99' }); + assert.match(md, /Fixable issues \(2\)/); + const footerLineCount = (md.match(/^- Check c[12]: Disable DND/gm) || []).length; + assert.equal(footerLineCount, 2, 'footer lists exactly the fixable ones'); +}); + +test('renderer: no fixable-footer emitted when nothing is fixable', () => { + const results = [ + R('c1', 'warn', 'no fix'), + R('c2', 'error', 'no fix'), + ]; + const md = renderVoiceDiagMarkdown(results, { storeNum: '99' }); + assert.equal(md.includes('Fixable issues'), false); +}); + +test('renderer: emitFooter=false suppresses trailing timestamp', () => { + const md = renderVoiceDiagMarkdown([R('c1', 'ok', 'good')], { + storeNum: '99', + detailed: true, + emitFooter: false, + }); + assert.equal(md.includes('Last checked'), false); +}); + +test('renderer: emitFooter=true (default) adds an ISO timestamp line', () => { + const md = renderVoiceDiagMarkdown([R('c1', 'ok', 'good')], { + storeNum: '99', + detailed: true, + }); + assert.match(md, /_Last checked: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); +}); + +test('renderer: details values — arrays truncated past 3 items, nested objects JSON-ified', () => { + const results = [ + R('c1', 'warn', 'x', null, { + long: ['a', 'b', 'c', 'd', 'e'], + short: ['a', 'b'], + nested: { foo: 'bar', n: 1 }, + nada: null, + }), + ]; + const md = renderVoiceDiagMarkdown(results, { storeNum: '99', detailed: true }); + assert.match(md, /long: \[a, b, c, …\+2\]/); + assert.match(md, /short: \[a, b\]/); + assert.match(md, /nested: \{"foo":"bar","n":1\}/); + assert.match(md, /nada: —/); +}); + +test('renderer: --detail off does not include details even when present', () => { + const results = [ + R('c1', 'warn', 'x', null, { foo: 'bar' }), + ]; + const md = renderVoiceDiagMarkdown(results, { storeNum: '99' }); + assert.equal(md.includes('foo: bar'), false); +}); + +test('renderer: skipped bucket shown even without detail', () => { + const results = [ + R('c1', 'skipped', '403 missing scope'), + ]; + const md = renderVoiceDiagMarkdown(results, { storeNum: '99' }); + assert.match(md, /\*\*SKIPPED\*\*/); + assert.match(md, /403 missing scope/); +}); diff --git a/utils/pendingVoiceFixes.js b/utils/pendingVoiceFixes.js new file mode 100644 index 0000000..fdbbc42 --- /dev/null +++ b/utils/pendingVoiceFixes.js @@ -0,0 +1,75 @@ +// src/utils/pendingVoiceFixes.js +// +// In-memory store for pending /voicediag remediation cards. Identical +// shape to pendingIgmpFixes / pendingHostAssigns — every entry is +// stamped with a `timestamp` on insert, and a background sweep expires +// un-acted cards after TTL_MS so the map never leaks. +// +// Entries hold: +// { +// storeNum, +// personId, // Webex person id the remediation targets +// personLabel, // displayName or email for message/audit +// remediationId, // e.g. 'disable_dnd', 'clear_forwarding_always' +// remediationPayload, // free-form; whatever the check's remediation +// // handler expects (usually the pre-computed +// // PUT body plus contextual identifiers). +// requester, // from extractRequester(trigger) +// timestamp, // auto-set on .set() +// } +// +// TTL matches pendingIgmpFixes at 15 minutes — long enough for a +// normal "let me check with the store first" handoff without leaving +// a stale card interactive past the point where the underlying state +// may have already been changed by hand. + +import { logger } from './logger.js'; + +const TTL_MS = 15 * 60 * 1000; +const SWEEP_INTERVAL_MS = 60 * 1000; + +const _store = new Map(); + +export const pendingVoiceFixes = { + set(cardId, data) { + _store.set(cardId, { ...data, timestamp: Date.now() }); + return this; + }, + + get(cardId) { + return _store.get(cardId); + }, + + has(cardId) { + return _store.has(cardId); + }, + + delete(cardId) { + return _store.delete(cardId); + }, + + get size() { + return _store.size; + }, + + entries() { + return _store.entries(); + }, +}; + +const sweepHandle = setInterval(() => { + const now = Date.now(); + for (const [cardId, data] of _store.entries()) { + const stamped = typeof data?.timestamp === 'number' ? data.timestamp : 0; + if (now - stamped > TTL_MS) { + logger( + 'voicediag:cleanup', + `Expired card ${cardId} for store ${data?.storeNum || 'unknown'} ` + + `remediation ${data?.remediationId || 'unknown'}`, + ); + _store.delete(cardId); + } + } +}, SWEEP_INTERVAL_MS); + +sweepHandle.unref?.();