1494 lines
46 KiB
JavaScript
1494 lines
46 KiB
JavaScript
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
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 getRegularPickupSchedule(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,
|
||
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();
|