collabSupport/services/phoneService.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

1137 lines
No EOL
46 KiB
JavaScript

// src/services/phoneService.js
import webex from '../integrations/webex/WebexClient.js';
import {
getClientsForStore,
getPortsForStore
} from '../integrations/meraki/clients.js';
import {
getSwitchMulticastSettings,
summarizeMulticast,
} from '../integrations/meraki/switches.js';
import { logger } from '../utils/logger.js';
import { normalizeMac } from './enrichment/normalizers.js';
import { attachMerakiClientWithPorts } from './enrichment/merakiEnrichment.js';
// ──────────────────────────────────────────────
// Main public function
// ──────────────────────────────────────────────
export async function collectPhoneStatus(storeNumber) {
logger('phone:service', `Starting phone status collection for store ${storeNumber}`, 'debug');
const storeNum = String(storeNumber);
const email = `ae${storeNum.padStart(5, '0')}@ae.com`;
logger('phone:service', `Looking up person for store ${storeNum} → email: ${email}`, 'debug');
const personId = await getPersonIdByEmail(email);
// Fetch Webex (phones + dect networks) + clients first, so we can determine relevant switches for ports
// (optimization to reduce Meraki API load / 429 risk, consistent with AV path)
const [phonesRes, dectNetworksRes, clientsRes, personDetailsRes, telephonyProfileRes] = await Promise.allSettled([
getWebexPhonesForStore(storeNum),
personId ? getDectNetworksForPerson(personId) : Promise.resolve([]),
getClientsForStore(storeNum, 1), // 1 day for recent activity/usage in phone status (AV paths keep 7d default)
personId ? getPersonDetails(personId) : Promise.resolve(null),
personId ? getTelephonyProfile(personId) : Promise.resolve({}),
]);
// Tier 2: location main number (auto attendant / full store DID, e.g. +12122194600).
// Kicked off here (right after batch, using dectNetworks result) so it runs in parallel
// with the DECT bases/handsets detail fetches inside the block below. 1 cheap call.
// Tier 2 location main number (auto attendant / full store DID)
let locationDetailsPromise = Promise.resolve(null);
const dectNetsRaw = dectNetworksRes.status === 'fulfilled' ? (dectNetworksRes.value || []) : [];
if (dectNetsRaw.length > 0) {
const locId = dectNetsRaw[0].locationId || dectNetsRaw[0].location?.id;
if (locId && locId !== '—') {
locationDetailsPromise = webex.request('GET', `telephony/config/locations/${locId}`).catch(() => null);
}
}
let phonesList = [];
if (phonesRes.status === 'fulfilled' && Array.isArray(phonesRes.value)) {
phonesList = phonesRes.value;
logger('phone:service', `Received ${phonesList.length} desk phones from Webex`, 'debug');
} else {
logger('phone:service', `Webex phones fetch failed`, 'warn');
}
let clientsData = clientsRes.status === 'fulfilled' ? (clientsRes.value || {}) : {};
const clients = Array.isArray(clientsData) ? clientsData : (clientsData.clients || []);
const networkUrl = clientsData.network?.url || '';
// Kick off the switch multicast fetch as soon as we know the network id.
// Overlaps with the DECT detail fetches below so it's essentially free
// wall-clock time. Wrapped in a promise that never rejects — the phone
// report should still render even if multicast lookup fails (missing
// API scope, non-switch network, etc.). Renderer stays silent when
// `needsFix` is false, so an error-only summary produces no noise.
const networkIdForMulticast = clientsData.network?.id
|| clientsData.networkId
|| null;
const multicastPromise = networkIdForMulticast
? getSwitchMulticastSettings(networkIdForMulticast)
.then((raw) => ({ ...summarizeMulticast(raw), networkId: networkIdForMulticast, networkName: clientsData.network?.name || null }))
.catch((err) => {
logger('phone:service', `Multicast settings fetch failed for network ${networkIdForMulticast}: ${err.message}`, 'warn');
return { needsFix: false, error: err.message, networkId: networkIdForMulticast, networkName: clientsData.network?.name || null };
})
: Promise.resolve({ needsFix: false, error: 'no networkId available', networkId: null, networkName: null });
const personDetails = personDetailsRes.status === 'fulfilled' ? personDetailsRes.value : null;
const telephonyProfile = telephonyProfileRes.status === 'fulfilled' ? telephonyProfileRes.value : {};
// DECT (needs person + networks)
let dectBasestations = [];
let dectHandsets = [];
let dectNetwork = null;
if (dectNetworksRes.status === 'fulfilled' && dectNetworksRes.value?.length > 0) {
dectNetwork = dectNetworksRes.value[0];
const networkId = dectNetwork.id;
const locationId = dectNetwork.locationId;
if (locationId && networkId) {
logger('phone:service', `Fetching DECT data for network ${networkId}`, 'debug');
const [basesResult, handsetsResult] = await Promise.allSettled([
getDectBasestations(locationId, networkId),
getDectHandsets(locationId, networkId)
]);
if (basesResult.status === 'fulfilled') dectBasestations = basesResult.value || [];
if (handsetsResult.status === 'fulfilled' && handsetsResult.value?.length > 0) {
const fullHandsets = await Promise.all(
handsetsResult.value.map(async basic => {
const detail = await getDectHandsetDetails(locationId, networkId, basic.id) || {};
return { ...basic, ...detail }; // preserve mac from basic list, baseStationId etc from detail
})
);
dectHandsets = fullHandsets.filter(h => h);
}
}
}
// Tier 2: await the parallel location fetch (started above) to get main number.
// This is the full E.164 assigned to the auto attendant for the store/location.
const locationDetails = await locationDetailsPromise;
const locationMainNumber = locationDetails?.callingLineId?.phoneNumber || locationDetails?.phoneNumber || null;
// Collect MACs of interest (phones + basestations) to compute relevant switches for ports
const interestMacs = new Set();
phonesList.forEach(p => {
const k = normalizeMac(p.mac);
if (k) interestMacs.add(k);
});
dectBasestations.forEach(b => {
const k = normalizeMac(b.mac);
if (k) interestMacs.add(k);
});
const relevantSwitchesForPorts = new Set();
for (const c of clients) {
const cMac = normalizeMac(c.mac);
if (interestMacs.has(cMac) && c.recentDeviceSerial) {
const conn = (c.recentDeviceConnection || '').toLowerCase();
if (!conn.includes('wireless')) {
relevantSwitchesForPorts.add(c.recentDeviceSerial);
}
}
}
// Fetch ports using relevant switches (saves API calls, consistent with AV optimization)
const portsRes = await getPortsForStore(storeNum, relevantSwitchesForPorts);
const portConfigs = Array.isArray(portsRes) ? portsRes : (portsRes?.ports || portsRes || []);
const portStatusCache = new Map();
// Enrich desk phones using shared Meraki attach (now supports direct .mac, reuses client+port logic + cache)
const phoneBaseDevices = phonesList.map(ph => ({
mac: ph.mac,
identifier: ph.name || ph.displayName || ph.mac || ''
}));
for (const dev of phoneBaseDevices) {
await attachMerakiClientWithPorts(dev, clients, portConfigs, portStatusCache, networkUrl);
}
const enrichedPhones = phonesList.map((ph, i) => {
const attached = phoneBaseDevices[i].meraki || {};
const client = attached.client || attached;
// Flatten for backward compat with phoneStatus command (expects .port, .switchName etc at top of meraki)
return {
...ph,
meraki: {
port: client.portNumber || client.switchport || client.port,
switchName: client.deviceName || client.recentDeviceName || client.switchName,
status: client.status || client.switchportStatus?.status || client.status,
vlan: client.vlan,
ip: client.ip,
lastSeen: client.lastSeen,
portName: client.portName,
poeEnabled: client.poeEnabled,
connectionType: attached.connectionType,
clientUrl: attached.clientUrl || '',
...client
}
};
});
logger('phone:service', `Final enriched desk phones: ${enrichedPhones.length}`, 'debug');
// Enrich DECT basestations the same way (they also show Meraki port info in command)
const baseBaseDevices = dectBasestations.map(b => ({
mac: b.mac,
identifier: b.name || b.mac || ''
}));
for (const dev of baseBaseDevices) {
await attachMerakiClientWithPorts(dev, clients, portConfigs, portStatusCache, networkUrl);
}
const enrichedBasestations = dectBasestations.map((b, i) => {
const attached = baseBaseDevices[i].meraki || {};
const client = attached.client || attached;
return {
...b,
meraki: {
port: client.portNumber || client.switchport || client.port,
switchName: client.deviceName || client.recentDeviceName || client.switchName,
status: client.status || client.switchportStatus?.status || client.status,
vlan: client.vlan,
ip: client.ip,
lastSeen: client.lastSeen,
portName: client.portName,
poeEnabled: client.poeEnabled,
connectionType: attached.connectionType,
clientUrl: attached.clientUrl || '',
...client
}
};
});
// Enrich DECT handsets with Meraki client data too (match on MAC, per user request for topology)
const handsetBaseDevices = dectHandsets.map(h => ({
mac: h.mac,
identifier: h.name || h.mac || ''
}));
for (const dev of handsetBaseDevices) {
await attachMerakiClientWithPorts(dev, clients, portConfigs, portStatusCache, networkUrl);
}
const enrichedHandsets = dectHandsets.map((h, i) => {
const attached = handsetBaseDevices[i].meraki || {};
const client = attached.client || attached;
return {
...h,
meraki: {
port: client.portNumber || client.switchport || client.port,
switchName: client.deviceName || client.recentDeviceName || client.switchName,
status: client.status || client.switchportStatus?.status || client.status,
vlan: client.vlan,
ip: client.ip,
lastSeen: client.lastSeen,
portName: client.portName,
poeEnabled: client.poeEnabled,
connectionType: attached.connectionType,
clientUrl: attached.clientUrl || '',
...client
}
};
});
// Resolve the multicast promise we fired earlier. Awaited here so it
// overlaps with all the DECT/Meraki enrichment above (essentially
// free wall-clock time). The promise catches its own errors so this
// await never rejects.
const multicast = await multicastPromise;
return {
phones: {
status: phonesRes.status === 'fulfilled' ? 'success' : 'failed',
data: enrichedPhones
},
dectBasestations: enrichedBasestations,
dectHandsets: enrichedHandsets,
dectNetwork,
person: personDetails,
telephonyProfile,
locationMainNumber, // Tier 2: full store main number (E.164) from location callingLineId; assigned to auto attendant for this store
meraki: {
status: clientsRes.status === 'fulfilled' ? 'success' : 'failed',
data: clients || [],
network: clientsData.network || null,
networkId: clientsData.networkId || (clientsData.network && clientsData.network.id) || null
},
multicast
};
}
// ──────────────────────────────────────────────
// DECT & Webex helper functions
// ──────────────────────────────────────────────
export async function getPersonIdByEmail(email) {
if (!email) {
logger('phone:service', 'No email provided for lookup', 'warn');
return null;
}
try {
logger('phone:service', `Looking up user by email: ${email}`, 'debug');
const response = await webex.request('GET', 'people', null, { email });
const people = response.items || [];
if (people.length === 0) {
logger('phone:service', `No user found for email: ${email}`, 'warn');
return null;
}
const person = people[0];
logger('phone:service', `Found person ID: ${person.id}`, 'debug');
return person.id;
} catch (err) {
logger('phone:service', `Error looking up user: ${err.message}`, 'error');
return null;
}
}
export async function getDectNetworksForPerson(personId) {
if (!personId) return [];
try {
logger('phone:service', `Fetching DECT networks for person ${personId}`);
const response = await webex.request('GET', `telephony/config/people/${personId}/dectNetworks`);
const networks = response.dectNetworks || [];
logger('phone:service', `DECT networks found: ${networks.length}`, 'debug');
return networks.map(net => ({
id: net.id,
name: net.name || 'Unknown',
handsetsCount: net.numberOfHandsetsAssigned || 0,
locationName: net.location?.name || '—',
locationId: net.location?.id || '—'
}));
} catch (err) {
logger('phone:service', `Error fetching DECT networks: ${err.message}`, 'error');
return [];
}
}
export async function getDectBasestations(locationId, dectNetworkId) {
if (!locationId || !dectNetworkId) return [];
try {
const response = await webex.request(
'GET',
`telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/baseStations`
);
const baseStations = response?.items ?? response?.baseStations ?? [];
return baseStations.map(base => ({
id: base.id,
mac: base.mac || base.macAddress || base.baseMac || '—',
name: base.displayName || `Basestation ${base.mac || base.macAddress || 'Unknown'}`,
status: base.status || 'unknown',
lastSeen: base.lastSeen || 'unknown',
firmware: base.softwareVersion || '—',
model: base.model || '—',
ipAddress: base.ip || '—',
linesRegistered: base.numberOfLinesRegistered || 0
}));
} catch (err) {
logger('phone:service', `Error fetching basestations: ${err.message}`, 'error');
return [];
}
}
export async function getDectHandsets(locationId, dectNetworkId) {
if (!locationId || !dectNetworkId) return [];
try {
const response = await webex.request(
'GET',
`telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/handsets`
);
const handsets = response?.items ?? response?.handsets ?? [];
return handsets.map(handset => ({
id: handset.id,
name: handset.defaultDisplayName || handset.displayName || `Handset ${handset.index || ''}`,
status: handset.status || 'unknown',
lastSeen: handset.lastSeen || 'unknown',
mac: handset.mac || '—',
firmware: handset.softwareVersion || '—',
model: handset.model || '—',
extension: handset.accessCode || handset.lines?.[0]?.esn || '—',
lines: handset.lines || []
}));
} catch (err) {
logger('phone:service', `Error fetching handsets: ${err.message}`, 'error');
return [];
}
}
export async function getDectHandsetDetails(locationId, dectNetworkId, handsetId) {
if (!locationId || !dectNetworkId || !handsetId) return null;
try {
const handset = await webex.request(
'GET',
`telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/handsets/${handsetId}`
);
return {
id: handset.id,
index: handset.index,
name: handset.defaultDisplayName || handset.displayName || `Handset ${handset.index || ''}`,
lastRegistrationTime: handset.lines?.[0]?.lastRegistrationTime || null,
extension: handset.lines?.[0]?.extension || null,
baseStationId: handset.baseStationId || null
};
} catch (err) {
logger('phone:service', `Error fetching handset details: ${err.message}`, 'error');
return null;
}
}
// Main entry point
export async function getWebexPhonesForStore(storeNumber) {
const email = `ae${String(storeNumber).padStart(5, '0')}@ae.com`;
logger('phone:service', `getWebexPhonesForStore called for ${storeNumber}`, 'debug');
const personId = await getPersonIdByEmail(email);
if (!personId) {
logger('phone:service', `No person found for store ${storeNumber}`, 'warn');
return [];
}
const phones = await getDevicesForPerson(personId);
logger('phone:service', `Returned ${phones.length} phones for store ${storeNumber}`, 'debug');
return phones.map(phone => ({
// Core (kept for backward compat + display)
mac: phone.mac || '—',
name: phone.displayName || phone.product + ' ' + (phone.mac?.slice(-4) || 'Unknown') || 'Unknown Phone',
status: phone.connectionStatus || phone.status || 'unknown',
lastSeen: phone.lastSeen || 'unknown',
firmware: phone.software || phone.softwareVersion || '—',
model: phone.product || phone.model || '—',
ipAddress: phone.ip || phone.ipAddress || '—',
// Rich additions for Tier 1 (from raw /devices, carried through ...ph in enrichment)
serial: phone.serial || '—',
product: phone.product || '—',
displayName: phone.displayName || null,
sipUrls: phone.sipUrls || [],
primarySipUrl: phone.primarySipUrl || '—',
errorCodes: phone.errorCodes || [],
capabilities: phone.capabilities || [],
activeInterface: phone.activeInterface || '—',
locationId: phone.locationId || phone.workspaceLocationId || '—',
created: phone.created || phone.firstSeen || null,
upgradeChannel: phone.upgradeChannel || '—',
managedBy: phone.managedBy || '—',
lifecycle: phone.lifecycle || '—'
}));
}
export async function getDevicesForPerson(personId) {
if (!personId) return [];
try {
logger('phone:service', `Fetching devices for person ${personId}`, 'debug');
let allDevices = [];
let next = null;
do {
const params = { personId, max: 100 };
if (next) params.next = next;
const response = await webex.request('GET', 'devices', null, params);
const pageItems = response.items || [];
allDevices = allDevices.concat(pageItems);
next = response.next;
} while (next);
logger('phone:service', `Total devices for person: ${allDevices.length}`, 'debug');
return allDevices;
} catch (err) {
logger('phone:service', `Error fetching devices: ${err.message}`, 'error');
return [];
}
}
// ──────────────────────────────────────────────
// Tier 2: cheap extra profile fetches (person details + telephony config that actually works)
// + location main number (auto attendant / store DID) using already-known locationId from DECT.
// Discovery showed:
// - telephony/config/people/{id} → {announcementLanguage, timeZone}
// - telephony/config/people/{id}/outgoingPermission → {useCustomEnabled, callingPermissions[]}
// - /people/{id} → full person (phoneNumbers, displayName etc.)
// - telephony/config/locations/{id} → callingLineId.phoneNumber (the main +1 number assigned to AA)
// DND/callForwarding etc. still 404 under current scopes → resilient, only log at debug/warn.
// The full per-person calling-features surface is exposed via
// /voicediag (see services/voiceDiag/) using the admin path
// `/v1/people/{id}/features/*` — no additional scopes required
// beyond `spark-admin:people_read` / `spark-admin:people_write`
// which we already hold.
// Location fetch started early for parallelism with DECT detail calls.
// ──────────────────────────────────────────────
export async function getPersonDetails(personId) {
if (!personId) return null;
try {
logger('phone:service', `Fetching full person details for ${personId}`, 'debug');
return await webex.request('GET', `people/${personId}`);
} catch (err) {
logger('phone:service', `Error fetching person details: ${err.message}`, 'warn');
return null;
}
}
export async function getTelephonyProfile(personId) {
if (!personId) return {};
try {
logger('phone:service', `Fetching telephony profile for ${personId}`, 'debug');
const [profileRes, permRes] = await Promise.allSettled([
webex.request('GET', `telephony/config/people/${personId}`),
webex.request('GET', `telephony/config/people/${personId}/outgoingPermission`),
]);
const base = profileRes.status === 'fulfilled' ? profileRes.value : {};
const outgoing = permRes.status === 'fulfilled' ? permRes.value : null;
return {
...base,
outgoingPermission: outgoing,
};
} catch (err) {
logger('phone:service', `Error fetching telephony profile: ${err.message}`, 'warn');
return {};
}
}
// ──────────────────────────────────────────────
// Tier 3 start: recent activity / "call proxy" using data we already have (lastSeen + 1d Meraki usage + DECT reg).
// Real historical (in/out/missed counts + samples) attempted via Detailed Call History CDR Feed (/cdr_feed on analytics* base).
// Falls back gracefully with reason + exact scopes/role hint until Control Hub role + scope propagate and return 2xx items.
// ──────────────────────────────────────────────
export function computeRecentActivity(phones = [], dectBases = [], dectHandsets = [], merakiClients = []) {
const now = Date.now();
const windowMs = 12 * 60 * 60 * 1000; // 12h to align with historical CDR max window
const phoneActivity = phones.map(ph => {
const ls = ph.lastSeen ? Date.parse(ph.lastSeen) : 0;
const mls = ph.meraki?.lastSeen ? Date.parse(ph.meraki.lastSeen) : 0;
const last = Math.max(ls, mls) || 0;
const within = last > 0 && (now - last) < windowMs;
const usage = ph.meraki?.usage || { sent: 0, recv: 0, total: 0 };
return {
identifier: ph.name || ph.mac,
mac: ph.mac,
recent12h: within,
lastSeen: ph.lastSeen || ph.meraki?.lastSeen || null,
usageBytes: usage.total || (usage.sent || 0) + (usage.recv || 0),
status: ph.status,
};
});
const baseActivity = dectBases.map(b => {
const ls = b.lastSeen && b.lastSeen !== 'unknown' ? Date.parse(b.lastSeen) : 0;
const mls = b.meraki?.lastSeen ? Date.parse(b.meraki.lastSeen) : 0;
const last = Math.max(ls, mls) || 0;
const within = last > 0 && (now - last) < windowMs;
const usage = b.meraki?.usage || { sent: 0, recv: 0, total: 0 };
return {
mac: b.mac,
recent12h: within,
lastSeen: b.lastSeen !== 'unknown' ? b.lastSeen : (b.meraki?.lastSeen || null),
linesRegistered: b.linesRegistered,
usageBytes: usage.total || (usage.sent || 0) + (usage.recv || 0),
hasSwitch: !!(b.meraki && (b.meraki.switchName || b.meraki.port)),
};
});
const handsetActivity = dectHandsets.map(h => {
const reg = h.lastRegistrationTime ? (typeof h.lastRegistrationTime === 'number' ? h.lastRegistrationTime : Date.parse(h.lastRegistrationTime)) : 0;
const within = reg > 0 && (now - reg) < windowMs;
return {
name: h.name,
extension: h.extension,
recent12h: within,
lastRegistration: h.lastRegistrationTime || null,
baseStationId: h.baseStationId,
};
});
const activePhones = phoneActivity.filter(a => a.recent12h).length;
const activeBases = baseActivity.filter(a => a.recent12h).length;
const activeHandsets = handsetActivity.filter(a => a.recent12h).length;
const totalUsage = [...phoneActivity, ...baseActivity].reduce((s, a) => s + (a.usageBytes || 0), 0);
return {
windowHours: 12,
summary: {
activePhones12h: activePhones,
activeDECTBases12h: activeBases,
activeHandsets12h: activeHandsets,
totalDataUsageBytes12h: totalUsage,
},
phones: phoneActivity,
dectBasestations: baseActivity,
handsets: handsetActivity,
};
}
// Tier 3: attempt to fetch historical call data via the Detailed Call History (CDR Feed) API.
// Canonical endpoint per the doc at:
// https://developer.webex.com/calling/docs/api/v1/reports-detailed-call-history/get-detailed-call-history
// - Path: /cdr_feed on https://analytics-calling.webexapis.com/v1 (preferred; the analytics.webexapis.com variant may also work depending on routing)
// - Required params: startTime + endTime (full ISO with ms+Z; **max 12h range per request**), locations (the *name* of the location, e.g. "Store 0782" or "Store 2477"; required)
// - Optional: max (500-5000 per page)
// - Hard constraints (per doc + testing):
// - endTime must be at least ~5 minutes in the past (data availability delay).
// - Max 12h of records per call.
// - Rate limit: 1 call per minute + up to 10 pagination calls per minute per token.
// - Auth: same Service App bearer (webex-service-tokens.json) + spark-admin:calling_cdr_read scope + Control Hub role "Webex Calling Detailed Call History API access".
// Response shape: { items: [...] } with call records.
// We always fetch a **single** compliant recent window (the freshest ~12h allowed by the 5min delay + 12h max) to strictly obey the 1 call/min rate limit.
// No multi-window back-to-back calls in one collectPhoneStatus (would 429).
// The `locations` name comes from the person's DECT network (already fetched for main number / dect logic).
// Secondary client-side filter on person numbers inside the location results.
// (recent activity and historical calls features disabled per request)
export async function getHistoricalCallActivity(personId, hours = 24, options = {}) {
if (!personId) return { available: false, reason: 'no personId', calls: [], summary: {} };
const { locationName: providedLocationName } = options || {};
// Prefer analytics-calling (user-confirmed working base for cdr_feed). Fall back to the other if needed.
const ANALYTICS_BASES = [
'https://analytics-calling.webexapis.com/v1',
'https://analytics.webexapis.com/v1'
];
const CDR_PATH = '/cdr_feed';
const MAX_PER_PAGE = 1000;
try {
const person = await webex.request('GET', `people/${personId}`);
const orgId = person?.orgId;
const token = await webex.auth.getAccessToken();
const axiosMod = (await import('axios')).default;
// Get the *required* locations=name from DECT (passed in or self-discover).
let locationName = providedLocationName;
if (!locationName || locationName === '—') {
try {
const dectNets = await getDectNetworksForPerson(personId);
if (dectNets.length > 0) {
locationName = dectNets[0].locationName || dectNets[0].location?.name || null;
}
} catch (e) {
logger('phone:service', `Could not self-fetch DECT networks for CDR location context: ${e.message}`, 'debug');
}
}
if (!locationName || locationName === '—') {
return {
available: false,
reason: 'no locationName (the /cdr_feed API requires the "locations" param using the location *name* from the store/person DECT config)',
scopesNeeded: 'Control Hub role + DECT/network location name must be resolvable for this person',
calls: [],
summary: {},
};
}
// Person identifiers for secondary filtering inside the location results.
const userIds = new Set();
if (person?.displayName) userIds.add(String(person.displayName).toLowerCase());
(person?.emails || []).forEach(e => { if (e) userIds.add(String(e).toLowerCase()); });
(person?.phoneNumbers || []).forEach(p => {
const v = p?.value || p;
if (!v) return;
userIds.add(String(v).toLowerCase());
const digits = String(v).replace(/\D/g, '');
if (digits) {
userIds.add(digits);
if (digits.length >= 4) userIds.add(digits.slice(-4));
if (digits.length >= 5) userIds.add(digits.slice(-5));
}
});
const matchItemToUser = (c) => {
if (!c) return false;
const candidates = [
c.callingNumber, c.calledNumber, c.redirectingNumber,
c.callingParty, c.calledParty, c.remoteParty, c.partyNumber,
c.userName, c.user, c.originator, c.terminator,
c.callingName, c.calledName
].filter(Boolean).map(x => String(x));
for (const val of candidates) {
const low = val.toLowerCase();
const dig = val.replace(/\D/g, '');
for (const id of userIds) {
const idStr = String(id);
if (low.includes(idStr) || (dig && dig.includes(idStr))) return true;
}
}
const uid = c.userId || c.personId || c.ownerId || (c.user && c.user.id);
if (uid && String(uid) === String(personId)) return true;
return false;
};
// Compute a *single* compliant window for the freshest possible data:
// - endTime = now - 5 minutes (API requires data at least ~5min old)
// - window = 12h max (API hard limit)
// This respects rate limits (exactly 1 cdr_feed call per collectPhoneStatus).
// We do not do multiple windows back-to-back (would violate 1 call/min).
const FIVE_MIN_MS = 5 * 60 * 1000;
const TWELVE_HOURS_MS = 12 * 3600 * 1000;
const endMs = Date.now() - FIVE_MIN_MS;
const startMs = endMs - TWELVE_HOURS_MS;
const startTime = new Date(startMs).toISOString();
const endTime = new Date(endMs).toISOString();
logger('phone:service', `Attempting Detailed Call History via cdr_feed for person ${personId} (org ${orgId || 'unknown'}) location="${locationName}" window=${startTime}..${endTime}`, 'debug');
const allRawItems = [];
const fetchErrors = [];
// Helper to robustly extract list, supporting:
// - direct array
// - .items as array
// - .items as { "0": rec, "1": rec, ... } (items[0], items[1] style)
// - top level numeric keys on the data object
// - fallback to first array value found
function extractList(data) {
if (!data) return [];
if (Array.isArray(data)) return data;
if (Array.isArray(data.items)) return data.items;
if (data.items && typeof data.items === 'object' && data.items !== null) {
const keys = Object.keys(data.items).filter(k => /^\d+$/.test(k)).sort((a, b) => parseInt(a) - parseInt(b));
if (keys.length > 0) return keys.map(k => data.items[k]);
}
// top-level numeric keyed?
const topKeys = Object.keys(data).filter(k => /^\d+$/.test(k)).sort((a, b) => parseInt(a) - parseInt(b));
if (topKeys.length > 0) return topKeys.map(k => data[k]);
// any array value
for (const v of Object.values(data)) {
if (Array.isArray(v)) return v;
}
return [];
}
let usedBase = null;
let firstRespData = null;
for (const base of ANALYTICS_BASES) {
const url = `${base}${CDR_PATH}`;
const params = { startTime, endTime, locations: locationName, max: MAX_PER_PAGE };
const queryStr = new URLSearchParams(params).toString();
const fullUrl = `${url}?${queryStr}`;
logger('phone:service', `cdr_feed request URL: ${fullUrl}`, 'debug');
try {
const resp = await axiosMod.get(url, {
headers: { Authorization: `Bearer ${token}` },
params,
timeout: 15000
});
logger('phone:service', `cdr_feed response status=${resp.status} base=${base}`, 'debug');
const d = resp.data || {};
firstRespData = d;
const list = extractList(d);
allRawItems.push(...list);
usedBase = base;
logger('phone:service', `cdr_feed hit on ${base} (loc=${locationName}) → ${list.length} raw (keys=${Object.keys(d).slice(0,8).join(',')})`, 'debug');
// Pagination support (up to 10 additional pages per rate limit)
let nextToken = d.next || d['next'] || (d.metadata && d.metadata.next) || null;
let page = 1;
const MAX_PAGES = 10;
while (nextToken && page < MAX_PAGES) {
page++;
let pageUrl = url;
let pageParams = { ...params, next: nextToken };
if (typeof nextToken === 'string' && nextToken.startsWith('http')) {
pageUrl = nextToken;
pageParams = null;
}
const pageQuery = pageParams ? new URLSearchParams(pageParams).toString() : '';
const pageFull = pageParams ? `${pageUrl}?${pageQuery}` : pageUrl;
logger('phone:service', `cdr_feed pagination page ${page} URL: ${pageFull}`, 'debug');
try {
const pResp = await axiosMod.get(pageUrl, {
headers: { Authorization: `Bearer ${token}` },
params: pageParams,
timeout: 15000
});
logger('phone:service', `cdr_feed pagination status=${pResp.status}`, 'debug');
const pd = pResp.data || {};
const pList = extractList(pd);
allRawItems.push(...pList);
nextToken = pd.next || pd['next'] || (pd.metadata && pd.metadata.next) || null;
} catch (pe) {
const pst = pe.response?.status;
logger('phone:service', `cdr_feed pagination ERROR status=${pst || 'n/a'}: ${pe.message}`, 'warn');
break;
}
}
break; // first successful HTTP response (even if 0 items = empty window is valid)
} catch (e) {
const st = e.response?.status;
const bd = e.response?.data || e.message;
logger('phone:service', `cdr_feed ERROR for ${fullUrl} status=${st || 'n/a'}: ${JSON.stringify(bd).slice(0,300)}`, 'debug');
fetchErrors.push({ base, startTime, endTime, status: st, body: bd });
}
}
if (allRawItems.length === 0) {
// Legacy fallback (rarely useful now)
try {
const legUrl = `${ANALYTICS_BASES[0]}/callHistory`;
const r = await axiosMod.get(legUrl, {
headers: { Authorization: `Bearer ${token}` },
params: { orgId, personId, startTime, endTime, max: 100 },
timeout: 15000
});
const d = r.data || {};
const li = extractList(d);
if (li.length >= 0) {
allRawItems.push(...li);
logger('phone:service', `legacy callHistory fallback gave ${li.length}`, 'debug');
}
} catch (_) { /* ignore */ }
}
const receivedRaw = allRawItems.length;
// Dedup across pages/windows (keep for safety). Use richer key to avoid over-collapsing.
const seen = new Set();
const items = [];
for (const c of allRawItems) {
const key = [
c.startTime || c.start || c.callStartTime || '',
c.callingNumber || '',
c.calledNumber || '',
c.duration || c.callDuration || '',
c.callId || c.id || c.uuid || ''
].join('|');
if (!seen.has(key)) {
seen.add(key);
items.push(c);
}
}
// Secondary person filter inside the location-scoped results.
const userItems = items.filter(matchItemToUser);
const effectiveItems = (userItems.length > 0) ? userItems : items;
if (userItems.length === 0 && items.length > 0) {
logger('phone:service', `cdr_feed: ${items.length} unique (received ${receivedRaw}, loc=${locationName}) but 0 matched person ids; using location results (common for main/AA numbers)`, 'debug');
}
// Flexible parse (CDR field names vary; covers common report columns)
let inbound = 0, outbound = 0, missed = 0, totalDuration = 0;
const samples = [];
for (const c of effectiveItems) {
const dir = String(c.direction || c.callDirection || c.callType || c.directionIndicator || '').toUpperCase();
const dur = Number(c.durationSeconds || c.duration || c.callDuration || c.talkDuration || c.length || 0);
const status = String(c.status || c.result || c.callResult || c.callOutcome || c.callStatus || '').toLowerCase();
if (dir.includes('IN') || dir === 'INBOUND' || dir.includes('INCOMING')) inbound++;
else if (dir.includes('OUT') || dir === 'OUTBOUND' || dir.includes('OUTGOING')) outbound++;
if (status.includes('miss') || dir.includes('MISS') || status.includes('no answer') || status.includes('failed') || status.includes('busy')) missed++;
totalDuration += dur;
if (samples.length < 5) {
samples.push({
start: c.startTime || c.start || c.callStartTime || c.answerTime || c.releaseTime,
direction: dir || c.direction,
duration: dur,
status: c.status || c.result || c.callResult,
otherParty: c.otherParty || c.calledParty || c.callingParty || c.remoteParty || c.calledNumber || c.callingNumber || 'unknown',
phoneNumber: c.phoneNumber || c.calledNumber || c.callingNumber,
});
}
}
return {
available: true,
source: 'analytics-calling.webexapis.com/v1/cdr_feed',
hours: 12, // actual window size (API max 12h, ending >=5min ago)
location: locationName,
startTime,
endTime,
summary: {
inbound,
outbound,
missed,
totalCalls: effectiveItems.length,
totalDurationSeconds: totalDuration,
},
samples,
rawCount: receivedRaw,
userMatchedCount: userItems.length,
};
} catch (err) {
const status = err.response?.status;
const body = err.response?.data || {};
const msg = body.message || body.error || body.description || err.message;
logger('phone:service', `Historical call history (detailed/cdr_feed) unavailable (${status || 'err'}): ${msg} — falling back to proxies.`, 'warn');
let reason = status ? `endpoint returned ${status}: ${msg}` : msg;
if (status === 429) {
reason = 'rate limited (1 cdr_feed call per minute + 10 pagination per min per token). Wait ~60s and retry.';
}
return {
available: false,
reason,
scopesNeeded: status === 429 ? 'Respect API rate limits (see doc)' : 'spark-admin:calling_cdr_read scope + Control Hub administrator role "Webex Calling Detailed Call History API access" enabled for the authorizing user',
errorDetails: body,
calls: [],
summary: {},
};
}
}
// ──────────────────────────────────────────────
// DECT Provisioning helpers for /provision-dect
// Lookup uses 5-digit padded email (aeXXXXX@ae.com)
// Network name is "Store XXXX" (4-digit padded)
// Access codes: 4 digits; 4-digit store uses store#; shorter stores prefix 8
// ──────────────────────────────────────────────
/**
* Find DECT network for store by looking up person (5-digit) then matching network name "Store XXXX" (4-digit pad).
*/
export async function findDectNetworkForStore(storeNumber) {
const storeStr = String(storeNumber).trim();
const padded5 = storeStr.padStart(5, '0');
const email = `ae${padded5}@ae.com`;
logger('phone:provision', `Looking up DECT network for store ${storeStr} (5-digit email ${email})`, 'debug');
const personId = await getPersonIdByEmail(email);
if (!personId) {
logger('phone:provision', `No person found for email ${email}`, 'warn');
return null;
}
const networks = await getDectNetworksForPerson(personId);
const padded4 = storeStr.padStart(4, '0');
const targetName = `Store ${padded4}`;
const match = networks.find(n => {
const nm = (n.name || '').trim();
return nm.toLowerCase() === targetName.toLowerCase() ||
nm.toLowerCase().includes(padded4);
});
if (match) {
logger('phone:provision', `Found DECT network "${match.name}" (id=${match.id}, loc=${match.locationId || match.location?.id})`, 'debug');
} else {
logger('phone:provision', `No matching DECT network "${targetName}" found among ${networks.length} networks`, 'warn');
}
return match || null;
}
/**
* Generate 4-digit access code per rules:
* - If store is 4+ digits: use first 4 digits of store number
* - If fewer digits: '8' + pad the digits to 3 positions (e.g. 347→8347, 67→8067)
*/
export function generateDectAccessCode(storeNumber) {
const digits = String(storeNumber).replace(/\D/g, '');
if (digits.length >= 4) {
return digits.substring(0, 4);
}
const rest = digits.padStart(3, '0');
return '8' + rest;
}
/**
* Get current DECT status for provisioning UI (network + bases + handsets with details).
*/
export async function getDectProvisioningStatus(storeNumber) {
const network = await findDectNetworkForStore(storeNumber);
if (!network) {
return { network: null, basestations: [], handsets: [] };
}
const locationId = network.locationId || network.location?.id;
const networkId = network.id;
if (!locationId || !networkId) {
return { network, basestations: [], handsets: [] };
}
const [basesRes, handsRes] = await Promise.allSettled([
getDectBasestations(locationId, networkId),
getDectHandsets(locationId, networkId)
]);
let basestations = basesRes.status === 'fulfilled' ? (basesRes.value || []) : [];
let handsets = handsRes.status === 'fulfilled' ? (handsRes.value || []) : [];
// Build map id -> mac for bases
const baseMacMap = {};
basestations.forEach(b => {
if (b.id) baseMacMap[b.id] = b.mac || '—';
});
// Enrich handsets with details (for baseStationId etc.) and base MAC
if (handsets.length > 0 && locationId && networkId) {
handsets = await Promise.all(handsets.map(async (h) => {
const detail = await getDectHandsetDetails(locationId, networkId, h.id);
const enriched = { ...h, ...(detail || {}) };
enriched.baseMac = baseMacMap[enriched.baseStationId] || '—';
return enriched;
}));
}
return { network, basestations, handsets };
}
/**
* Add one or more basestations (MACs comma or space separated).
* Idempotent: skips if MAC already present.
*/
export async function addDectBasestation(locationId, dectNetworkId, macInput) {
if (!locationId || !dectNetworkId || !macInput) {
throw new Error('locationId, dectNetworkId and mac(s) required');
}
const macs = String(macInput)
.split(/[\s,]+/)
.map(m => m.trim())
.filter(Boolean);
const results = [];
for (const rawMac of macs) {
const clean = rawMac.replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (clean.length !== 12) {
results.push({ mac: rawMac, error: 'invalid MAC (need 12 hex chars)' });
continue;
}
const formatted = clean.match(/.{1,2}/g).join(':');
try {
// Idempotency check
const existing = await getDectBasestations(locationId, dectNetworkId);
const already = existing.some(b => {
const bmac = String(b.mac || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
return bmac === clean;
});
if (already) {
logger('phone:provision', `Basestation ${formatted} already present - skipping`, 'debug');
results.push({ mac: formatted, alreadyExists: true });
continue;
}
const body = {
mac: formatted,
displayName: `Basestation ${formatted}`
};
const res = await webex.request('POST', `telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/baseStations`, body);
logger('phone:provision', `Added basestation ${formatted}`, 'debug');
results.push({ mac: formatted, success: true, id: res?.id });
} catch (err) {
logger('phone:provision', `Add basestation ${formatted} failed: ${err.message}`, 'error');
results.push({ mac: formatted, error: err.message });
}
}
return results;
}
/**
* Remove a basestation by ID (or MAC - will resolve).
*/
export async function removeDectBasestation(locationId, dectNetworkId, baseIdOrMac) {
if (!locationId || !dectNetworkId || !baseIdOrMac) {
throw new Error('locationId, networkId and base id/mac required');
}
let baseId = baseIdOrMac;
// If looks like MAC, resolve to id
if (/^[0-9a-fA-F:.-]{12,17}$/.test(String(baseIdOrMac))) {
const clean = String(baseIdOrMac).replace(/[^0-9a-fA-F]/g, '').toUpperCase();
const bases = await getDectBasestations(locationId, dectNetworkId);
const found = bases.find(b => {
const bm = String(b.mac || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
return bm === clean;
});
if (!found) throw new Error(`Basestation with MAC ${baseIdOrMac} not found`);
baseId = found.id;
}
try {
await webex.request('DELETE', `telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/baseStations/${baseId}`);
logger('phone:provision', `Removed basestation ${baseId}`, 'debug');
return { success: true, id: baseId };
} catch (err) {
logger('phone:provision', `Remove basestation failed: ${err.message}`, 'error');
throw err;
}
}
/**
* Add a handset.
* Idempotent check on accessCode.
*/
export async function addDectHandset(locationId, dectNetworkId, { displayName, accessCode, baseStationId }) {
if (!locationId || !dectNetworkId || !accessCode) {
throw new Error('locationId, networkId and accessCode required');
}
const code = String(accessCode).trim();
if (!/^\d{4}$/.test(code)) {
throw new Error('Access code must be exactly 4 digits');
}
try {
// Idempotency
const existing = await getDectHandsets(locationId, dectNetworkId);
if (existing.some(h => (h.extension || h.accessCode) === code)) {
logger('phone:provision', `Handset with accessCode ${code} already exists`, 'debug');
return { alreadyExists: true };
}
const body = {
defaultDisplayName: displayName || code,
accessCode: code,
lines: [
{
index: 1,
extension: code
}
]
};
if (baseStationId) {
body.baseStationId = baseStationId;
}
const res = await webex.request('POST', `telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/handsets`, body);
logger('phone:provision', `Added handset ${code}${baseStationId ? ` to base ${baseStationId}` : ''}`, 'debug');
return res;
} catch (err) {
logger('phone:provision', `Add handset ${code} failed: ${err.message}`, 'error');
throw err;
}
}
/**
* Remove handset by id.
*/
export async function removeDectHandset(locationId, dectNetworkId, handsetId) {
if (!locationId || !dectNetworkId || !handsetId) {
throw new Error('locationId, networkId and handsetId required');
}
try {
await webex.request('DELETE', `telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/handsets/${handsetId}`);
logger('phone:provision', `Removed handset ${handsetId}`, 'debug');
return { success: true, id: handsetId };
} catch (err) {
logger('phone:provision', `Remove handset failed: ${err.message}`, 'error');
throw err;
}
}