/** * Atlas (Xyte) device discovery. * * The Atlas org-wide device list endpoint is paginated and not free, so we * maintain a 1-hour in-process cache (same TTL the collabFinder reference * uses). `getAtlasDeviceList()` lazily populates the cache; everything else * filters on top of it. * * Store matching mirrors the collabFinder convention: device names follow * `US<6-digit padded store>` (e.g. `US000782AMP`), so we zero-pad the store * number before doing a case-insensitive substring match. Padding is * deliberate — a raw "782" would also substring-match unrelated devices like * `US007820AMP`. */ const { atlasGet, AtlasUnavailableError } = require('./atlasClient'); const logger = require('../../utils/logger'); const PAGE_SIZE = 100; const PAGE_THROTTLE_MS = 80; const HARD_PAGE_CAP = 100; const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour let _cachedDevices = []; let _lastCacheTime = 0; function resetCacheForTests() { _cachedDevices = []; _lastCacheTime = 0; } /** * Walk the paginated `/organization/devices` endpoint and replace the cache. * Bails out early on auth/transport failure and preserves the previous cache * (so a single bad refresh doesn't blank the bot's data view). */ async function refreshAtlasDevicesCache() { const start = Date.now(); logger.debug('Refreshing Atlas device cache'); let allItems = []; let page = 1; while (page <= HARD_PAGE_CAP) { let data; try { data = await atlasGet('organization/devices', { page, per_page: PAGE_SIZE }); } catch (err) { if (err instanceof AtlasUnavailableError) throw err; logger.error('Atlas device cache refresh failed mid-pagination', { page, error: err.message, }); // If we have a previously populated cache, keep serving it so a // transient outage doesn't blank the AV view. With nothing cached, the // caller has no fallback — re-throw so getAtlasDevicesForStore can // surface the unavailable banner. if (_cachedDevices.length === 0) throw err; return; } const items = Array.isArray(data?.items) ? data.items : []; allItems = allItems.concat(items); const nextPage = data?.next_page; // Stop on a short page or a missing/falsy `next_page`. Either signal // means we've exhausted the list. if (!nextPage || items.length < PAGE_SIZE) break; page = Number(nextPage) || page + 1; await new Promise(r => setTimeout(r, PAGE_THROTTLE_MS)); } _cachedDevices = allItems; _lastCacheTime = Date.now(); logger.info('Atlas device cache refreshed', { count: _cachedDevices.length, elapsedMs: Date.now() - start, }); } /** * Return the cached device list, refreshing on first call or TTL expiry. * Pass `forceRefresh: true` to ignore the TTL. */ async function getAtlasDeviceList(forceRefresh = false) { const stale = Date.now() - _lastCacheTime > CACHE_TTL_MS; if (forceRefresh || _cachedDevices.length === 0 || stale) { await refreshAtlasDevicesCache(); } return _cachedDevices; } /** * Find devices whose name contains the zero-padded store number. * Padding to 6 digits matches Atlas's `US` naming. */ async function findAtlasDevicesForStore(storeNumber) { const padded = String(storeNumber).trim().padStart(6, '0'); const devices = await getAtlasDeviceList(); const matches = devices.filter(dev => String(dev.name || '') .toUpperCase() .includes(padded) ); logger.debug('Atlas devices matched for store', { storeNumber, padded, matched: matches.length, }); return matches; } /** * Fetch detail for a single device. Returns null on failure rather than * throwing — most callers iterate a small list and want best-effort enrichment. */ async function getAtlasDeviceDetail(deviceId) { if (!deviceId) return null; try { return await atlasGet(`organization/devices/${deviceId}`); } catch (err) { if (err instanceof AtlasUnavailableError) throw err; logger.warn('Atlas device detail fetch failed', { deviceId, error: err.message }); return null; } } /** * Convenience: find devices by store and fetch detail for each in parallel. * Returns `{ devices: [...], unavailable, reason }` so the caller has the * same shape phone uses. */ async function getAtlasDevicesForStore(storeNumber) { let candidates; try { candidates = await findAtlasDevicesForStore(storeNumber); } catch (err) { if (err instanceof AtlasUnavailableError) { return { devices: [], unavailable: true, reason: err.message }; } return { devices: [], unavailable: true, reason: `Atlas lookup failed: ${err.message}`, }; } if (candidates.length === 0) { return { devices: [] }; } const detailResults = await Promise.allSettled(candidates.map(c => getAtlasDeviceDetail(c.id))); // Stitch detail back over the list summary so we don't lose the name when // detail returned null. Detail wins for everything it does provide. const devices = candidates.map((summary, idx) => { const detail = detailResults[idx]; const detailValue = detail.status === 'fulfilled' ? detail.value : null; return { ...summary, ...(detailValue || {}) }; }); return { devices }; } module.exports = { refreshAtlasDevicesCache, getAtlasDeviceList, findAtlasDevicesForStore, getAtlasDeviceDetail, getAtlasDevicesForStore, resetCacheForTests, PAGE_SIZE, HARD_PAGE_CAP, CACHE_TTL_MS, };