// 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; } }