collabSupport/services/voiceDiag/checks/phoneOnline.js
jmcqueen 2eb31a2ddc Add /voicediag rules-engine command with 9 per-user calling checks
Introduces a new diagnostic command that walks a registry of check
modules against a store user's Webex Calling configuration and
surfaces per-issue adaptive-card remediation for the fixable ones.

Checks (services/voiceDiag/checks/): dnd, callForwarding, callWaiting,
callIntercept, voicemail, hoteling, executiveAssistant,
outgoingPermission, phoneOnline. Remediations offered for DND,
forwarding, waiting, and intercept.

Uses the /v1/people/{id}/features/* admin surface (spark-admin:people_read
+ spark-admin:people_write scopes we already hold) — the earlier
telephony/config/people/*/callSettings/* path scheme returns 404 from
the Webex gateway and is not a live surface. Runner distinguishes
routing-404s ("URL moved") from "not applicable" 404s ("no calling
license") via the response body.

Arg parser accepts detail/detailed/--detail/--detailed and normalises
macOS smart-dashes so --detailed doesn't die when auto-correct
turns it into an em-dash.

Wires a VOICEDIAG_ACTIONS dispatcher in index.js mirroring the IGMP
branch, and registers /voicediag in commands/registry.js. 170 tests
pass (52 new: 39 check + 12 renderer + 5 arg-normalization).

Docs updated in .env.example, services/phoneService.js:467, and a new
services/voiceDiag/README.md that includes a "how to add a check"
recipe plus a note on the earlier wrong URL scheme.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 18:51:31 -04:00

130 lines
4.1 KiB
JavaScript

// src/services/voiceDiag/checks/phoneOnline.js
//
// Summarises how many of the store's registered devices are online
// right now, plus a per-device drill-down. Deliberately reuses the
// phone-status snapshot already fetched during buildContext() — this
// check makes ZERO new API calls, which keeps it fast and keeps the
// /voicediag output aligned with /phonestatus for the same store
// (same source, same view of "which phones are up right now").
//
// Signal shape:
// status: 'error' when at least one phone is offline (a store with
// a dead phone is a real ticket).
// status: 'warn' when the store has no registered devices at all
// (this is often "we didn't ship / provision yet" but still worth
// surfacing).
// status: 'ok' when every device reports `connected`.
//
// Device shape from collectPhoneStatus:
// data.phones.data[i] = { mac, name, status, lastSeen, firmware, model, ... }
// data.dectBasestations[i] = { mac, name, status, lastSeen, firmware, ... }
//
// Statuses observed in the wild: 'connected', 'disconnected',
// 'unknown', 'activating', 'offline'. We treat 'connected' as the
// only truly OK value; everything else counts as offline for the
// purposes of the summary.
const OK_STATUSES = new Set(['connected']);
function isOnline(dev) {
const s = String(dev?.status || '').toLowerCase();
return OK_STATUSES.has(s);
}
export const phoneOnlineCheck = {
id: 'phoneOnline',
label: 'Phone Online Status',
requires: ['phoneStatus'],
// No new API scope required — reuses collectPhoneStatus() output,
// which is already gated on the /phonestatus set of scopes.
scope: null,
async run(ctx) {
const data = ctx.phoneStatus;
if (!data) {
return {
status: 'skipped',
message: 'phoneStatus snapshot unavailable — cannot report device online counts.',
details: null,
remediation: null,
};
}
const phones = Array.isArray(data.phones?.data) ? data.phones.data : [];
const dectBases = Array.isArray(data.dectBasestations) ? data.dectBasestations : [];
const phonesOnline = phones.filter(isOnline);
const phonesOffline = phones.filter((p) => !isOnline(p));
const basesOnline = dectBases.filter(isOnline);
const basesOffline = dectBases.filter((b) => !isOnline(b));
const totalDevices = phones.length + dectBases.length;
if (totalDevices === 0) {
return {
status: 'warn',
message: 'No desk phones or DECT basestations are registered for this store.',
details: {
phonesTotal: 0,
phonesOnline: 0,
phonesOffline: 0,
basesTotal: 0,
basesOnline: 0,
basesOffline: 0,
},
remediation: null,
};
}
const offlineCount = phonesOffline.length + basesOffline.length;
const summary =
`Desk phones: ${phonesOnline.length}/${phones.length} online. ` +
`DECT bases: ${basesOnline.length}/${dectBases.length} online.`;
const offlineList = [
...phonesOffline.map((p) => ({
kind: 'phone',
name: p.name || 'Unknown phone',
mac: p.mac || null,
status: p.status || 'unknown',
lastSeen: p.lastSeen || null,
model: p.model || null,
})),
...basesOffline.map((b) => ({
kind: 'dect-base',
name: b.name || 'Unknown DECT base',
mac: b.mac || null,
status: b.status || 'unknown',
lastSeen: b.lastSeen || null,
model: b.model || null,
})),
];
const details = {
phonesTotal: phones.length,
phonesOnline: phonesOnline.length,
phonesOffline: phonesOffline.length,
basesTotal: dectBases.length,
basesOnline: basesOnline.length,
basesOffline: basesOffline.length,
offlineDevices: offlineList,
};
if (offlineCount === 0) {
return {
status: 'ok',
message: summary,
details,
remediation: null,
};
}
return {
status: 'error',
message:
`${offlineCount} device(s) offline for this store. ${summary}`,
details,
remediation: null,
};
},
};