collabSupport/services/voiceDiag/checks/dnd.js
jmcqueen d12723d010 Voicediag: store voice standards + port-hygiene checks + apply-all card
Refactor every /voicediag check to declare a top-level `standards`
object so the desired state is legible without reading run() logic
and can drive a documented reference table. Upgrade callForwarding
to error severity, tighten voicemail with three send-to-VM error
paths + a `stop_sending_to_voicemail` remediation, and add a
`disable_hoteling` remediation.

Add a port-hygiene check bucket under services/voiceDiag/checks/port
(portType, portVlan, portPoe, portEnabled) that reuses the phone-
status snapshot to enforce switchport standards. Configurable via
VOICE_STANDARD_PHONE_VLAN (default 102) and VOICE_STANDARD_ENABLED
(kill-switch). Preserve Meraki `portType`/`voiceVlan`/`dataVlan`
through the enrichment chain so the checks have clean data to read.

Add an "apply all N fixes" combined card that shows up when 2+
remediations are available. New confirm_voicediag_all /
cancel_voicediag_all actions run each fix in sequence (readable
audit trail, no per-person write-throttle stacking), accumulate
individual failures into a summary rather than aborting.

Adds regression tests asserting every check exposes .standards,
plus coverage for port checks, kill-switch, and combined-card
iteration. 63 tests in the checks file, 188 total, all green.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 14:14:38 -04:00

133 lines
5 KiB
JavaScript

// 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`;
// The desired-state contract for a store phone user. Exposed as a
// top-level field on the check descriptor so the "voice standards"
// reference table + regression tests can inspect it without reading
// run() logic. Change the values here, not in the run() body.
export const DND_STANDARDS = Object.freeze({
enabled: false,
ringSplashEnabled: false,
});
export const dndCheck = {
id: 'dnd',
label: 'Do Not Disturb',
requires: ['personId'],
scope: 'spark-admin:people_read',
standards: DND_STANDARDS,
async run(ctx) {
const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId));
const enabled = !!data?.enabled;
const ringSplashEnabled = !!data?.ringSplashEnabled;
// Compliance = DND off. ringSplashEnabled only matters when DND
// is on (per Webex spec — it's the "visual reminder" toggle for
// splash notifications while calls are being silenced), so we
// don't count a stale ringSplash=true as a deviation while
// DND is already off. The standard's ringSplashEnabled=false is
// the value we PUT during remediation, not an independent rule.
if (enabled === DND_STANDARDS.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), { ...DND_STANDARDS });
} 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})`,
);
},
},
};