Files
Pickup-Config/services/pickupScheduler.js
Meik 881b3d8d86 fix: retry transient pickup checks
Retry transient Foodsharing network failures such as socket hang up for safe read/check actions and add explicit pickup fallback scheduling, tick, and result logs so missed fallback checks can be diagnosed.
2026-06-16 22:11:18 +02:00

1664 lines
52 KiB
JavaScript
Raw 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');
const maintenanceMode = require('./maintenanceMode');
const { computeReminderSchedule } = require('./journalReminderUtils');
const { startStoreMessageWatcher } = require('./storeMessageWatcher');
function wait(ms) {
if (!ms || ms <= 0) {
return Promise.resolve();
}
return new Promise((resolve) => setTimeout(resolve, ms));
}
function shouldPauseForMaintenance(scope) {
if (!maintenanceMode.isActive()) {
return false;
}
console.warn(`[MAINTENANCE] ${scope} übersprungen, weil Wartungsmodus aktiv ist.`);
return true;
}
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';
function hasDormantMemberAccess(store) {
if (!store) {
return false;
}
if (store.isManaging) {
return true;
}
const membershipStatus = Number(store.membershipStatus);
if (!Number.isFinite(membershipStatus)) {
return true;
}
return membershipStatus === 1;
}
function resolveStoreTeamStatus(details) {
const status = Number(details?.teamStatus ?? details?.teamSearchStatus);
return Number.isFinite(status) ? status : null;
}
function isStoreStatusAccessError(error) {
const status = error?.response?.status;
return status === 403 || status === 404;
}
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 = cacheEntry != null;
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 fetchedAt = Date.now();
try {
const marker = await withSessionRetry(
session,
() => foodsharingClient.fetchStoreMarker(storeId, session.cookieHeader, session),
{ label: 'fetchStoreMarker' }
);
const status = resolveStoreTeamStatus(marker);
setStoreStatus(storeId, { teamSearchStatus: status, fetchedAt });
return { status, fetchedAt, fromCache: false };
} catch (error) {
if (!isStoreStatusAccessError(error)) {
throw error;
}
setStoreStatus(storeId, { teamSearchStatus: null, fetchedAt });
return { status: null, 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,
messageWatcherSocketEnabled:
settings.messageWatcherSocketEnabled === undefined
? DEFAULT_SETTINGS.messageWatcherSocketEnabled
: !!settings.messageWatcherSocketEnabled,
messageWatcherFallbackEnabled:
settings.messageWatcherFallbackEnabled === undefined
? DEFAULT_SETTINGS.messageWatcherFallbackEnabled
: !!settings.messageWatcherFallbackEnabled,
messageWatcherFallbackCron:
settings.messageWatcherFallbackCron || DEFAULT_SETTINGS.messageWatcherFallbackCron,
messageWatcherDebugLogging:
settings.messageWatcherDebugLogging === undefined
? DEFAULT_SETTINGS.messageWatcherDebugLogging
: !!settings.messageWatcherDebugLogging,
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 };
}
if (shouldPauseForMaintenance(`Regular-Pickup-Refresh für Store ${storeId}`)) {
return { rules: [], error: 'maintenance-mode', 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 false;
}
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);
}
return changed;
} catch (error) {
console.error(`[CONFIG] Konnte Eintrag ${entryId} für Profil ${profileId} nicht deaktivieren:`, error.message);
return false;
}
}
function deactivateEntry(session, entry, options = {}) {
if (!session?.profile?.id || !entry?.id) {
deactivateEntryInMemory(entry);
return false;
}
deactivateEntryInMemory(entry);
const changed = persistEntryDeactivation(session.profile.id, entry.id, options);
if (changed) {
const config = readConfig(session.profile.id);
const settings = resolveSettings();
scheduleConfig(session.id, config, settings);
}
return changed;
}
function shouldAutoDeactivateEntry(entry) {
return entry?.autoDeactivate !== false;
}
function deactivateEntryAfterSuccess(session, entry, options = {}) {
if (!shouldAutoDeactivateEntry(entry)) {
return false;
}
return deactivateEntry(session, entry, options);
}
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.`
);
resetDesiredWindow(entry);
deactivateEntry(session, entry, { 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
});
deactivateEntryAfterSuccess(session, entry);
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
});
deactivateEntryAfterSuccess(session, entry);
} 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 (shouldPauseForMaintenance(`Pickup-Check für Store ${entry?.id || 'unbekannt'}`)) {
return;
}
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 = {}) {
if (shouldPauseForMaintenance(`Store-Watch für Session ${sessionId}`)) {
return [];
}
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 () => {
if (shouldPauseForMaintenance('Regular-Pickup-Cronlauf')) {
return;
}
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) {
console.log(`[PICKUP] Fallback-Check für Session ${sessionId} nicht geplant: kein Cron-Ausdruck konfiguriert.`);
return null;
}
if (!cron.validate(cronExpression)) {
console.warn(
`[PICKUP] Fallback-Check für Session ${sessionId} nicht geplant: ungültiger Cron-Ausdruck "${cronExpression}".`
);
return null;
}
const job = cron.schedule(
cronExpression,
() => {
const session = sessionStore.get(sessionId);
if (!session?.profile?.id) {
console.warn(`[PICKUP] Fallback-Check übersprungen: Session ${sessionId} nicht gefunden.`);
return;
}
const delay = randomDelayMs(settings.randomDelayMinSeconds, settings.randomDelayMaxSeconds);
console.log(
`[PICKUP] Fallback-Check ausgelöst für Session ${sessionId} ` +
`(Profil ${session.profile.id}, Delay ${delay}ms, Cron: ${cronExpression}).`
);
setTimeout(() => {
const config = readConfig(session.profile.id);
runImmediatePickupCheck(sessionId, config, settings)
.then((result) => {
console.log(
`[PICKUP] Fallback-Check abgeschlossen für Session ${sessionId}: ` +
`${result.checked} aktive Einträge geprüft.`
);
})
.catch((error) => {
console.error('[PICKUP] Fallback-Check fehlgeschlagen:', error.message);
});
}, delay);
},
{ timezone: TIME_ZONE }
);
sessionStore.attachJob(sessionId, job);
console.log(`[PICKUP] Fallback-Check geplant für Session ${sessionId} (Cron: ${cronExpression}).`);
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);
const entries = Array.isArray(config) ? config : [];
sessionStore.clearJobs(sessionId);
scheduleJournalReminders(sessionId);
if (shouldPauseForMaintenance(`Scheduler für Session ${sessionId}`)) {
return;
}
scheduleDormantMembershipCheck(sessionId, resolvedSettings);
const watchScheduled = scheduleStoreWatchers(sessionId, resolvedSettings);
scheduleFallbackPickupChecks(sessionId, resolvedSettings);
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;
}
sessionStore.attachJob(
sessionId,
startStoreMessageWatcher({
sessionId,
config: entries,
settings: resolvedSettings,
runPickupCheck: runImmediatePickupCheck
})
);
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 = {}) {
maintenanceMode.ensureInactive('Store-Watch ist während des Wartungsmodus deaktiviert.');
const resolvedSettings = resolveSettings(settings);
return checkWatchedStores(sessionId, resolvedSettings, options);
}
async function runImmediatePickupCheck(sessionId, config, settings) {
maintenanceMode.ensureInactive('Pickup-Prüfungen sind während des Wartungsmodus deaktiviert.');
const resolvedSettings = resolveSettings(settings);
const entries = Array.isArray(config) ? config : [];
const activeEntries = entries.filter((entry) => entry?.active);
if (activeEntries.length === 0) {
console.log(`[PICKUP] Sofortprüfung für Session ${sessionId}: keine aktiven Einträge.`);
return { checked: 0 };
}
console.log(
`[PICKUP] Sofortprüfung für Session ${sessionId}: ${activeEntries.length} aktive Einträge ` +
`(${activeEntries.map((entry) => entry.id).join(', ')}).`
);
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 = {}) {
if (shouldPauseForMaintenance(`Dormant-Check für Session ${sessionId}`)) {
return;
}
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.`);
return;
}
const activeStoreIds = new Set();
const activeStoresById = new Map();
stores.forEach((store) => {
const storeId = store?.id ? String(store.id) : null;
if (!storeId) {
return;
}
activeStoreIds.add(storeId);
activeStoresById.set(storeId, store);
if (!storeTargets.has(storeId)) {
return;
}
const target = storeTargets.get(storeId);
storeTargets.set(storeId, {
...target,
storeName: store.name || target.storeName
});
});
Array.from(storeTargets.keys()).forEach((storeId) => {
if (activeStoreIds.has(storeId)) {
return;
}
console.info(
`[DORMANT] Store ${storeId} wird für Profil ${profileId} übersprungen, weil er nicht mehr in den aktuellen Store-Mitgliedschaften enthalten ist.`
);
storeTargets.delete(storeId);
});
Array.from(storeTargets.keys()).forEach((storeId) => {
const store = activeStoresById.get(storeId);
if (hasDormantMemberAccess(store)) {
return;
}
console.info(
`[DORMANT] Store ${storeId} wird für Profil ${profileId} übersprungen, weil die aktuelle Mitgliedschaft (${store?.membershipStatus ?? 'unbekannt'}) keinen Zugriff auf die Mitgliederliste erlaubt.`
);
storeTargets.delete(storeId);
});
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) {
const status = error?.response?.status;
if (status === 403 || status === 404) {
console.warn(
`[DORMANT] Mitglieder von Store ${storeId} konnten für Profil ${profileId} nicht geladen werden (${status}). Der Store wird für diese Session übersprungen.`
);
continue;
}
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 lastFetchRaw = memberEntry.lastFetch ?? memberEntry.last_fetch ?? null;
let lastFetchMs = null;
if (typeof lastFetchRaw === 'string') {
const parsedLastFetch = new Date(lastFetchRaw);
if (!Number.isNaN(parsedLastFetch.getTime())) {
lastFetchMs = parsedLastFetch.getTime();
}
} else if (Number.isFinite(Number(lastFetchRaw))) {
const numericLastFetch = Number(lastFetchRaw);
lastFetchMs = numericLastFetch > 1e12 ? numericLastFetch : numericLastFetch * 1000;
}
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)`);
}
const hygieneCertificateUntil =
memberEntry.hygieneCertificateUntil ?? memberEntry.hygiene_certificate_until ?? null;
if (hygieneCertificateUntil) {
const expiry = new Date(String(hygieneCertificateUntil).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 = {}) {
maintenanceMode.ensureInactive('Dormant-Prüfungen sind während des Wartungsmodus deaktiviert.');
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 schedule = computeReminderSchedule(entry, todayStart);
if (!schedule?.occurrence || !schedule?.reminderDate) {
continue;
}
const { occurrence, reminderDate } = schedule;
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();