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

149
server.js
View File

@@ -20,6 +20,7 @@ const { readNotificationSettings, writeNotificationSettings } = require('./servi
const notificationService = require('./services/notificationService');
const { readStoreWatch, writeStoreWatch, listWatcherProfiles } = require('./services/storeWatchStore');
const { readPreferences, writePreferences, sanitizeLocation } = require('./services/userPreferencesStore');
const { readStoresCache, writeStoresCache } = require('./services/storeCacheStore');
const requestLogStore = require('./services/requestLogStore');
const {
readJournal,
@@ -28,12 +29,14 @@ const {
deleteJournalImage,
getProfileImageDir
} = require('./services/journalStore');
const { normalizeReminderReference } = require('./services/journalReminderUtils');
const { withSessionRetry } = require('./services/sessionRefresh');
const {
getStoreStatus: getCachedStoreStatusEntry,
setStoreStatus: setCachedStoreStatusEntry,
persistStoreStatusCache
} = require('./services/storeStatusCache');
const maintenanceMode = require('./services/maintenanceMode');
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
const adminEmail = (process.env.ADMIN_EMAIL || '').toLowerCase();
@@ -52,6 +55,36 @@ let storeLocationIndexUpdatedAt = 0;
const STORE_LOCATION_INDEX_TTL_MS = 12 * 60 * 60 * 1000;
const BACKGROUND_STORE_REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000;
function maintenanceSummary() {
const snapshot = maintenanceMode.getSnapshot();
return {
active: snapshot.active,
activatedAt: snapshot.activatedAt,
reason: snapshot.reason
};
}
function normalizePickupEntry(pickup) {
if (!pickup || typeof pickup !== 'object') {
return null;
}
const date = pickup.date || pickup.pickupDate || null;
const parsedDate = date ? new Date(date) : null;
if (!parsedDate || Number.isNaN(parsedDate.getTime())) {
return null;
}
const store = pickup.store && typeof pickup.store === 'object' ? pickup.store : {};
const storeId = store.id != null ? String(store.id) : pickup.storeId != null ? String(pickup.storeId) : '';
return {
date: parsedDate.toISOString(),
storeId,
storeName: store.name || pickup.storeName || (storeId ? `Store ${storeId}` : 'Unbekannter Betrieb'),
description: pickup.description || '',
slots: Number.isFinite(Number(pickup.slots)) ? Number(pickup.slots) : null,
isConfirmed: pickup.isConfirmed === true
};
}
function toRadians(value) {
return (value * Math.PI) / 180;
}
@@ -194,6 +227,7 @@ function rescheduleAllSessions() {
function mergeStoresIntoConfig(config = [], stores = []) {
const entries = Array.isArray(config) ? config : [];
const storeList = Array.isArray(stores) ? stores : [];
const map = new Map();
entries.forEach((entry) => {
if (!entry || !entry.id) {
@@ -203,7 +237,7 @@ function mergeStoresIntoConfig(config = [], stores = []) {
});
let changed = false;
stores.forEach((store) => {
storeList.forEach((store) => {
if (!store?.id) {
return;
}
@@ -229,6 +263,20 @@ function mergeStoresIntoConfig(config = [], stores = []) {
}
});
const activeStoreIds = new Set(
storeList
.filter((store) => store?.id)
.map((store) => String(store.id))
);
Array.from(map.keys()).forEach((id) => {
if (activeStoreIds.has(id)) {
return;
}
map.delete(id);
changed = true;
});
return { merged: Array.from(map.values()), changed };
}
@@ -264,6 +312,16 @@ function getCachedStoreStatus(storeId) {
return getCachedStoreStatusEntry(storeId);
}
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;
}
function normalizeJournalReminder(reminder = {}) {
const unit = ['days', 'weeks', 'months'].includes(reminder.beforeUnit) ? reminder.beforeUnit : 'days';
const parsedBeforeValue = Number(reminder.beforeValue);
@@ -274,14 +332,18 @@ function normalizeJournalReminder(reminder = {}) {
? Math.max(0, parsedDaysBefore)
: 6;
const daysBefore = unit === 'weeks' ? beforeValue * 7 : unit === 'months' ? beforeValue * 30 : beforeValue;
const reference = normalizeReminderReference(reminder.reference);
return {
enabled: !!reminder.enabled,
interval: ['monthly', 'quarterly', 'yearly'].includes(reminder.interval)
interval: reference.mode === 'event'
? 'yearly'
: ['monthly', 'quarterly', 'yearly'].includes(reminder.interval)
? reminder.interval
: 'yearly',
beforeUnit: unit,
beforeValue,
daysBefore
daysBefore,
reference
};
}
@@ -469,13 +531,12 @@ async function refreshStoreStatus(
continue;
}
try {
const details = await withSessionRetry(
const marker = await withSessionRetry(
session,
() => foodsharingClient.fetchStoreDetails(storeId, session.cookieHeader, session),
{ label: 'fetchStoreDetails' }
() => foodsharingClient.fetchStoreMarker(storeId, session.cookieHeader, session),
{ label: 'fetchStoreMarker' }
);
const status = Number(details?.teamSearchStatus);
const normalized = Number.isFinite(status) ? status : null;
const normalized = resolveStoreTeamStatus(marker);
const previous = entry ? entry.teamSearchStatus : null;
setCachedStoreStatusEntry(storeId, {
teamSearchStatus: normalized,
@@ -494,6 +555,14 @@ async function refreshStoreStatus(
}
refreshed += 1;
} catch (error) {
if (isStoreStatusAccessError(error)) {
setCachedStoreStatusEntry(storeId, {
teamSearchStatus: null,
fetchedAt: now
});
refreshed += 1;
continue;
}
console.error(`[STORE-STATUS] Status für Store ${storeId} konnte nicht aktualisiert werden:`, error.message);
}
}
@@ -603,6 +672,9 @@ function triggerStoreRefresh(session, { force = false, reason } = {}) {
if (!session?.id) {
return { started: false };
}
if (maintenanceMode.isActive()) {
return { started: false, maintenance: true, maintenanceStatus: maintenanceSummary() };
}
const existing = getStoreRefreshJob(session.id);
if (existing && existing.status === 'running') {
return { started: false, job: existing };
@@ -636,6 +708,7 @@ function triggerStoreRefresh(session, { force = false, reason } = {}) {
}
async function runStoreRefreshJob(session, job) {
maintenanceMode.ensureInactive('Store-Refresh ist während des Wartungsmodus deaktiviert.');
job.status = 'running';
job.startedAt = Date.now();
const settings = adminConfig.readSettings();
@@ -657,6 +730,9 @@ async function runStoreRefreshJob(session, job) {
),
{ label: 'fetchStores' }
);
if (stores.length === 0) {
throw new Error('Store-Refresh lieferte keine Betriebe; bestehende Daten bleiben unverändert.');
}
job.processed = stores.length;
job.total = stores.length;
job.currentStore = null;
@@ -664,6 +740,7 @@ async function runStoreRefreshJob(session, job) {
sessionStore.update(session.id, {
storesCache: { data: stores, fetchedAt: Date.now() }
});
writeStoresCache(session.profile.id, sessionStore.get(session.id)?.storesCache);
let config = readConfig(session.profile.id);
const { merged, changed } = mergeStoresIntoConfig(config, stores);
@@ -761,13 +838,14 @@ async function restoreSessionsFromDisk() {
credentials,
isAdmin: isAdminUser
}, credentials.token, ONE_YEAR_MS);
const persistedStoresCache = readStoresCache(profile.id);
credentialStore.save(profile.id, {
email: credentials.email,
password: credentials.password,
token: session.id
});
sessionStore.update(session.id, {
storesCache: sessionStore.get(session.id)?.storesCache || null
storesCache: persistedStoresCache
});
scheduleConfig(session.id, config, schedulerSettings);
triggerStoreRefresh(sessionStore.get(session.id), { force: true, reason: 'restore' });
@@ -850,6 +928,11 @@ app.post('/api/auth/login', async (req, res) => {
} else if (cachedStoreSnapshots.has(profile.id)) {
sessionStore.update(session.id, { storesCache: cachedStoreSnapshots.get(profile.id) });
cachedStoreSnapshots.delete(profile.id);
} else {
const persistedStoresCache = readStoresCache(profile.id);
if (persistedStoresCache) {
sessionStore.update(session.id, { storesCache: persistedStoresCache });
}
}
const currentSession = sessionStore.get(session.id);
const needsRefresh = !isStoreCacheFresh(currentSession);
@@ -1028,6 +1111,7 @@ app.post('/api/store-watch/subscriptions', requireAuth, (req, res) => {
app.post('/api/store-watch/check', requireAuth, async (req, res) => {
try {
maintenanceMode.ensureInactive('Ad-hoc-Store-Watch ist während des Wartungsmodus deaktiviert.');
const settings = adminConfig.readSettings();
const summary = await runStoreWatchCheck(req.session.id, settings, {
sendSummary: true,
@@ -1035,6 +1119,12 @@ app.post('/api/store-watch/check', requireAuth, async (req, res) => {
});
res.json({ success: true, stores: Array.isArray(summary) ? summary : [] });
} catch (error) {
if (error?.code === 'MAINTENANCE_MODE_ACTIVE') {
return res.status(error.statusCode || 503).json({
error: error.message,
maintenance: maintenanceSummary()
});
}
console.error('[STORE-WATCH] Ad-hoc-Prüfung fehlgeschlagen:', error.message);
res.status(500).json({ error: 'Ad-hoc-Prüfung fehlgeschlagen' });
}
@@ -1231,6 +1321,7 @@ app.put('/api/journal/:id', requireAuth, (req, res) => {
}
const pickupDateChanged = existing.pickupDate !== pickupDate;
const reminderChanged = JSON.stringify(existing.reminder || {}) !== JSON.stringify(normalizedReminder);
const updated = {
...existing,
storeId: String(storeId),
@@ -1240,7 +1331,7 @@ app.put('/api/journal/:id', requireAuth, (req, res) => {
reminder: normalizedReminder,
images: collectedImages,
updatedAt: new Date().toISOString(),
lastReminderAt: pickupDateChanged ? null : existing.lastReminderAt
lastReminderAt: pickupDateChanged || reminderChanged ? null : existing.lastReminderAt
};
entries[index] = updated;
@@ -1296,12 +1387,24 @@ app.post('/api/config', requireAuth, (req, res) => {
if (!Array.isArray(req.body)) {
return res.status(400).json({ error: 'Konfiguration muss ein Array sein' });
}
const currentConfig = readConfig(req.session.profile.id);
if (req.body.length === 0 && currentConfig.length > 0) {
return res.status(400).json({
error: 'Leere Konfiguration wurde nicht gespeichert, weil bereits Einträge vorhanden sind.'
});
}
writeConfig(req.session.profile.id, req.body);
scheduleWithCurrentSettings(req.session.id, req.body);
res.json({ success: true });
});
app.post('/api/config/check', requireAuth, (req, res) => {
if (maintenanceMode.isActive()) {
return res.status(503).json({
error: 'Sofortprüfung ist während des Wartungsmodus deaktiviert.',
maintenance: maintenanceSummary()
});
}
const config = readConfig(req.session.profile.id);
const settings = adminConfig.readSettings();
runImmediatePickupCheck(req.session.id, config, settings).catch((error) => {
@@ -1310,6 +1413,22 @@ app.post('/api/config/check', requireAuth, (req, res) => {
res.json({ success: true });
});
app.get('/api/pickups/registered', requireAuth, async (req, res) => {
try {
const pickups = await withSessionRetry(
req.session,
() => foodsharingClient.fetchRegisteredPickups('current', req.session.cookieHeader, req.session),
{ label: 'fetchRegisteredPickups' }
);
res.json({
pickups: (Array.isArray(pickups) ? pickups : []).map(normalizePickupEntry).filter(Boolean)
});
} catch (error) {
console.error('[PICKUP] Eingetragene Pickups konnten nicht geladen werden:', error.message);
res.status(502).json({ error: 'Eingetragene Foodsharing-Slots konnten nicht geladen werden.' });
}
});
app.get('/api/notifications/settings', requireAuth, (req, res) => {
const userSettings = readNotificationSettings(req.session.profile.id);
const adminSettings = adminConfig.readSettings();
@@ -1381,13 +1500,21 @@ app.get('/api/stores', requireAuth, async (req, res) => {
});
app.post('/api/stores/refresh', requireAuth, (req, res) => {
if (maintenanceMode.isActive()) {
return res.status(503).json({
started: false,
error: 'Store-Refresh ist während des Wartungsmodus deaktiviert.',
maintenance: maintenanceSummary()
});
}
const force = req.body?.force !== undefined ? !!req.body.force : true;
const reason = req.body?.reason || 'manual';
const result = triggerStoreRefresh(req.session, { force, reason });
res.json({
started: !!result.started,
storesFresh: isStoreCacheFresh(req.session),
job: summarizeJob(result.job)
job: summarizeJob(result.job),
maintenance: result.maintenanceStatus || null
});
});