Add store/email/phone filtering, richer call-line formatting, CDR feed pagination and queueing, and split Jira poller enrichment into testable modules. Co-authored-by: Cursor <cursoragent@cursor.com>
1264 lines
No EOL
49 KiB
JavaScript
1264 lines
No EOL
49 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';
|
|
import {
|
|
extractCdrFeedList,
|
|
describeCdrFeedPayload,
|
|
resolveCdrFeedNextPage,
|
|
cdrCalledNumber,
|
|
cdrCallingNumber,
|
|
cdrDedupKey,
|
|
cdrDirectionValue,
|
|
cdrDispositionValue,
|
|
cdrDurationSeconds,
|
|
cdrStartTime,
|
|
} from './cdrFeedParser.js';
|
|
import { enqueueCdrFeedFetch } from './cdrFeedQueue.js';
|
|
|
|
// ──────────────────────────────────────────────
|
|
// Main public function
|
|
// ──────────────────────────────────────────────
|
|
export async function collectPhoneStatus(storeNumber) {
|
|
logger('phone:service', `Starting phone status collection for store ${storeNumber}`, 'debug');
|
|
|
|
const storeNum = String(storeNumber);
|
|
const email = `ae${storeNum.padStart(5, '0')}@ae.com`;
|
|
logger('phone:service', `Looking up person for store ${storeNum} → email: ${email}`, 'debug');
|
|
|
|
const personId = await getPersonIdByEmail(email);
|
|
|
|
// Fetch Webex (phones + dect networks) + clients first, so we can determine relevant switches for ports
|
|
// (optimization to reduce Meraki API load / 429 risk, consistent with AV path)
|
|
const [phonesRes, dectNetworksRes, clientsRes, personDetailsRes, telephonyProfileRes] = await Promise.allSettled([
|
|
getWebexPhonesForStore(storeNum),
|
|
personId ? getDectNetworksForPerson(personId) : Promise.resolve([]),
|
|
getClientsForStore(storeNum, 1), // 1 day for recent activity/usage in phone status (AV paths keep 7d default)
|
|
personId ? getPersonDetails(personId) : Promise.resolve(null),
|
|
personId ? getTelephonyProfile(personId) : Promise.resolve({}),
|
|
]);
|
|
|
|
// Tier 2: location main number (auto attendant / full store DID, e.g. +12122194600).
|
|
// Kicked off here (right after batch, using dectNetworks result) so it runs in parallel
|
|
// with the DECT bases/handsets detail fetches inside the block below. 1 cheap call.
|
|
// Tier 2 location main number (auto attendant / full store DID)
|
|
let locationDetailsPromise = Promise.resolve(null);
|
|
const dectNetsRaw = dectNetworksRes.status === 'fulfilled' ? (dectNetworksRes.value || []) : [];
|
|
if (dectNetsRaw.length > 0) {
|
|
const locId = dectNetsRaw[0].locationId || dectNetsRaw[0].location?.id;
|
|
if (locId && locId !== '—') {
|
|
locationDetailsPromise = webex.request('GET', `telephony/config/locations/${locId}`).catch(() => null);
|
|
}
|
|
}
|
|
|
|
let phonesList = [];
|
|
if (phonesRes.status === 'fulfilled' && Array.isArray(phonesRes.value)) {
|
|
phonesList = phonesRes.value;
|
|
logger('phone:service', `Received ${phonesList.length} desk phones from Webex`, 'debug');
|
|
} else {
|
|
logger('phone:service', `Webex phones fetch failed`, 'warn');
|
|
}
|
|
|
|
let clientsData = clientsRes.status === 'fulfilled' ? (clientsRes.value || {}) : {};
|
|
const clients = Array.isArray(clientsData) ? clientsData : (clientsData.clients || []);
|
|
const networkUrl = clientsData.network?.url || '';
|
|
|
|
// Kick off the switch multicast fetch as soon as we know the network id.
|
|
// Overlaps with the DECT detail fetches below so it's essentially free
|
|
// wall-clock time. Wrapped in a promise that never rejects — the phone
|
|
// report should still render even if multicast lookup fails (missing
|
|
// API scope, non-switch network, etc.). Renderer stays silent when
|
|
// `needsFix` is false, so an error-only summary produces no noise.
|
|
const networkIdForMulticast = clientsData.network?.id
|
|
|| clientsData.networkId
|
|
|| null;
|
|
const multicastPromise = networkIdForMulticast
|
|
? getSwitchMulticastSettings(networkIdForMulticast)
|
|
.then((raw) => ({ ...summarizeMulticast(raw), networkId: networkIdForMulticast, networkName: clientsData.network?.name || null }))
|
|
.catch((err) => {
|
|
logger('phone:service', `Multicast settings fetch failed for network ${networkIdForMulticast}: ${err.message}`, 'warn');
|
|
return { needsFix: false, error: err.message, networkId: networkIdForMulticast, networkName: clientsData.network?.name || null };
|
|
})
|
|
: Promise.resolve({ needsFix: false, error: 'no networkId available', networkId: null, networkName: null });
|
|
|
|
const personDetails = personDetailsRes.status === 'fulfilled' ? personDetailsRes.value : null;
|
|
const telephonyProfile = telephonyProfileRes.status === 'fulfilled' ? telephonyProfileRes.value : {};
|
|
|
|
// DECT (needs person + networks)
|
|
let dectBasestations = [];
|
|
let dectHandsets = [];
|
|
let dectNetwork = null;
|
|
if (dectNetworksRes.status === 'fulfilled' && dectNetworksRes.value?.length > 0) {
|
|
dectNetwork = dectNetworksRes.value[0];
|
|
const networkId = dectNetwork.id;
|
|
const locationId = dectNetwork.locationId;
|
|
|
|
if (locationId && networkId) {
|
|
logger('phone:service', `Fetching DECT data for network ${networkId}`, 'debug');
|
|
|
|
const [basesResult, handsetsResult] = await Promise.allSettled([
|
|
getDectBasestations(locationId, networkId),
|
|
getDectHandsets(locationId, networkId)
|
|
]);
|
|
|
|
if (basesResult.status === 'fulfilled') dectBasestations = basesResult.value || [];
|
|
|
|
if (handsetsResult.status === 'fulfilled' && handsetsResult.value?.length > 0) {
|
|
const fullHandsets = await Promise.all(
|
|
handsetsResult.value.map(async basic => {
|
|
const detail = await getDectHandsetDetails(locationId, networkId, basic.id) || {};
|
|
return { ...basic, ...detail }; // preserve mac from basic list, baseStationId etc from detail
|
|
})
|
|
);
|
|
dectHandsets = fullHandsets.filter(h => h);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Tier 2: await the parallel location fetch (started above) to get main number.
|
|
// This is the full E.164 assigned to the auto attendant for the store/location.
|
|
const locationDetails = await locationDetailsPromise;
|
|
const locationMainNumber = locationDetails?.callingLineId?.phoneNumber || locationDetails?.phoneNumber || null;
|
|
|
|
// Collect MACs of interest (phones + basestations) to compute relevant switches for ports
|
|
const interestMacs = new Set();
|
|
phonesList.forEach(p => {
|
|
const k = normalizeMac(p.mac);
|
|
if (k) interestMacs.add(k);
|
|
});
|
|
dectBasestations.forEach(b => {
|
|
const k = normalizeMac(b.mac);
|
|
if (k) interestMacs.add(k);
|
|
});
|
|
|
|
const relevantSwitchesForPorts = new Set();
|
|
for (const c of clients) {
|
|
const cMac = normalizeMac(c.mac);
|
|
if (interestMacs.has(cMac) && c.recentDeviceSerial) {
|
|
const conn = (c.recentDeviceConnection || '').toLowerCase();
|
|
if (!conn.includes('wireless')) {
|
|
relevantSwitchesForPorts.add(c.recentDeviceSerial);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fetch ports using relevant switches (saves API calls, consistent with AV optimization)
|
|
const portsRes = await getPortsForStore(storeNum, relevantSwitchesForPorts);
|
|
const portConfigs = Array.isArray(portsRes) ? portsRes : (portsRes?.ports || portsRes || []);
|
|
|
|
const portStatusCache = new Map();
|
|
|
|
// Enrich desk phones using shared Meraki attach (now supports direct .mac, reuses client+port logic + cache)
|
|
const phoneBaseDevices = phonesList.map(ph => ({
|
|
mac: ph.mac,
|
|
identifier: ph.name || ph.displayName || ph.mac || ''
|
|
}));
|
|
for (const dev of phoneBaseDevices) {
|
|
await attachMerakiClientWithPorts(dev, clients, portConfigs, portStatusCache, networkUrl);
|
|
}
|
|
|
|
const enrichedPhones = phonesList.map((ph, i) => {
|
|
const attached = phoneBaseDevices[i].meraki || {};
|
|
const client = attached.client || attached;
|
|
// Flatten for backward compat with phoneStatus command (expects .port, .switchName etc at top of meraki)
|
|
return {
|
|
...ph,
|
|
meraki: {
|
|
port: client.portNumber || client.switchport || client.port,
|
|
switchName: client.deviceName || client.recentDeviceName || client.switchName,
|
|
status: client.status || client.switchportStatus?.status || client.status,
|
|
vlan: client.vlan,
|
|
ip: client.ip,
|
|
lastSeen: client.lastSeen,
|
|
portName: client.portName,
|
|
poeEnabled: client.poeEnabled,
|
|
connectionType: attached.connectionType,
|
|
clientUrl: attached.clientUrl || '',
|
|
...client
|
|
}
|
|
};
|
|
});
|
|
|
|
logger('phone:service', `Final enriched desk phones: ${enrichedPhones.length}`, 'debug');
|
|
|
|
// Enrich DECT basestations the same way (they also show Meraki port info in command)
|
|
const baseBaseDevices = dectBasestations.map(b => ({
|
|
mac: b.mac,
|
|
identifier: b.name || b.mac || ''
|
|
}));
|
|
for (const dev of baseBaseDevices) {
|
|
await attachMerakiClientWithPorts(dev, clients, portConfigs, portStatusCache, networkUrl);
|
|
}
|
|
|
|
const enrichedBasestations = dectBasestations.map((b, i) => {
|
|
const attached = baseBaseDevices[i].meraki || {};
|
|
const client = attached.client || attached;
|
|
return {
|
|
...b,
|
|
meraki: {
|
|
port: client.portNumber || client.switchport || client.port,
|
|
switchName: client.deviceName || client.recentDeviceName || client.switchName,
|
|
status: client.status || client.switchportStatus?.status || client.status,
|
|
vlan: client.vlan,
|
|
ip: client.ip,
|
|
lastSeen: client.lastSeen,
|
|
portName: client.portName,
|
|
poeEnabled: client.poeEnabled,
|
|
connectionType: attached.connectionType,
|
|
clientUrl: attached.clientUrl || '',
|
|
...client
|
|
}
|
|
};
|
|
});
|
|
|
|
// Enrich DECT handsets with Meraki client data too (match on MAC, per user request for topology)
|
|
const handsetBaseDevices = dectHandsets.map(h => ({
|
|
mac: h.mac,
|
|
identifier: h.name || h.mac || ''
|
|
}));
|
|
for (const dev of handsetBaseDevices) {
|
|
await attachMerakiClientWithPorts(dev, clients, portConfigs, portStatusCache, networkUrl);
|
|
}
|
|
|
|
const enrichedHandsets = dectHandsets.map((h, i) => {
|
|
const attached = handsetBaseDevices[i].meraki || {};
|
|
const client = attached.client || attached;
|
|
return {
|
|
...h,
|
|
meraki: {
|
|
port: client.portNumber || client.switchport || client.port,
|
|
switchName: client.deviceName || client.recentDeviceName || client.switchName,
|
|
status: client.status || client.switchportStatus?.status || client.status,
|
|
vlan: client.vlan,
|
|
ip: client.ip,
|
|
lastSeen: client.lastSeen,
|
|
portName: client.portName,
|
|
poeEnabled: client.poeEnabled,
|
|
connectionType: attached.connectionType,
|
|
clientUrl: attached.clientUrl || '',
|
|
...client
|
|
}
|
|
};
|
|
});
|
|
|
|
// Resolve the multicast promise we fired earlier. Awaited here so it
|
|
// overlaps with all the DECT/Meraki enrichment above (essentially
|
|
// free wall-clock time). The promise catches its own errors so this
|
|
// await never rejects.
|
|
const multicast = await multicastPromise;
|
|
|
|
return {
|
|
phones: {
|
|
status: phonesRes.status === 'fulfilled' ? 'success' : 'failed',
|
|
data: enrichedPhones
|
|
},
|
|
dectBasestations: enrichedBasestations,
|
|
dectHandsets: enrichedHandsets,
|
|
dectNetwork,
|
|
person: personDetails,
|
|
telephonyProfile,
|
|
locationMainNumber, // Tier 2: full store main number (E.164) from location callingLineId; assigned to auto attendant for this store
|
|
meraki: {
|
|
status: clientsRes.status === 'fulfilled' ? 'success' : 'failed',
|
|
data: clients || [],
|
|
network: clientsData.network || null,
|
|
networkId: clientsData.networkId || (clientsData.network && clientsData.network.id) || null
|
|
},
|
|
multicast
|
|
};
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// DECT & Webex helper functions
|
|
// ──────────────────────────────────────────────
|
|
|
|
export async function getPersonIdByEmail(email) {
|
|
if (!email) {
|
|
logger('phone:service', 'No email provided for lookup', 'warn');
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
logger('phone:service', `Looking up user by email: ${email}`, 'debug');
|
|
const response = await webex.request('GET', 'people', null, { email });
|
|
const people = response.items || [];
|
|
|
|
if (people.length === 0) {
|
|
logger('phone:service', `No user found for email: ${email}`, 'warn');
|
|
return null;
|
|
}
|
|
|
|
const person = people[0];
|
|
logger('phone:service', `Found person ID: ${person.id}`, 'debug');
|
|
return person.id;
|
|
} catch (err) {
|
|
logger('phone:service', `Error looking up user: ${err.message}`, 'error');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function getDectNetworksForPerson(personId) {
|
|
if (!personId) return [];
|
|
|
|
try {
|
|
logger('phone:service', `Fetching DECT networks for person ${personId}`);
|
|
const response = await webex.request('GET', `telephony/config/people/${personId}/dectNetworks`);
|
|
const networks = response.dectNetworks || [];
|
|
logger('phone:service', `DECT networks found: ${networks.length}`, 'debug');
|
|
return networks.map(net => ({
|
|
id: net.id,
|
|
name: net.name || 'Unknown',
|
|
handsetsCount: net.numberOfHandsetsAssigned || 0,
|
|
locationName: net.location?.name || '—',
|
|
locationId: net.location?.id || '—'
|
|
}));
|
|
} catch (err) {
|
|
logger('phone:service', `Error fetching DECT networks: ${err.message}`, 'error');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function getDectBasestations(locationId, dectNetworkId) {
|
|
if (!locationId || !dectNetworkId) return [];
|
|
|
|
try {
|
|
const response = await webex.request(
|
|
'GET',
|
|
`telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/baseStations`
|
|
);
|
|
const baseStations = response?.items ?? response?.baseStations ?? [];
|
|
return baseStations.map(base => ({
|
|
id: base.id,
|
|
mac: base.mac || base.macAddress || base.baseMac || '—',
|
|
name: base.displayName || `Basestation ${base.mac || base.macAddress || 'Unknown'}`,
|
|
status: base.status || 'unknown',
|
|
lastSeen: base.lastSeen || 'unknown',
|
|
firmware: base.softwareVersion || '—',
|
|
model: base.model || '—',
|
|
ipAddress: base.ip || '—',
|
|
linesRegistered: base.numberOfLinesRegistered || 0
|
|
}));
|
|
} catch (err) {
|
|
logger('phone:service', `Error fetching basestations: ${err.message}`, 'error');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function getDectHandsets(locationId, dectNetworkId) {
|
|
if (!locationId || !dectNetworkId) return [];
|
|
|
|
try {
|
|
const response = await webex.request(
|
|
'GET',
|
|
`telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/handsets`
|
|
);
|
|
const handsets = response?.items ?? response?.handsets ?? [];
|
|
return handsets.map(handset => ({
|
|
id: handset.id,
|
|
name: handset.defaultDisplayName || handset.displayName || `Handset ${handset.index || ''}`,
|
|
status: handset.status || 'unknown',
|
|
lastSeen: handset.lastSeen || 'unknown',
|
|
mac: handset.mac || '—',
|
|
firmware: handset.softwareVersion || '—',
|
|
model: handset.model || '—',
|
|
extension: handset.accessCode || handset.lines?.[0]?.esn || '—',
|
|
lines: handset.lines || []
|
|
}));
|
|
} catch (err) {
|
|
logger('phone:service', `Error fetching handsets: ${err.message}`, 'error');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function getDectHandsetDetails(locationId, dectNetworkId, handsetId) {
|
|
if (!locationId || !dectNetworkId || !handsetId) return null;
|
|
|
|
try {
|
|
const handset = await webex.request(
|
|
'GET',
|
|
`telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/handsets/${handsetId}`
|
|
);
|
|
|
|
return {
|
|
id: handset.id,
|
|
index: handset.index,
|
|
name: handset.defaultDisplayName || handset.displayName || `Handset ${handset.index || ''}`,
|
|
lastRegistrationTime: handset.lines?.[0]?.lastRegistrationTime || null,
|
|
extension: handset.lines?.[0]?.extension || null,
|
|
baseStationId: handset.baseStationId || null
|
|
};
|
|
} catch (err) {
|
|
logger('phone:service', `Error fetching handset details: ${err.message}`, 'error');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Main entry point
|
|
export async function getWebexPhonesForStore(storeNumber) {
|
|
const email = `ae${String(storeNumber).padStart(5, '0')}@ae.com`;
|
|
logger('phone:service', `getWebexPhonesForStore called for ${storeNumber}`, 'debug');
|
|
|
|
const personId = await getPersonIdByEmail(email);
|
|
if (!personId) {
|
|
logger('phone:service', `No person found for store ${storeNumber}`, 'warn');
|
|
return [];
|
|
}
|
|
|
|
const phones = await getDevicesForPerson(personId);
|
|
logger('phone:service', `Returned ${phones.length} phones for store ${storeNumber}`, 'debug');
|
|
return phones.map(phone => ({
|
|
// Core (kept for backward compat + display)
|
|
mac: phone.mac || '—',
|
|
name: phone.displayName || phone.product + ' ' + (phone.mac?.slice(-4) || 'Unknown') || 'Unknown Phone',
|
|
status: phone.connectionStatus || phone.status || 'unknown',
|
|
lastSeen: phone.lastSeen || 'unknown',
|
|
firmware: phone.software || phone.softwareVersion || '—',
|
|
model: phone.product || phone.model || '—',
|
|
ipAddress: phone.ip || phone.ipAddress || '—',
|
|
// Rich additions for Tier 1 (from raw /devices, carried through ...ph in enrichment)
|
|
serial: phone.serial || '—',
|
|
product: phone.product || '—',
|
|
displayName: phone.displayName || null,
|
|
sipUrls: phone.sipUrls || [],
|
|
primarySipUrl: phone.primarySipUrl || '—',
|
|
errorCodes: phone.errorCodes || [],
|
|
capabilities: phone.capabilities || [],
|
|
activeInterface: phone.activeInterface || '—',
|
|
locationId: phone.locationId || phone.workspaceLocationId || '—',
|
|
created: phone.created || phone.firstSeen || null,
|
|
upgradeChannel: phone.upgradeChannel || '—',
|
|
managedBy: phone.managedBy || '—',
|
|
lifecycle: phone.lifecycle || '—'
|
|
}));
|
|
}
|
|
|
|
export async function getDevicesForPerson(personId) {
|
|
if (!personId) return [];
|
|
|
|
try {
|
|
logger('phone:service', `Fetching devices for person ${personId}`, 'debug');
|
|
let allDevices = [];
|
|
let next = null;
|
|
|
|
do {
|
|
const params = { personId, max: 100 };
|
|
if (next) params.next = next;
|
|
|
|
const response = await webex.request('GET', 'devices', null, params);
|
|
const pageItems = response.items || [];
|
|
allDevices = allDevices.concat(pageItems);
|
|
next = response.next;
|
|
} while (next);
|
|
|
|
logger('phone:service', `Total devices for person: ${allDevices.length}`, 'debug');
|
|
return allDevices;
|
|
} catch (err) {
|
|
logger('phone:service', `Error fetching devices: ${err.message}`, 'error');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// Tier 2: cheap extra profile fetches (person details + telephony config that actually works)
|
|
// + location main number (auto attendant / store DID) using already-known locationId from DECT.
|
|
// Discovery showed:
|
|
// - telephony/config/people/{id} → {announcementLanguage, timeZone}
|
|
// - telephony/config/people/{id}/outgoingPermission → {useCustomEnabled, callingPermissions[]}
|
|
// - /people/{id} → full person (phoneNumbers, displayName etc.)
|
|
// - telephony/config/locations/{id} → callingLineId.phoneNumber (the main +1 number assigned to AA)
|
|
// DND/callForwarding etc. still 404 under current scopes → resilient, only log at debug/warn.
|
|
// The full per-person calling-features surface is exposed via
|
|
// /voicediag (see services/voiceDiag/) using the admin path
|
|
// `/v1/people/{id}/features/*` — no additional scopes required
|
|
// beyond `spark-admin:people_read` / `spark-admin:people_write`
|
|
// which we already hold.
|
|
// Location fetch started early for parallelism with DECT detail calls.
|
|
// ──────────────────────────────────────────────
|
|
|
|
export async function getPersonDetails(personId) {
|
|
if (!personId) return null;
|
|
try {
|
|
logger('phone:service', `Fetching full person details for ${personId}`, 'debug');
|
|
return await webex.request('GET', `people/${personId}`);
|
|
} catch (err) {
|
|
logger('phone:service', `Error fetching person details: ${err.message}`, 'warn');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function getTelephonyProfile(personId) {
|
|
if (!personId) return {};
|
|
try {
|
|
logger('phone:service', `Fetching telephony profile for ${personId}`, 'debug');
|
|
const [profileRes, permRes] = await Promise.allSettled([
|
|
webex.request('GET', `telephony/config/people/${personId}`),
|
|
webex.request('GET', `telephony/config/people/${personId}/outgoingPermission`),
|
|
]);
|
|
const base = profileRes.status === 'fulfilled' ? profileRes.value : {};
|
|
const outgoing = permRes.status === 'fulfilled' ? permRes.value : null;
|
|
return {
|
|
...base,
|
|
outgoingPermission: outgoing,
|
|
};
|
|
} catch (err) {
|
|
logger('phone:service', `Error fetching telephony profile: ${err.message}`, 'warn');
|
|
return {};
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// Tier 3 start: recent activity / "call proxy" using data we already have (lastSeen + 1d Meraki usage + DECT reg).
|
|
// Real historical (in/out/missed counts + samples) attempted via Detailed Call History CDR Feed (/cdr_feed on analytics* base).
|
|
// Falls back gracefully with reason + exact scopes/role hint until Control Hub role + scope propagate and return 2xx items.
|
|
// ──────────────────────────────────────────────
|
|
|
|
export function computeRecentActivity(phones = [], dectBases = [], dectHandsets = [], merakiClients = []) {
|
|
const now = Date.now();
|
|
const windowMs = 12 * 60 * 60 * 1000; // 12h to align with historical CDR max window
|
|
|
|
const phoneActivity = phones.map(ph => {
|
|
const ls = ph.lastSeen ? Date.parse(ph.lastSeen) : 0;
|
|
const mls = ph.meraki?.lastSeen ? Date.parse(ph.meraki.lastSeen) : 0;
|
|
const last = Math.max(ls, mls) || 0;
|
|
const within = last > 0 && (now - last) < windowMs;
|
|
const usage = ph.meraki?.usage || { sent: 0, recv: 0, total: 0 };
|
|
return {
|
|
identifier: ph.name || ph.mac,
|
|
mac: ph.mac,
|
|
recent12h: within,
|
|
lastSeen: ph.lastSeen || ph.meraki?.lastSeen || null,
|
|
usageBytes: usage.total || (usage.sent || 0) + (usage.recv || 0),
|
|
status: ph.status,
|
|
};
|
|
});
|
|
|
|
const baseActivity = dectBases.map(b => {
|
|
const ls = b.lastSeen && b.lastSeen !== 'unknown' ? Date.parse(b.lastSeen) : 0;
|
|
const mls = b.meraki?.lastSeen ? Date.parse(b.meraki.lastSeen) : 0;
|
|
const last = Math.max(ls, mls) || 0;
|
|
const within = last > 0 && (now - last) < windowMs;
|
|
const usage = b.meraki?.usage || { sent: 0, recv: 0, total: 0 };
|
|
return {
|
|
mac: b.mac,
|
|
recent12h: within,
|
|
lastSeen: b.lastSeen !== 'unknown' ? b.lastSeen : (b.meraki?.lastSeen || null),
|
|
linesRegistered: b.linesRegistered,
|
|
usageBytes: usage.total || (usage.sent || 0) + (usage.recv || 0),
|
|
hasSwitch: !!(b.meraki && (b.meraki.switchName || b.meraki.port)),
|
|
};
|
|
});
|
|
|
|
const handsetActivity = dectHandsets.map(h => {
|
|
const reg = h.lastRegistrationTime ? (typeof h.lastRegistrationTime === 'number' ? h.lastRegistrationTime : Date.parse(h.lastRegistrationTime)) : 0;
|
|
const within = reg > 0 && (now - reg) < windowMs;
|
|
return {
|
|
name: h.name,
|
|
extension: h.extension,
|
|
recent12h: within,
|
|
lastRegistration: h.lastRegistrationTime || null,
|
|
baseStationId: h.baseStationId,
|
|
};
|
|
});
|
|
|
|
const activePhones = phoneActivity.filter(a => a.recent12h).length;
|
|
const activeBases = baseActivity.filter(a => a.recent12h).length;
|
|
const activeHandsets = handsetActivity.filter(a => a.recent12h).length;
|
|
const totalUsage = [...phoneActivity, ...baseActivity].reduce((s, a) => s + (a.usageBytes || 0), 0);
|
|
|
|
return {
|
|
windowHours: 12,
|
|
summary: {
|
|
activePhones12h: activePhones,
|
|
activeDECTBases12h: activeBases,
|
|
activeHandsets12h: activeHandsets,
|
|
totalDataUsageBytes12h: totalUsage,
|
|
},
|
|
phones: phoneActivity,
|
|
dectBasestations: baseActivity,
|
|
handsets: handsetActivity,
|
|
};
|
|
}
|
|
|
|
// Tier 3: attempt to fetch historical call data via the Detailed Call History (CDR Feed) API.
|
|
// Canonical endpoint per the doc at:
|
|
// https://developer.webex.com/calling/docs/api/v1/reports-detailed-call-history/get-detailed-call-history
|
|
// - Path: /cdr_feed on https://analytics-calling.webexapis.com/v1 (preferred; the analytics.webexapis.com variant may also work depending on routing)
|
|
// - Required params: startTime + endTime (full ISO with ms+Z; **max 12h range per request**), locations (the *name* of the location, e.g. "Store 0782" or "Store 2477"; required)
|
|
// - Optional: max (500-5000 per page)
|
|
// - Hard constraints (per doc + testing):
|
|
// - endTime must be at least ~5 minutes in the past (data availability delay).
|
|
// - Max 12h of records per call.
|
|
// - Rate limit: 1 call per minute + up to 10 pagination calls per minute per token.
|
|
// - Auth: same Service App bearer (webex-service-tokens.json) + spark-admin:calling_cdr_read scope + Control Hub role "Webex Calling Detailed Call History API access".
|
|
// Response shape: { items: [...] } with call records.
|
|
// We always fetch a **single** compliant recent window (the freshest ~12h allowed by the 5min delay + 12h max) to strictly obey the 1 call/min rate limit.
|
|
// No multi-window back-to-back calls in one collectPhoneStatus (would 429).
|
|
// The `locations` name comes from the person's DECT network (already fetched for main number / dect logic).
|
|
// Secondary client-side filter on person numbers inside the location results.
|
|
// (recent activity and historical calls features disabled per request)
|
|
/** Last 6 chars of bearer token — enough to confirm curl vs bot use the same credential. */
|
|
function tokenFingerprint(token) {
|
|
if (!token || typeof token !== 'string') return 'none';
|
|
return `…${token.slice(-6)}`;
|
|
}
|
|
|
|
async function executeCdrFeedHttpFetch({
|
|
locationName,
|
|
startTime,
|
|
endTime,
|
|
tokenHint,
|
|
analyticsBases,
|
|
cdrPath,
|
|
maxPerPage,
|
|
}) {
|
|
const allRawItems = [];
|
|
const fetchErrors = [];
|
|
|
|
let usedBase = null;
|
|
let firstRespData = null;
|
|
let lastHttpStatus = null;
|
|
const maxPages = Number(process.env.CDR_FEED_MAX_PAGES || 10);
|
|
|
|
for (const base of analyticsBases) {
|
|
const url = `${base}${cdrPath}`;
|
|
const params = { startTime, endTime, locations: locationName, max: maxPerPage };
|
|
const queryStr = new URLSearchParams(params).toString();
|
|
const fullUrl = `${url}?${queryStr}`;
|
|
|
|
try {
|
|
let pageUrl = url;
|
|
let pageParams = params;
|
|
let page = 0;
|
|
let truncated = false;
|
|
|
|
while (page < maxPages) {
|
|
page += 1;
|
|
const pageQuery = pageParams ? new URLSearchParams(pageParams).toString() : '';
|
|
const pageFull = pageParams ? `${pageUrl}?${pageQuery}` : pageUrl;
|
|
if (page === 1) {
|
|
logger('phone:service', `cdr_feed GET ${pageFull}`, 'info');
|
|
} else {
|
|
logger('phone:service', `cdr_feed pagination page ${page} GET ${pageFull}`, 'info');
|
|
}
|
|
|
|
const resp = await webex.analyticsRequestRaw('GET', pageUrl, {
|
|
params: pageParams || undefined,
|
|
timeout: 30000,
|
|
});
|
|
lastHttpStatus = resp.status;
|
|
const d = resp.data || {};
|
|
if (page === 1) firstRespData = d;
|
|
|
|
const list = extractCdrFeedList(d);
|
|
allRawItems.push(...list);
|
|
usedBase = base;
|
|
logger(
|
|
'phone:service',
|
|
`cdr_feed ${base} page ${page} → HTTP ${resp.status}, parsed ${list.length} record(s) ` +
|
|
`(running total ${allRawItems.length}; ${describeCdrFeedPayload(d)})`,
|
|
'info',
|
|
);
|
|
if (page === 1 && list.length === 0 && d && Object.keys(d).length > 0) {
|
|
logger(
|
|
'phone:service',
|
|
`cdr_feed HTTP ${resp.status} but 0 parseable records — payload shape may have changed (${describeCdrFeedPayload(d)})`,
|
|
'warn',
|
|
);
|
|
}
|
|
|
|
const next = resolveCdrFeedNextPage(d, resp.headers, url, params);
|
|
if (!next) break;
|
|
|
|
if (page >= maxPages) {
|
|
truncated = true;
|
|
break;
|
|
}
|
|
|
|
pageUrl = next.url;
|
|
pageParams = next.params;
|
|
}
|
|
|
|
if (truncated) {
|
|
logger(
|
|
'phone:service',
|
|
`cdr_feed pagination stopped at CDR_FEED_MAX_PAGES=${maxPages} ` +
|
|
`(${allRawItems.length} records collected; more may exist)`,
|
|
'warn',
|
|
);
|
|
}
|
|
|
|
break;
|
|
} catch (e) {
|
|
const st = e.response?.status;
|
|
const errMsg = formatCdrFetchError(e);
|
|
const bd = e.response?.data || errMsg;
|
|
logger(
|
|
'phone:service',
|
|
`cdr_feed ERROR ${fullUrl} status=${st || 'n/a'}: ${typeof bd === 'string' ? bd : JSON.stringify(bd).slice(0, 300)}`,
|
|
'warn',
|
|
);
|
|
fetchErrors.push({ base, startTime, endTime, status: st, body: bd, message: errMsg });
|
|
}
|
|
}
|
|
|
|
return { allRawItems, fetchErrors, usedBase, firstRespData, lastHttpStatus };
|
|
}
|
|
|
|
function getCdrAnalyticsBases() {
|
|
const env = process.env.WEBEX_CDR_ANALYTICS_BASES;
|
|
if (env) {
|
|
return env.split(',').map((s) => s.trim()).filter(Boolean);
|
|
}
|
|
return [
|
|
'https://analytics-calling.webexapis.com/v1',
|
|
'https://analytics.webexapis.com/v1',
|
|
];
|
|
}
|
|
|
|
function formatCdrFetchError(err) {
|
|
const msg = err?.message || String(err);
|
|
if (msg.includes('ECONNREFUSED') && (msg.includes('0.0.0.0') || msg.includes('127.0.0.1'))) {
|
|
return (
|
|
`${msg} — analytics host unreachable from this container. ` +
|
|
'Often caused by HTTP(S)_PROXY routing analytics-calling.webexapis.com through a broken proxy ' +
|
|
'while webexapis.com is in NO_PROXY. CDR requests bypass proxy by default; add *.webexapis.com to NO_PROXY ' +
|
|
'or set WEBEX_CDR_USE_PROXY=true if a proxy is required.'
|
|
);
|
|
}
|
|
return msg;
|
|
}
|
|
|
|
export async function getHistoricalCallActivity(personId, hours = 24, options = {}) {
|
|
const {
|
|
locationName: providedLocationName,
|
|
startTime: optStartTime,
|
|
endTime: optEndTime,
|
|
returnRawItems = false,
|
|
skipPersonFilter = false,
|
|
onQueued,
|
|
} = options || {};
|
|
|
|
if (!personId && !providedLocationName) {
|
|
return { available: false, reason: 'no personId or locationName', calls: [], summary: {} };
|
|
}
|
|
|
|
// Prefer analytics-calling (user-confirmed working base for cdr_feed). Fall back to the other if needed.
|
|
const ANALYTICS_BASES = getCdrAnalyticsBases();
|
|
const CDR_PATH = '/cdr_feed';
|
|
const MAX_PER_PAGE = Number(process.env.CDR_FEED_MAX_PER_PAGE || 5000);
|
|
|
|
try {
|
|
const person = personId ? await webex.request('GET', `people/${personId}`) : null;
|
|
const orgId = person?.orgId;
|
|
|
|
const token = await webex.auth.getAccessToken();
|
|
|
|
// Get the *required* locations=name from DECT (passed in or self-discover).
|
|
let locationName = providedLocationName;
|
|
if (!locationName || locationName === '—') {
|
|
if (!personId) {
|
|
return {
|
|
available: false,
|
|
reason: 'no locationName (the /cdr_feed API requires the "locations" param)',
|
|
calls: [],
|
|
summary: {},
|
|
};
|
|
}
|
|
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: {},
|
|
};
|
|
}
|
|
locationName = String(locationName).trim();
|
|
|
|
// 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;
|
|
|
|
let endMs;
|
|
let startMs;
|
|
if (optStartTime && optEndTime) {
|
|
startMs = new Date(optStartTime).getTime();
|
|
endMs = new Date(optEndTime).getTime();
|
|
const latestAllowedEnd = Date.now() - FIVE_MIN_MS;
|
|
if (endMs > latestAllowedEnd) endMs = latestAllowedEnd;
|
|
if (endMs - startMs > TWELVE_HOURS_MS) startMs = endMs - TWELVE_HOURS_MS;
|
|
} else {
|
|
endMs = Date.now() - FIVE_MIN_MS;
|
|
startMs = endMs - TWELVE_HOURS_MS;
|
|
}
|
|
|
|
const startTime = new Date(startMs).toISOString();
|
|
const endTime = new Date(endMs).toISOString();
|
|
|
|
const tokenHint = tokenFingerprint(token);
|
|
logger(
|
|
'phone:service',
|
|
`cdr_feed query location=${JSON.stringify(locationName)} window=${startTime}..${endTime} ` +
|
|
`token=${tokenHint}`,
|
|
'info',
|
|
);
|
|
|
|
const {
|
|
allRawItems,
|
|
fetchErrors,
|
|
usedBase,
|
|
lastHttpStatus,
|
|
} = await enqueueCdrFeedFetch(
|
|
() => executeCdrFeedHttpFetch({
|
|
locationName,
|
|
startTime,
|
|
endTime,
|
|
tokenHint,
|
|
analyticsBases: ANALYTICS_BASES,
|
|
cdrPath: CDR_PATH,
|
|
maxPerPage: MAX_PER_PAGE,
|
|
}),
|
|
{ onQueued, label: locationName },
|
|
);
|
|
|
|
if (allRawItems.length === 0 && fetchErrors.length > 0) {
|
|
const errSummary = fetchErrors
|
|
.map((e) => `${e.base.replace('https://', '')}:${e.status || 'err'}`)
|
|
.join(', ');
|
|
const first = fetchErrors[0];
|
|
const msg = first?.message || first?.body?.message || first?.body?.error || first?.body || 'unknown error';
|
|
let reason = `cdr_feed failed on all bases (${errSummary}): ${typeof msg === 'string' ? msg : JSON.stringify(msg).slice(0, 200)}`;
|
|
if (fetchErrors.some((e) => e.status === 429)) {
|
|
reason = 'rate limited (1 cdr_feed call per minute + 10 pagination per min per token). Wait ~60s and retry.';
|
|
}
|
|
logger('phone:service', `cdr_feed unavailable — ${reason}`, 'warn');
|
|
return {
|
|
available: false,
|
|
reason,
|
|
fetchErrors,
|
|
scopesNeeded: fetchErrors.some((e) => e.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',
|
|
calls: [],
|
|
summary: {},
|
|
};
|
|
}
|
|
|
|
if (allRawItems.length === 0) {
|
|
// Legacy fallback (rarely useful now)
|
|
try {
|
|
const legUrl = `${ANALYTICS_BASES[0]}/callHistory`;
|
|
const r = await webex.analyticsRequestRaw('GET', legUrl, {
|
|
params: { orgId, personId, startTime, endTime, max: 100 },
|
|
timeout: 15000,
|
|
});
|
|
const d = r.data || {};
|
|
const li = extractCdrFeedList(d);
|
|
if (li.length >= 0) {
|
|
allRawItems.push(...li);
|
|
logger('phone:service', `cdr_feed legacy callHistory fallback → ${li.length} record(s)`, 'info');
|
|
}
|
|
} catch (legacyErr) {
|
|
logger('phone:service', `cdr_feed legacy callHistory fallback failed: ${legacyErr.message}`, 'debug');
|
|
}
|
|
}
|
|
|
|
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 = cdrDedupKey(c);
|
|
if (!seen.has(key)) {
|
|
seen.add(key);
|
|
items.push(c);
|
|
}
|
|
}
|
|
|
|
// Secondary person filter inside the location-scoped results.
|
|
const userItems = skipPersonFilter ? items : items.filter(matchItemToUser);
|
|
const effectiveItems = (userItems.length > 0) ? userItems : items;
|
|
if (userItems.length === 0 && items.length > 0 && !skipPersonFilter) {
|
|
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');
|
|
}
|
|
|
|
logger(
|
|
'phone:service',
|
|
`cdr_feed done base=${usedBase || 'none'} http=${lastHttpStatus ?? 'n/a'} ` +
|
|
`raw=${receivedRaw} unique=${items.length} returned=${effectiveItems.length} ` +
|
|
`personFilter=${skipPersonFilter ? 'off' : 'on'} token=${tokenHint}`,
|
|
'info',
|
|
);
|
|
|
|
// 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 = cdrDirectionValue(c);
|
|
const dur = cdrDurationSeconds(c);
|
|
const status = cdrDispositionValue(c).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: cdrStartTime(c) || c.releaseTime,
|
|
direction: dir || c.direction,
|
|
duration: dur,
|
|
status: status || cdrDispositionValue(c),
|
|
otherParty: c.otherParty || c.calledParty || c.callingParty || c.remoteParty
|
|
|| cdrCalledNumber(c) || cdrCallingNumber(c) || 'unknown',
|
|
phoneNumber: c.phoneNumber || cdrCalledNumber(c) || cdrCallingNumber(c),
|
|
});
|
|
}
|
|
}
|
|
|
|
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,
|
|
rawItems: returnRawItems ? effectiveItems : undefined,
|
|
};
|
|
} 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;
|
|
}
|
|
} |