const { getStoreLocation, getStoreGeneral, getStoreRegisters, getStorePrinters, getStorePaymentTerminals, } = require('../services/siw'); const { findMerakiNetwork, getMerakiDeviceAvailabilities, getMerakiClients, } = require('../services/meraki'); const { getMDMDevices } = require('../services/mdm'); const { collectPhoneStatus } = require('../services/webexPhone'); const { collectAvStatus } = require('../services/avService'); const { createStore } = require('../models/Store'); const { findMatchingClient, getClientStatus, formatLastSeen, buildMerakiClientLink, extractHostname, } = require('../utils/merakiMatcher'); const { STORE_MODES, MDM_DEVICE_TYPES, AV_CATEGORIES, AV_FRIENDLY_NAME_PATTERN, filterMdmByType, classifyMdmAvDevice, mdmDeviceName, } = require('../constants'); const logger = require('../utils/logger'); /** * What each mode needs from the upstream services. Used to gate parallel * fetches so we don't hit MDM/Meraki for an INFO-only request. */ const MODE_FETCH_PLAN = { [STORE_MODES.INFO]: { siw: false, header: true, meraki: false, mdm: false, phone: false, av: false, }, [STORE_MODES.NETWORK]: { siw: false, header: false, meraki: true, mdm: true, phone: false, av: false, }, [STORE_MODES.POS]: { siw: true, header: false, meraki: true, mdm: true, phone: false, av: false, }, [STORE_MODES.IOS]: { siw: false, header: false, meraki: true, mdm: true, phone: false, av: false, }, // PHONE needs Meraki clients (for MAC matching) + the Webex Service App // phone service. [STORE_MODES.PHONE]: { siw: false, header: false, meraki: true, mdm: false, phone: true, av: false, }, // AV pulls Atlas (AMPs) AND MDM (Apple TVs, video walls, music, LED), // then MAC-matches each into Meraki for the where-connected line. [STORE_MODES.AV]: { siw: false, header: false, meraki: true, mdm: true, phone: false, av: true, }, }; const PLACEHOLDER_MESSAGES = {}; async function getStoreDetail(storeNumber, mode = STORE_MODES.INFO) { logger.info('Starting store analysis', { storeNumber, mode }); // Future-mode placeholders short-circuit before any upstream calls. if (PLACEHOLDER_MESSAGES[mode]) { return PLACEHOLDER_MESSAGES[mode]; } const plan = MODE_FETCH_PLAN[mode] || MODE_FETCH_PLAN[STORE_MODES.INFO]; let locationData = null, generalData = null, merakiNetwork = null, registers = [], printers = [], paymentTerminals = [], merakiClients = [], mdmDevices = [], phoneData = null, avData = null; try { // Phase 1: header data (location + general) and Meraki network discovery // so phase 2 has a network ID to query clients against. [locationData, generalData, merakiNetwork] = await Promise.all([ plan.header ? getStoreLocation(storeNumber) : Promise.resolve(null), plan.header ? getStoreGeneral(storeNumber) : Promise.resolve(null), plan.meraki ? findMerakiNetwork(storeNumber) : Promise.resolve(null), ]); // Phase 2: heavy data, fanned out in parallel. const dataPromises = []; if (plan.siw) { dataPromises.push( getStoreRegisters(storeNumber), getStorePrinters(storeNumber), getStorePaymentTerminals(storeNumber) ); } else { dataPromises.push(Promise.resolve([]), Promise.resolve([]), Promise.resolve([])); } dataPromises.push( merakiNetwork && plan.meraki ? getMerakiClients(merakiNetwork.id) : Promise.resolve([]) ); dataPromises.push(plan.mdm ? getMDMDevices(storeNumber) : Promise.resolve([])); // Webex phone data is independent of SIW/MDM — collectPhoneStatus catches // its own errors and yields { unavailable: true, reason } on failure. dataPromises.push( plan.phone ? collectPhoneStatus(storeNumber).catch(err => ({ unavailable: true, reason: err.message, })) : Promise.resolve(null) ); // Atlas AV data is similarly independent — collectAvStatus already // returns { unavailable, reason } on failure, but we still defend // against unexpected throws so a transport hiccup can't take the whole // report down (MDM-side AV devices should still render). dataPromises.push( plan.av ? collectAvStatus(storeNumber).catch(err => ({ devices: [], unavailable: true, reason: err.message, })) : Promise.resolve(null) ); const [reg, prn, pay, clients, mdm, phone, av] = await Promise.all(dataPromises); registers = reg || []; printers = prn || []; paymentTerminals = pay || []; merakiClients = clients || []; mdmDevices = mdm || []; phoneData = phone || null; avData = av || null; logger.info('Store data fetched', { mode }); } catch (err) { logger.error('Error fetching store data', { error: err.message }); } let report = ''; if (mode === STORE_MODES.INFO) { // INFO needs location to render anything — fetch StoreGeneral too for // brand/status/environment. The Store model handles missing data // gracefully. report += createStore(locationData, storeNumber, { general: generalData }).toSummary(); } else if (mode === STORE_MODES.NETWORK) { report += await buildActiveNetworkDevices(merakiNetwork); report += buildStoreServers(mdmDevices, merakiClients, merakiNetwork); } else if (mode === STORE_MODES.POS) { report += buildStoreServers(mdmDevices, merakiClients, merakiNetwork); report += buildRegisters(registers, merakiClients, merakiNetwork); report += buildMobileRegisters(mdmDevices, merakiClients, merakiNetwork); report += buildCustomerDisplays(mdmDevices, merakiClients, merakiNetwork); report += buildPrinters(printers, merakiClients, merakiNetwork); report += buildPaymentTerminals(paymentTerminals, merakiClients, merakiNetwork); } else if (mode === STORE_MODES.IOS) { report += buildIOSDevices(mdmDevices, merakiClients, merakiNetwork); } else if (mode === STORE_MODES.PHONE) { report += buildPhoneReport(phoneData, merakiClients, merakiNetwork); } else if (mode === STORE_MODES.AV) { report += buildAvReport(avData, mdmDevices, merakiClients, merakiNetwork); } return report.trim() || '_No matching data for this view._'; } // === Section Builders === /** * Render a single "device → matched Meraki client" line. * `prefixParts` is rendered before the status; `identifiers` is passed * straight through to findMatchingClient. */ function renderClientLine({ prefixParts, identifiers, merakiClients, merakiNetwork }) { const match = findMatchingClient(merakiClients, identifiers); const status = getClientStatus(match); const lastSeen = formatLastSeen(match?.lastSeen); const recentDevice = match?.recentDeviceName ? ` via ${match.recentDeviceName}` : ''; const connection = match?.recentDeviceConnection ? ` (${match.recentDeviceConnection})` : ''; const link = match ? ` → [Meraki Client](${buildMerakiClientLink(merakiNetwork, match)})` : ''; const prefix = prefixParts.filter(Boolean).join(' — '); return `${prefix} — ${status} — ${lastSeen}${recentDevice}${connection}${link}\n`; } async function buildActiveNetworkDevices(merakiNetwork) { if (!merakiNetwork) return '\n\n⚠️ No matching Meraki network found.\n'; // Leading \n\n forces the section splitter (\n\n followed by **) to break // this header onto its own message instead of gluing it to the previous // section's last line. let out = `\n\n**🌐 [${merakiNetwork.name}](${merakiNetwork.url})**\n\n`; let activeDevices = []; try { const availabilities = await getMerakiDeviceAvailabilities(merakiNetwork.id); activeDevices = availabilities.filter(d => d.status !== 'dormant'); } catch (e) { logger.error('Device availabilities failed', { error: e.message }); } activeDevices.sort((a, b) => { const nameA = (a.name || '').trim().toUpperCase(); const nameB = (b.name || '').trim().toUpperCase(); if (nameA.includes('SWR') || /SW.*R\d?$/.test(nameA)) return -1; if (nameB.includes('SWR') || /SW.*R\d?$/.test(nameB)) return 1; if (nameA.includes('SWF') || /SW.*F\d/.test(nameA)) return -1; if (nameB.includes('SWF') || /SW.*F\d/.test(nameB)) return 1; return nameA.localeCompare(nameB); }); out += `**🛠️ Active Network Devices (${activeDevices.length})**\n`; activeDevices.forEach(dev => { const statusEmoji = dev.status === 'online' ? '✅' : '⚠️'; const deviceName = (dev.name || dev.serial || 'Unknown').trim(); out += `**${deviceName}** — ${statusEmoji} ${dev.status}`; if (dev.serial && merakiNetwork.url) { const urlParts = merakiNetwork.url.split('/n/'); const networkCode = urlParts[1] ? urlParts[1].split('/')[0] : merakiNetwork.id; let link = `${urlParts[0]}/n/${networkCode}`; if (dev.productType === 'switch') link += `/manage/switches/${dev.serial}/summary`; else if (dev.productType === 'wireless') link += `/manage/access_points/${dev.serial}/summary`; else link += `/manage/${dev.serial}/summary`; out += ` → [Dashboard](${link})`; } out += `\n`; }); return out; } function buildMdmSection({ devices, marker, title, fallbackName, merakiClients, merakiNetwork, formatPrefix, }) { const filtered = filterMdmByType(devices, marker); if (filtered.length === 0) return ''; let out = `\n**${title} (${filtered.length})**\n`; filtered.forEach(dev => { const name = dev.UserName || dev.DeviceFriendlyName || fallbackName; out += renderClientLine({ prefixParts: formatPrefix ? formatPrefix(name, dev) : [`**${name}**`], identifiers: { UserName: dev.UserName, DeviceFriendlyName: dev.DeviceFriendlyName, name, }, merakiClients, merakiNetwork, }); }); return out; } function buildStoreServers(mdmDevices, merakiClients, merakiNetwork) { return buildMdmSection({ devices: mdmDevices, marker: MDM_DEVICE_TYPES.SERVER, title: '🖥️ Store Servers', fallbackName: 'Unknown Server', merakiClients, merakiNetwork, }); } function buildMobileRegisters(mdmDevices, merakiClients, merakiNetwork) { return buildMdmSection({ devices: mdmDevices, marker: MDM_DEVICE_TYPES.MOBILE_REGISTER, title: '📱 Mobile Registers', fallbackName: 'Unknown Mobile Register', merakiClients, merakiNetwork, }); } // Customer-facing displays (MDM naming convention: `USCD##`, // e.g. `US000782CD01`). Meraki advertises them with the same short hostname // as the client description, so the default name-based match path works. function buildCustomerDisplays(mdmDevices, merakiClients, merakiNetwork) { return buildMdmSection({ devices: mdmDevices, marker: MDM_DEVICE_TYPES.CUSTOMER_DISPLAY, title: '📟 Customer Displays', fallbackName: 'Unknown Customer Display', merakiClients, merakiNetwork, }); } function buildIOSDevices(mdmDevices, merakiClients, merakiNetwork) { return buildMdmSection({ devices: mdmDevices, marker: MDM_DEVICE_TYPES.IPHONE, title: '📱 Store iPhones', fallbackName: 'Unknown iPhone', merakiClients, merakiNetwork, }); } function buildRegisters(registers, merakiClients, merakiNetwork) { if (!registers || registers.length === 0) return ''; let out = `\n**🖥️ Store Registers (${registers.length})**\n`; registers.forEach(reg => { const regNum = reg.register_number ? `Register ${reg.register_number}` : 'Register'; const brand = reg.brand_display_name || 'Unknown'; const type = reg.register_type_name || 'Unknown'; const name = (reg.register_display_name || reg.printer_name || 'Unknown').trim(); out += renderClientLine({ prefixParts: [`**${regNum}**`, brand, type], identifiers: { name, register_display_name: name }, merakiClients, merakiNetwork, }); }); return out; } function buildPrinters(printers, merakiClients, merakiNetwork) { const filtered = (printers || []).filter( p => (p.connection_type_name || '').toLowerCase() !== 'usb' ); if (filtered.length === 0) return ''; let out = `\n**🖨️ Store Printers (${filtered.length})**\n`; filtered.forEach(printer => { const name = (printer.printer_name || 'Unknown').trim(); const model = printer.printer_model_name || 'N/A'; out += renderClientLine({ prefixParts: [`**${name}**`, model], identifiers: { name, printer_name: name }, merakiClients, merakiNetwork, }); }); return out; } function buildPaymentTerminals(paymentTerminals, merakiClients, merakiNetwork) { if (!paymentTerminals || paymentTerminals.length === 0) return ''; let out = `\n**💳 Payment Terminals (${paymentTerminals.length})**\n`; paymentTerminals.forEach(term => { const deviceName = term.device_name || 'Unknown Terminal'; const adyenName = term.adyen_device_name || 'N/A'; const model = term.device_model_name || 'N/A'; const type = term.device_type_name || 'N/A'; // SIW stores the FQDN in `ip_address`; the hostname before the first dot // is what Meraki uses as the client description (e.g. "VFI-807-005-168"). const hostname = extractHostname(term.ip_address); out += renderClientLine({ prefixParts: [`**${deviceName}**`, adyenName, model, type], identifiers: { name: hostname, deviceName, adyenName, ip_address: term.ip_address, }, merakiClients, merakiNetwork, }); }); return out; } // === Phone (Webex Service App) === const WEBEX_UNAVAILABLE_BANNER = reason => '\n\n**⚠️ Webex phone data unavailable**\n\n' + `_${reason || 'Service App is not configured or tokens are missing.'}_\n\n` + 'Run `npm run webex:seed` on the host to bootstrap or re-seed the Service ' + 'App tokens, then try again.\n'; /** * Build the full PHONE-mode report: a header (Webex location + store DID), * wired 78xx phones, DECT basestations with their currently-registered * handsets nested underneath, and a trailing "Unregistered Handsets" section * for anything that doesn't have a recent registration or doesn't map to a * known base. */ function buildPhoneReport(phoneData, merakiClients, merakiNetwork) { if (!phoneData) { return WEBEX_UNAVAILABLE_BANNER('Webex Service App not configured.'); } if (phoneData.unavailable) { return WEBEX_UNAVAILABLE_BANNER(phoneData.reason); } let out = ''; out += buildPhoneHeader(phoneData); out += buildWiredPhones(phoneData.phones, merakiClients, merakiNetwork); out += buildDectSection(phoneData, merakiClients, merakiNetwork); if (!out.trim()) { return '\n\n_No Webex phones, DECT basestations, or handsets registered for this store._'; } return out; } function buildPhoneHeader(phoneData) { const locationName = phoneData?.dectNetwork?.locationName; const mainNumber = phoneData?.locationMainNumber; if (!locationName && !mainNumber) return ''; const parts = []; if (locationName) parts.push(`**📍 ${locationName}**`); if (mainNumber) parts.push(`📞 Main: **${mainNumber}**`); return `\n\n${parts.join(' — ')}\n`; } function buildWiredPhones(phones, merakiClients, merakiNetwork) { if (!phones || phones.length === 0) return ''; let out = `\n\n**📞 Wired Phones (${phones.length})**\n`; phones.forEach(p => { const model = p.model || 'Cisco IP Phone'; const extPart = p.extension ? `ext ${p.extension}` : null; const prefixParts = [`**${p.name}**`, model]; if (extPart) prefixParts.push(extPart); out += renderClientLine({ prefixParts, identifiers: { mac: p.mac, name: p.name }, merakiClients, merakiNetwork, }); }); return out; } // Handsets don't have an IP presence Webex can poll — they're DECT radio // devices that only register through their basestation. So `handset.status` // from the Webex list endpoint is unreliable (frequently empty/"unknown"). // Instead, we treat the line's `lastRegistrationTime` as the health signal: // a fresh registration means the handset is talking to its base right now. const HANDSET_FRESH_REG_WINDOW_MS = 24 * 60 * 60 * 1000; // 24h function isHandsetRegistered(h) { if (!h.lastRegistrationTime) return false; const lastRegMs = new Date(h.lastRegistrationTime).getTime(); return Number.isFinite(lastRegMs) && Date.now() - lastRegMs <= HANDSET_FRESH_REG_WINDOW_MS; } /** * Compose the user-facing handset name. Webex stores the meaningful slot * index separately from the extension/access-code, so we render the * "-" form operators recognise (e.g. "1-50782", "2-50782") * when both are present, falling back to whatever displayName we got. */ function formatHandsetName(h) { if (h.index != null && h.extension) { return `${h.index}-${h.extension}`; } if (h.extension) return String(h.extension); return h.name || `Handset ${h.index ?? '?'}`; } function buildDectSection(phoneData, merakiClients, merakiNetwork) { const bases = phoneData.basestations || []; const handsets = phoneData.handsets || []; if (bases.length === 0 && handsets.length === 0) return ''; let out = `\n\n**📡 DECT Network**`; if (phoneData.dectNetwork?.name) { out += ` — ${phoneData.dectNetwork.name}`; } out += `\n`; // Partition handsets: // - "registered + assigned to a known base" → nested under that base. // - everything else (stale registration, no registration data, or a // baseStationId that doesn't map to a current base) → trailing // "Unregistered Handsets" section. const baseIds = new Set(bases.map(b => b.id)); const handsetsByBase = new Map(); const unregisteredHandsets = []; handsets.forEach(h => { if (isHandsetRegistered(h) && h.baseStationId && baseIds.has(h.baseStationId)) { const list = handsetsByBase.get(h.baseStationId) || []; list.push(h); handsetsByBase.set(h.baseStationId, list); } else { unregisteredHandsets.push(h); } }); bases.forEach(base => { const baseHandsets = handsetsByBase.get(base.id) || []; const firmware = base.firmware ? ` fw ${base.firmware}` : ''; const lines = base.linesRegistered ? ` — ${base.linesRegistered} lines registered` : ''; out += '\n'; out += renderClientLine({ prefixParts: [`**🛰️ ${base.name}**`, `${base.model || 'DECT Base'}${firmware}${lines}`], identifiers: { mac: base.mac, name: base.name }, merakiClients, merakiNetwork, }); if (baseHandsets.length === 0) { out += ` _no registered handsets_\n`; } else { baseHandsets.forEach(h => { out += ` - ${formatHandsetLine(h)}\n`; }); } }); if (unregisteredHandsets.length > 0) { out += `\n**📵 Unregistered Handsets (${unregisteredHandsets.length})**\n`; unregisteredHandsets.forEach(h => { out += `- ${formatHandsetLine(h)}\n`; }); } return out; } function formatHandsetLine(h) { const displayName = formatHandsetName(h); const lastReg = h.lastRegistrationTime ? new Date(h.lastRegistrationTime) : null; const ago = lastReg ? formatLastSeen(lastReg) : null; let presence; if (!lastReg) { presence = '❓ No registration data'; } else if (Date.now() - lastReg.getTime() <= HANDSET_FRESH_REG_WINDOW_MS) { presence = `✅ Registered — last sync ${ago}`; } else { presence = `⚠️ Last registered ${ago}`; } return `**${displayName}** — ${presence}`; } // === AV (Atlas + MDM hardware) === // Ordered so the report always renders Atlas first, then the MDM categories // in a consistent (and visually grouped) sequence. Keep this aligned with // `AV_SUBSECTIONS` below. const AV_SUBSECTIONS = [ { category: AV_CATEGORIES.APPLE_TV, title: '📺 Apple TVs', fallbackName: 'Unknown Apple TV' }, { category: AV_CATEGORIES.VIDEO_WALL, title: '🖼️ Video Walls', fallbackName: 'Unknown Video Wall', }, { category: AV_CATEGORIES.MUSIC, title: '🎵 Music Players', fallbackName: 'Unknown Music Player', }, { category: AV_CATEGORIES.LED, title: '💡 LED Displays', fallbackName: 'Unknown LED Display' }, ]; const ATLAS_UNAVAILABLE_BANNER = reason => '\n\n**⚠️ Atlas AV data unavailable**\n\n' + `_${reason || 'Atlas is not configured (set ATLAS_AUTH_KEY).'}_\n`; function buildAvReport(avData, mdmDevices, merakiClients, merakiNetwork) { let out = ''; // Atlas section first (with inline banner on failure so MDM hardware // below still renders). Treat a missing avData payload as "Atlas wasn't // attempted" — defensive in case the plan fetch shape changes. if (avData && avData.unavailable) { out += ATLAS_UNAVAILABLE_BANNER(avData.reason); } else if (avData && Array.isArray(avData.devices) && avData.devices.length > 0) { out += buildAtlasAmpSection(avData.devices, merakiClients, merakiNetwork); } // Then each MDM-derived AV category. for (const sub of AV_SUBSECTIONS) { out += buildMdmAvSection({ mdmDevices, category: sub.category, title: sub.title, fallbackName: sub.fallbackName, merakiClients, merakiNetwork, }); } if (!out.trim()) { return '\n\n_No AV hardware registered for this store._'; } return out; } function buildAtlasAmpSection(devices, merakiClients, merakiNetwork) { let out = `\n\n**📡 Atlas AMP (${devices.length})**\n`; devices.forEach(dev => { const prefixParts = [`**${dev.name}**`]; if (dev.model) prefixParts.push(dev.model); prefixParts.push(formatAtlasPresence(dev)); out += renderClientLine({ prefixParts, identifiers: { mac: dev.mac, name: dev.name, ip_address: dev.ip }, merakiClients, merakiNetwork, }); }); return out; } function formatAtlasPresence(dev) { const lastSeen = dev.lastSeen ? new Date(dev.lastSeen) : null; const lastSeenStr = lastSeen ? formatLastSeen(lastSeen) : null; const suffix = lastSeenStr ? ` — last seen ${lastSeenStr}` : ''; if (dev.online === true) return `✅ Online${suffix}`; if (dev.online === false) return `⚠️ Offline${suffix}`; return `❓ Unknown${suffix}`; } function buildMdmAvSection({ mdmDevices, category, title, fallbackName, merakiClients, merakiNetwork, }) { const filtered = (mdmDevices || []).filter(d => { const name = mdmDeviceName(d); return AV_FRIENDLY_NAME_PATTERN.test(name) && classifyMdmAvDevice(name) === category; }); if (filtered.length === 0) return ''; let out = `\n\n**${title} (${filtered.length})**\n`; filtered.forEach(dev => { const name = dev.DeviceFriendlyName || dev.UserName || fallbackName; const model = dev.Model || dev.ModelId || null; const prefixParts = [`**${name}**`]; if (model) prefixParts.push(model); out += renderClientLine({ prefixParts, identifiers: { UserName: dev.UserName, DeviceFriendlyName: dev.DeviceFriendlyName, mac: dev.MacAddress, name, }, merakiClients, merakiNetwork, }); }); return out; } module.exports = { getStoreDetail };