/** * Webex phone-discovery service. * * Focused subset of the collabFinder phoneService.js: * - resolves a store number to its `ae<5digit>@ae.com` person * - lists the wired desk phones registered to that person (filtered to * the two Cisco IP Phones in store use: 7821, 7841) * - lists the person's DECT network and its basestations + handsets * (handsets carry baseStationId so the caller can group them under * their parent base) * - fetches the store's main DID number (callingLineId on the DECT * network's location) * * The shape returned is intentionally flat so renderers in * integrations/storeDetail.js can iterate without diving through nested * status wrappers. Graceful-degradation contract: any unrecoverable failure * (e.g. Service App not configured, tokens missing) yields * `{ unavailable: true, reason }` rather than throwing, so the bot can * print a single warning banner the same way it does for SIW. */ const webex = require('./webexService'); const logger = require('../utils/logger'); // Cisco IP Phone 7821 / 7841 — the only wired desk phones we care about in // store mode. Pattern is intentionally loose (some product strings use // "Cisco 7841", others "CP-7841-K9", etc.). const WIRED_PHONE_PATTERN = /78(21|41)/; function storeEmail(storeNumber) { const padded = String(storeNumber).trim().padStart(5, '0'); return `ae${padded}@ae.com`; } async function getPersonIdByEmail(email) { if (!email) return null; try { const res = await webex.request('GET', 'people', null, { email }); const items = res.items || []; if (items.length === 0) { logger.warn('Webex person lookup empty', { email }); return null; } return items[0].id; } catch (err) { logger.error('Webex person lookup failed', { email, error: err.message }); return null; } } /** * Pull the phone extension assigned to this person. Webex Calling exposes it * either as a top-level `extension` field on the person, or in `phoneNumbers` * with type `work_extension`. Returns null if neither is present. */ async function getPersonExtension(personId) { if (!personId) return null; try { const person = await webex.request('GET', `people/${personId}`); if (person?.extension) return String(person.extension); const fromNumbers = (person?.phoneNumbers || []).find(p => String(p.type || '') .toLowerCase() .includes('extension') ); return fromNumbers?.value ? String(fromNumbers.value) : null; } catch (err) { logger.warn('Webex person extension lookup failed', { personId, error: err.message, }); return null; } } async function getDevicesForPerson(personId) { if (!personId) return []; const all = []; let next = null; try { do { const params = { personId, max: 100 }; if (next) params.next = next; const res = await webex.request('GET', 'devices', null, params); const items = res.items || []; all.push(...items); next = res.next || null; } while (next); return all; } catch (err) { logger.error('Webex device list failed', { personId, error: err.message }); return []; } } async function getDectNetworksForPerson(personId) { if (!personId) return []; try { const res = await webex.request('GET', `telephony/config/people/${personId}/dectNetworks`); const networks = res.dectNetworks || []; return networks.map(net => ({ id: net.id, name: net.name || 'Unknown', handsetsCount: net.numberOfHandsetsAssigned || 0, locationName: net.location?.name || null, locationId: net.location?.id || null, })); } catch (err) { logger.error('Webex DECT networks lookup failed', { personId, error: err.message }); return []; } } async function getDectBasestations(locationId, networkId) { if (!locationId || !networkId) return []; try { const res = await webex.request( 'GET', `telephony/config/locations/${locationId}/dectNetworks/${networkId}/baseStations` ); const items = res.items || res.baseStations || []; return items.map(b => ({ id: b.id, mac: b.mac || b.macAddress || b.baseMac || null, name: b.displayName || `Basestation ${b.mac || b.macAddress || 'Unknown'}`, status: b.status || 'unknown', lastSeen: b.lastSeen || null, firmware: b.softwareVersion || null, model: b.model || null, ipAddress: b.ip || null, linesRegistered: b.numberOfLinesRegistered || 0, })); } catch (err) { logger.error('Webex DECT basestations lookup failed', { locationId, networkId, error: err.message, }); return []; } } async function getDectHandsets(locationId, networkId) { if (!locationId || !networkId) return []; try { const res = await webex.request( 'GET', `telephony/config/locations/${locationId}/dectNetworks/${networkId}/handsets` ); const items = res.items || res.handsets || []; return items.map(h => ({ id: h.id, // The handset's slot index in the DECT network (1, 2, 3, ...). Used by // the renderer to compose the "-" display name. index: h.index ?? null, name: h.defaultDisplayName || h.displayName || `Handset ${h.index || ''}`, status: h.status || 'unknown', lastSeen: h.lastSeen || null, mac: h.mac || null, firmware: h.softwareVersion || null, model: h.model || null, extension: h.accessCode || h.lines?.[0]?.esn || null, // baseStationId is only present on the detail endpoint; the per-handset // detail fetch below fills it in. baseStationId: h.baseStationId || null, })); } catch (err) { logger.error('Webex DECT handsets lookup failed', { locationId, networkId, error: err.message, }); return []; } } async function getDectHandsetDetail(locationId, networkId, handsetId) { if (!locationId || !networkId || !handsetId) return null; try { const h = await webex.request( 'GET', `telephony/config/locations/${locationId}/dectNetworks/${networkId}/handsets/${handsetId}` ); return { id: h.id, index: h.index ?? null, baseStationId: h.baseStationId || null, lastRegistrationTime: h.lines?.[0]?.lastRegistrationTime || null, extension: h.lines?.[0]?.extension || null, }; } catch (err) { logger.warn('Webex DECT handset detail failed', { handsetId, error: err.message, }); return null; } } async function getLocationMainNumber(locationId) { if (!locationId) return null; try { const loc = await webex.request('GET', `telephony/config/locations/${locationId}`); return loc?.callingLineId?.phoneNumber || loc?.phoneNumber || null; } catch (err) { logger.warn('Webex location main-number lookup failed', { locationId, error: err.message, }); return null; } } function shapeWiredPhone(dev, extension = null) { return { mac: dev.mac || null, name: dev.displayName || dev.product || 'Unknown Phone', model: dev.product || dev.model || null, firmware: dev.software || dev.softwareVersion || null, status: dev.connectionStatus || dev.status || 'unknown', lastSeen: dev.lastSeen || null, ipAddress: dev.ip || dev.ipAddress || null, // All wired phones in store mode belong to the store service-account // person, so they share that person's primary extension. Worth surfacing // because the device itself doesn't carry it. extension, }; } /** * Collect every phone artefact we render for a store. Returns * `{ unavailable: true, reason }` if the Service App is not configured or * the bootstrap tokens file is missing — callers should surface the reason * as a banner rather than treating it as an error. */ async function collectPhoneStatus(storeNumber) { const email = storeEmail(storeNumber); logger.debug('collectPhoneStatus start', { storeNumber, email }); let personId; try { personId = await getPersonIdByEmail(email); } catch (err) { // getPersonIdByEmail catches its own errors so this only fires when // request setup fails (e.g. missing client id / missing tokens file). return { unavailable: true, reason: err.message }; } if (!personId) { return { unavailable: true, reason: `No Webex person found for ${email}. ` + 'Confirm the store has a service account provisioned in Webex.', }; } const [devicesRes, networksRes, extensionRes] = await Promise.allSettled([ getDevicesForPerson(personId), getDectNetworksForPerson(personId), getPersonExtension(personId), ]); const allDevices = devicesRes.status === 'fulfilled' ? devicesRes.value : []; const dectNetworks = networksRes.status === 'fulfilled' ? networksRes.value : []; const dectNetwork = dectNetworks[0] || null; const personExtension = extensionRes.status === 'fulfilled' ? extensionRes.value : null; const wiredPhones = allDevices .filter(d => WIRED_PHONE_PATTERN.test(String(d.product || d.model || ''))) .map(d => shapeWiredPhone(d, personExtension)); let basestations = []; let handsets = []; let locationMainNumber = null; if (dectNetwork?.locationId && dectNetwork?.id) { const [basesRes, handsetsRes, mainNumRes] = await Promise.allSettled([ getDectBasestations(dectNetwork.locationId, dectNetwork.id), getDectHandsets(dectNetwork.locationId, dectNetwork.id), getLocationMainNumber(dectNetwork.locationId), ]); basestations = basesRes.status === 'fulfilled' ? basesRes.value : []; const rawHandsets = handsetsRes.status === 'fulfilled' ? handsetsRes.value : []; locationMainNumber = mainNumRes.status === 'fulfilled' ? mainNumRes.value : null; // Per-handset detail pulls in baseStationId / lastRegistrationTime so we // can group handsets under their parent basestation. handsets = await Promise.all( rawHandsets.map(async h => { const detail = await getDectHandsetDetail(dectNetwork.locationId, dectNetwork.id, h.id); return { ...h, ...(detail || {}) }; }) ); } logger.info('collectPhoneStatus done', { storeNumber, wired: wiredPhones.length, bases: basestations.length, handsets: handsets.length, }); return { phones: wiredPhones, basestations, handsets, dectNetwork, locationMainNumber, }; } module.exports = { collectPhoneStatus, // Exposed for unit tests: storeEmail, WIRED_PHONE_PATTERN, getPersonIdByEmail, getPersonExtension, getDevicesForPerson, getDectNetworksForPerson, getDectBasestations, getDectHandsets, getDectHandsetDetail, getLocationMainNumber, };