// services/enrichment/avEnrichmentCore.js // // Core orchestrator for AV enrichment. // Single place that composes: // - base collection (MDM + Atlas via shared filters) // - relevant Meraki prefilters for ports/clients // - parallel domain (RED/Opti) // - full Meraki enrichment (clients + switches/APs via shared) // - attach domain data with shape adapters // - final assembly for different output shapes // // Shapes: // 'chat' -> same shape as old collectDeviceStatus (for /avstatus, commands) // 'rich' -> same shape as old buildAVDevices (for /av/devices/build + topology) // 'dashboard'-> the enriched list (for modals / avEnricher) - fully unified now // // This centralizes rate-limit smarts (relevant only) and makes future expansion (new sources, new commands) // much easier without copy/paste drift. // // Thin callers in deviceService / avDeviceBuilder / avEnricher delegate here + adapt if needed. import { logger } from '../../utils/logger.js'; import { collectBaseAVData, filterAVDevices } from './filters.js'; import { getClientsForStore, getPortsForStore } from '../../integrations/meraki/clients.js'; import { getREDStatusForStore } from '../../integrations/red/players.js'; import { getOptiSignStatus } from '../../integrations/optisigns/client.js'; import { findMerakiNetwork } from '../../integrations/meraki/networks.js'; import { enrichAllMerakiData, attachMerakiClientWithPorts, enrichWirelessDetails, enrichMerakiDeviceDetails } from './merakiEnrichment.js'; import { attachDomainData } from './domainEnrichment.js'; import { enrichDomainData as sharedEnrichDomainData } from './domainEnrichment.js'; import { findBestMerakiClientMatch } from './merakiMatcher.js'; import { normalizeMac } from './normalizers.js'; import { normalizePlayerName } from '../../utils/normalize.js'; import { getAllMerakiDevices, getMerakiTopology, } from '../../integrations/meraki/devices.js'; // ====================== CHAT ATTACHERS (MDM + Atlas + domain) ====================== // Moved here from deviceService to allow core to own the chat composition without cycles. // These are chat-slim specific (full red object etc flow via domain attach). export async function enrichMdmDevices(mdmResult, enrichedMeraki, redResult, optisignsResult) { let mdmDevices = mdmResult.value || []; const redPlayers = redResult.value || []; const optiDevices = optisignsResult.value?.devices || []; // Base collection already filtered (unified), no need to re-filter here logger('device:service', `MDM from unified base: ${mdmDevices.length} (already filtered)`, 'debug'); // First pass: meraki matching per MDM device (using shared) const mdmWithMeraki = await Promise.all(mdmDevices.map(async (mdmDevice) => { // Be defensive: raw MDM objects may expose UserName / DeviceFriendlyName instead of friendlyName const friendly = mdmDevice.friendlyName || mdmDevice.DeviceFriendlyName || mdmDevice.UserName || mdmDevice.name || ''; const normName = normalizePlayerName(friendly).toLowerCase().trim(); const mdmMac = normalizeMac(mdmDevice.mac || mdmDevice.serial || mdmDevice.MacAddress || ''); const matchDevice = { identifier: friendly, mdmData: mdmDevice }; const matched = findBestMerakiClientMatch(matchDevice, enrichedMeraki.devices || []); let matchedClient = matched || (enrichedMeraki.devices || []).find(c => { const clientMeraki = c.meraki || c; const cName = clientMeraki.name || clientMeraki.description || clientMeraki.UserName || ''; return ( normalizeMac(clientMeraki.mac) === mdmMac || normalizePlayerName(cName).toLowerCase().trim() === normName ); }); const merakiData = matchedClient ? { ... (matchedClient.meraki || matchedClient) } : {}; return { ...mdmDevice, meraki: merakiData }; })); // Second pass: unified domain attach (RED+Opti) for the whole list (slim for chat) attachDomainData(mdmWithMeraki, redPlayers, optiDevices, { identifierField: 'friendlyName', optiShape: 'display', // slim display for /avstatus onlyForMSCRed: false, onlyForVWLEDopti: false }); return mdmWithMeraki; } export function enrichAtlasDevices(atlasResult, enrichedMeraki) { const atlasDevices = atlasResult.value || []; return atlasDevices.map(atlasDev => { const state = atlasDev.state || {}; const atlasIp = (state.IpAddress || '').trim(); const atlasNameLower = (atlasDev.name || atlasDev.displayName || '').toLowerCase(); // Match Atlas to enriched client using shared advanced matcher (for name strategies) + ip fallback // Pass ip so the general matcher can use its IP strategy (consistent with rich path) let matchedClient = findBestMerakiClientMatch({ identifier: atlasDev.name || atlasDev.displayName || '', ip: atlasIp }, enrichedMeraki.devices || []); if (!matchedClient) { // Fallback search like in enrichMdmDevices, to ensure we attach Meraki for AMPs even if // exact matcher misses due to list shape or naming (by mac, name, or ip). matchedClient = (enrichedMeraki.devices || []).find(c => { const clientMeraki = c.meraki || c; const cName = clientMeraki.name || clientMeraki.description || clientMeraki.UserName || ''; const cIp = (clientMeraki.ip || '').trim(); const devMac = normalizeMac( atlasDev.mac || (atlasDev.state && atlasDev.state.MacAddress) || '' ); return ( (devMac && normalizeMac(clientMeraki.mac) === devMac) || normalizePlayerName(cName).toLowerCase().trim() === normalizePlayerName(atlasDev.name || '').toLowerCase().trim() || (atlasIp && cIp === atlasIp) ); }); } const merakiData = matchedClient ? { ...(matchedClient.meraki || matchedClient) } : {}; return { ...atlasDev, meraki: merakiData, // ← flat meraki object apDetails: matchedClient?.apDetails || null, switchDetails: matchedClient?.switchDetails || null }; }); } // ====================== MAIN ENTRY ====================== /** * Main entry: enrich AV data for a store, returning shape-specific result. * For 'chat' we preserve exact prior collectDeviceStatus contract (so existing commands + char + avEnricher unchanged). */ export async function enrichAVForStore(storeNumber, { shape = 'chat' } = {}) { const storeNum = String(storeNumber).trim(); logger('av:core', `enrichAVForStore(${storeNum}, shape=${shape})`, 'debug'); if (shape === 'chat') { return collectDeviceStatusViaCore(storeNum); } if (shape === 'rich' || shape === 'build') { return buildAVDevicesViaCore(storeNum); } if (shape === 'dashboard' || shape === 'enricher') { const chatShape = await collectDeviceStatusViaCore(storeNum); return buildDashboardList(chatShape); } throw new Error(`Unknown shape: ${shape}`); } /** * Chat/collect shape implemented via the shared pieces (no behavior change). * This is the body that used to live in deviceService.collectDeviceStatus. */ async function collectDeviceStatusViaCore(storeNum) { logger('device:service', `Starting device status collection for store ${storeNum} (via core)`, 'debug'); // Get clients first so we can prefilter switches for ports (to interrogate fewer switches and avoid rate limits) const clientsData = await getClientsForStore(storeNum); const merakiClientsResultRaw = { value: clientsData }; const clientsForPorts = Array.isArray(clientsData) ? clientsData : (clientsData?.clients || []); const relevantSwitchesForPorts = new Set(); for (const c of clientsForPorts) { const conn = (c.recentDeviceConnection || '').toLowerCase(); if (c.recentDeviceSerial && !conn.includes('wireless')) { relevantSwitchesForPorts.add(c.recentDeviceSerial); } } // Use unified base collection for MDM + Atlas const baseAV = await collectBaseAVData(storeNum, { includeDomain: true }); // For chat/enrichMdmDevices + avStatus consumers, ensure top-level friendlyName/name + lastSeen (camelCase) // (the raw MDM objects from integration use PascalCase like UserName / DeviceFriendlyName / LastSeen / LastSystemSampleTime; // prepareMDMBaseDevice normalizes into mdmDataSummary but for chat we extract .mdmData raw for full original data. // Patch the expected top-level fields so display (last seen, name) + matching in enrichMdm + domain attach all work correctly. // This keeps the per-device Meraki/port attachment distinct.) const mdmBase = baseAV.filter(d => d.source === 'mdm').map(d => { const raw = { ...(d.mdmData || d) }; const bestName = raw.friendlyName || raw.DeviceFriendlyName || raw.UserName || d.identifier || raw.name || ''; if (!raw.friendlyName) raw.friendlyName = bestName; if (!raw.name) raw.name = bestName; raw.lastSeen = raw.lastSeen || raw.LastSeen || raw.LastSystemSampleTime || ''; return raw; }); const atlasBase = baseAV.filter(d => d.source === 'atlas').map(d => d.atlasData); const [ merakiPortsResultRaw, redResult, optisignsResult ] = await Promise.allSettled([ getPortsForStore(storeNum, relevantSwitchesForPorts), getREDStatusForStore(storeNum), getOptiSignStatus(storeNum) ]); const mdmResult = { status: 'fulfilled', value: mdmBase }; const atlasResult = { status: 'fulfilled', value: atlasBase }; const networkInfo = await findMerakiNetwork(storeNum); // FULL MERAKI via the shared (now in enrichment layer) const merakiEnriched = await enrichAllMerakiData( networkInfo?.id, merakiClientsResultRaw, merakiPortsResultRaw, networkInfo ); // Enrich MDM with Meraki + RED + OptiSigns (chat slim shape) const enrichedMdm = await enrichMdmDevices( mdmResult, merakiEnriched, redResult, optisignsResult ); const enrichedAtlas = enrichAtlasDevices(atlasResult, merakiEnriched); logger('device:service', `Collected for store ${storeNum}: MDM=${enrichedMdm.length}, Atlas=${enrichedAtlas.length}, ` + `Meraki clients=${merakiEnriched.devices.length}`); return { mdm: { status: mdmResult.status, data: enrichedMdm }, atlas: { status: atlasResult.status, data: enrichedAtlas }, optisigns: prepareResult(optisignsResult, 'optisigns'), red: prepareResult(redResult, 'red'), meraki: merakiEnriched, topology: null }; } /** * Rich/build shape implemented via shared pieces (no behavior change from old avDeviceBuilder). * Replicates the orchestration but delegates client/ports/wireless/details/domain to shared, * keeps relevant-only for rate limits, assembles exact prior result shape. */ async function buildAVDevicesViaCore(storeNum) { logger('av:builder', `Building AV devices for store ${storeNum} (via core)`, 'debug'); try { const baseDevices = await collectBaseAVData(storeNum, { includeDomain: false }); if (!baseDevices || baseDevices.length === 0) { logger('av:builder', `No base devices found for store ${storeNum}`, 'warn'); } const merakiData = await getClientsForStore(storeNum); // Prefilter switches for ports using the clients' connected device serials (only wired ones need switch ports) const relevantSwitchesForPorts = new Set(); for (const c of (merakiData.clients || [])) { const conn = (c.recentDeviceConnection || '').toLowerCase(); if (c.recentDeviceSerial && !conn.includes('wireless')) { relevantSwitchesForPorts.add(c.recentDeviceSerial); } } const portsData = await getPortsForStore(storeNum, relevantSwitchesForPorts); // Step 1: Meraki client + port enrichment (uses shared) let enrichedDevices = await enrichWithMerakiViaCore( baseDevices, merakiData.clients || [], merakiData.network?.url || '', portsData ); // Step 2: Wireless details (only for wireless clients) - shared enrichedDevices = await enrichWirelessDetails(enrichedDevices, merakiData.network?.id); // Step 3: Meraki device details (switches + APs with wirelessStatus) - relevant only const allMerakiDevices = merakiData.network ? await getAllMerakiDevices(merakiData.network.id) : []; const relevantSerials = new Set(); for (const device of enrichedDevices) { const c = device.meraki?.client || {}; if (c.recentDeviceSerial) relevantSerials.add(c.recentDeviceSerial); if (c.switchSerial) relevantSerials.add(c.switchSerial); if (device.meraki?.apSerial) relevantSerials.add(device.meraki.apSerial); if (device.meraki?.switchSerial) relevantSerials.add(device.meraki.switchSerial); } const relevantDevices = allMerakiDevices.filter(d => relevantSerials.has(d.serial)); logger('av:builder', `Enriching details only for ${relevantDevices.length}/${allMerakiDevices.length} relevant Meraki devices (to reduce rate limits)`); const merakiDeviceDetails = await enrichMerakiDeviceDetails(merakiData.network?.id, relevantDevices); // Step 4: Topology const topology = await getMerakiTopology(merakiData.network?.id); // Step 5: Domain data (RED + OptiSigns) — MUST be last, rich shape enrichedDevices = await sharedEnrichDomainData(enrichedDevices, storeNum, { optiShape: 'rich' }); const result = { success: true, storeNumber: storeNum, network: merakiData.network || null, merakiDevices: allMerakiDevices, merakiDeviceDetails, merakiTopology: topology, deviceCount: enrichedDevices.length, devices: enrichedDevices, lastUpdated: new Date().toISOString() }; logger('av:builder', `Build complete for store ${storeNum} (${enrichedDevices.length} devices)`); return result; } catch (err) { logger('av:builder', `Build failed for store ${storeNum}: ${err.message}`, 'error'); throw err; } } async function enrichWithMerakiViaCore(baseDevices, allMerakiClients, networkUrl = '', portsData = null) { const enriched = []; const portStatusCache = new Map(); const portConfigs = (portsData && (portsData.ports || portsData)) || []; logger('av:meraki', `Enriching ${baseDevices.length} devices with Meraki data`, 'debug'); for (const device of baseDevices) { await attachMerakiClientWithPorts(device, allMerakiClients, portConfigs, portStatusCache, networkUrl); enriched.push(device); } // Extra visibility for the reported case (multiple historical client records for same AV) enriched.filter(d => /2477/i.test(d.identifier || '')).forEach(d => { const c = d.meraki?.client || {}; const mdm = d.mdmData || d.mdm || {}; const mdmLast = mdm.LastSeen || mdm.lastSeen || mdm.LastSystemSampleTime || (mdm.mdmDataSummary && mdm.mdmDataSummary.lastSeen) || 'n/a'; logger('av:meraki', `2477 device after attach: ${d.identifier} → client lastSeen=${c.lastSeen || 'n/a'} port/switchport=${c.switchport || c.portNumber || c.recentDevicePort || 'n/a'} status=${c.status || 'n/a'} recentDev=${c.recentDeviceSerial || 'n/a'} mdmLastSeen=${mdmLast}`); }); logger('av:meraki', `Meraki enrichment complete: ${enriched.filter(d => d.meraki?.client).length} matched`, 'debug'); return enriched; } // ====================== DASHBOARD / AV ENRICHER LIST BUILDER ====================== // Moved here from avEnricher.js for full unification. 'dashboard' shape now returns the // enriched list directly (used by avModalService, and avEnricher for compat). // Uses 'raw' opti shape + identifier 'name' on the built objects. const normalizeName = name => normalizePlayerName(name || '').toLowerCase().trim(); function getCategory(name) { const u = name.toUpperCase(); if (u.includes('AMP')) return 'AMP'; if (u.includes('VW')) return 'VW'; if (u.includes('LED')) return 'LED'; if (u.includes('MSC')) return 'MSC'; return 'Other'; } function findMerakiFallback(deviceName, merakiClients) { if (!Array.isArray(merakiClients)) return {}; const normName = normalizeName(deviceName); return merakiClients.find(c => { const m = c.meraki || c || {}; return normalizeName(m.description || m.user || m.name || '') === normName; }) || {}; } function buildDashboardList(rawData) { const { mdm, atlas, meraki, red, optisigns } = rawData; logger('av:dashboard', `Enriching AV devices - MDM:${mdm?.data?.length || 0}, Atlas:${atlas?.data?.length || 0}`); const avDevices = []; // MDM Players (VW, LED, MSC) - use shared filter (strict, no domain here) const mdmPlayers = filterAVDevices(mdm?.data || [], false); for (const device of mdmPlayers) { avDevices.push(enrichOneDevice(device, 'MDM', rawData)); } // Atlas AMPs const atlasAmps = (atlas?.data || []).filter(d => (d.name || d.displayName || '').toLowerCase().includes('amp') ); for (const device of atlasAmps) { avDevices.push(enrichOneDevice(device, 'Atlas', rawData)); } // Domain (red/opti) attachment now unified via shared (use 'raw' shape to preserve previous dashboard behavior) attachDomainData(avDevices, rawData.red?.data || [], rawData.optisigns?.data?.devices || [], { identifierField: 'name', optiShape: 'raw', onlyForMSCRed: false, onlyForVWLEDopti: false }); logger('av:dashboard', `Final enriched AV devices: ${avDevices.length}`); return avDevices; } function enrichOneDevice(sourceDevice, sourceType, rawData) { const name = sourceDevice.friendlyName || sourceDevice.name || sourceDevice.displayName || 'Unknown'; const category = getCategory(name); const isAtlasAmp = category === 'AMP'; // Use the meraki object already attached (best source) let merakiData = sourceDevice.meraki || {}; // If it's empty or not an object, do a strict fallback search using the shared advanced matcher if (!merakiData || typeof merakiData !== 'object' || Object.keys(merakiData).length < 5) { const matchDevice = { identifier: name }; const matched = findBestMerakiClientMatch(matchDevice, rawData.meraki?.devices || []); merakiData = matched || findMerakiFallback(name, rawData.meraki?.devices || []); } const switchportStatus = (merakiData && merakiData.switchportStatus) || {}; const switchportConfig = (merakiData && merakiData.switchportConfig) || {}; return { identifier: name, name: name, deviceCategory: category, source: sourceType, isAtlasAmp, ip: merakiData.ip || sourceDevice.state?.IpAddress || sourceDevice.ip, mac: merakiData.mac || sourceDevice.mac || sourceDevice.serialNumber, vlan: merakiData.vlan, switchport: merakiData.switchport || merakiData.portNumber, status: merakiData.status || 'Online', lastSeen: merakiData.lastSeen || sourceDevice.lastSeen, meraki: merakiData, switchportStatus, switchportConfig, mdm: sourceType === 'MDM' ? sourceDevice : null, atlas: isAtlasAmp ? sourceDevice : null, // red/optisigns attached post-build via shared attachDomainData (raw shape for dashboard) // (removed from here to unify) usage: merakiData.usage || { sent: 0, recv: 0 } }; } function prepareResult(result, name) { if (result.status === 'fulfilled') return { status: 'success', data: result.value }; logger('device:service', `${name} fetch failed: ${result.reason?.message || result.reason}`, 'warn'); return { status: 'failed', error: result.reason?.message || result.reason || 'Unknown error' }; } export { buildDashboardList }; // for avEnricher compat / direct dashboard list use export default { enrichAVForStore, };