Files
Pickup-Config/services/pickupScheduler.js
2026-02-01 17:35:51 +01:00

1485 lines
45 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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');
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_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';
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 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,
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,
dormantMembershipCron: settings.dormantMembershipCron || DEFAULT_SETTINGS.dormantMembershipCron,
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
}
}
};
}
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 { rules: [], error: 'missing-session-or-store', fromCache: false };
}
const key = String(storeId);
const cached = getRegularPickupCacheEntry(key);
if (cached) {
return { rules: cached.rules, error: cached.error, fromCache: true };
}
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);
}
}
}
async function getNextPickupCheckTime(session, entry, settings) {
const now = new Date();
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 getRegularPickupSchedule(session, entry.id);
if (!Array.isArray(rules) || rules.length === 0) {
return null;
}
const nowParts = getTimeZoneParts(now, TIME_ZONE);
if (nowParts.weekday === null) {
return null;
}
const todayStart = getStartOfDayInTimeZone(now, 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() > now.getTime());
if (!candidate) {
slotDate = addDays(slotDate, 7);
candidate = offsets
.map((offset) => new Date(slotDate.getTime() + offset))
.find((date) => date.getTime() > now.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,
profileName: session.profile?.name,
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,
profileName: session.profile?.name,
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,
profileName: session.profile?.name,
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,
profileName: session.profile?.name,
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,
profileName: session.profile?.name,
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 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) {
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, resolvedSettings);
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) {
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);
}
}
}
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, settings) {
const cronExpression =
settings?.dormantMembershipCron || DEFAULT_SETTINGS.dormantMembershipCron || '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 profileName = session.profile?.name;
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,
profileName,
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,
getRegularPickupSchedule,
scheduleRegularPickupRefresh
};
loadRegularPickupCacheFromDisk();