Verbesserte

This commit is contained in:
root
2025-11-09 15:49:07 +01:00
parent 79a271b453
commit b8aceb695e
6 changed files with 345 additions and 96 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.git
node_modules
build
config
data
Dockerfile.dev
.env
npm-debug.log
.DS_Store
rebuildContainer.sh

View File

@@ -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"]

196
server.js
View File

@@ -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) => {

View File

@@ -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);
}

View File

@@ -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 () => {

22
startContainer.sh Executable file
View File

@@ -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!"