From b8aceb695e590832829e0238a2d928f4a730320f Mon Sep 17 00:00:00 2001 From: root Date: Sun, 9 Nov 2025 15:49:07 +0100 Subject: [PATCH] Verbesserte --- .dockerignore | 10 ++ Dockerfile | 26 +++-- server.js | 196 +++++++++++++++++++++++++--------- services/foodsharingClient.js | 15 ++- src/App.js | 172 ++++++++++++++++++++++++----- startContainer.sh | 22 ++++ 6 files changed, 345 insertions(+), 96 deletions(-) create mode 100644 .dockerignore create mode 100755 startContainer.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8b76f04 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +node_modules +build +config +data +Dockerfile.dev +.env +npm-debug.log +.DS_Store +rebuildContainer.sh diff --git a/Dockerfile b/Dockerfile index cb5a808..974be0c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,22 +1,20 @@ -FROM node:18-alpine - -# Arbeitsverzeichnis im Container +FROM node:18-alpine AS base WORKDIR /app +ENV NODE_ENV=production -# Kopieren der package.json-Dateien für effizienteres Caching +# 1) Install dependencies (cached until package files change) COPY package*.json ./ +RUN npm ci -# Installation von Abhängigkeiten -RUN npm install - -# Kopieren des Quellcodes -COPY . . - -# Build der React-App +# 2) Build the React client (only re-run when client sources change) +COPY public ./public +COPY src ./src RUN npm run build -# Freigegebener Port -EXPOSE 3000 +# 3) Copy server-side files (changes here skip the expensive client build layer) +COPY server.js ./ +COPY services ./services +RUN mkdir -p config -# Starten des Node.js-Servers +EXPOSE 3000 CMD ["node", "server.js"] diff --git a/server.js b/server.js index 5808864..5b2f9b5 100644 --- a/server.js +++ b/server.js @@ -2,6 +2,7 @@ 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'); @@ -15,6 +16,7 @@ const SIXTY_DAYS_MS = 60 * 24 * 60 * 60 * 1000; const app = express(); const port = process.env.PORT || 3000; +const storeRefreshJobs = new Map(); app.use(cors()); app.use(express.json({ limit: '1mb' })); @@ -83,30 +85,124 @@ function mergeStoresIntoConfig(config = [], stores = []) { return { merged: Array.from(map.values()), changed }; } -async function loadStoresForSession(session, settings, { forceRefresh = false } = {}) { +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 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}`; + } + }); + 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); + } + + job.status = 'done'; + job.finishedAt = Date.now(); +} + +async function loadStoresForSession(session, _settings, { forceRefresh = false, reason } = {}) { if (!session?.profile?.id) { return { stores: [], refreshed: false }; } - const cache = session.storesCache; - const now = Date.now(); - const isCacheValid = - cache && - Array.isArray(cache.data) && - Number.isFinite(cache.fetchedAt) && - now - cache.fetchedAt <= SIXTY_DAYS_MS; - - if (isCacheValid && !forceRefresh) { - return { stores: cache.data, refreshed: false }; + const cacheFresh = isStoreCacheFresh(session); + if ((forceRefresh || !cacheFresh) && session.cookieHeader) { + triggerStoreRefresh(session, { force: true, reason: reason || 'session-check' }); } - const stores = await foodsharingClient.fetchStores(session.cookieHeader, session.profile.id, { - delayBetweenRequestsMs: settings.storePickupCheckDelayMs - }); - sessionStore.update(session.id, { - storesCache: { data: stores, fetchedAt: now } - }); - return { stores, refreshed: true }; + return { + stores: session.storesCache?.data || [], + refreshed: cacheFresh + }; } async function restoreSessionsFromDisk() { @@ -202,16 +298,7 @@ app.post('/api/auth/login', async (req, res) => { }; const isAdminUser = isAdmin(profile); const settings = adminConfig.readSettings(); - let config = readConfig(profile.id); - const stores = await foodsharingClient.fetchStores(auth.cookieHeader, profile.id, { - delayBetweenRequestsMs: settings.storePickupCheckDelayMs - }); - const { merged, changed } = mergeStoresIntoConfig(config, stores); - if (changed) { - config = merged; - writeConfig(profile.id, config); - } const existingCredentials = credentialStore.get(profile.id); const existingToken = existingCredentials?.token; @@ -228,18 +315,18 @@ app.post('/api/auth/login', async (req, res) => { }, existingToken, ONE_YEAR_MS); credentialStore.save(profile.id, { email, password, token: session.id }); - sessionStore.update(session.id, { - storesCache: { data: stores, fetchedAt: Date.now() } - }); scheduleConfig(session.id, config, settings); + const refreshResult = triggerStoreRefresh(session, { force: true, reason: 'login' }); return res.json({ token: session.id, profile, - stores, + stores: session.storesCache?.data || [], config, isAdmin: isAdminUser, - adminSettings: isAdminUser ? settings : undefined + adminSettings: isAdminUser ? settings : undefined, + storeRefreshJob: summarizeJob(refreshResult.job), + storesFresh: isStoreCacheFresh(session) }); } catch (error) { console.error('Login fehlgeschlagen:', error.message); @@ -250,24 +337,20 @@ app.post('/api/auth/login', async (req, res) => { app.post('/api/auth/logout', requireAuth, (req, res) => { sessionStore.delete(req.session.id); credentialStore.remove(req.session.profile.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, refreshed } = await loadStoresForSession(req.session, settings); - if (refreshed) { - let config = readConfig(req.session.profile.id); - const { merged, changed } = mergeStoresIntoConfig(config, stores); - if (changed) { - writeConfig(req.session.profile.id, merged); - } - } + 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 + adminSettings: req.session.isAdmin ? settings : undefined, + storeRefreshJob: summarizeJob(getStoreRefreshJob(req.session.id)), + storesFresh: isStoreCacheFresh(req.session) }); }); @@ -293,16 +376,27 @@ app.post('/api/config', requireAuth, (req, res) => { }); app.get('/api/stores', requireAuth, async (req, res) => { - const settings = adminConfig.readSettings(); - const { stores, refreshed } = await loadStoresForSession(req.session, settings, { forceRefresh: true }); - if (refreshed) { - let config = readConfig(req.session.profile.id); - const { merged, changed } = mergeStoresIntoConfig(config, stores); - if (changed) { - writeConfig(req.session.profile.id, merged); - } - } - res.json(stores); + 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) => { diff --git a/services/foodsharingClient.js b/services/foodsharingClient.js index 9e74d42..22acb69 100644 --- a/services/foodsharingClient.js +++ b/services/foodsharingClient.js @@ -126,6 +126,10 @@ async function fetchStores(cookieHeader, profileId, options = {}) { const delayBetweenRequestsMs = Number.isFinite(options.delayBetweenRequestsMs) ? Math.max(0, options.delayBetweenRequestsMs) : 0; + const onStoreCheck = + typeof options.onStoreCheck === 'function' + ? options.onStoreCheck + : null; try { const response = await client.get(`/api/user/${profileId}/stores`, { headers: buildHeaders(cookieHeader), @@ -143,14 +147,14 @@ async function fetchStores(cookieHeader, profileId, options = {}) { zip: store.zip || '' })); - return annotateStoresWithPickupSlots(normalized, cookieHeader, delayBetweenRequestsMs); + return annotateStoresWithPickupSlots(normalized, cookieHeader, delayBetweenRequestsMs, onStoreCheck); } catch (error) { console.warn('Stores konnten nicht geladen werden:', error.message); return []; } } -async function annotateStoresWithPickupSlots(stores, cookieHeader, delayBetweenRequestsMs = 0) { +async function annotateStoresWithPickupSlots(stores, cookieHeader, delayBetweenRequestsMs = 0, onStoreCheck) { if (!Array.isArray(stores) || stores.length === 0) { return []; } @@ -160,6 +164,13 @@ async function annotateStoresWithPickupSlots(stores, cookieHeader, delayBetweenR for (let index = 0; index < stores.length; index += 1) { const store = stores[index]; + if (onStoreCheck) { + try { + onStoreCheck(store, index + 1, stores.length); + } catch (callbackError) { + console.warn('Store-Progress-Callback fehlgeschlagen:', callbackError); + } + } if (delayMs > 0 && index > 0) { await wait(delayMs); } diff --git a/src/App.js b/src/App.js index c7ea047..5c4ef9e 100644 --- a/src/App.js +++ b/src/App.js @@ -28,6 +28,7 @@ function App() { const [syncProgress, setSyncProgress] = useState({ active: false, percent: 0, message: '', block: false }); const weekdays = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']; + const delay = useCallback((ms) => new Promise((resolve) => setTimeout(resolve, ms)), []); const startSyncProgress = useCallback((message, percent, block = false) => { setSyncProgress({ active: true, percent, message, block }); @@ -117,7 +118,7 @@ function App() { const bootstrapSession = useCallback( async (token, { progress } = {}) => { if (!token) { - return; + return {}; } setLoading(true); setError(''); @@ -153,45 +154,70 @@ function App() { progress?.update?.('Konfiguration wird geladen...', 75); setConfig(Array.isArray(configData) ? configData : []); progress?.update?.('Synchronisierung abgeschlossen', 95); + return { + storeRefreshJob: data.storeRefreshJob, + storesFresh: data.storesFresh + }; } catch (err) { setError(`Session konnte nicht wiederhergestellt werden: ${err.message}`); } finally { setLoading(false); } + return {}; }, [handleUnauthorized, normalizeAdminSettings] ); useEffect(() => { let ticker; - try { - const storedToken = localStorage.getItem(TOKEN_STORAGE_KEY); - if (storedToken) { + let cancelled = false; + (async () => { + try { + const storedToken = localStorage.getItem(TOKEN_STORAGE_KEY); + if (!storedToken) { + return; + } startSyncProgress('Session wird wiederhergestellt...', 5, true); ticker = setInterval(() => nudgeSyncProgress('Session wird wiederhergestellt...', 1, 40), 1000); - (async () => { - try { - await bootstrapSession(storedToken, { progress: { update: updateSyncProgress } }); - } finally { - if (ticker) { - clearInterval(ticker); - } - finishSyncProgress(); + const result = await bootstrapSession(storedToken, { progress: { update: updateSyncProgress } }); + if (ticker) { + clearInterval(ticker); + ticker = null; + } + if (!cancelled) { + const needsStoreSync = !result?.storesFresh || !!result?.storeRefreshJob; + if (needsStoreSync) { + await syncStoresWithProgress({ + reason: 'session-auto', + startJob: !result?.storeRefreshJob, + reuseOverlay: true, + block: true + }); } - })(); + } + } catch (err) { + console.warn('Konnte gespeicherten Token nicht lesen oder wiederherstellen:', err); + } finally { + if (ticker) { + clearInterval(ticker); + } + finishSyncProgress(); } - } catch (err) { - console.warn('Konnte gespeicherten Token nicht lesen:', err); - if (ticker) { - clearInterval(ticker); - } - } + })(); return () => { + cancelled = true; if (ticker) { clearInterval(ticker); } }; - }, [bootstrapSession, startSyncProgress, updateSyncProgress, finishSyncProgress, nudgeSyncProgress]); + }, [ + bootstrapSession, + startSyncProgress, + updateSyncProgress, + finishSyncProgress, + nudgeSyncProgress, + syncStoresWithProgress + ]); const authorizedFetch = useCallback( async (url, options = {}, tokenOverride) => { @@ -276,8 +302,17 @@ function App() { } clearInterval(ticker); updateSyncProgress('Zugang bestätigt. Session wird aufgebaut...', 45); - await bootstrapSession(data.token, { progress: { update: updateSyncProgress } }); - updateSyncProgress('Login abgeschlossen', 95); + const bootstrapResult = await bootstrapSession(data.token, { progress: { update: updateSyncProgress } }); + const needsStoreSync = !bootstrapResult?.storesFresh || !!bootstrapResult?.storeRefreshJob; + if (needsStoreSync) { + await syncStoresWithProgress({ + reason: 'login-auto', + startJob: !bootstrapResult?.storeRefreshJob, + reuseOverlay: true, + block: true + }); + } + updateSyncProgress('Login abgeschlossen', 98); setStatus('Anmeldung erfolgreich. Konfiguration geladen.'); setTimeout(() => setStatus(''), 3000); } catch (err) { @@ -351,22 +386,101 @@ function App() { } }, [session?.token, authorizedFetch]); - const refreshStoresAndConfig = useCallback( - async ({ block = false } = {}) => { + const syncStoresWithProgress = useCallback( + async ({ block = false, reason = 'manual', startJob = true, reuseOverlay = false } = {}) => { if (!session?.token) { return; } - startSyncProgress('Betriebe werden geprüft...', 15, block); + if (!reuseOverlay) { + startSyncProgress('Betriebe werden geprüft...', 5, block); + } else { + updateSyncProgress('Betriebe werden geprüft...', 35); + } try { + let jobStarted = false; + const triggerRefresh = async () => { + const response = await authorizedFetch('/api/stores/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ force: true, reason }) + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + await response.json(); + jobStarted = true; + }; + + if (startJob) { + await triggerRefresh(); + } + + let completed = false; + while (!completed) { + const statusResp = await authorizedFetch('/api/stores/refresh/status'); + if (!statusResp.ok) { + throw new Error(`HTTP ${statusResp.status}`); + } + const statusData = await statusResp.json(); + const job = statusData.job; + if (job?.status === 'running') { + const total = job.total || 0; + const processed = job.processed || 0; + const percent = total > 0 ? Math.min(95, 10 + Math.round((processed / total) * 80)) : undefined; + const message = job.currentStore + ? `Prüfe ${job.currentStore} (${processed}/${total || '?'})` + : 'Betriebe werden geprüft...'; + updateSyncProgress(message, percent); + } else if (!job) { + if (statusData.storesFresh) { + updateSyncProgress('Betriebe aktuell.', 90); + completed = true; + } else if (!jobStarted) { + await triggerRefresh(); + await delay(500); + } else { + updateSyncProgress('Warte auf Rückmeldung...', undefined); + } + } else if (job.status === 'done') { + updateSyncProgress('Synchronisierung abgeschlossen', 95); + completed = true; + } else if (job.status === 'error') { + throw new Error(job.error || 'Unbekannter Fehler beim Prüfen der Betriebe.'); + } + if (!completed) { + await delay(1000); + } + } + await fetchStoresList(); - updateSyncProgress('Konfiguration wird aktualisiert...', 70); await fetchConfig(undefined, { silent: true }); - updateSyncProgress('Synchronisierung abgeschlossen', 95); + setStatus('Betriebe aktualisiert.'); + setTimeout(() => setStatus(''), 3000); + } catch (err) { + setError(`Aktualisieren der Betriebe fehlgeschlagen: ${err.message}`); } finally { - finishSyncProgress(); + if (!reuseOverlay) { + finishSyncProgress(); + } } }, - [session?.token, fetchStoresList, fetchConfig, startSyncProgress, updateSyncProgress, finishSyncProgress] + [ + session?.token, + authorizedFetch, + startSyncProgress, + updateSyncProgress, + finishSyncProgress, + delay, + fetchStoresList, + fetchConfig, + setError, + setStatus + ] + ); + + const refreshStoresAndConfig = useCallback( + ({ block = false } = {}) => syncStoresWithProgress({ block, reason: 'manual', startJob: true }), + [syncStoresWithProgress] ); const saveConfig = async () => { diff --git a/startContainer.sh b/startContainer.sh new file mode 100755 index 0000000..06b5140 --- /dev/null +++ b/startContainer.sh @@ -0,0 +1,22 @@ +#!/bin/bash +set -e + +# Optionaler erster Parameter als Git-Message +MSG="${1:-Aktueller Stand}" + +# Prüfen, ob Änderungen vorhanden sind +if [[ -n $(git status --porcelain) ]]; then + echo "📦 Änderungen erkannt – committe mit Nachricht: '$MSG'" + git add . + git commit -m "$MSG" + git push +else + echo "✅ Keine Änderungen – überspringe Git-Commit." +fi + +# Container neu bauen und starten +echo "🐳 Starte Docker Compose Build & Up..." +DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 docker compose up --build -d pickup-config-app + +echo "🚀 Fertig!" +