const cron = require('node-cron'); const foodsharingClient = require('./foodsharingClient'); const sessionStore = require('./sessionStore'); const { DEFAULT_SETTINGS } = require('./adminConfig'); const notificationService = require('./notificationService'); const { readConfig, writeConfig } = require('./configStore'); const { readStoreWatch, writeStoreWatch } = require('./storeWatchStore'); const { readJournal, writeJournal } = require('./journalStore'); const { getStoreStatus, setStoreStatus, persistStoreStatusCache } = require('./storeStatusCache'); const { sendDormantPickupWarning, sendJournalReminderNotification } = require('./notificationService'); const { ensureSession, withSessionRetry } = require('./sessionRefresh'); function wait(ms) { if (!ms || ms <= 0) { return Promise.resolve(); } return new Promise((resolve) => setTimeout(resolve, ms)); } const DEFAULT_STORE_WATCH_STATUS_MAX_AGE_MINUTES = 120; const storeWatchInFlight = new Map(); const pickupCheckInFlight = new Map(); const pickupCheckLastRun = new Map(); const PICKUP_CHECK_DEDUP_MS = 30 * 1000; const regularPickupCache = new Map(); const REGULAR_PICKUP_CACHE_TTL_MS = 12 * 60 * 60 * 1000; const PICKUP_FALLBACK_RETRY_MS = 60 * 60 * 1000; const TIME_ZONE = 'Europe/Berlin'; async function fetchSharedStoreStatus(session, storeId, { forceRefresh = false, maxAgeMs } = {}) { if (!storeId) { return { status: null, fetchedAt: null, fromCache: false }; } const cacheEntry = getStoreStatus(storeId); const cachedStatus = cacheEntry?.teamSearchStatus; const hasCachedStatus = cachedStatus === 0 || cachedStatus === 1; const cachedAt = Number(cacheEntry?.fetchedAt) || 0; const effectiveMaxAge = Number.isFinite(maxAgeMs) && maxAgeMs >= 0 ? maxAgeMs : DEFAULT_STORE_WATCH_STATUS_MAX_AGE_MINUTES * 60 * 1000; const cacheFresh = hasCachedStatus && Date.now() - cachedAt <= effectiveMaxAge; if (cacheFresh && !forceRefresh) { return { status: cachedStatus, fetchedAt: cachedAt, fromCache: true }; } const key = String(storeId); if (!forceRefresh && storeWatchInFlight.has(key)) { return storeWatchInFlight.get(key); } const fetchPromise = (async () => { const details = await withSessionRetry( session, () => foodsharingClient.fetchStoreDetails(storeId, session.cookieHeader, session), { label: 'fetchStoreDetails' } ); const status = details?.teamSearchStatus === 1 ? 1 : 0; const fetchedAt = Date.now(); setStoreStatus(storeId, { teamSearchStatus: status, fetchedAt }); return { status, fetchedAt, fromCache: false }; })(); storeWatchInFlight.set(key, fetchPromise); try { return await fetchPromise; } finally { if (storeWatchInFlight.get(key) === fetchPromise) { storeWatchInFlight.delete(key); } } } const weekdayMap = { Montag: 'Monday', Dienstag: 'Tuesday', Mittwoch: 'Wednesday', Donnerstag: 'Thursday', Freitag: 'Friday', Samstag: 'Saturday', Sonntag: 'Sunday' }; function randomDelayMs(minSeconds = 10, maxSeconds = 120) { const min = minSeconds * 1000; const max = maxSeconds * 1000; return Math.floor(Math.random() * (max - min + 1)) + min; } function getTimeZoneParts(date, timeZone) { const formatter = new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', weekday: 'short', hour12: false }); const parts = formatter.formatToParts(date); const values = {}; parts.forEach((part) => { if (part.type !== 'literal') { values[part.type] = part.value; } }); const weekdayMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }; return { year: Number(values.year), month: Number(values.month), day: Number(values.day), hour: Number(values.hour), minute: Number(values.minute), second: Number(values.second), weekday: weekdayMap[values.weekday] ?? null }; } function getTimeZoneOffsetMinutes(timeZone, date) { const parts = getTimeZoneParts(date, timeZone); const utcFromParts = Date.UTC( parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second ); return (utcFromParts - date.getTime()) / 60000; } function makeDateInTimeZone(timeZone, year, month, day, hour, minute, second = 0) { const utcGuess = new Date(Date.UTC(year, month - 1, day, hour, minute, second)); const offset = getTimeZoneOffsetMinutes(timeZone, utcGuess); return new Date(utcGuess.getTime() - offset * 60000); } function getStartOfDayInTimeZone(date, timeZone) { const parts = getTimeZoneParts(date, timeZone); if (!parts.year || !parts.month || !parts.day) { return null; } return makeDateInTimeZone(timeZone, parts.year, parts.month, parts.day, 0, 0, 0); } function normalizeRegularPickupWeekday(value) { const num = Number(value); if (!Number.isFinite(num)) { return null; } if (num === 0) { return 0; } if (num >= 1 && num <= 7) { return num % 7; } return null; } function parseTimeString(value) { if (!value || typeof value !== 'string') { return null; } const [hourText, minuteText, secondText] = value.split(':'); const hour = Number(hourText); const minute = Number(minuteText); const second = Number(secondText || 0); if (![hour, minute, second].every((entry) => Number.isFinite(entry))) { return null; } return { hour, minute, second }; } function addDays(date, days) { return new Date(date.getTime() + days * 24 * 60 * 60 * 1000); } function startOfDay(date) { return new Date(date.getFullYear(), date.getMonth(), date.getDate()); } function isSameDay(a, b) { return ( a && b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() ); } function addMonths(date, months) { const copy = new Date(date.getTime()); copy.setMonth(copy.getMonth() + months); return copy; } function getIntervalMonths(interval) { if (interval === 'monthly') { return 1; } if (interval === 'quarterly') { return 3; } return 12; } function getNextOccurrence(baseDate, intervalMonths, todayStart) { if (!baseDate || Number.isNaN(baseDate.getTime())) { return null; } let candidate = startOfDay(baseDate); const guardYear = todayStart.getFullYear() + 200; while (candidate < todayStart && candidate.getFullYear() < guardYear) { candidate = startOfDay(addMonths(candidate, intervalMonths)); } return candidate; } function resolveSettings(settings) { if (!settings) { return { ...DEFAULT_SETTINGS }; } return { scheduleCron: settings.scheduleCron || DEFAULT_SETTINGS.scheduleCron, pickupFallbackCron: settings.pickupFallbackCron || DEFAULT_SETTINGS.pickupFallbackCron, pickupWindowOffsetsMinutes: Array.isArray(settings.pickupWindowOffsetsMinutes) && settings.pickupWindowOffsetsMinutes.length > 0 ? settings.pickupWindowOffsetsMinutes : DEFAULT_SETTINGS.pickupWindowOffsetsMinutes, randomDelayMinSeconds: Number.isFinite(settings.randomDelayMinSeconds) ? settings.randomDelayMinSeconds : DEFAULT_SETTINGS.randomDelayMinSeconds, randomDelayMaxSeconds: Number.isFinite(settings.randomDelayMaxSeconds) ? settings.randomDelayMaxSeconds : DEFAULT_SETTINGS.randomDelayMaxSeconds, initialDelayMinSeconds: Number.isFinite(settings.initialDelayMinSeconds) ? settings.initialDelayMinSeconds : DEFAULT_SETTINGS.initialDelayMinSeconds, initialDelayMaxSeconds: Number.isFinite(settings.initialDelayMaxSeconds) ? settings.initialDelayMaxSeconds : DEFAULT_SETTINGS.initialDelayMaxSeconds, storeWatchCron: settings.storeWatchCron || DEFAULT_SETTINGS.storeWatchCron, storeWatchInitialDelayMinSeconds: Number.isFinite(settings.storeWatchInitialDelayMinSeconds) ? settings.storeWatchInitialDelayMinSeconds : DEFAULT_SETTINGS.storeWatchInitialDelayMinSeconds, storeWatchInitialDelayMaxSeconds: Number.isFinite(settings.storeWatchInitialDelayMaxSeconds) ? settings.storeWatchInitialDelayMaxSeconds : DEFAULT_SETTINGS.storeWatchInitialDelayMaxSeconds, storeWatchRequestDelayMs: Number.isFinite(settings.storeWatchRequestDelayMs) ? settings.storeWatchRequestDelayMs : DEFAULT_SETTINGS.storeWatchRequestDelayMs, storeWatchStatusCacheMaxAgeMinutes: Number.isFinite(settings.storeWatchStatusCacheMaxAgeMinutes) ? settings.storeWatchStatusCacheMaxAgeMinutes : DEFAULT_SETTINGS.storeWatchStatusCacheMaxAgeMinutes, ignoredSlots: Array.isArray(settings.ignoredSlots) ? settings.ignoredSlots : DEFAULT_SETTINGS.ignoredSlots, notifications: { ntfy: { enabled: !!settings.notifications?.ntfy?.enabled, serverUrl: settings.notifications?.ntfy?.serverUrl || DEFAULT_SETTINGS.notifications.ntfy.serverUrl, topicPrefix: settings.notifications?.ntfy?.topicPrefix || DEFAULT_SETTINGS.notifications.ntfy.topicPrefix, username: settings.notifications?.ntfy?.username || DEFAULT_SETTINGS.notifications.ntfy.username, password: settings.notifications?.ntfy?.password || DEFAULT_SETTINGS.notifications.ntfy.password }, telegram: { enabled: !!settings.notifications?.telegram?.enabled, botToken: settings.notifications?.telegram?.botToken || DEFAULT_SETTINGS.notifications.telegram.botToken } } }; } async function fetchRegularPickupSchedule(session, storeId) { if (!session?.profile?.id || !storeId) { return []; } const key = String(storeId); const cached = regularPickupCache.get(key); if (cached && Date.now() - cached.fetchedAt <= REGULAR_PICKUP_CACHE_TTL_MS) { return cached.rules; } 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; } return []; } } function resolveEffectiveNow(entry, now) { const startValue = toDateValue(entry?.desiredDateRange?.start || entry?.desiredDate); if (startValue && startValue > now.getTime()) { return new Date(startValue); } return now; } async function getNextPickupCheckTime(session, entry, settings) { const now = new Date(); const effectiveNow = resolveEffectiveNow(entry, now); const offsets = (settings.pickupWindowOffsetsMinutes || []) .map((value) => Number(value)) .filter((value) => Number.isFinite(value)) .map((value) => value * 60 * 1000) .sort((a, b) => a - b); if (offsets.length === 0) { return null; } const rules = await fetchRegularPickupSchedule(session, entry.id); if (!Array.isArray(rules) || rules.length === 0) { return null; } const nowParts = getTimeZoneParts(effectiveNow, TIME_ZONE); if (nowParts.weekday === null) { return null; } const todayStart = getStartOfDayInTimeZone(effectiveNow, TIME_ZONE); if (!todayStart) { return null; } let best = null; rules.forEach((rule) => { const weekday = normalizeRegularPickupWeekday(rule.weekday); const timeParts = parseTimeString(rule.startTimeOfPickup); if (weekday === null || !timeParts) { return; } let daysAhead = (weekday - nowParts.weekday + 7) % 7; let candidateStart = addDays(todayStart, daysAhead); const candidateParts = getTimeZoneParts(candidateStart, TIME_ZONE); let slotDate = makeDateInTimeZone( TIME_ZONE, candidateParts.year, candidateParts.month, candidateParts.day, timeParts.hour, timeParts.minute, timeParts.second ); let candidate = offsets .map((offset) => new Date(slotDate.getTime() + offset)) .find((date) => date.getTime() > effectiveNow.getTime()); if (!candidate) { slotDate = addDays(slotDate, 7); candidate = offsets .map((offset) => new Date(slotDate.getTime() + offset)) .find((date) => date.getTime() > effectiveNow.getTime()); } if (candidate && (!best || candidate.getTime() < best.getTime())) { best = candidate; } }); return best; } function deactivateEntryInMemory(entry) { if (entry) { entry.active = false; } } function persistEntryDeactivation(profileId, entryId, options = {}) { if (!profileId || !entryId) { return; } try { const config = readConfig(profileId); let changed = false; const updated = config.map((item) => { if (String(item?.id) === String(entryId)) { let mutated = false; const updatedEntry = { ...item }; if (item?.active !== false) { updatedEntry.active = false; mutated = true; } if (options.resetDesiredWindow) { if (updatedEntry.desiredDate !== undefined) { delete updatedEntry.desiredDate; mutated = true; } if (updatedEntry.desiredDateRange !== undefined) { delete updatedEntry.desiredDateRange; mutated = true; } } if (mutated) { changed = true; return updatedEntry; } } return item; }); if (changed) { writeConfig(profileId, updated); } } catch (error) { console.error(`[CONFIG] Konnte Eintrag ${entryId} für Profil ${profileId} nicht deaktivieren:`, error.message); } } function isEntryActiveInConfig(profileId, entryId) { if (!profileId || !entryId) { return false; } const config = readConfig(profileId); const entry = config.find((item) => String(item?.id) === String(entryId)); return !!entry && entry.active !== false; } function toDateValue(input) { if (!input) { return null; } const date = input instanceof Date ? new Date(input.getTime()) : new Date(input); if (Number.isNaN(date.getTime())) { return null; } return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); } function formatDesiredWindowLabel(entry) { if (!entry) { return null; } const formatValue = (value) => { if (!value) { return null; } const date = new Date(value); if (Number.isNaN(date.getTime())) { return null; } return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); }; if (entry.desiredDateRange) { const startLabel = formatValue(entry.desiredDateRange.start); const endLabel = formatValue(entry.desiredDateRange.end); if (startLabel && endLabel) { return startLabel === endLabel ? startLabel : `${startLabel} – ${endLabel}`; } return startLabel || endLabel; } return formatValue(entry.desiredDate); } function desiredWindowExpired(entry) { if (!entry?.active) { return false; } const todayValue = toDateValue(new Date()); if (todayValue === null) { return false; } if (entry.desiredDateRange?.start || entry.desiredDateRange?.end) { const endValue = toDateValue(entry.desiredDateRange.end) ?? toDateValue(entry.desiredDateRange.start); return endValue !== null && endValue < todayValue; } const desiredValue = toDateValue(entry.desiredDate); return desiredValue !== null && desiredValue < todayValue; } function resetDesiredWindow(entry) { if (!entry) { return; } if (entry.desiredDate !== undefined) { delete entry.desiredDate; } if (entry.desiredDateRange !== undefined) { delete entry.desiredDateRange; } } async function handleExpiredDesiredWindow(session, entry) { const profileId = session?.profile?.id; const storeName = entry.label || entry.id; const desiredLabel = formatDesiredWindowLabel(entry); console.log( `[INFO] Wunschzeitraum abgelaufen für ${storeName}${desiredLabel ? ` (${desiredLabel})` : ''}. Eintrag wird deaktiviert.` ); deactivateEntryInMemory(entry); resetDesiredWindow(entry); persistEntryDeactivation(profileId, entry.id, { resetDesiredWindow: true }); if (profileId) { try { await notificationService.sendDesiredWindowMissedNotification({ profileId, storeName, desiredWindowLabel: desiredLabel }); } catch (error) { console.error( `[NOTIFY] Benachrichtigung zum verpassten Zeitraum für ${storeName} fehlgeschlagen:`, error.message ); } } } function matchesDesiredDate(pickupDate, desiredDate, desiredDateRange) { const pickupValue = toDateValue(pickupDate); if (pickupValue === null) { return false; } const hasRange = Boolean(desiredDateRange && (desiredDateRange.start || desiredDateRange.end)); if (hasRange) { const startValue = toDateValue(desiredDateRange.start); const endValue = toDateValue(desiredDateRange.end); const normalizedStart = startValue !== null ? startValue : endValue; const normalizedEnd = endValue !== null ? endValue : startValue; if (normalizedStart !== null && pickupValue < normalizedStart) { return false; } if (normalizedEnd !== null && pickupValue > normalizedEnd) { return false; } return true; } const desiredValue = toDateValue(desiredDate); if (desiredValue === null) { return true; } return pickupValue === desiredValue; } function matchesDesiredWeekday(pickupDate, desiredWeekday) { if (!desiredWeekday) { return true; } const weekday = pickupDate.toLocaleDateString('en-US', { weekday: 'long' }); return weekday === desiredWeekday; } function shouldIgnoreSlot(entry, pickup, settings) { const rules = settings.ignoredSlots || []; return rules.some((rule) => { if (!rule?.storeId) { return false; } if (String(rule.storeId) !== entry.id) { return false; } const slotName = rule.slotName || rule.description; return slotName ? pickup.description === slotName : true; }); } async function processBooking(session, entry, pickup) { const readableDate = new Date(pickup.date).toLocaleString('de-DE'); const storeName = entry.label || entry.id; if (entry.onlyNotify) { console.log(`[INFO] Slot gefunden (nur Hinweis) für ${storeName} am ${readableDate}`); await notificationService.sendSlotNotification({ profileId: session.profile.id, storeName, pickupDate: pickup.date, onlyNotify: true, booked: false, storeId: entry.id }); deactivateEntryInMemory(entry); persistEntryDeactivation(session.profile.id, entry.id); return; } const utcDate = new Date(pickup.date).toISOString(); try { const allowed = await withSessionRetry( session, () => foodsharingClient.pickupRuleCheck(entry.id, utcDate, session.profile.id, session), { label: 'pickupRuleCheck' } ); if (!allowed) { console.warn(`[WARN] Rule-Check fehlgeschlagen für ${storeName} am ${readableDate}`); return; } await withSessionRetry( session, () => foodsharingClient.bookSlot(entry.id, utcDate, session.profile.id, session), { label: 'bookSlot' } ); console.log(`[SUCCESS] Slot gebucht für ${storeName} am ${readableDate}`); await notificationService.sendSlotNotification({ profileId: session.profile.id, storeName, pickupDate: pickup.date, onlyNotify: false, booked: true, storeId: entry.id }); deactivateEntryInMemory(entry); persistEntryDeactivation(session.profile.id, entry.id); } catch (error) { console.error(`[ERROR] Buchung für ${storeName} am ${readableDate} fehlgeschlagen:`, error.message); try { await notificationService.sendAdminBookingErrorNotification({ profileId: session.profile.id, profileEmail: session.profile.email, storeName, storeId: entry.id, pickupDate: pickup.date, error: error.message }); } catch (notifyError) { console.error( `[NOTIFY] Admin-Benachrichtigung für fehlgeschlagene Buchung bei ${storeName} fehlgeschlagen:`, notifyError.message ); } } } async function checkEntry(sessionId, entry, settings) { if (entry?.active === false) { return; } const session = sessionStore.get(sessionId); if (!session) { return; } const dedupKey = `${sessionId}:${entry?.id ?? 'unknown'}`; const lastRun = pickupCheckLastRun.get(dedupKey); if (lastRun && Date.now() - lastRun < PICKUP_CHECK_DEDUP_MS) { return; } pickupCheckLastRun.set(dedupKey, Date.now()); const inFlightKey = `${sessionId}:${entry?.id ?? 'unknown'}`; if (pickupCheckInFlight.has(inFlightKey)) { return; } pickupCheckInFlight.set(inFlightKey, Date.now()); try { if (desiredWindowExpired(entry)) { await handleExpiredDesiredWindow(session, entry); return; } if (!isEntryActiveInConfig(session.profile.id, entry.id)) { deactivateEntryInMemory(entry); return; } const ready = await ensureSession(session); if (!ready) { return; } const pickups = await withSessionRetry( session, () => foodsharingClient.fetchPickups(entry.id, session.cookieHeader, session), { label: 'fetchPickups' } ); let hasProfileId = false; let availablePickup = null; const desiredWeekday = entry.desiredWeekday ? weekdayMap[entry.desiredWeekday] || entry.desiredWeekday : null; pickups.forEach((pickup) => { const pickupDate = new Date(pickup.date); if ( entry.checkProfileId && pickup.occupiedSlots?.some((slot) => String(slot.profile?.id) === String(session.profile.id)) ) { hasProfileId = true; } if (!matchesDesiredDate(pickupDate, entry.desiredDate, entry.desiredDateRange)) { return; } if (!matchesDesiredWeekday(pickupDate, desiredWeekday)) { return; } if (pickup.isAvailable && !availablePickup) { availablePickup = pickup; } }); if (entry.checkProfileId && hasProfileId) { console.log( `[INFO] Profil bereits in einem Slot für ${entry.label || entry.id} eingetragen – überspringe Buchung.` ); return; } if (!availablePickup) { console.log( `[INFO] Kein freier Slot für ${entry.label || entry.id} in dieser Runde gefunden. Profil bereits eingetragen: ${ hasProfileId ? 'ja' : 'nein' }` ); return; } if (shouldIgnoreSlot(entry, availablePickup, settings)) { console.log(`[INFO] Slot für ${entry.id} aufgrund einer Admin-Regel ignoriert.`); return; } if (!entry.checkProfileId || !hasProfileId) { await processBooking(session, entry, availablePickup); } } catch (error) { console.error(`[ERROR] Pickup-Check für Store ${entry.id} fehlgeschlagen:`, error.message); } finally { pickupCheckInFlight.delete(inFlightKey); } } async function checkWatchedStores(sessionId, settings = DEFAULT_SETTINGS, options = {}) { const session = sessionStore.get(sessionId); if (!session?.profile?.id) { return []; } const watchers = readStoreWatch(session.profile.id); if (!Array.isArray(watchers) || watchers.length === 0) { return []; } const ready = await ensureSession(session); if (!ready) { return []; } const perRequestDelay = Math.max(0, Number(settings?.storeWatchRequestDelayMs) || 0); let changed = false; let statusCacheUpdated = false; const summary = []; for (let index = 0; index < watchers.length; index += 1) { const watcher = watchers[index]; try { const cacheMaxAgeMs = Math.max( 0, Number(settings?.storeWatchStatusCacheMaxAgeMinutes ?? DEFAULT_STORE_WATCH_STATUS_MAX_AGE_MINUTES) ) * 60 * 1000; const { status, fromCache } = await fetchSharedStoreStatus(session, watcher.storeId, { maxAgeMs: cacheMaxAgeMs }); const checkedAt = Date.now(); if (status === 1 && watcher.lastTeamSearchStatus !== 1) { await notificationService.sendStoreWatchNotification({ profileId: session.profile.id, storeName: watcher.storeName, storeId: watcher.storeId, regionName: watcher.regionName }); } if (watcher.lastTeamSearchStatus !== status) { watcher.lastTeamSearchStatus = status; changed = true; } watcher.lastStatusCheckAt = checkedAt; changed = true; if (!fromCache) { statusCacheUpdated = true; } summary.push({ storeId: watcher.storeId, storeName: watcher.storeName, regionName: watcher.regionName, status, checkedAt }); } catch (error) { console.error(`[WATCH] Prüfung für Store ${watcher.storeId} fehlgeschlagen:`, error.message); summary.push({ storeId: watcher.storeId, storeName: watcher.storeName, regionName: watcher.regionName, status: null, checkedAt: Date.now(), error: error.message || 'Unbekannter Fehler' }); } finally { const hasNext = index < watchers.length - 1; if (hasNext && perRequestDelay > 0) { await wait(perRequestDelay); } } } if (changed) { writeStoreWatch(session.profile.id, watchers); } if (statusCacheUpdated) { persistStoreStatusCache(); } if (options.sendSummary && summary.length > 0) { try { await notificationService.sendStoreWatchSummaryNotification({ profileId: session.profile.id, entries: summary, triggeredBy: options.triggeredBy || 'manual' }); } catch (error) { console.error('[WATCH] Zusammenfassung konnte nicht versendet werden:', error.message); } } return summary; } function scheduleStoreWatchers(sessionId, settings) { const effectiveSettings = settings || DEFAULT_SETTINGS; const session = sessionStore.get(sessionId); if (!session?.profile?.id) { return false; } const watchers = readStoreWatch(session.profile.id); if (!Array.isArray(watchers) || watchers.length === 0) { return false; } const cronExpression = effectiveSettings.storeWatchCron || DEFAULT_SETTINGS.storeWatchCron; const job = cron.schedule( cronExpression, () => { checkWatchedStores(sessionId, effectiveSettings).catch((error) => { console.error('[WATCH] Regelmäßige Prüfung fehlgeschlagen:', error.message); }); }, { timezone: 'Europe/Berlin' } ); sessionStore.attachJob(sessionId, job); setTimeout( () => checkWatchedStores(sessionId, effectiveSettings), randomDelayMs( effectiveSettings.storeWatchInitialDelayMinSeconds, effectiveSettings.storeWatchInitialDelayMaxSeconds ) ); console.log( `[WATCH] Überwache ${watchers.length} Betriebe für Session ${sessionId} (Cron: ${cronExpression}).` ); return true; } function scheduleFallbackPickupChecks(sessionId, settings) { const cronExpression = settings.pickupFallbackCron || DEFAULT_SETTINGS.pickupFallbackCron; if (!cronExpression) { return null; } const job = cron.schedule( cronExpression, () => { const session = sessionStore.get(sessionId); if (!session?.profile?.id) { return; } const delay = randomDelayMs(settings.randomDelayMinSeconds, settings.randomDelayMaxSeconds); setTimeout(() => { const config = readConfig(session.profile.id); runImmediatePickupCheck(sessionId, config, settings).catch((error) => { console.error('[PICKUP] Fallback-Check fehlgeschlagen:', error.message); }); }, delay); }, { timezone: TIME_ZONE } ); sessionStore.attachJob(sessionId, job); return job; } function scheduleEntry(sessionId, entry, settings) { let timeoutId = null; let stopped = false; const scheduleNext = async () => { if (stopped) { return; } const session = sessionStore.get(sessionId); if (!session) { return; } let nextTime = null; try { nextTime = await getNextPickupCheckTime(session, entry, settings); } catch (error) { console.warn(`[PICKUP] Konnte nächste Slot-Zeit für ${entry?.id} nicht berechnen:`, error.message); } const delayMs = nextTime ? Math.max(0, nextTime.getTime() - Date.now()) : PICKUP_FALLBACK_RETRY_MS; timeoutId = setTimeout(async () => { timeoutId = null; if (stopped) { return; } await checkEntry(sessionId, entry, settings); scheduleNext(); }, delayMs); }; scheduleNext(); const job = { stop: () => { stopped = true; if (timeoutId) { clearTimeout(timeoutId); } } }; sessionStore.attachJob(sessionId, job); } function scheduleConfig(sessionId, config, settings) { const resolvedSettings = resolveSettings(settings); sessionStore.clearJobs(sessionId); scheduleDormantMembershipCheck(sessionId); const watchScheduled = scheduleStoreWatchers(sessionId, resolvedSettings); scheduleFallbackPickupChecks(sessionId, resolvedSettings); scheduleJournalReminders(sessionId); const entries = Array.isArray(config) ? config : []; const activeEntries = entries.filter((entry) => entry.active); if (activeEntries.length === 0) { if (watchScheduled) { console.log( `[INFO] Keine aktiven Pickup-Einträge für Session ${sessionId} – Store-Watch bleibt aktiv.` ); } else { console.log(`[INFO] Keine aktiven Einträge für Session ${sessionId} – Scheduler ruht.`); } return; } activeEntries.forEach((entry) => scheduleEntry(sessionId, entry, resolvedSettings)); console.log( `[INFO] Scheduler für Session ${sessionId} mit ${activeEntries.length} Jobs aktiv.` ); } async function runStoreWatchCheck(sessionId, settings, options = {}) { const resolvedSettings = resolveSettings(settings); return checkWatchedStores(sessionId, resolvedSettings, options); } async function runImmediatePickupCheck(sessionId, config, settings) { const resolvedSettings = resolveSettings(settings); const entries = Array.isArray(config) ? config : []; const activeEntries = entries.filter((entry) => entry?.active); if (activeEntries.length === 0) { return { checked: 0 }; } for (const entry of activeEntries) { await checkEntry(sessionId, entry, resolvedSettings); } return { checked: activeEntries.length }; } function setMonthOffset(date, offset) { const copy = new Date(date.getTime()); copy.setMonth(copy.getMonth() + offset); return copy; } function getMissingLastPickupStoreIds(config = []) { if (!Array.isArray(config)) { return []; } return config .filter((entry) => entry && entry.id && !entry.hidden && !entry.skipDormantCheck && !entry.lastPickupAt) .map((entry) => String(entry.id)); } async function checkDormantMembers(sessionId, options = {}) { const session = sessionStore.get(sessionId); if (!session?.profile?.id) { return; } const storeIdSet = Array.isArray(options.storeIds) ? new Set(options.storeIds.map((storeId) => String(storeId))) : null; const profileId = session.profile.id; const ensured = await ensureSession(session); if (!ensured) { return; } const config = readConfig(profileId); const skipMap = new Map(); const configEntryMap = new Map(); config.forEach((entry, index) => { if (entry?.id) { const id = String(entry.id); skipMap.set(id, !!entry.skipDormantCheck); configEntryMap.set(id, { entry, index }); } }); let configChanged = false; const storeTargets = new Map(); config.forEach((entry) => { if (!entry?.id || entry.hidden) { return; } const storeId = String(entry.id); if (storeIdSet && !storeIdSet.has(storeId)) { return; } if (skipMap.get(storeId)) { return; } storeTargets.set(storeId, { storeId, storeName: entry.label || `Store ${storeId}` }); }); const stores = Array.isArray(session.storesCache?.data) ? session.storesCache.data : []; if (stores.length === 0) { console.warn(`[DORMANT] Keine Stores für Session ${sessionId} im Cache gefunden.`); } else { stores.forEach((store) => { const storeId = store?.id ? String(store.id) : null; if (!storeId || !storeTargets.has(storeId)) { return; } const target = storeTargets.get(storeId); storeTargets.set(storeId, { ...target, storeName: store.name || target.storeName }); }); } if (storeTargets.size === 0) { return; } const fourMonthsAgo = setMonthOffset(new Date(), -4).getTime(); const hygieneCutoff = Date.now() + 6 * 7 * 24 * 60 * 60 * 1000; for (const target of storeTargets.values()) { const storeId = target.storeId; let members = []; try { members = await withSessionRetry( session, () => foodsharingClient.fetchStoreMembers(storeId, session.cookieHeader, session), { label: 'fetchStoreMembers' } ); } catch (error) { console.warn(`[DORMANT] Mitglieder von Store ${storeId} konnten nicht geladen werden:`, error.message); continue; } const memberEntry = members.find((m) => String(m?.id) === String(profileId)); if (!memberEntry) { continue; } const reasons = []; const lastFetchMs = memberEntry.last_fetch ? Number(memberEntry.last_fetch) * 1000 : null; if (Number.isFinite(lastFetchMs)) { const configEntry = configEntryMap.get(storeId)?.entry; const lastPickupAt = new Date(lastFetchMs).toISOString(); if (configEntry && configEntry.lastPickupAt !== lastPickupAt) { configEntry.lastPickupAt = lastPickupAt; configChanged = true; } } if (!lastFetchMs || lastFetchMs < fourMonthsAgo) { const lastFetchLabel = lastFetchMs ? new Date(lastFetchMs).toLocaleDateString('de-DE') : 'unbekannt'; reasons.push(`Letzte Abholung: ${lastFetchLabel} (älter als 4 Monate)`); } if (memberEntry.hygiene_certificate_until) { const expiry = new Date(memberEntry.hygiene_certificate_until.replace(' ', 'T')); if (!Number.isNaN(expiry.getTime()) && expiry.getTime() < hygieneCutoff) { reasons.push( `Hygiene-Nachweis läuft bald ab: ${expiry.toLocaleDateString('de-DE')} (unter 6 Wochen)` ); } } if (reasons.length > 0) { try { await sendDormantPickupWarning({ profileId, storeName: target.storeName, storeId, reasonLines: reasons }); } catch (error) { console.error(`[DORMANT] Warnung für Store ${storeId} konnte nicht versendet werden:`, error.message); } } } if (configChanged) { try { writeConfig(profileId, config); } catch (error) { console.error(`[DORMANT] Letzte Abholung für Profil ${profileId} konnte nicht gespeichert werden:`, error.message); } } } function scheduleDormantMembershipCheck(sessionId) { const cronExpression = '0 4 */14 * *'; const job = cron.schedule( cronExpression, () => { checkDormantMembers(sessionId).catch((error) => { console.error('[DORMANT] Prüfung fehlgeschlagen:', error.message); }); }, { timezone: 'Europe/Berlin' } ); sessionStore.attachJob(sessionId, job); const session = sessionStore.get(sessionId); const profileId = session?.profile?.id; if (!profileId) { return; } const config = readConfig(profileId); const missingIds = getMissingLastPickupStoreIds(config); if (missingIds.length === 0) { if (session.dormantBootstrapSignature) { sessionStore.update(sessionId, { dormantBootstrapSignature: null }); } return; } const signature = missingIds.sort().join(','); if (session.dormantBootstrapSignature === signature) { return; } sessionStore.update(sessionId, { dormantBootstrapSignature: signature }); setTimeout(() => checkDormantMembers(sessionId), randomDelayMs(30, 180)); } async function runDormantMembershipCheck(sessionId, options = {}) { await checkDormantMembers(sessionId, options); } async function checkJournalReminders(sessionId) { const session = sessionStore.get(sessionId); if (!session?.profile?.id) { return; } const profileId = session.profile.id; const entries = readJournal(profileId); if (!Array.isArray(entries) || entries.length === 0) { return; } const todayStart = startOfDay(new Date()); const todayLabel = todayStart.toISOString().slice(0, 10); let updated = false; for (const entry of entries) { if (!entry?.reminder?.enabled || !entry.pickupDate) { continue; } const intervalMonths = getIntervalMonths(entry.reminder.interval); const baseDate = new Date(`${entry.pickupDate}T00:00:00`); const occurrence = getNextOccurrence(baseDate, intervalMonths, todayStart); if (!occurrence) { continue; } const daysBefore = Number.isFinite(entry.reminder.daysBefore) ? Math.max(0, entry.reminder.daysBefore) : 42; const reminderDate = new Date(occurrence.getTime()); reminderDate.setDate(reminderDate.getDate() - daysBefore); if (!isSameDay(reminderDate, todayStart)) { continue; } if (entry.lastReminderAt === todayLabel) { continue; } await sendJournalReminderNotification({ profileId, storeName: entry.storeName || `Store ${entry.storeId || ''}`, pickupDate: occurrence, reminderDate, note: entry.note || '' }); entry.lastReminderAt = todayLabel; entry.updatedAt = new Date().toISOString(); updated = true; } if (updated) { writeJournal(profileId, entries); } } function scheduleJournalReminders(sessionId) { const cronExpression = '0 8 * * *'; const job = cron.schedule( cronExpression, () => { checkJournalReminders(sessionId).catch((error) => { console.error('[JOURNAL] Erinnerung fehlgeschlagen:', error.message); }); }, { timezone: 'Europe/Berlin' } ); sessionStore.attachJob(sessionId, job); setTimeout(() => { checkJournalReminders(sessionId).catch((error) => { console.error('[JOURNAL] Initiale Erinnerung fehlgeschlagen:', error.message); }); }, randomDelayMs(30, 120)); } module.exports = { scheduleConfig, runStoreWatchCheck, runImmediatePickupCheck, runDormantMembershipCheck };