DECT basestations rely on multicast for handset discovery/registration.
When Meraki switches have IGMP snooping enabled without a querier —
or per-switch overrides that deviate from a DECT-safe policy — those
frames are pruned and DECT handsets silently fail to register.
- integrations/meraki/switches.js: getSwitchMulticastSettings,
setSwitchMulticastSettings, and pure summarizeMulticast verdict fn.
- services/phoneService.js: fires the multicast fetch as soon as the
network id is known, overlapping DECT enrichment; attaches
data.multicast summary to the collector output. Non-fatal on error.
- services/renderers/phoneStatusRenderer.js: emits a single warning
line inside the DECT Basestations section only when needsFix is
true, itemizing which parts deviate (default snoop, default flood,
N overrides).
- commands/igmpFix.js: frozen DECT_SAFE_MULTICAST_PAYLOAD constant
({snoop:false, flood:true, overrides:[]}), adaptive-card builder,
and confirm/cancel handlers.
- commands/phoneStatus.js: appends the adaptive card when needsFix
and the trigger came from chat (skipped on HTTP path).
- index.js: IGMP_FIX_ACTIONS set + dispatch branch mirroring the
HOST_ASSIGN pattern (one-shot pending lookup, censorActionCard,
domain call).
- utils/pendingIgmpFixes.js: 15-min TTL pending-card store.
- tests: 11 summarizer cases (all deviation permutations + malformed
input), 4 renderer cases (each warning-line shape + silence when
needsFix false or no DECT), and a frozen-constant regression guard
on the PUT payload. Full suite: 47/47 passing.
1132 lines
No EOL
46 KiB
JavaScript
1132 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.
|
|
// 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;
|
|
}
|
|
} |