Files
Pickup-Config/services/pickupScheduler.js
2025-11-17 21:57:03 +01:00

588 lines
18 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 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 { setStoreStatus, persistStoreStatusCache } = require('./storeStatusCache');
function wait(ms) {
if (!ms || ms <= 0) {
return Promise.resolve();
}
return new Promise((resolve) => setTimeout(resolve, ms));
}
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 resolveSettings(settings) {
if (!settings) {
return { ...DEFAULT_SETTINGS };
}
return {
scheduleCron: settings.scheduleCron || DEFAULT_SETTINGS.scheduleCron,
randomDelayMinSeconds: Number.isFinite(settings.randomDelayMinSeconds)
? settings.randomDelayMinSeconds
: DEFAULT_SETTINGS.randomDelayMinSeconds,
randomDelayMaxSeconds: Number.isFinite(settings.randomDelayMaxSeconds)
? settings.randomDelayMaxSeconds
: DEFAULT_SETTINGS.randomDelayMaxSeconds,
initialDelayMinSeconds: Number.isFinite(settings.initialDelayMinSeconds)
? settings.initialDelayMinSeconds
: DEFAULT_SETTINGS.initialDelayMinSeconds,
initialDelayMaxSeconds: Number.isFinite(settings.initialDelayMaxSeconds)
? settings.initialDelayMaxSeconds
: DEFAULT_SETTINGS.initialDelayMaxSeconds,
storeWatchCron: settings.storeWatchCron || DEFAULT_SETTINGS.storeWatchCron,
storeWatchInitialDelayMinSeconds: Number.isFinite(settings.storeWatchInitialDelayMinSeconds)
? settings.storeWatchInitialDelayMinSeconds
: DEFAULT_SETTINGS.storeWatchInitialDelayMinSeconds,
storeWatchInitialDelayMaxSeconds: Number.isFinite(settings.storeWatchInitialDelayMaxSeconds)
? settings.storeWatchInitialDelayMaxSeconds
: DEFAULT_SETTINGS.storeWatchInitialDelayMaxSeconds,
storeWatchRequestDelayMs: Number.isFinite(settings.storeWatchRequestDelayMs)
? settings.storeWatchRequestDelayMs
: DEFAULT_SETTINGS.storeWatchRequestDelayMs,
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 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);
}
}
async function ensureSession(session) {
const profileId = session.profile?.id;
if (!profileId) {
return false;
}
const stillValid = await foodsharingClient.checkSession(session.cookieHeader, profileId);
if (stillValid) {
return true;
}
if (!session.credentials) {
console.warn(`Session ${session.id} kann nicht erneuert werden keine Zugangsdaten gespeichert.`);
return false;
}
try {
const refreshed = await foodsharingClient.login(
session.credentials.email,
session.credentials.password
);
sessionStore.update(session.id, {
cookieHeader: refreshed.cookieHeader,
csrfToken: refreshed.csrfToken,
profile: {
...session.profile,
...refreshed.profile
}
});
console.log(`Session ${session.id} wurde erfolgreich erneuert.`);
return true;
} catch (error) {
console.error(`Session ${session.id} konnte nicht erneuert werden:`, error.message);
return false;
}
}
function toDateValue(input) {
if (!input) {
return null;
}
const date = input instanceof Date ? new Date(input.getTime()) : new Date(input);
if (Number.isNaN(date.getTime())) {
return null;
}
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
}
function formatDesiredWindowLabel(entry) {
if (!entry) {
return null;
}
const formatValue = (value) => {
if (!value) {
return null;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
};
if (entry.desiredDateRange) {
const startLabel = formatValue(entry.desiredDateRange.start);
const endLabel = formatValue(entry.desiredDateRange.end);
if (startLabel && endLabel) {
return startLabel === endLabel ? startLabel : `${startLabel} ${endLabel}`;
}
return startLabel || endLabel;
}
return formatValue(entry.desiredDate);
}
function desiredWindowExpired(entry) {
if (!entry?.active) {
return false;
}
const todayValue = toDateValue(new Date());
if (todayValue === null) {
return false;
}
if (entry.desiredDateRange?.start || entry.desiredDateRange?.end) {
const endValue = toDateValue(entry.desiredDateRange.end) ?? toDateValue(entry.desiredDateRange.start);
return endValue !== null && endValue < todayValue;
}
const desiredValue = toDateValue(entry.desiredDate);
return desiredValue !== null && desiredValue < todayValue;
}
function resetDesiredWindow(entry) {
if (!entry) {
return;
}
if (entry.desiredDate !== undefined) {
delete entry.desiredDate;
}
if (entry.desiredDateRange !== undefined) {
delete entry.desiredDateRange;
}
}
async function handleExpiredDesiredWindow(session, entry) {
const profileId = session?.profile?.id;
const storeName = entry.label || entry.id;
const desiredLabel = formatDesiredWindowLabel(entry);
console.log(
`[INFO] Wunschzeitraum abgelaufen für ${storeName}${desiredLabel ? ` (${desiredLabel})` : ''}. Eintrag wird deaktiviert.`
);
deactivateEntryInMemory(entry);
resetDesiredWindow(entry);
persistEntryDeactivation(profileId, entry.id, { resetDesiredWindow: true });
if (profileId) {
try {
await notificationService.sendDesiredWindowMissedNotification({
profileId,
storeName,
desiredWindowLabel: desiredLabel
});
} catch (error) {
console.error(
`[NOTIFY] Benachrichtigung zum verpassten Zeitraum für ${storeName} fehlgeschlagen:`,
error.message
);
}
}
}
function matchesDesiredDate(pickupDate, desiredDate, desiredDateRange) {
const pickupValue = toDateValue(pickupDate);
if (pickupValue === null) {
return false;
}
const hasRange = Boolean(desiredDateRange && (desiredDateRange.start || desiredDateRange.end));
if (hasRange) {
const startValue = toDateValue(desiredDateRange.start);
const endValue = toDateValue(desiredDateRange.end);
const normalizedStart = startValue !== null ? startValue : endValue;
const normalizedEnd = endValue !== null ? endValue : startValue;
if (normalizedStart !== null && pickupValue < normalizedStart) {
return false;
}
if (normalizedEnd !== null && pickupValue > normalizedEnd) {
return false;
}
return true;
}
const desiredValue = toDateValue(desiredDate);
if (desiredValue === null) {
return true;
}
return pickupValue === desiredValue;
}
function matchesDesiredWeekday(pickupDate, desiredWeekday) {
if (!desiredWeekday) {
return true;
}
const weekday = pickupDate.toLocaleDateString('en-US', { weekday: 'long' });
return weekday === desiredWeekday;
}
function shouldIgnoreSlot(entry, pickup, settings) {
const rules = settings.ignoredSlots || [];
return rules.some((rule) => {
if (!rule?.storeId) {
return false;
}
if (String(rule.storeId) !== entry.id) {
return false;
}
if (rule.description) {
return pickup.description === rule.description;
}
return true;
});
}
async function processBooking(session, entry, pickup) {
const readableDate = new Date(pickup.date).toLocaleString('de-DE');
const storeName = entry.label || entry.id;
if (entry.onlyNotify) {
console.log(`[INFO] Slot gefunden (nur Hinweis) für ${storeName} am ${readableDate}`);
await notificationService.sendSlotNotification({
profileId: session.profile.id,
storeName,
pickupDate: pickup.date,
onlyNotify: true,
booked: false,
storeId: entry.id
});
deactivateEntryInMemory(entry);
persistEntryDeactivation(session.profile.id, entry.id);
return;
}
const utcDate = new Date(pickup.date).toISOString();
try {
const allowed = await foodsharingClient.pickupRuleCheck(entry.id, utcDate, session.profile.id, session);
if (!allowed) {
console.warn(`[WARN] Rule-Check fehlgeschlagen für ${storeName} am ${readableDate}`);
return;
}
await foodsharingClient.bookSlot(entry.id, utcDate, session.profile.id, session);
console.log(`[SUCCESS] Slot gebucht für ${storeName} am ${readableDate}`);
await notificationService.sendSlotNotification({
profileId: session.profile.id,
storeName,
pickupDate: pickup.date,
onlyNotify: false,
booked: true,
storeId: entry.id
});
deactivateEntryInMemory(entry);
persistEntryDeactivation(session.profile.id, entry.id);
} catch (error) {
console.error(`[ERROR] Buchung für ${storeName} am ${readableDate} fehlgeschlagen:`, error.message);
}
}
async function checkEntry(sessionId, entry, settings) {
if (entry?.active === false) {
return;
}
const session = sessionStore.get(sessionId);
if (!session) {
return;
}
if (desiredWindowExpired(entry)) {
await handleExpiredDesiredWindow(session, entry);
return;
}
const ready = await ensureSession(session);
if (!ready) {
return;
}
try {
const pickups = await foodsharingClient.fetchPickups(entry.id, session.cookieHeader);
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 (!matchesDesiredDate(pickupDate, entry.desiredDate, entry.desiredDateRange)) {
return;
}
if (!matchesDesiredWeekday(pickupDate, desiredWeekday)) {
return;
}
if (entry.checkProfileId && pickup.occupiedSlots?.some((slot) => slot.profile?.id === session.profile.id)) {
hasProfileId = true;
return;
}
if (pickup.isAvailable && !availablePickup) {
availablePickup = pickup;
}
});
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);
}
}
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 details = await foodsharingClient.fetchStoreDetails(watcher.storeId, session.cookieHeader);
const status = details?.teamSearchStatus === 1 ? 1 : 0;
const checkedAt = Date.now();
if (status === 1 && watcher.lastTeamSearchStatus !== 1) {
await notificationService.sendStoreWatchNotification({
profileId: session.profile.id,
storeName: watcher.storeName,
storeId: watcher.storeId,
regionName: watcher.regionName
});
}
if (watcher.lastTeamSearchStatus !== status) {
watcher.lastTeamSearchStatus = status;
changed = true;
}
watcher.lastStatusCheckAt = checkedAt;
changed = true;
setStoreStatus(watcher.storeId, { teamSearchStatus: status, fetchedAt: checkedAt });
statusCacheUpdated = true;
summary.push({
storeId: watcher.storeId,
storeName: watcher.storeName,
regionName: watcher.regionName,
status,
checkedAt
});
} catch (error) {
console.error(`[WATCH] Prüfung für Store ${watcher.storeId} fehlgeschlagen:`, error.message);
summary.push({
storeId: watcher.storeId,
storeName: watcher.storeName,
regionName: watcher.regionName,
status: null,
checkedAt: Date.now(),
error: error.message || 'Unbekannter Fehler'
});
} finally {
const hasNext = index < watchers.length - 1;
if (hasNext && perRequestDelay > 0) {
await wait(perRequestDelay);
}
}
}
if (changed) {
writeStoreWatch(session.profile.id, watchers);
}
if (statusCacheUpdated) {
persistStoreStatusCache();
}
if (options.sendSummary && summary.length > 0) {
try {
await notificationService.sendStoreWatchSummaryNotification({
profileId: session.profile.id,
entries: summary,
triggeredBy: options.triggeredBy || 'manual'
});
} catch (error) {
console.error('[WATCH] Zusammenfassung konnte nicht versendet werden:', error.message);
}
}
return summary;
}
function scheduleStoreWatchers(sessionId, settings) {
const effectiveSettings = settings || DEFAULT_SETTINGS;
const session = sessionStore.get(sessionId);
if (!session?.profile?.id) {
return false;
}
const watchers = readStoreWatch(session.profile.id);
if (!Array.isArray(watchers) || watchers.length === 0) {
return false;
}
const cronExpression = effectiveSettings.storeWatchCron || DEFAULT_SETTINGS.storeWatchCron;
const job = cron.schedule(
cronExpression,
() => {
checkWatchedStores(sessionId, effectiveSettings).catch((error) => {
console.error('[WATCH] Regelmäßige Prüfung fehlgeschlagen:', error.message);
});
},
{ timezone: 'Europe/Berlin' }
);
sessionStore.attachJob(sessionId, job);
setTimeout(
() => checkWatchedStores(sessionId, effectiveSettings),
randomDelayMs(
effectiveSettings.storeWatchInitialDelayMinSeconds,
effectiveSettings.storeWatchInitialDelayMaxSeconds
)
);
console.log(
`[WATCH] Überwache ${watchers.length} Betriebe für Session ${sessionId} (Cron: ${cronExpression}).`
);
return true;
}
function scheduleEntry(sessionId, entry, settings) {
const cronExpression = settings.scheduleCron || DEFAULT_SETTINGS.scheduleCron;
const job = cron.schedule(
cronExpression,
() => {
const delay = randomDelayMs(
settings.randomDelayMinSeconds,
settings.randomDelayMaxSeconds
);
setTimeout(() => checkEntry(sessionId, entry, settings), delay);
},
{
timezone: 'Europe/Berlin'
}
);
sessionStore.attachJob(sessionId, job);
setTimeout(
() => checkEntry(sessionId, entry, settings),
randomDelayMs(settings.initialDelayMinSeconds, settings.initialDelayMaxSeconds)
);
}
function scheduleConfig(sessionId, config, settings) {
const resolvedSettings = resolveSettings(settings);
sessionStore.clearJobs(sessionId);
const watchScheduled = scheduleStoreWatchers(sessionId, resolvedSettings);
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 (Cron: ${resolvedSettings.scheduleCron}).`
);
}
async function runStoreWatchCheck(sessionId, settings, options = {}) {
const resolvedSettings = resolveSettings(settings);
return checkWatchedStores(sessionId, resolvedSettings, options);
}
module.exports = {
scheduleConfig,
runStoreWatchCheck
};