const axios = require('axios'); const config = require('../config'); const { parseStoreNumber } = require('../utils/validate'); const { withRetry } = require('../utils/retry'); const logger = require('../utils/logger'); const merakiAxios = axios.create({ baseURL: config.meraki.baseUrl, headers: { 'X-Cisco-Meraki-API-Key': config.meraki.apiKey, 'Content-Type': 'application/json', }, timeout: 20000, }); // In-memory cache let cachedNetworks = []; let lastCacheTime = 0; let inFlightRefresh = null; const CACHE_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours const RETRY_OPTS = { retries: 2, initialDelayMs: 500 }; async function refreshMerakiNetworksCache() { // Coalesce concurrent callers onto a single in-flight refresh. if (inFlightRefresh) return inFlightRefresh; const start = Date.now(); logger.info('Refreshing Meraki networks cache'); inFlightRefresh = (async () => { try { const url = `/organizations/${config.meraki.orgId}/networks?perPage=5000`; const res = await withRetry(() => merakiAxios.get(url), RETRY_OPTS); cachedNetworks = res.data || []; lastCacheTime = Date.now(); logger.info('Meraki networks cache refreshed', { count: cachedNetworks.length, durationMs: Date.now() - start, }); } catch (err) { logger.error('Failed to refresh Meraki networks cache', { error: err.message }); // Keep the (possibly stale) cache and let callers decide what to do. } finally { inFlightRefresh = null; } })(); return inFlightRefresh; } async function getMerakiNetworks(forceRefresh = false) { const now = Date.now(); if (forceRefresh || cachedNetworks.length === 0 || now - lastCacheTime > CACHE_TTL_MS) { await refreshMerakiNetworksCache(); } return cachedNetworks; } async function findMerakiNetwork(storeNum) { const normalized = parseStoreNumber(storeNum); if (!normalized) return null; // Many store networks in Meraki are named with a 5-digit padded store number. const searchTerm = normalized.padStart(5, '0').slice(-5); const networks = await getMerakiNetworks(); logger.debug('Searching Meraki networks', { storeNum: normalized, searchTerm }); let bestMatch = null; let bestScore = -1; for (const net of networks) { const name = (net.name || '').toLowerCase(); const term = searchTerm.toLowerCase(); if (!name.includes(term)) continue; const score = name.includes(` ${term}`) || name.includes(`-${term}`) || name.includes(`${term} `) ? 100 : 50; if (score > bestScore) { bestScore = score; bestMatch = net; } } if (bestMatch) { logger.info('Best Meraki network match', { name: bestMatch.name, id: bestMatch.id }); } else { logger.info('No Meraki network match', { storeNum }); } return bestMatch; } async function getMerakiDeviceAvailabilities(networkId) { if (!networkId || !config.meraki.orgId) return []; try { const res = await withRetry( () => merakiAxios.get(`/organizations/${config.meraki.orgId}/devices/availabilities`, { params: { networkIds: [networkId] }, }), RETRY_OPTS ); return res.data || []; } catch (err) { logger.error('Device availabilities failed', { error: err.message }); return []; } } async function getMerakiClients(networkId) { if (!networkId) return []; try { // 7-day window catches registers and printers that only check in occasionally. const timespanSeconds = 7 * 24 * 60 * 60; const res = await withRetry( () => merakiAxios.get(`/networks/${networkId}/clients`, { params: { perPage: 1000, timespan: timespanSeconds }, }), RETRY_OPTS ); const clients = res.data || []; logger.info('Fetched Meraki clients', { count: clients.length, networkId }); return clients; } catch (err) { logger.error('Failed to fetch Meraki clients', { error: err.message, networkId }); return []; } } module.exports = { findMerakiNetwork, getMerakiClients, getMerakiDeviceAvailabilities, refreshMerakiNetworksCache, };