/** * In-memory counter store with TTL windows for pattern detection. * Windows: 'today' resets at midnight, 'week' resets Monday 00:00, 'month' resets 1st 00:00. */ // store[window][namespace][key] = count const store = { today: new Map(), week: new Map(), month: new Map() }; function getNamespaceMap(window, namespace) { const windowMap = store[window]; if (!windowMap) return null; if (!windowMap.has(namespace)) windowMap.set(namespace, new Map()); return windowMap.get(namespace); } function increment(namespace, key, window) { const map = getNamespaceMap(window, namespace); if (!map) return; map.set(key, (map.get(key) || 0) + 1); } function get(namespace, key, window) { const map = getNamespaceMap(window, namespace); if (!map) return 0; return map.get(key) || 0; } function reset(namespace, window) { const windowMap = store[window]; if (!windowMap) return; windowMap.delete(namespace); } function getAll(namespace, window) { const map = getNamespaceMap(window, namespace); if (!map) return new Map(); return new Map(map); } // --- Scheduled resets --- function msUntilNextMidnight() { const now = new Date(); const next = new Date(now); next.setHours(24, 0, 0, 0); return next.getTime() - now.getTime(); } function msUntilNextMonday() { const now = new Date(); const day = now.getDay(); // 0=Sun const daysUntilMonday = day === 0 ? 1 : (8 - day); const next = new Date(now); next.setDate(now.getDate() + daysUntilMonday); next.setHours(0, 0, 0, 0); return next.getTime() - now.getTime(); } function msUntilNextMonth() { const now = new Date(); const next = new Date(now.getFullYear(), now.getMonth() + 1, 1, 0, 0, 0, 0); return next.getTime() - now.getTime(); } // Callbacks to run on daily reset (e.g. clear firedToday in patternChecker) const dailyResetCallbacks = []; const weeklyResetCallbacks = []; function onDailyReset(fn) { dailyResetCallbacks.push(fn); } function onWeeklyReset(fn) { weeklyResetCallbacks.push(fn); } // --- Threshold firing state --- // key -> Set that have fired within the key's window. const firedThresholds = new Map(); // key -> window type used for threshold clearing ("today" | "week" | "month") const firedThresholdWindows = new Map(); function clearFiredThresholdsForWindow(windowType) { for (const [key, mappedWindowType] of firedThresholdWindows.entries()) { if (mappedWindowType === windowType) { firedThresholds.delete(key); firedThresholdWindows.delete(key); } } } function shouldFireThreshold(key, ageMs, thresholdsMs, windowType) { if (!Array.isArray(thresholdsMs) || thresholdsMs.length === 0) return null; if (!['today', 'week', 'month'].includes(windowType)) return null; firedThresholdWindows.set(key, windowType); const firedForKey = firedThresholds.get(key) || new Set(); const sortedThresholds = [...thresholdsMs].sort((a, b) => a - b); let highestUnfiredCrossed = null; for (const thresholdMs of sortedThresholds) { if (ageMs >= thresholdMs && !firedForKey.has(thresholdMs)) { highestUnfiredCrossed = thresholdMs; } } if (highestUnfiredCrossed === null) return null; firedForKey.add(highestUnfiredCrossed); firedThresholds.set(key, firedForKey); return highestUnfiredCrossed; } // --- Escalating cooldown state --- // key -> { startedAtMs, lastFireAtMs, fireCount } const escalatingCooldowns = new Map(); function shouldFireCooldownEscalating(key, thresholdsMs) { if (!Array.isArray(thresholdsMs) || thresholdsMs.length === 0) return null; const sortedThresholds = [...thresholdsMs].sort((a, b) => a - b); const now = Date.now(); let state = escalatingCooldowns.get(key); if (!state) { state = { startedAtMs: now, lastFireAtMs: null, fireCount: 0, lastUsed: now }; escalatingCooldowns.set(key, state); } state.lastUsed = now; const nextThreshold = sortedThresholds[state.fireCount]; if (typeof nextThreshold !== 'number') return null; const referenceMs = state.fireCount === 0 ? state.startedAtMs : state.lastFireAtMs; if ((now - referenceMs) < nextThreshold) return null; state.fireCount += 1; state.lastFireAtMs = now; return nextThreshold; } function clearEscalating(key) { escalatingCooldowns.delete(key); } const ESCALATING_COOLDOWN_TTL_MS = 48 * 60 * 60 * 1000; const ESCALATING_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000; function cleanupStaleEscalatingCooldowns() { const cutoff = Date.now() - ESCALATING_COOLDOWN_TTL_MS; for (const [key, state] of escalatingCooldowns.entries()) { const lastUsed = state.lastUsed || state.lastFireAtMs || state.startedAtMs || 0; if (lastUsed < cutoff) escalatingCooldowns.delete(key); } } setInterval(cleanupStaleEscalatingCooldowns, ESCALATING_CLEANUP_INTERVAL_MS).unref?.(); function scheduleDailyReset() { setTimeout(() => { store.today = new Map(); clearFiredThresholdsForWindow('today'); for (const fn of dailyResetCallbacks) { try { fn(); } catch (_) {} } scheduleDailyReset(); }, msUntilNextMidnight()); } function scheduleWeeklyReset() { setTimeout(() => { store.week = new Map(); clearFiredThresholdsForWindow('week'); for (const fn of weeklyResetCallbacks) { try { fn(); } catch (_) {} } scheduleWeeklyReset(); }, msUntilNextMonday()); } function scheduleMonthlyReset() { setTimeout(() => { store.month = new Map(); clearFiredThresholdsForWindow('month'); scheduleMonthlyReset(); }, msUntilNextMonth()); } function scheduleResets() { scheduleDailyReset(); scheduleWeeklyReset(); scheduleMonthlyReset(); } // --- Cooldown store --- const cooldowns = new Map(); function setCooldown(key) { cooldowns.set(key, Date.now()); } function isOnCooldown(key, cooldownMinutes) { const last = cooldowns.get(key); if (!last) return false; return (Date.now() - last) < cooldownMinutes * 60 * 1000; } // --- Staff last-seen tracker (fallback for missing presence intent) --- const staffLastSeen = new Map(); function updateStaffLastSeen(staffId) { staffLastSeen.set(staffId, Date.now()); } function getStaffLastSeen(staffId) { return staffLastSeen.get(staffId) || null; } function isStaffRecentlyActive(staffId, withinMinutes = 60) { const last = staffLastSeen.get(staffId); if (!last) return false; return (Date.now() - last) < withinMinutes * 60 * 1000; } module.exports = { increment, get, reset, getAll, scheduleResets, onDailyReset, onWeeklyReset, firedThresholds, shouldFireThreshold, shouldFireCooldownEscalating, clearEscalating, setCooldown, isOnCooldown, updateStaffLastSeen, getStaffLastSeen, isStaffRecentlyActive };