Files
Pickup-Config/server.js
2025-11-10 20:50:04 +01:00

826 lines
26 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 } = 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 } = require('./services/userPreferencesStore');
const { readStoreStatus, writeStoreStatus } = require('./services/storeStatusStore');
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 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 storeStatusCache = new Map();
(function bootstrapStoreStatusCache() {
try {
const cached = readStoreStatus();
Object.entries(cached || {}).forEach(([storeId, entry]) => {
if (entry && typeof entry === 'object') {
storeStatusCache.set(String(storeId), {
teamSearchStatus:
entry.teamSearchStatus === null || entry.teamSearchStatus === undefined
? null
: Number(entry.teamSearchStatus),
fetchedAt: Number(entry.fetchedAt) || 0
});
}
});
} catch (error) {
console.error('[STORE-STATUS] Bootstrap fehlgeschlagen:', error.message);
}
})();
function persistStoreStatusCache() {
const payload = {};
storeStatusCache.forEach((value, key) => {
payload[key] = value;
});
writeStoreStatus(payload);
}
app.use(cors());
app.use(express.json({ limit: '1mb' }));
app.use(express.static(path.join(__dirname, 'build')));
function isAdmin(profile) {
if (!adminEmail || !profile?.email) {
return false;
}
return profile.email.toLowerCase() === adminEmail;
}
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 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 storeStatusCache.get(String(storeId)) || null;
}
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;
changed = true;
}
}
if (changed) {
writeStoreWatch(profileId, watchers);
}
}
}
async function refreshStoreStatus(
storeIds = [],
cookieHeader,
{ force = false, storeInfoMap = new Map() } = {}
) {
if (!Array.isArray(storeIds) || storeIds.length === 0 || !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 = storeStatusCache.get(storeId);
const ageOk = entry && now - (entry.fetchedAt || 0) <= STORE_STATUS_MAX_AGE_MS;
if (!force && ageOk) {
continue;
}
try {
const details = await foodsharingClient.fetchStoreDetails(storeId, cookieHeader);
const status = Number(details?.teamSearchStatus);
const normalized = Number.isFinite(status) ? status : null;
const previous = entry ? entry.teamSearchStatus : null;
storeStatusCache.set(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 = [], cookieHeader, { 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, cookieHeader, {
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 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 cacheFresh = isStoreCacheFresh(session);
if ((forceRefresh || !cacheFresh) && session.cookieHeader) {
triggerStoreRefresh(session, { force: true, reason: reason || 'session-check' });
}
return {
stores: session.storesCache?.data || [],
refreshed: cacheFresh
};
}
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) => {
const details = await foodsharingClient.fetchProfile(req.session.cookieHeader);
res.json({
profile: details || req.session.profile
});
});
app.get('/api/store-watch/regions', requireAuth, async (req, res) => {
try {
const details = await foodsharingClient.fetchProfile(req.session.cookieHeader);
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 foodsharingClient.fetchRegionStores(regionId, req.session.cookieHeader);
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) }));
const { stores: enrichedStores, statusMeta } = await enrichStoresWithTeamStatus(
filteredStores,
req.session.cookieHeader,
{ 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
};
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.get('/api/user/preferences', requireAuth, (req, res) => {
const preferences = readPreferences(req.session.profile.id);
res.json(preferences);
});
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.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/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);
}
app.listen(port, () => {
console.log(`Server läuft auf Port ${port}`);
});
}
startServer().catch((error) => {
console.error('[STARTUP] Unerwarteter Fehler:', error);
process.exit(1);
});