234 lines
6.0 KiB
JavaScript
234 lines
6.0 KiB
JavaScript
/**
|
|
* 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<thresholdMs> 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 };
|
|
escalatingCooldowns.set(key, state);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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
|
|
};
|