Files
Pickup-Config/server.js
Meik bd41e8c079 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.
2026-06-14 21:21:07 +02:00

1571 lines
49 KiB
JavaScript

const express = require('express');
const path = require('path');
const cors = require('cors');
const { v4: uuid } = require('uuid');
const sessionStore = require('./services/sessionStore');
const credentialStore = require('./services/credentialStore');
const { readConfig, writeConfig } = require('./services/configStore');
const foodsharingClient = require('./services/foodsharingClient');
const {
scheduleConfig,
runStoreWatchCheck,
runImmediatePickupCheck,
runDormantMembershipCheck,
getRegularPickupSchedule,
scheduleRegularPickupRefresh
} = require('./services/pickupScheduler');
const adminConfig = require('./services/adminConfig');
const { readNotificationSettings, writeNotificationSettings } = require('./services/userSettingsStore');
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,
writeJournal,
saveJournalImage,
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();
const SIXTY_DAYS_MS = 60 * 24 * 60 * 60 * 1000;
const PROFILE_DETAILS_TTL_MS = 6 * 60 * 60 * 1000;
const app = express();
const port = process.env.PORT || 3000;
const storeRefreshJobs = new Map();
const cachedStoreSnapshots = new Map();
const regionStoreCache = new Map();
const REGION_STORE_CACHE_MS = 15 * 60 * 1000;
const STORE_STATUS_MAX_AGE_MS = 60 * 24 * 60 * 60 * 1000;
const storeLocationIndex = new Map();
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;
}
function haversineDistanceKm(lat1, lon1, lat2, lon2) {
if (
!Number.isFinite(lat1) ||
!Number.isFinite(lon1) ||
!Number.isFinite(lat2) ||
!Number.isFinite(lon2)
) {
return null;
}
const R = 6371;
const dLat = toRadians(lat2 - lat1);
const dLon = toRadians(lon2 - lon1);
const radLat1 = toRadians(lat1);
const radLat2 = toRadians(lat2);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(radLat1) * Math.cos(radLat2) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
app.use(cors());
app.use(express.json({ limit: '15mb' }));
app.use(express.static(path.join(__dirname, 'build')));
app.use((req, res, next) => {
const startedAt = Date.now();
let responseBodySnippet = null;
const captureBody = (body) => {
responseBodySnippet = body;
return body;
};
const originalJson = res.json.bind(res);
res.json = (body) => originalJson(captureBody(body));
const originalSend = res.send.bind(res);
res.send = (body) => originalSend(captureBody(body));
res.on('finish', () => {
try {
requestLogStore.add({
direction: 'incoming',
method: req.method,
path: req.originalUrl || req.url,
status: res.statusCode,
durationMs: Date.now() - startedAt,
sessionId: req.session?.id || null,
profileId: req.session?.profile?.id || null,
profileName: req.session?.profile?.name || null,
responseBody: responseBodySnippet
});
} catch (error) {
console.warn('[REQUEST-LOG] Schreiben fehlgeschlagen:', error.message);
}
});
next();
});
function isAdmin(profile) {
if (!adminEmail || !profile?.email) {
return false;
}
return profile.email.toLowerCase() === adminEmail;
}
async function fetchProfileWithCache(session, { force = false } = {}) {
if (!session?.id) {
return null;
}
const cached = session.profileDetailsCache;
const isFresh = cached?.fetchedAt && Date.now() - cached.fetchedAt <= PROFILE_DETAILS_TTL_MS;
if (!force && isFresh && cached?.data) {
return cached.data;
}
try {
const details = await withSessionRetry(
session,
() => foodsharingClient.fetchProfile(session.cookieHeader, { throwOnError: true }, session),
{ label: 'fetchProfile' }
);
sessionStore.update(session.id, {
profileDetailsCache: { data: details, fetchedAt: Date.now() }
});
return details;
} catch (error) {
if (cached?.data) {
return cached.data;
}
throw error;
}
}
async function buildRegularPickupMapForConfig(session, config) {
try {
const entries = Array.isArray(config) ? config : [];
const storeIds = Array.from(
new Set(entries.filter((entry) => entry?.id && !entry.hidden).map((entry) => String(entry.id)))
);
if (storeIds.length === 0) {
return {};
}
const results = await Promise.all(
storeIds.map(async (storeId) => {
const result = await getRegularPickupSchedule(session, storeId);
return [storeId, Array.isArray(result.rules) ? result.rules : []];
})
);
return results.reduce((acc, [storeId, rules]) => {
acc[storeId] = rules;
return acc;
}, {});
} catch (error) {
console.warn('[PICKUP] Regular-Pickup-Map konnte nicht geladen werden:', error.message);
return {};
}
}
function scheduleWithCurrentSettings(sessionId, config) {
const settings = adminConfig.readSettings();
scheduleConfig(sessionId, config, settings);
}
function rescheduleAllSessions() {
const settings = adminConfig.readSettings();
sessionStore.list().forEach((session) => {
if (!session?.profile?.id) {
return;
}
const config = readConfig(session.profile.id);
scheduleConfig(session.id, config, settings);
});
scheduleRegularPickupRefresh(settings);
}
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) {
return;
}
map.set(String(entry.id), { ...entry, id: String(entry.id) });
});
let changed = false;
storeList.forEach((store) => {
if (!store?.id) {
return;
}
const id = String(store.id);
if (!map.has(id)) {
const hideByDefault = store.hasPickupSlots === false;
map.set(id, {
id,
label: store.name || `Store ${id}`,
active: false,
checkProfileId: true,
onlyNotify: false,
hidden: hideByDefault
});
changed = true;
return;
}
const existing = map.get(id);
if (!existing.label && store.name) {
existing.label = store.name;
changed = true;
}
});
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 };
}
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));
}
function getCachedRegionStores(regionId) {
const entry = regionStoreCache.get(String(regionId));
if (!entry) {
return null;
}
if (Date.now() - entry.fetchedAt > REGION_STORE_CACHE_MS) {
regionStoreCache.delete(String(regionId));
return null;
}
return entry.payload;
}
function setCachedRegionStores(regionId, payload) {
regionStoreCache.set(String(regionId), {
fetchedAt: Date.now(),
payload
});
}
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);
const parsedDaysBefore = Number(reminder.daysBefore);
const beforeValue = Number.isFinite(parsedBeforeValue)
? Math.max(0, parsedBeforeValue)
: Number.isFinite(parsedDaysBefore)
? 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: reference.mode === 'event'
? 'yearly'
: ['monthly', 'quarterly', 'yearly'].includes(reminder.interval)
? reminder.interval
: 'yearly',
beforeUnit: unit,
beforeValue,
daysBefore,
reference
};
}
function parseDataUrl(dataUrl) {
if (typeof dataUrl !== 'string') {
return null;
}
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
if (!match) {
return null;
}
const mimeType = match[1];
const buffer = Buffer.from(match[2], 'base64');
return { mimeType, buffer };
}
function resolveImageExtension(mimeType) {
if (mimeType === 'image/jpeg') {
return '.jpg';
}
if (mimeType === 'image/png') {
return '.png';
}
if (mimeType === 'image/gif') {
return '.gif';
}
if (mimeType === 'image/webp') {
return '.webp';
}
return '';
}
function ingestStoreLocations(stores = []) {
let changed = false;
stores.forEach((store) => {
const lat = Number(store?.location?.lat);
const lon = Number(store?.location?.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
return;
}
const storeId = String(store.id);
const entry = {
storeId,
lat,
lon,
name: store.name || `Store ${storeId}`,
city: store.city || '',
regionName: store.region?.name || '',
label:
store.city && store.region?.name && store.city.toLowerCase() !== store.region.name.toLowerCase()
? `${store.city}${store.region.name}`
: store.city || store.region?.name || store.name || `Store ${storeId}`
};
storeLocationIndex.set(storeId, entry);
changed = true;
});
if (changed) {
storeLocationIndexUpdatedAt = Date.now();
}
}
async function ensureStoreLocationIndex(session, { force = false } = {}) {
if (!session?.cookieHeader) {
throw new Error('Keine gültige Session für Standortbestimmung verfügbar.');
}
const fresh = Date.now() - storeLocationIndexUpdatedAt < STORE_LOCATION_INDEX_TTL_MS;
if (!force && storeLocationIndex.size > 0 && fresh) {
return;
}
const details = await fetchProfileWithCache(session);
const regions = Array.isArray(details?.regions)
? details.regions.filter((region) => Number(region?.classification) === 1)
: [];
if (regions.length === 0) {
return;
}
for (const region of regions) {
let payload = getCachedRegionStores(region.id);
if (!payload) {
const result = await withSessionRetry(
session,
() => foodsharingClient.fetchRegionStores(region.id, session.cookieHeader, session),
{ label: 'fetchRegionStores' }
);
payload = {
total: Number(result?.total) || 0,
stores: Array.isArray(result?.stores) ? result.stores : []
};
setCachedRegionStores(region.id, payload);
}
const filtered = payload.stores
.filter((store) => Number(store.cooperationStatus) === 5)
.map((store) => ({ ...store, id: String(store.id) }));
ingestStoreLocations(filtered);
}
}
function findNearestStoreLocation(lat, lon) {
if (storeLocationIndex.size === 0) {
return null;
}
let closest = null;
storeLocationIndex.forEach((entry) => {
const distance = haversineDistanceKm(lat, lon, entry.lat, entry.lon);
if (distance === null) {
return;
}
if (!closest || distance < closest.distanceKm) {
closest = {
storeId: entry.storeId,
label: entry.label,
name: entry.name,
city: entry.city,
regionName: entry.regionName,
distanceKm: distance
};
}
});
return closest;
}
async function notifyWatchersForStatusChanges(changes = [], storeInfoMap = new Map()) {
if (!Array.isArray(changes) || changes.length === 0) {
return;
}
const changeMap = new Map();
changes.forEach((change) => {
if (!change) {
return;
}
changeMap.set(String(change.storeId), change);
});
if (changeMap.size === 0) {
return;
}
const profiles = listWatcherProfiles();
for (const profileId of profiles) {
const watchers = readStoreWatch(profileId);
if (!Array.isArray(watchers) || watchers.length === 0) {
continue;
}
let changed = false;
for (const watcher of watchers) {
const change = changeMap.get(String(watcher.storeId));
if (!change) {
continue;
}
if (watcher.lastTeamSearchStatus !== change.newStatus) {
if (change.newStatus === 1 && watcher.lastTeamSearchStatus !== 1) {
const details = storeInfoMap.get(String(watcher.storeId)) || {};
await notificationService.sendStoreWatchNotification({
profileId,
storeName: details.name || watcher.storeName,
storeId: watcher.storeId,
regionName: details.region?.name || watcher.regionName
});
}
watcher.lastTeamSearchStatus = change.newStatus;
watcher.lastStatusCheckAt = change.fetchedAt || Date.now();
changed = true;
}
}
if (changed) {
writeStoreWatch(profileId, watchers);
}
}
}
async function refreshStoreStatus(
storeIds = [],
session,
{ force = false, storeInfoMap = new Map() } = {}
) {
if (!Array.isArray(storeIds) || storeIds.length === 0 || !session?.cookieHeader) {
return { refreshed: 0, changes: [] };
}
const now = Date.now();
let refreshed = 0;
const changes = [];
for (const id of storeIds) {
const storeId = String(id);
const entry = getCachedStoreStatus(storeId);
const ageOk = entry && now - (entry.fetchedAt || 0) <= STORE_STATUS_MAX_AGE_MS;
if (!force && ageOk) {
continue;
}
try {
const marker = await withSessionRetry(
session,
() => foodsharingClient.fetchStoreMarker(storeId, session.cookieHeader, session),
{ label: 'fetchStoreMarker' }
);
const normalized = resolveStoreTeamStatus(marker);
const previous = entry ? entry.teamSearchStatus : null;
setCachedStoreStatusEntry(storeId, {
teamSearchStatus: normalized,
fetchedAt: now
});
if (previous !== normalized) {
const info = storeInfoMap.get(storeId) || {};
changes.push({
storeId,
previousStatus: previous,
newStatus: normalized,
fetchedAt: now,
storeName: info.name || null,
regionName: info.region?.name || info.regionName || null
});
}
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);
}
}
if (refreshed > 0) {
persistStoreStatusCache();
}
return { refreshed, changes };
}
async function enrichStoresWithTeamStatus(stores = [], session, { forceRefresh = false } = {}) {
if (!Array.isArray(stores) || stores.length === 0) {
return { stores, statusMeta: { total: 0, refreshed: 0, fromCache: 0, missing: 0 } };
}
const now = Date.now();
const staleIds = [];
let cacheHits = 0;
const storeInfoMap = new Map();
stores.forEach((store) => {
if (store?.id) {
storeInfoMap.set(String(store.id), store);
}
const entry = getCachedStoreStatus(store.id);
const fresh = entry && now - (entry.fetchedAt || 0) <= STORE_STATUS_MAX_AGE_MS;
if (entry && fresh && !forceRefresh) {
store.teamSearchStatus = entry.teamSearchStatus;
store.teamStatusUpdatedAt = entry.fetchedAt || null;
cacheHits += 1;
} else {
staleIds.push(store.id);
}
});
let refreshed = 0;
let changes = [];
if (staleIds.length > 0) {
const result = await refreshStoreStatus(staleIds, session, {
force: forceRefresh,
storeInfoMap
});
refreshed = result.refreshed || 0;
changes = result.changes || [];
}
stores.forEach((store) => {
if (store.teamSearchStatus === undefined) {
const entry = getCachedStoreStatus(store.id);
if (entry) {
store.teamSearchStatus = entry.teamSearchStatus;
store.teamStatusUpdatedAt = entry.fetchedAt || null;
} else {
store.teamSearchStatus = null;
store.teamStatusUpdatedAt = null;
}
}
});
const statusMeta = {
total: stores.length,
refreshed,
fromCache: cacheHits,
missing: stores.filter((store) => store.teamStatusUpdatedAt == null).length,
generatedAt: Date.now()
};
if (changes.length > 0) {
await notifyWatchersForStatusChanges(changes, storeInfoMap);
}
return { stores, statusMeta };
}
function isStoreCacheFresh(session) {
if (!session?.storesCache?.fetchedAt) {
return false;
}
return Date.now() - session.storesCache.fetchedAt <= SIXTY_DAYS_MS;
}
function summarizeJob(job) {
if (!job) {
return null;
}
return {
id: job.id,
status: job.status,
processed: job.processed,
total: job.total,
currentStore: job.currentStore,
startedAt: job.startedAt,
finishedAt: job.finishedAt,
reason: job.reason || null,
error: job.error || null
};
}
function getStoreRefreshJob(sessionId) {
const job = storeRefreshJobs.get(sessionId);
if (!job) {
return null;
}
if (job.status === 'done' && Date.now() - (job.finishedAt || 0) > 5 * 60 * 1000) {
storeRefreshJobs.delete(sessionId);
return null;
}
return job;
}
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 };
}
const cacheFresh = isStoreCacheFresh(session);
if (!force && cacheFresh) {
return { started: false, cacheFresh: true };
}
const job = {
id: uuid(),
status: 'queued',
processed: 0,
total: 0,
currentStore: null,
startedAt: null,
finishedAt: null,
reason: reason || null,
error: null
};
storeRefreshJobs.set(session.id, job);
runStoreRefreshJob(session, job).catch((error) => {
console.error('[STORE-REFRESH] Job fehlgeschlagen:', error.message);
job.status = 'error';
job.error = error.message;
job.finishedAt = Date.now();
});
return { started: true, job };
}
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();
const stores = await withSessionRetry(
session,
() =>
foodsharingClient.fetchStores(
session.cookieHeader,
session.profile.id,
{
delayBetweenRequestsMs: settings.storePickupCheckDelayMs,
onStoreCheck: (store, processed, total) => {
job.processed = processed;
job.total = total;
job.currentStore = store.name || `Store ${store.id}`;
}
},
session
),
{ 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;
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);
if (changed) {
config = merged;
writeConfig(session.profile.id, config);
scheduleWithCurrentSettings(session.id, config);
}
const missingLastPickupStoreIds = getMissingLastPickupStoreIds(config);
if (missingLastPickupStoreIds.length > 0) {
try {
await runDormantMembershipCheck(session.id, { storeIds: missingLastPickupStoreIds });
} catch (error) {
console.warn(
`[DORMANT] Letzte Abholung nach Store-Refresh konnte nicht aktualisiert werden:`,
error.message
);
}
}
job.status = 'done';
job.finishedAt = Date.now();
}
async function loadStoresForSession(session, _settings, { forceRefresh = false, reason } = {}) {
if (!session?.profile?.id) {
return { stores: [], refreshed: false };
}
const cacheFresh = isStoreCacheFresh(session);
if ((forceRefresh || !cacheFresh) && session.cookieHeader) {
triggerStoreRefresh(session, { force: true, reason: reason || 'session-check' });
}
return {
stores: session.storesCache?.data || [],
refreshed: cacheFresh
};
}
function startBackgroundStoreRefreshTicker() {
const runCheck = () => {
sessionStore.list().forEach((session) => {
if (!session?.id || !session.cookieHeader) {
return;
}
const cacheFresh = isStoreCacheFresh(session);
const job = getStoreRefreshJob(session.id);
const jobRunning = job?.status === 'running';
if (jobRunning || cacheFresh) {
return;
}
const reason = session.storesCache?.fetchedAt ? 'background-stale-cache' : 'background-no-cache';
const result = triggerStoreRefresh(session, { force: true, reason });
if (result?.started) {
console.log(`[STORE-REFRESH] Hintergrund-Refresh gestartet für Session ${session.id} (${reason})`);
}
});
};
setTimeout(() => {
runCheck();
setInterval(runCheck, BACKGROUND_STORE_REFRESH_INTERVAL_MS);
}, 60 * 1000);
}
async function restoreSessionsFromDisk() {
const saved = credentialStore.loadAll();
const entries = Object.entries(saved);
if (entries.length === 0) {
return;
}
console.log(`[RESTORE] Versuche ${entries.length} gespeicherte Anmeldung(en) zu laden...`);
const schedulerSettings = adminConfig.readSettings();
for (const [profileId, credentials] of entries) {
if (!credentials?.email || !credentials?.password) {
continue;
}
try {
const auth = await foodsharingClient.login(credentials.email, credentials.password);
const profile = {
id: String(auth.profile.id),
name: auth.profile.name,
email: auth.profile.email || credentials.email
};
const isAdminUser = isAdmin(profile);
let config = readConfig(profile.id);
const session = sessionStore.create({
cookieHeader: auth.cookieHeader,
csrfToken: auth.csrfToken,
profile,
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: persistedStoresCache
});
scheduleConfig(session.id, config, schedulerSettings);
triggerStoreRefresh(sessionStore.get(session.id), { force: true, reason: 'restore' });
console.log(`[RESTORE] Session fuer Profil ${profile.id} (${profile.name}) reaktiviert.`);
} catch (error) {
console.error(`[RESTORE] Login fuer Profil ${profileId} fehlgeschlagen:`, error.message);
try {
await notificationService.sendAdminSessionErrorNotification({
profileId,
profileEmail: credentials?.email,
error: error?.message,
label: 'restore'
});
} catch (notifyError) {
console.error('[NOTIFY] Admin-Session-Fehler beim Restore konnte nicht gemeldet werden:', notifyError.message);
}
}
}
}
function requireAuth(req, res, next) {
const header = req.headers.authorization || '';
const [scheme, token] = header.split(' ');
if (scheme !== 'Bearer' || !token) {
return res.status(401).json({ error: 'Unautorisiert' });
}
const session = sessionStore.get(token);
if (!session) {
return res.status(401).json({ error: 'Session nicht gefunden oder abgelaufen' });
}
req.session = session;
next();
}
function requireAdmin(req, res, next) {
if (!req.session?.isAdmin) {
return res.status(403).json({ error: 'Nur für Admins verfügbar' });
}
next();
}
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body || {};
if (!email || !password) {
return res.status(400).json({ error: 'E-Mail und Passwort sind erforderlich' });
}
try {
const auth = await foodsharingClient.login(email, password);
const profile = {
id: String(auth.profile.id),
name: auth.profile.name,
email: auth.profile.email || email
};
const isAdminUser = isAdmin(profile);
const settings = adminConfig.readSettings();
let config = readConfig(profile.id);
const existingCredentials = credentialStore.get(profile.id);
const existingToken = existingCredentials?.token;
const previousSession = existingToken ? sessionStore.get(existingToken) : null;
if (existingToken) {
sessionStore.delete(existingToken);
}
const session = sessionStore.create({
cookieHeader: auth.cookieHeader,
csrfToken: auth.csrfToken,
profile,
credentials: { email, password },
isAdmin: isAdminUser
}, existingToken, ONE_YEAR_MS);
credentialStore.save(profile.id, { email, password, token: session.id });
scheduleConfig(session.id, config, settings);
if (previousSession?.storesCache) {
sessionStore.update(session.id, { storesCache: previousSession.storesCache });
} 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);
const refreshResult = needsRefresh ? triggerStoreRefresh(currentSession, { force: true, reason: 'login' }) : {};
return res.json({
token: session.id,
profile,
stores: sessionStore.get(session.id)?.storesCache?.data || [],
config,
isAdmin: isAdminUser,
adminSettings: isAdminUser ? settings : undefined,
storeRefreshJob: summarizeJob(refreshResult?.job),
storesFresh: isStoreCacheFresh(sessionStore.get(session.id))
});
} catch (error) {
console.error('Login fehlgeschlagen:', error.message);
return res.status(401).json({ error: 'Login fehlgeschlagen' });
}
});
app.post('/api/auth/logout', requireAuth, (req, res) => {
if (req.session?.profile?.id && req.session?.storesCache) {
cachedStoreSnapshots.set(req.session.profile.id, req.session.storesCache);
}
sessionStore.update(req.session.id, { uiLoggedOutAt: Date.now() });
storeRefreshJobs.delete(req.session.id);
res.json({ success: true, backgroundJobsActive: true });
});
app.get('/api/auth/session', requireAuth, async (req, res) => {
const settings = adminConfig.readSettings();
const { stores } = await loadStoresForSession(req.session, settings, { reason: 'session-check' });
const config = readConfig(req.session.profile.id);
const regularPickupMap = await buildRegularPickupMapForConfig(req.session, config);
res.json({
profile: req.session.profile,
stores,
config,
regularPickupMap,
isAdmin: !!req.session.isAdmin,
adminSettings: req.session.isAdmin ? settings : undefined,
storeRefreshJob: summarizeJob(getStoreRefreshJob(req.session.id)),
storesFresh: isStoreCacheFresh(req.session)
});
});
app.get('/api/profile', requireAuth, async (req, res) => {
try {
const details = await fetchProfileWithCache(req.session);
res.json({
profile: details || req.session.profile
});
} catch (error) {
console.error('[PROFILE] Profil konnte nicht geladen werden:', error.message);
res.status(500).json({ error: 'Profil konnte nicht geladen werden' });
}
});
app.get('/api/location/nearest-store', requireAuth, async (req, res) => {
const lat = Number(req.query.lat);
const lon = Number(req.query.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
return res.status(400).json({ error: 'Ungültige Koordinaten' });
}
try {
await ensureStoreLocationIndex(req.session);
const store = findNearestStoreLocation(lat, lon);
res.json({ store });
} catch (error) {
console.error('[LOCATION] Reverse Lookup fehlgeschlagen:', error.message);
res.status(500).json({ error: 'Ort konnte nicht bestimmt werden' });
}
});
app.get('/api/store-watch/regions', requireAuth, async (req, res) => {
try {
const details = await fetchProfileWithCache(req.session);
const regions = Array.isArray(details?.regions)
? details.regions.filter((region) => Number(region?.classification) === 1)
: [];
res.json({ regions });
} catch (error) {
console.error('[STORE-WATCH] Regionen konnten nicht geladen werden:', error.message);
res.status(500).json({ error: 'Regionen konnten nicht geladen werden' });
}
});
app.get('/api/store-watch/regions/:regionId/stores', requireAuth, async (req, res) => {
const { regionId } = req.params;
if (!regionId) {
return res.status(400).json({ error: 'Region-ID fehlt' });
}
const forceRegionRefresh = req.query.force === '1';
const forceStatusRefresh = req.query.forceStatus === '1';
let basePayload = null;
if (!forceRegionRefresh) {
const cached = getCachedRegionStores(regionId);
if (cached) {
basePayload = cached;
}
}
if (!basePayload) {
try {
const result = await withSessionRetry(
req.session,
() => foodsharingClient.fetchRegionStores(regionId, req.session.cookieHeader, req.session),
{ label: 'fetchRegionStores' }
);
basePayload = {
total: Number(result?.total) || 0,
stores: Array.isArray(result?.stores) ? result.stores : []
};
setCachedRegionStores(regionId, basePayload);
} catch (error) {
console.error(`[STORE-WATCH] Stores für Region ${regionId} konnten nicht geladen werden:`, error.message);
return res.status(500).json({ error: 'Betriebe konnten nicht geladen werden' });
}
}
const filteredStores = basePayload.stores
.filter((store) => Number(store.cooperationStatus) === 5)
.map((store) => ({ ...store, id: String(store.id) }));
ingestStoreLocations(filteredStores);
const { stores: enrichedStores, statusMeta } = await enrichStoresWithTeamStatus(
filteredStores,
req.session,
{ forceRefresh: forceStatusRefresh }
);
res.json({
total: filteredStores.length,
stores: enrichedStores,
statusMeta
});
});
app.get('/api/store-watch/subscriptions', requireAuth, (req, res) => {
const stores = readStoreWatch(req.session.profile.id);
res.json({ stores });
});
app.post('/api/store-watch/subscriptions', requireAuth, (req, res) => {
if (!req.body || !Array.isArray(req.body.stores)) {
return res.status(400).json({ error: 'Erwartet eine Liste von Betrieben' });
}
const previous = readStoreWatch(req.session.profile.id);
const previousMap = new Map(previous.map((entry) => [entry.storeId, entry]));
const normalized = [];
req.body.stores.forEach((store) => {
const storeId = store?.storeId || store?.id;
const regionId = store?.regionId || store?.region?.id;
if (!storeId || !regionId) {
return;
}
const entry = {
storeId: String(storeId),
storeName: store?.storeName || store?.name || `Store ${storeId}`,
regionId: String(regionId),
regionName: store?.regionName || store?.region?.name || '',
lastTeamSearchStatus: previousMap.get(String(storeId))?.lastTeamSearchStatus ?? null,
lastStatusCheckAt: previousMap.get(String(storeId))?.lastStatusCheckAt ?? null
};
normalized.push(entry);
});
const persisted = writeStoreWatch(req.session.profile.id, normalized);
const config = readConfig(req.session.profile.id);
scheduleWithCurrentSettings(req.session.id, config);
res.json({ success: true, stores: persisted });
});
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,
triggeredBy: 'manual'
});
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' });
}
});
app.get('/api/user/preferences', requireAuth, async (req, res) => {
const preferences = readPreferences(req.session.profile.id);
let location = preferences.location;
try {
const details = await fetchProfileWithCache(req.session);
const coords = details?.coordinates;
const sanitized = sanitizeLocation({ lat: coords?.lat, lon: coords?.lon });
if (sanitized) {
const city = details?.city?.trim() || '';
location = {
...sanitized,
label: city || preferences.location?.label || 'Foodsharing-Profil',
city,
updatedAt: sanitized.updatedAt
};
}
} catch (error) {
console.error('[PREFERENCES] Profilstandort konnte nicht geladen werden:', error.message);
}
res.json({
...preferences,
location
});
});
app.post('/api/user/preferences/location', requireAuth, (req, res) => {
const { lat, lon } = req.body || {};
if (lat === null || lon === null || lat === undefined || lon === undefined) {
const updated = writePreferences(req.session.profile.id, { location: null });
return res.json({ location: updated.location });
}
const parsedLat = Number(lat);
const parsedLon = Number(lon);
if (
!Number.isFinite(parsedLat) ||
!Number.isFinite(parsedLon) ||
parsedLat < -90 ||
parsedLat > 90 ||
parsedLon < -180 ||
parsedLon > 180
) {
return res.status(400).json({ error: 'Ungültige Koordinaten' });
}
const updated = writePreferences(req.session.profile.id, {
location: { lat: parsedLat, lon: parsedLon }
});
res.json({ location: updated.location });
});
app.get('/api/journal', requireAuth, (req, res) => {
const entries = readJournal(req.session.profile.id);
const normalized = entries.map((entry) => ({
...entry,
images: Array.isArray(entry.images)
? entry.images.map((image) => ({
...image,
url: `/api/journal/images/${image.id}`
}))
: []
}));
res.json(normalized);
});
app.post('/api/journal', requireAuth, (req, res) => {
const profileId = req.session.profile.id;
const { storeId, storeName, pickupDate, note, reminder, images } = req.body || {};
if (!storeId || !pickupDate) {
return res.status(400).json({ error: 'Store und Abholdatum sind erforderlich' });
}
const parsedDate = new Date(`${pickupDate}T00:00:00`);
if (Number.isNaN(parsedDate.getTime())) {
return res.status(400).json({ error: 'Ungültiges Abholdatum' });
}
const entryId = uuid();
const timestamp = new Date().toISOString();
const normalizedReminder = normalizeJournalReminder(reminder);
const maxImages = 5;
const maxImageBytes = 5 * 1024 * 1024;
const collectedImages = [];
if (Array.isArray(images)) {
images.slice(0, maxImages).forEach((image) => {
const payload = parseDataUrl(image?.dataUrl);
if (!payload) {
return;
}
if (payload.buffer.length > maxImageBytes) {
return;
}
const imageId = uuid();
const ext = resolveImageExtension(payload.mimeType) || path.extname(image?.name || '');
const filename = `${imageId}${ext || ''}`;
const saved = saveJournalImage(profileId, {
id: imageId,
filename,
buffer: payload.buffer
});
collectedImages.push({
id: imageId,
filename: saved.filename,
originalName: image?.name || saved.filename,
mimeType: payload.mimeType,
uploadedAt: timestamp
});
});
}
const entry = {
id: entryId,
storeId: String(storeId),
storeName: storeName || `Store ${storeId}`,
pickupDate,
note: note || '',
reminder: normalizedReminder,
images: collectedImages,
createdAt: timestamp,
updatedAt: timestamp,
lastReminderAt: null
};
const entries = readJournal(profileId);
entries.push(entry);
writeJournal(profileId, entries);
res.json({
...entry,
images: collectedImages.map((image) => ({
...image,
url: `/api/journal/images/${image.id}`
}))
});
});
app.put('/api/journal/:id', requireAuth, (req, res) => {
const profileId = req.session.profile.id;
const { storeId, storeName, pickupDate, note, reminder, images, keepImageIds } = req.body || {};
if (!storeId || !pickupDate) {
return res.status(400).json({ error: 'Store und Abholdatum sind erforderlich' });
}
const parsedDate = new Date(`${pickupDate}T00:00:00`);
if (Number.isNaN(parsedDate.getTime())) {
return res.status(400).json({ error: 'Ungültiges Abholdatum' });
}
const entries = readJournal(profileId);
const index = entries.findIndex((entry) => entry.id === req.params.id);
if (index === -1) {
return res.status(404).json({ error: 'Eintrag nicht gefunden' });
}
const existing = entries[index];
const normalizedReminder = normalizeJournalReminder(reminder);
const maxImages = 5;
const maxImageBytes = 5 * 1024 * 1024;
const keepIds = Array.isArray(keepImageIds) ? new Set(keepImageIds.map(String)) : new Set();
const keptImages = (existing.images || []).filter((image) => keepIds.has(String(image.id)));
const removedImages = (existing.images || []).filter((image) => !keepIds.has(String(image.id)));
removedImages.forEach((image) => deleteJournalImage(profileId, image.filename));
const availableSlots = Math.max(0, maxImages - keptImages.length);
const collectedImages = [...keptImages];
if (Array.isArray(images) && availableSlots > 0) {
images.slice(0, availableSlots).forEach((image) => {
const payload = parseDataUrl(image?.dataUrl);
if (!payload) {
return;
}
if (payload.buffer.length > maxImageBytes) {
return;
}
const imageId = uuid();
const ext = resolveImageExtension(payload.mimeType) || path.extname(image?.name || '');
const filename = `${imageId}${ext || ''}`;
const saved = saveJournalImage(profileId, {
id: imageId,
filename,
buffer: payload.buffer
});
collectedImages.push({
id: imageId,
filename: saved.filename,
originalName: image?.name || saved.filename,
mimeType: payload.mimeType,
uploadedAt: new Date().toISOString()
});
});
}
const pickupDateChanged = existing.pickupDate !== pickupDate;
const reminderChanged = JSON.stringify(existing.reminder || {}) !== JSON.stringify(normalizedReminder);
const updated = {
...existing,
storeId: String(storeId),
storeName: storeName || existing.storeName || `Store ${storeId}`,
pickupDate,
note: note || '',
reminder: normalizedReminder,
images: collectedImages,
updatedAt: new Date().toISOString(),
lastReminderAt: pickupDateChanged || reminderChanged ? null : existing.lastReminderAt
};
entries[index] = updated;
writeJournal(profileId, entries);
res.json({
...updated,
images: collectedImages.map((image) => ({
...image,
url: `/api/journal/images/${image.id}`
}))
});
});
app.delete('/api/journal/:id', requireAuth, (req, res) => {
const profileId = req.session.profile.id;
const entries = readJournal(profileId);
const filtered = entries.filter((entry) => entry.id !== req.params.id);
if (filtered.length === entries.length) {
return res.status(404).json({ error: 'Eintrag nicht gefunden' });
}
const removed = entries.find((entry) => entry.id === req.params.id);
if (removed?.images) {
removed.images.forEach((image) => deleteJournalImage(profileId, image.filename));
}
writeJournal(profileId, filtered);
res.json({ success: true });
});
app.get('/api/journal/images/:imageId', requireAuth, (req, res) => {
const profileId = req.session.profile.id;
const entries = readJournal(profileId);
const imageEntry = entries
.flatMap((entry) => (Array.isArray(entry.images) ? entry.images : []))
.find((image) => image.id === req.params.imageId);
if (!imageEntry?.filename) {
return res.status(404).json({ error: 'Bild nicht gefunden' });
}
const baseDir = getProfileImageDir(profileId);
const filePath = path.join(baseDir, imageEntry.filename);
if (!filePath || !path.resolve(filePath).startsWith(path.resolve(baseDir))) {
return res.status(404).json({ error: 'Bild nicht gefunden' });
}
res.sendFile(filePath);
});
app.get('/api/config', requireAuth, (req, res) => {
const config = readConfig(req.session.profile.id);
res.json(config);
});
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) => {
console.error('[PICKUP] Sofortprüfung fehlgeschlagen:', error.message);
});
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();
res.json({
settings: userSettings.notifications,
capabilities: {
ntfy: {
enabled: !!(
adminSettings.notifications?.ntfy?.enabled && adminSettings.notifications?.ntfy?.serverUrl
),
serverUrl: adminSettings.notifications?.ntfy?.serverUrl || '',
topicPrefix: adminSettings.notifications?.ntfy?.topicPrefix || ''
},
telegram: {
enabled: !!(
adminSettings.notifications?.telegram?.enabled && adminSettings.notifications?.telegram?.botToken
)
}
}
});
});
app.post('/api/notifications/settings', requireAuth, (req, res) => {
const payload = {
notifications: {
ntfy: {
enabled: !!req.body?.notifications?.ntfy?.enabled,
topic: req.body?.notifications?.ntfy?.topic || '',
serverUrl: req.body?.notifications?.ntfy?.serverUrl || ''
},
telegram: {
enabled: !!req.body?.notifications?.telegram?.enabled,
chatId: req.body?.notifications?.telegram?.chatId || ''
}
}
};
const updated = writeNotificationSettings(req.session.profile.id, payload);
res.json(updated.notifications);
});
app.post('/api/notifications/test', requireAuth, async (req, res) => {
try {
await notificationService.sendTestNotification(req.session.profile.id, req.body?.channel);
res.json({ success: true });
} catch (error) {
res.status(400).json({ error: error.message || 'Testbenachrichtigung fehlgeschlagen' });
}
});
app.get('/api/stores/:storeId/regular-pickup', requireAuth, async (req, res) => {
const { storeId } = req.params;
if (!storeId) {
return res.status(400).json({ error: 'Store-ID fehlt' });
}
try {
const result = await getRegularPickupSchedule(req.session, storeId);
res.json(result);
} catch (error) {
const message = error?.message || 'Regular-Pickup konnte nicht geladen werden';
return res.json({ rules: [], error: message });
}
});
app.get('/api/stores', requireAuth, async (req, res) => {
const stores = req.session.storesCache?.data || [];
const config = readConfig(req.session.profile.id);
const regularPickupMap = await buildRegularPickupMapForConfig(req.session, config);
res.json({ stores, regularPickupMap });
});
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),
maintenance: result.maintenanceStatus || null
});
});
app.get('/api/stores/refresh/status', requireAuth, (req, res) => {
const job = getStoreRefreshJob(req.session.id);
res.json({
job: summarizeJob(job),
storesFresh: isStoreCacheFresh(req.session),
cacheUpdatedAt: req.session.storesCache?.fetchedAt || null
});
});
app.get('/api/admin/settings', requireAuth, requireAdmin, (_req, res) => {
res.json(adminConfig.readSettings());
});
app.post('/api/admin/settings', requireAuth, requireAdmin, (req, res) => {
const updated = adminConfig.writeSettings(req.body || {});
rescheduleAllSessions();
res.json(updated);
});
app.get('/api/debug/requests', requireAuth, requireAdmin, (req, res) => {
const limit = req.query?.limit ? Number(req.query.limit) : undefined;
const logs = requestLogStore.list(limit);
res.json({ logs });
});
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'build', 'index.html'));
});
async function startServer() {
try {
await restoreSessionsFromDisk();
} catch (error) {
console.error('[RESTORE] Fehler bei der Session-Wiederherstellung:', error.message);
}
scheduleRegularPickupRefresh(adminConfig.readSettings());
startBackgroundStoreRefreshTicker();
app.listen(port, () => {
console.log(`Server läuft auf Port ${port}`);
});
}
startServer().catch((error) => {
console.error('[STARTUP] Unerwarteter Fehler:', error);
process.exit(1);
});