aktueller stand
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
const cron = require('node-cron');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const foodsharingClient = require('./foodsharingClient');
|
||||
const sessionStore = require('./sessionStore');
|
||||
const { DEFAULT_SETTINGS } = require('./adminConfig');
|
||||
@@ -23,7 +25,96 @@ const pickupCheckInFlight = new Map();
|
||||
const pickupCheckLastRun = new Map();
|
||||
const PICKUP_CHECK_DEDUP_MS = 30 * 1000;
|
||||
const regularPickupCache = new Map();
|
||||
const REGULAR_PICKUP_CACHE_FILE = path.join(__dirname, '..', 'config', 'regular-pickup-cache.json');
|
||||
const REGULAR_PICKUP_CACHE_WRITE_DEBOUNCE_MS = 2000;
|
||||
let regularPickupCacheWriteTimer = null;
|
||||
let regularPickupCacheDirty = false;
|
||||
const REGULAR_PICKUP_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
const REGULAR_PICKUP_ERROR_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const REGULAR_PICKUP_TRANSIENT_ERROR_TTL_MS = 30 * 1000;
|
||||
const REGULAR_PICKUP_MAX_CONCURRENT = 3;
|
||||
const REGULAR_PICKUP_MIN_DELAY_MS = 150;
|
||||
const REGULAR_PICKUP_MAX_DELAY_MS = 350;
|
||||
const regularPickupInFlight = new Map();
|
||||
const regularPickupQueue = [];
|
||||
let regularPickupActive = 0;
|
||||
let regularPickupRefreshJob = null;
|
||||
const dormantWarningCooldowns = new Map();
|
||||
const DORMANT_WARNING_COOLDOWN_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
function ensureRegularPickupCacheDir() {
|
||||
const dir = path.dirname(REGULAR_PICKUP_CACHE_FILE);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function persistRegularPickupCache() {
|
||||
if (!regularPickupCacheDirty) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ensureRegularPickupCacheDir();
|
||||
const now = Date.now();
|
||||
const entries = {};
|
||||
regularPickupCache.forEach((value, key) => {
|
||||
if (value?.expiresAt && value.expiresAt < now) {
|
||||
return;
|
||||
}
|
||||
entries[key] = value;
|
||||
});
|
||||
fs.writeFileSync(
|
||||
REGULAR_PICKUP_CACHE_FILE,
|
||||
JSON.stringify({ version: 1, entries }, null, 2)
|
||||
);
|
||||
regularPickupCacheDirty = false;
|
||||
} catch (error) {
|
||||
console.warn('[PICKUP] Regular-Pickup-Cache konnte nicht geschrieben werden:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRegularPickupCachePersist() {
|
||||
if (regularPickupCacheWriteTimer) {
|
||||
return;
|
||||
}
|
||||
regularPickupCacheWriteTimer = setTimeout(() => {
|
||||
regularPickupCacheWriteTimer = null;
|
||||
persistRegularPickupCache();
|
||||
}, REGULAR_PICKUP_CACHE_WRITE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function markRegularPickupCacheDirty() {
|
||||
regularPickupCacheDirty = true;
|
||||
scheduleRegularPickupCachePersist();
|
||||
}
|
||||
|
||||
function loadRegularPickupCacheFromDisk() {
|
||||
try {
|
||||
if (!fs.existsSync(REGULAR_PICKUP_CACHE_FILE)) {
|
||||
return;
|
||||
}
|
||||
const raw = fs.readFileSync(REGULAR_PICKUP_CACHE_FILE, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
const entries = parsed?.entries && typeof parsed.entries === 'object' ? parsed.entries : {};
|
||||
const now = Date.now();
|
||||
Object.entries(entries).forEach(([key, value]) => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
if (value.expiresAt && value.expiresAt < now) {
|
||||
return;
|
||||
}
|
||||
regularPickupCache.set(String(key), {
|
||||
fetchedAt: Number(value.fetchedAt) || Date.now(),
|
||||
expiresAt: Number(value.expiresAt) || null,
|
||||
rules: Array.isArray(value.rules) ? value.rules : [],
|
||||
error: value.error || null
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[PICKUP] Regular-Pickup-Cache konnte nicht geladen werden:', error.message);
|
||||
}
|
||||
}
|
||||
const PICKUP_FALLBACK_RETRY_MS = 60 * 60 * 1000;
|
||||
const TIME_ZONE = 'Europe/Berlin';
|
||||
|
||||
@@ -87,6 +178,12 @@ function randomDelayMs(minSeconds = 10, maxSeconds = 120) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
function randomDelayMsBetween(minMs, maxMs) {
|
||||
const min = Math.max(0, Number(minMs) || 0);
|
||||
const max = Math.max(min, Number(maxMs) || 0);
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
function getTimeZoneParts(date, timeZone) {
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
@@ -250,6 +347,7 @@ function resolveSettings(settings) {
|
||||
initialDelayMaxSeconds: Number.isFinite(settings.initialDelayMaxSeconds)
|
||||
? settings.initialDelayMaxSeconds
|
||||
: DEFAULT_SETTINGS.initialDelayMaxSeconds,
|
||||
dormantMembershipCron: settings.dormantMembershipCron || DEFAULT_SETTINGS.dormantMembershipCron,
|
||||
storeWatchCron: settings.storeWatchCron || DEFAULT_SETTINGS.storeWatchCron,
|
||||
storeWatchInitialDelayMinSeconds: Number.isFinite(settings.storeWatchInitialDelayMinSeconds)
|
||||
? settings.storeWatchInitialDelayMinSeconds
|
||||
@@ -280,29 +378,121 @@ function resolveSettings(settings) {
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchRegularPickupSchedule(session, storeId) {
|
||||
function describeRegularPickupError(error) {
|
||||
const status = error?.response?.status;
|
||||
if (status) {
|
||||
return `HTTP ${status}`;
|
||||
}
|
||||
const code = error?.code;
|
||||
if (code) {
|
||||
return code;
|
||||
}
|
||||
const message = error?.message;
|
||||
if (message) {
|
||||
return message;
|
||||
}
|
||||
return 'Unknown error';
|
||||
}
|
||||
|
||||
function getRegularPickupErrorTtlMs(error) {
|
||||
const status = error?.response?.status;
|
||||
if (status === 403 || status === 404) {
|
||||
return REGULAR_PICKUP_ERROR_CACHE_TTL_MS;
|
||||
}
|
||||
const code = error?.code;
|
||||
if (code) {
|
||||
return REGULAR_PICKUP_TRANSIENT_ERROR_TTL_MS;
|
||||
}
|
||||
return REGULAR_PICKUP_ERROR_CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
function runWithRegularPickupLimiter(task) {
|
||||
if (regularPickupActive < REGULAR_PICKUP_MAX_CONCURRENT) {
|
||||
regularPickupActive += 1;
|
||||
return Promise.resolve()
|
||||
.then(async () => {
|
||||
const delayMs = randomDelayMsBetween(REGULAR_PICKUP_MIN_DELAY_MS, REGULAR_PICKUP_MAX_DELAY_MS);
|
||||
if (delayMs > 0) {
|
||||
await wait(delayMs);
|
||||
}
|
||||
return task();
|
||||
})
|
||||
.finally(() => {
|
||||
regularPickupActive -= 1;
|
||||
const next = regularPickupQueue.shift();
|
||||
if (next) {
|
||||
next();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
regularPickupQueue.push(() => {
|
||||
runWithRegularPickupLimiter(task).then(resolve).catch(reject);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getRegularPickupCacheEntry(storeId) {
|
||||
const entry = regularPickupCache.get(String(storeId));
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
if (entry.expiresAt && entry.expiresAt < Date.now()) {
|
||||
regularPickupCache.delete(String(storeId));
|
||||
markRegularPickupCacheDirty();
|
||||
return null;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
function setRegularPickupCacheEntry(storeId, rules, ttlMs, error) {
|
||||
regularPickupCache.set(String(storeId), {
|
||||
fetchedAt: Date.now(),
|
||||
expiresAt: Date.now() + ttlMs,
|
||||
rules: Array.isArray(rules) ? rules : [],
|
||||
error: error || null
|
||||
});
|
||||
markRegularPickupCacheDirty();
|
||||
}
|
||||
|
||||
async function getRegularPickupSchedule(session, storeId) {
|
||||
if (!session?.profile?.id || !storeId) {
|
||||
return [];
|
||||
return { rules: [], error: 'missing-session-or-store', fromCache: false };
|
||||
}
|
||||
const key = String(storeId);
|
||||
const cached = regularPickupCache.get(key);
|
||||
if (cached && Date.now() - cached.fetchedAt <= REGULAR_PICKUP_CACHE_TTL_MS) {
|
||||
return cached.rules;
|
||||
const cached = getRegularPickupCacheEntry(key);
|
||||
if (cached) {
|
||||
return { rules: cached.rules, error: cached.error, fromCache: true };
|
||||
}
|
||||
try {
|
||||
const rules = await withSessionRetry(
|
||||
session,
|
||||
() => foodsharingClient.fetchRegularPickup(storeId, session.cookieHeader, session),
|
||||
{ label: 'fetchRegularPickup' }
|
||||
);
|
||||
const normalized = Array.isArray(rules) ? rules : [];
|
||||
regularPickupCache.set(key, { fetchedAt: Date.now(), rules: normalized });
|
||||
return normalized;
|
||||
} catch (error) {
|
||||
if (cached?.rules) {
|
||||
return cached.rules;
|
||||
const existing = regularPickupInFlight.get(key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const fetchPromise = runWithRegularPickupLimiter(async () => {
|
||||
try {
|
||||
const rules = await withSessionRetry(
|
||||
session,
|
||||
() => foodsharingClient.fetchRegularPickup(storeId, session.cookieHeader, session),
|
||||
{ label: 'fetchRegularPickup' }
|
||||
);
|
||||
const normalized = Array.isArray(rules) ? rules : [];
|
||||
setRegularPickupCacheEntry(key, normalized, REGULAR_PICKUP_CACHE_TTL_MS);
|
||||
return { rules: normalized, error: null, fromCache: false };
|
||||
} catch (error) {
|
||||
const message = describeRegularPickupError(error);
|
||||
const ttl = getRegularPickupErrorTtlMs(error);
|
||||
setRegularPickupCacheEntry(key, [], ttl, message);
|
||||
return { rules: [], error: message, fromCache: false };
|
||||
}
|
||||
});
|
||||
regularPickupInFlight.set(key, fetchPromise);
|
||||
try {
|
||||
return await fetchPromise;
|
||||
} finally {
|
||||
if (regularPickupInFlight.get(key) === fetchPromise) {
|
||||
regularPickupInFlight.delete(key);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,7 +517,7 @@ async function getNextPickupCheckTime(session, entry, settings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rules = await fetchRegularPickupSchedule(session, entry.id);
|
||||
const { rules } = await getRegularPickupSchedule(session, entry.id);
|
||||
if (!Array.isArray(rules) || rules.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -518,6 +708,7 @@ async function handleExpiredDesiredWindow(session, entry) {
|
||||
try {
|
||||
await notificationService.sendDesiredWindowMissedNotification({
|
||||
profileId,
|
||||
profileName: session.profile?.name,
|
||||
storeName,
|
||||
desiredWindowLabel: desiredLabel
|
||||
});
|
||||
@@ -588,6 +779,7 @@ async function processBooking(session, entry, pickup) {
|
||||
console.log(`[INFO] Slot gefunden (nur Hinweis) für ${storeName} am ${readableDate}`);
|
||||
await notificationService.sendSlotNotification({
|
||||
profileId: session.profile.id,
|
||||
profileName: session.profile?.name,
|
||||
storeName,
|
||||
pickupDate: pickup.date,
|
||||
onlyNotify: true,
|
||||
@@ -618,6 +810,7 @@ async function processBooking(session, entry, pickup) {
|
||||
console.log(`[SUCCESS] Slot gebucht für ${storeName} am ${readableDate}`);
|
||||
await notificationService.sendSlotNotification({
|
||||
profileId: session.profile.id,
|
||||
profileName: session.profile?.name,
|
||||
storeName,
|
||||
pickupDate: pickup.date,
|
||||
onlyNotify: false,
|
||||
@@ -775,6 +968,7 @@ async function checkWatchedStores(sessionId, settings = DEFAULT_SETTINGS, option
|
||||
if (status === 1 && watcher.lastTeamSearchStatus !== 1) {
|
||||
await notificationService.sendStoreWatchNotification({
|
||||
profileId: session.profile.id,
|
||||
profileName: session.profile?.name,
|
||||
storeName: watcher.storeName,
|
||||
storeId: watcher.storeId,
|
||||
regionName: watcher.regionName
|
||||
@@ -824,6 +1018,7 @@ async function checkWatchedStores(sessionId, settings = DEFAULT_SETTINGS, option
|
||||
try {
|
||||
await notificationService.sendStoreWatchSummaryNotification({
|
||||
profileId: session.profile.id,
|
||||
profileName: session.profile?.name,
|
||||
entries: summary,
|
||||
triggeredBy: options.triggeredBy || 'manual'
|
||||
});
|
||||
@@ -868,6 +1063,51 @@ function scheduleStoreWatchers(sessionId, settings) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function scheduleRegularPickupRefresh(settings) {
|
||||
if (regularPickupRefreshJob) {
|
||||
regularPickupRefreshJob.stop();
|
||||
regularPickupRefreshJob = null;
|
||||
}
|
||||
const cronExpression = settings.regularPickupRefreshCron || DEFAULT_SETTINGS.regularPickupRefreshCron;
|
||||
if (!cronExpression) {
|
||||
return null;
|
||||
}
|
||||
regularPickupRefreshJob = cron.schedule(
|
||||
cronExpression,
|
||||
async () => {
|
||||
const sessions = sessionStore.list();
|
||||
const storeSessionMap = new Map();
|
||||
for (const session of sessions) {
|
||||
if (!session?.profile?.id) {
|
||||
continue;
|
||||
}
|
||||
const config = readConfig(session.profile.id);
|
||||
const entries = Array.isArray(config) ? config : [];
|
||||
const storeIds = Array.from(
|
||||
new Set(entries.filter((entry) => entry?.id && !entry.hidden).map((entry) => String(entry.id)))
|
||||
);
|
||||
const sessionUpdatedAt = Number(session.updatedAt) || 0;
|
||||
storeIds.forEach((storeId) => {
|
||||
const existing = storeSessionMap.get(storeId);
|
||||
if (!existing || (Number(existing.updatedAt) || 0) < sessionUpdatedAt) {
|
||||
storeSessionMap.set(storeId, session);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const [storeId, session] of storeSessionMap.entries()) {
|
||||
const ready = await ensureSession(session);
|
||||
if (!ready) {
|
||||
continue;
|
||||
}
|
||||
await getRegularPickupSchedule(session, storeId);
|
||||
}
|
||||
},
|
||||
{ timezone: TIME_ZONE }
|
||||
);
|
||||
return regularPickupRefreshJob;
|
||||
}
|
||||
|
||||
function scheduleFallbackPickupChecks(sessionId, settings) {
|
||||
const cronExpression = settings.pickupFallbackCron || DEFAULT_SETTINGS.pickupFallbackCron;
|
||||
if (!cronExpression) {
|
||||
@@ -940,7 +1180,7 @@ function scheduleEntry(sessionId, entry, settings) {
|
||||
function scheduleConfig(sessionId, config, settings) {
|
||||
const resolvedSettings = resolveSettings(settings);
|
||||
sessionStore.clearJobs(sessionId);
|
||||
scheduleDormantMembershipCheck(sessionId);
|
||||
scheduleDormantMembershipCheck(sessionId, resolvedSettings);
|
||||
const watchScheduled = scheduleStoreWatchers(sessionId, resolvedSettings);
|
||||
scheduleFallbackPickupChecks(sessionId, resolvedSettings);
|
||||
scheduleJournalReminders(sessionId);
|
||||
@@ -1101,13 +1341,20 @@ async function checkDormantMembers(sessionId, options = {}) {
|
||||
}
|
||||
}
|
||||
if (reasons.length > 0) {
|
||||
const cooldownKey = `${profileId}:${storeId}`;
|
||||
const lastSentAt = dormantWarningCooldowns.get(cooldownKey);
|
||||
if (lastSentAt && Date.now() - lastSentAt < DORMANT_WARNING_COOLDOWN_MS) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await sendDormantPickupWarning({
|
||||
profileId,
|
||||
profileName: session.profile?.name,
|
||||
storeName: target.storeName,
|
||||
storeId,
|
||||
reasonLines: reasons
|
||||
});
|
||||
dormantWarningCooldowns.set(cooldownKey, Date.now());
|
||||
} catch (error) {
|
||||
console.error(`[DORMANT] Warnung für Store ${storeId} konnte nicht versendet werden:`, error.message);
|
||||
}
|
||||
@@ -1122,8 +1369,9 @@ async function checkDormantMembers(sessionId, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleDormantMembershipCheck(sessionId) {
|
||||
const cronExpression = '0 4 */14 * *';
|
||||
function scheduleDormantMembershipCheck(sessionId, settings) {
|
||||
const cronExpression =
|
||||
settings?.dormantMembershipCron || DEFAULT_SETTINGS.dormantMembershipCron || '0 4 */14 * *';
|
||||
const job = cron.schedule(
|
||||
cronExpression,
|
||||
() => {
|
||||
@@ -1165,6 +1413,7 @@ async function checkJournalReminders(sessionId) {
|
||||
return;
|
||||
}
|
||||
const profileId = session.profile.id;
|
||||
const profileName = session.profile?.name;
|
||||
const entries = readJournal(profileId);
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
return;
|
||||
@@ -1197,6 +1446,7 @@ async function checkJournalReminders(sessionId) {
|
||||
}
|
||||
await sendJournalReminderNotification({
|
||||
profileId,
|
||||
profileName,
|
||||
storeName: entry.storeName || `Store ${entry.storeId || ''}`,
|
||||
pickupDate: occurrence,
|
||||
reminderDate,
|
||||
@@ -1235,5 +1485,9 @@ module.exports = {
|
||||
scheduleConfig,
|
||||
runStoreWatchCheck,
|
||||
runImmediatePickupCheck,
|
||||
runDormantMembershipCheck
|
||||
runDormantMembershipCheck,
|
||||
getRegularPickupSchedule,
|
||||
scheduleRegularPickupRefresh
|
||||
};
|
||||
|
||||
loadRegularPickupCacheFromDisk();
|
||||
|
||||
Reference in New Issue
Block a user