chore: sync running docker state

Commit the app code currently running in Docker, including scheduler, store status, journal reminder, maintenance mode, and Foodsharing API check updates. Remove generated runtime logs from version control and ignore local runtime captures.
This commit is contained in:
2026-06-14 21:21:07 +02:00
parent 1e754023c3
commit bd41e8c079
46 changed files with 3010 additions and 7522 deletions

View File

@@ -11,6 +11,9 @@ 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) {
@@ -19,6 +22,14 @@ function wait(ms) {
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();
@@ -118,13 +129,37 @@ function loadRegularPickupCacheFromDisk() {
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 = cachedStatus === 0 || cachedStatus === 1;
const hasCachedStatus = cacheEntry != null;
const cachedAt = Number(cacheEntry?.fetchedAt) || 0;
const effectiveMaxAge =
Number.isFinite(maxAgeMs) && maxAgeMs >= 0
@@ -141,15 +176,23 @@ async function fetchSharedStoreStatus(session, storeId, { forceRefresh = false,
}
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 };
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);
@@ -361,6 +404,20 @@ function resolveSettings(settings) {
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: {
@@ -460,6 +517,9 @@ 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) {
@@ -570,7 +630,7 @@ function deactivateEntryInMemory(entry) {
function persistEntryDeactivation(profileId, entryId, options = {}) {
if (!profileId || !entryId) {
return;
return false;
}
try {
const config = readConfig(profileId);
@@ -603,11 +663,28 @@ function persistEntryDeactivation(profileId, entryId, options = {}) {
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 isEntryActiveInConfig(profileId, entryId) {
if (!profileId || !entryId) {
return false;
@@ -692,9 +769,8 @@ async function handleExpiredDesiredWindow(session, entry) {
console.log(
`[INFO] Wunschzeitraum abgelaufen für ${storeName}${desiredLabel ? ` (${desiredLabel})` : ''}. Eintrag wird deaktiviert.`
);
deactivateEntryInMemory(entry);
resetDesiredWindow(entry);
persistEntryDeactivation(profileId, entry.id, { resetDesiredWindow: true });
deactivateEntry(session, entry, { resetDesiredWindow: true });
if (profileId) {
try {
await notificationService.sendDesiredWindowMissedNotification({
@@ -777,8 +853,7 @@ async function processBooking(session, entry, pickup) {
booked: false,
storeId: entry.id
});
deactivateEntryInMemory(entry);
persistEntryDeactivation(session.profile.id, entry.id);
deactivateEntry(session, entry);
return;
}
@@ -808,8 +883,7 @@ async function processBooking(session, entry, pickup) {
booked: true,
storeId: entry.id
});
deactivateEntryInMemory(entry);
persistEntryDeactivation(session.profile.id, entry.id);
deactivateEntry(session, entry);
} catch (error) {
console.error(`[ERROR] Buchung für ${storeName} am ${readableDate} fehlgeschlagen:`, error.message);
try {
@@ -831,6 +905,9 @@ async function processBooking(session, entry, pickup) {
}
async function checkEntry(sessionId, entry, settings) {
if (shouldPauseForMaintenance(`Pickup-Check für Store ${entry?.id || 'unbekannt'}`)) {
return;
}
if (entry?.active === false) {
return;
}
@@ -927,6 +1004,9 @@ async function checkEntry(sessionId, entry, settings) {
}
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 [];
@@ -1066,6 +1146,9 @@ function scheduleRegularPickupRefresh(settings) {
regularPickupRefreshJob = cron.schedule(
cronExpression,
async () => {
if (shouldPauseForMaintenance('Regular-Pickup-Cronlauf')) {
return;
}
const sessions = sessionStore.list();
const storeSessionMap = new Map();
for (const session of sessions) {
@@ -1170,12 +1253,15 @@ function scheduleEntry(sessionId, entry, settings) {
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);
scheduleJournalReminders(sessionId);
const entries = Array.isArray(config) ? config : [];
const activeEntries = entries.filter((entry) => entry.active);
if (activeEntries.length === 0) {
if (watchScheduled) {
@@ -1187,6 +1273,15 @@ function scheduleConfig(sessionId, config, settings) {
}
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.`
@@ -1194,11 +1289,13 @@ function scheduleConfig(sessionId, config, settings) {
}
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);
@@ -1227,6 +1324,9 @@ function getMissingLastPickupStoreIds(config = []) {
}
async function checkDormantMembers(sessionId, options = {}) {
if (shouldPauseForMaintenance(`Dormant-Check für Session ${sessionId}`)) {
return;
}
const session = sessionStore.get(sessionId);
if (!session?.profile?.id) {
return;
@@ -1272,20 +1372,49 @@ async function checkDormantMembers(sessionId, options = {}) {
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
});
});
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;
}
@@ -1302,6 +1431,13 @@ async function checkDormantMembers(sessionId, options = {}) {
{ 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;
}
@@ -1310,7 +1446,17 @@ async function checkDormantMembers(sessionId, options = {}) {
continue;
}
const reasons = [];
const lastFetchMs = memberEntry.last_fetch ? Number(memberEntry.last_fetch) * 1000 : null;
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();
@@ -1323,8 +1469,10 @@ async function checkDormantMembers(sessionId, options = {}) {
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'));
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)`
@@ -1395,6 +1543,7 @@ function scheduleDormantMembershipCheck(sessionId, settings) {
}
async function runDormantMembershipCheck(sessionId, options = {}) {
maintenanceMode.ensureInactive('Dormant-Prüfungen sind während des Wartungsmodus deaktiviert.');
await checkDormantMembers(sessionId, options);
}
@@ -1417,17 +1566,11 @@ async function checkJournalReminders(sessionId) {
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) {
const schedule = computeReminderSchedule(entry, todayStart);
if (!schedule?.occurrence || !schedule?.reminderDate) {
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);
const { occurrence, reminderDate } = schedule;
if (!isSameDay(reminderDate, todayStart)) {
continue;