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

241 lines
8.7 KiB
JavaScript

// src/services/voiceDiag/checks/voicemail.js
//
// Detects voicemail configuration for the store user. Store-line
// standard is:
//
// - voicemail ENABLED (so the user can access the box directly if
// they ever need to), but
// - NO call should ever be routed to voicemail — the three
// "send to VM" triggers (sendAllCalls / sendBusyCalls /
// sendUnansweredCalls) must all be OFF.
// - MWI (message-waiting indicator) on so any manually-left
// messages light up the phone.
//
// This is stricter than the initial cut. The rationale: at AE, a
// store phone that goes to VM is a lost customer call. Better for
// the phone to keep ringing (or drop) than to silently swallow the
// call into a VM box nobody checks.
//
// Endpoint: GET/PUT /v1/people/{personId}/features/voicemail
// Scope: spark-admin:people_read (GET) + spark-admin:people_write (PUT).
// 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: { ... }
// }
//
// PUT semantics: partial updates work — you can send just the
// send-*Calls sub-blocks and the other fields are preserved.
// Remediation takes advantage of that to make a minimal-touch PUT.
import { logger } from '../../../utils/logger.js';
import { describeRequester } from '../../../utils/requester.js';
const ENDPOINT = (personId) =>
`people/${personId}/features/voicemail`;
const AE_EMAIL_RE = /@ae\.com$/i;
export const VOICEMAIL_STANDARDS = Object.freeze({
enabled: true,
sendAllCalls: { enabled: false },
sendBusyCalls: { enabled: false },
sendUnansweredCalls: { enabled: false },
messageStorage: { mwiEnabled: true },
});
export const voicemailCheck = {
id: 'voicemail',
label: 'Voicemail',
requires: ['personId'],
scope: 'spark-admin:people_read',
standards: VOICEMAIL_STANDARDS,
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 sendAllCallsEnabled = !!data?.sendAllCalls?.enabled;
const sendBusyCallsEnabled = !!data?.sendBusyCalls?.enabled;
const sendUnansweredCallsEnabled = !!data?.sendUnansweredCalls?.enabled;
const activeSendTriggers = [];
if (sendAllCallsEnabled) activeSendTriggers.push('sendAllCalls');
if (sendBusyCallsEnabled) activeSendTriggers.push('sendBusyCalls');
if (sendUnansweredCallsEnabled) activeSendTriggers.push('sendUnansweredCalls');
const details = {
enabled,
mwiEnabled,
storageType: storage?.storageType || null,
externalEmail,
emailCopyEnabled: !!emailCopy?.enabled,
emailCopyTarget: forwardEmailTarget,
transferToEnabled: !!transferTo?.enabled,
transferToDestination: transferTo?.destination || null,
sendAllCallsEnabled,
sendBusyCallsEnabled,
sendUnansweredCallsEnabled,
activeSendTriggers,
};
// Hard rule #1: VM enabled at all. If VM is disabled we surface
// it as a warn rather than an error — some sites intentionally
// disable VM and route unanswered calls elsewhere, so we don't
// auto-remediate.
if (!enabled) {
return {
status: 'warn',
message: 'Voicemail is disabled — callers will not be able to leave messages.',
details,
remediation: null,
};
}
// Hard rule #2: none of the three send-to-VM triggers may be
// active. This is the store-line standard: VM exists so the
// operator can check the box manually, but no call ever gets
// silently swallowed into it.
if (activeSendTriggers.length > 0) {
const humanTriggers = activeSendTriggers
.map((k) => k.replace(/([A-Z])/g, ' $1').toLowerCase().trim())
.join(', ');
return {
status: 'error',
message:
`Voicemail is receiving calls: ${humanTriggers} ${activeSendTriggers.length === 1 ? 'trigger is' : 'triggers are'} active. ` +
`Store phone standard requires voicemail be reachable manually but never used as a call target.`,
details,
remediation: {
action: 'stop_sending_to_voicemail',
title: 'Stop sending calls to voicemail',
summary:
`Disable ${activeSendTriggers.length} VM trigger(s) for ${ctx.personLabel} so incoming calls stop being routed to voicemail. ` +
`Voicemail itself stays enabled.`,
payload: {
personId: ctx.personId,
personLabel: ctx.personLabel,
storeNum: ctx.storeNum,
activeSendTriggers,
before: {
sendAllCallsEnabled,
sendBusyCallsEnabled,
sendUnansweredCallsEnabled,
},
},
},
};
}
// Soft rules below — no auto-remediation, just visibility.
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, no send-to-VM triggers active, MWI on. Compliant.',
details,
remediation: null,
};
},
remediations: {
async stop_sending_to_voicemail(bot, data, requester) {
const { personId, personLabel, storeNum, activeSendTriggers, before } = data;
if (!Array.isArray(activeSendTriggers) || activeSendTriggers.length === 0) {
await bot.say(
'markdown',
`⚠️ No send-to-VM triggers to clear for **${personLabel}** — nothing to do.`,
);
return;
}
logger(
'voicediag:audit',
`CONFIRMED stop_sending_to_voicemail for ${personLabel} (person=${personId}, store=${storeNum}) ` +
`by ${describeRequester(requester)} — clearing=${activeSendTriggers.join(',')}, ` +
`before=${JSON.stringify(before)}`,
);
try {
const { default: webex } = await import('../../../integrations/webex/WebexClient.js');
// Minimal PUT: only the three send-* sub-blocks. Voicemail
// itself stays enabled + all other config (greetings,
// storage, notifications) is preserved by Webex's partial-
// update semantics on this endpoint.
const body = {
sendAllCalls: { ...VOICEMAIL_STANDARDS.sendAllCalls },
sendBusyCalls: { ...VOICEMAIL_STANDARDS.sendBusyCalls },
sendUnansweredCalls: { ...VOICEMAIL_STANDARDS.sendUnansweredCalls },
};
await webex.request('PUT', ENDPOINT(personId), body);
} catch (err) {
logger(
'voicediag:audit',
`FAILED stop_sending_to_voicemail for ${personLabel}: ${err.message}`,
'error',
);
await bot.say(
'markdown',
`❌ Failed to stop send-to-VM for **${personLabel}**: ${err.message}`,
);
return;
}
await bot.say(
'markdown',
`✅ Voicemail send triggers cleared for **${personLabel}** (store ${storeNum}): ${activeSendTriggers.join(', ')}. ` +
`Voicemail is still enabled but no calls will be routed to it. ` +
`Re-run \`/voicediag ${storeNum}\` to verify.`,
);
logger(
'voicediag:audit',
`COMPLETED stop_sending_to_voicemail for ${personLabel} (store ${storeNum}) — cleared=${activeSendTriggers.join(',')}`,
);
},
},
};