// 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(',')}`, ); }, }, };