collabSupport/services/voiceDiag/checks/callForwarding.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

186 lines
7.1 KiB
JavaScript

// 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' },
];
// Store-line standard: no forwarding of any variant. Forwarding a
// store line silently routes calls elsewhere, which is a top cause
// of "the store phone isn't ringing" tickets. Hard rule → error
// severity (was warn in the initial cut).
export const CALL_FORWARDING_STANDARDS = Object.freeze({
always: { enabled: false },
busy: { enabled: false },
noAnswer: { enabled: false },
});
export const callForwardingCheck = {
id: 'callForwarding',
label: 'Call Forwarding',
requires: ['personId'],
scope: 'spark-admin:people_read',
standards: CALL_FORWARDING_STANDARDS,
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: 'error',
message:
`${active.length} forwarding ${active.length === 1 ? 'variant is' : 'variants are'} ` +
`active: ${summaryLine}. Store phone standard requires all forwarding disabled.`,
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(',')}`,
);
},
},
};