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 } = 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 requestLogStore = require('./services/requestLogStore'); const { withSessionRetry } = require('./services/sessionRefresh'); const { getStoreStatus: getCachedStoreStatusEntry, setStoreStatus: setCachedStoreStatusEntry, persistStoreStatusCache } = require('./services/storeStatusCache'); 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 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: '1mb' })); 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; } } 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); }); } function mergeStoresIntoConfig(config = [], stores = []) { const entries = Array.isArray(config) ? config : []; 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; stores.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; } }); 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 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 details = await withSessionRetry( session, () => foodsharingClient.fetchStoreDetails(storeId, session.cookieHeader, session), { label: 'fetchStoreDetails' } ); const status = Number(details?.teamSearchStatus); const normalized = Number.isFinite(status) ? status : null; 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) { 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 }; } 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) { 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' } ); job.processed = stores.length; job.total = stores.length; job.currentStore = null; sessionStore.update(session.id, { storesCache: { data: stores, fetchedAt: Date.now() } }); 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); credentialStore.save(profile.id, { email: credentials.email, password: credentials.password, token: session.id }); sessionStore.update(session.id, { storesCache: sessionStore.get(session.id)?.storesCache || null }); 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); } } } 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); } 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.delete(req.session.id); storeRefreshJobs.delete(req.session.id); res.json({ success: true }); }); app.get('/api/auth/session', requireAuth, async (req, res) => { const settings = adminConfig.readSettings(); const { stores } = await loadStoresForSession(req.session, settings, { reason: 'session-check' }); res.json({ profile: req.session.profile, stores, 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 { 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) { 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/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' }); } 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) => { 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/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', requireAuth, async (req, res) => { res.json(req.session.storesCache?.data || []); }); app.post('/api/stores/refresh', requireAuth, (req, res) => { 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) }); }); 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); } startBackgroundStoreRefreshTicker(); app.listen(port, () => { console.log(`Server läuft auf Port ${port}`); }); } startServer().catch((error) => { console.error('[STARTUP] Unerwarteter Fehler:', error); process.exit(1); });