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>
241 lines
8.7 KiB
JavaScript
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(',')}`,
|
|
);
|
|
},
|
|
},
|
|
};
|