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

108 lines
3.9 KiB
JavaScript

// src/services/voiceDiag/checks/outgoingPermission.js
//
// Info-only check for outgoing call permissions. Extends the
// count-only surface currently in `/phonestatus` with per-rule
// detail. When a call type is set to BLOCK (rather than ALLOW /
// AUTH_CODE / TRANSFER), users on that line can't dial calls of that
// class. Blocked domestic-toll or international are especially
// common tickets ("we can't dial out from the store"), so we flag
// those as a warning.
//
// Endpoint: GET /v1/people/{personId}/features/outgoingPermission
// Scope: spark-admin:people_read
// Response shape:
// {
// useCustomEnabled: boolean,
// callingPermissions: [
// { callType: 'INTERNAL_CALL' | 'LOCAL' | 'TOLL_FREE' | 'TOLL' |
// 'NATIONAL' | 'INTERNATIONAL' | 'OPERATOR_ASSISTED' |
// 'CHARGEABLE_DIRECTORY_ASSISTED' | 'SPECIAL_SERVICES_I'|II |
// 'PREMIUM_SERVICES_I'|II | 'CASUAL',
// action: 'ALLOW' | 'BLOCK' | 'AUTH_CODE' | 'TRANSFER_NUMBER_1'..3,
// transferEnabled: boolean }
// ]
// }
//
// When `useCustomEnabled=false` the account defers to the site's
// default outgoing permission set — in that case there's nothing
// user-scoped to report, so we return OK with a note.
const ENDPOINT = (personId) =>
`people/${personId}/features/outgoingPermission`;
// Call types that being BLOCKED tends to be a bug for a store line.
// Others (premium/casual/operator) are almost always intentionally
// blocked, so blocking them is fine.
const HIGH_IMPACT_CALL_TYPES = new Set([
'LOCAL',
'NATIONAL',
'TOLL_FREE',
'TOLL',
'INTERNATIONAL',
]);
// Store-line standard: no HIGH_IMPACT_CALL_TYPE may be BLOCKED. Any
// other rule (BLOCK on premium/casual/operator, ALLOW on everything)
// is fine. Documented as data so the standards reference table +
// tests can enumerate what "compliant" means.
export const OUTGOING_PERMISSION_STANDARDS = Object.freeze({
highImpactCallTypes: Array.from(HIGH_IMPACT_CALL_TYPES),
highImpactBlockedAllowed: false,
});
export const outgoingPermissionCheck = {
id: 'outgoingPermission',
label: 'Outgoing Call Permissions',
requires: ['personId'],
scope: 'spark-admin:people_read',
standards: OUTGOING_PERMISSION_STANDARDS,
async run(ctx) {
const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId));
const useCustomEnabled = !!data?.useCustomEnabled;
const rules = Array.isArray(data?.callingPermissions) ? data.callingPermissions : [];
if (!useCustomEnabled) {
return {
status: 'ok',
message: 'Using the location default outgoing permission set (no user override).',
details: { useCustomEnabled, rules },
remediation: null,
};
}
const blocked = rules
.filter((r) => r?.action === 'BLOCK')
.map((r) => r.callType);
const highImpactBlocked = blocked.filter((t) => HIGH_IMPACT_CALL_TYPES.has(t));
if (highImpactBlocked.length > 0) {
return {
status: 'warn',
message:
`Custom outgoing permissions are set and the following high-impact call types ` +
`are BLOCKED: ${highImpactBlocked.join(', ')}. Users on this line will hear a fast-busy for those calls.`,
details: { useCustomEnabled, rules, blocked, highImpactBlocked },
remediation: null,
};
}
if (blocked.length > 0) {
return {
status: 'ok',
message: `Custom outgoing permissions with ${blocked.length} blocked call type(s): ${blocked.join(', ')}. None are high-impact.`,
details: { useCustomEnabled, rules, blocked, highImpactBlocked: [] },
remediation: null,
};
}
return {
status: 'ok',
message: `Custom outgoing permissions (${rules.length} rules) — nothing is blocked.`,
details: { useCustomEnabled, rules, blocked: [], highImpactBlocked: [] },
remediation: null,
};
},
};