# /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. ## Store voice standards The store phone standard is enforced by the checks below. Every check descriptor exports a `standards` object so the desired state is legible from the check file without reading `run()`. Changes here are meant to be a two-step change: update `standards`, then teach `run()` to interpret it — the checks compare the live state against `standards` and emit the severity in the "when non-compliant" column. ### Per-user Webex Calling standards | Check | Standard | Non-compliant severity | Auto-remediation | | -------------------- | -------------------------------------------------------- | ---------------------- | ------------------------------------ | | `dnd` | `enabled: false` | warn | `disable_dnd` | | `callForwarding` | `{always, busy, noAnswer}.enabled: false` | **error** | `clear_call_forwarding` | | `callWaiting` | `enabled: true` | warn | `enable_call_waiting` | | `callIntercept` | `enabled: false` | error | `disable_call_intercept` | | `voicemail` | `enabled: true`, all three `send*Calls.enabled: false`, `mwiEnabled: true` | error on send-to-VM, warn on MWI-off / disabled / off-org email | `stop_sending_to_voicemail` for the send-to-VM path only | | `hoteling` | `enabled: false` | warn | `disable_hoteling` | | `executiveAssistant` | `type: 'UNASSIGNED'` | warn | none — Control Hub cleanup | | `outgoingPermission` | no high-impact call type BLOCKED (LOCAL, NATIONAL, TOLL_FREE, TOLL, INTERNATIONAL) | warn | none — location-scoped fix | `callForwarding` is intentionally at `error` severity: forwarding active on a store line silently drops customer calls, and it's the single most common voice ticket. Voicemail's `error` path is narrower — only the three send-* triggers upgrade to error, since that also silently swallows calls. MWI-off, off-org email forwarding, and voicemail-disabled all stay at warn. ### Switchport / port-hygiene standards These reuse the phone-status snapshot from `/phonestatus` — no extra Webex API calls — and cross-reference against the Meraki port config that already flows through `services/enrichment/merakiEnrichment.js`. Trunk uplinks are treated as a visibility boundary: the phone is behind a non-Meraki switch (typically a Cisco stack in a store) and per-port policy isn't ours to enforce, so downstream VLAN / PoE / admin-state checks defer to the operator. | Check | Standard | Non-compliant severity | Notes | | ------------- | ---------------------------------------------------- | ---------------------- | ----------------------------------------------- | | `portType` | `portType: 'access'` on every wired phone / DECT base | warn | Trunk uplinks flagged so operator checks the downstream switch | | `portVlan` | `vlan === VOICE_STANDARD_PHONE_VLAN` (default `102`) | warn | Env-configurable — VLAN may move from data-side to a proper voice VLAN in the future | | `portPoe` | `poeEnabled: true` | warn | Skipped for trunk-uplinked devices | | `portEnabled` | `portEnabled: true` | **error** | Admin-disabled port → phone is dead | No auto-remediation on any port check — Meraki port-config PUTs are a separate scope of work; the operator handles fixes in the Meraki Dashboard. ### Environment overrides Both port-hygiene knobs live in `.env`: | Var | Default | Purpose | | ------------------------------ | ------- | ---------------------------------------------------------------------------------------------- | | `VOICE_STANDARD_PHONE_VLAN` | `102` | Expected VLAN for a store phone. Set per-site if the fleet moves onto a proper voice VLAN. | | `VOICE_STANDARD_ENABLED` | `true` | Global kill-switch for the port-hygiene bucket. `false` silences portType / portVlan / portPoe / portEnabled while Meraki cleanup is in progress. Feature-config checks always run. | ## Apply-all-N-fixes card When two or more checks return fixable results, `/voicediag` posts one extra adaptive card at the bottom offering to apply the whole batch in a single click. The individual per-issue cards stay on-screen so operators can still pick and choose; the combined card is a shortcut for the common "everything looks right, do it all" case. The batch executes each fix in sequence (not parallel) so audit lines stay readable and per-person Webex API write throttling doesn't stack; failures accumulate into a final summary line rather than aborting the run. Dispatcher-side, the combined card uses the same `pendingVoiceFixes` map and the same voicediag branch in `index.js` — the payload's `combined: true` flag + the `confirm_voicediag_all` / `cancel_voicediag_all` action ids are what pick the batch handler over the single-fix handler. ## Adding a new check 1. Create `services/voiceDiag/checks/.js` and export a descriptor: ```js // The standards block is *the* source of truth for the desired // state. Keep the run() comparison in sync — regression tests // in tests/voiceDiag.checks.test.js assert every check has one. export const MY_NEW_STANDARDS = Object.freeze({ enabled: false }); export const myNewCheck = { id: 'myNew', label: 'My New Check', requires: ['personId'], // subset of ['personId','phoneStatus','telephonyProfile'] scope: 'spark-admin:people_read', standards: MY_NEW_STANDARDS, async run(ctx) { const data = await ctx.webex.request('GET', `people/${ctx.personId}/features/whatever`); if (!!data?.enabled === MY_NEW_STANDARDS.enabled) { return { status: 'ok', message: 'Compliant.', details: null, remediation: null }; } 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.