Multi-integration Webex chat/HTTP bot that unifies phone, AV, and network status for retail store support. Consolidates data from Webex Calling, Meraki, Workspace ONE (MDM), Atlas AMP, RED digital signage, and OptiSigns into rich per-store status commands. Key surfaces: - /phonestatus, /avstatus — per-store phone & AV device reports with clickable Meraki deep-links and per-port detail. - /webexhost — check/assign Webex Meetings host licenses via the Service App; adaptive-card confirmation flow, HTTP-API-gated. - /offboarduser — full Webex Admin offboarding (auth revoke, device wipe, license removal); adaptive-card confirmation. - /jirapoll — on-demand trigger for the hourly Jira poller. - /bulkavstatuscsv — bulk store CSV export with concurrency limits. Automation: - Hourly Jira poller (node-cron) with an X.AI (Grok) ticket classifier that categorizes unassigned tickets as phone/av/skip, extracts store numbers from free-text, and enriches Jira with the same detailed markdown the chat commands emit (converted to Jira ADF, preserves bold + Meraki links). Idempotent via a `bot-enriched` Jira label. Architecture: - Node.js 20+, ESM, Express 5, webex-node-bot-framework. - Layered integrations (integrations/*), services (services/*), commands (commands/*), utils (utils/*). - Shared markdown renderers (services/renderers/*) feed both chat handlers and the Jira poller so the two surfaces stay in sync. - Hand-rolled markdown-to-ADF converter (utils/markdownToAdf.js) — no new npm dependency. - Node built-in test runner (`node --test tests/*.test.js`), 30 tests covering the converter, renderers, and poller ADF assembly. Docker + docker-compose deployment. Config via .env (see .env.example for the full option surface).
1103 lines
No EOL
44 KiB
JavaScript
1103 lines
No EOL
44 KiB
JavaScript
// src/services/phoneService.js
|
|
import webex from '../integrations/webex/WebexClient.js';
|
|
|
|
import {
|
|
getClientsForStore,
|
|
getPortsForStore
|
|
} from '../integrations/meraki/clients.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 || '';
|
|
|
|
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
|
|
}
|
|
};
|
|
});
|
|
|
|
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
|
|
}
|
|
};
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// 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;
|
|
}
|
|
} |