1444 lines
45 KiB
JavaScript
1444 lines
45 KiB
JavaScript
const express = require('express');
|
|
const path = require('path');
|
|
const cors = require('cors');
|
|
|
|
const { v4: uuid } = require('uuid');
|
|
const sessionStore = require('./services/sessionStore');
|
|
const credentialStore = require('./services/credentialStore');
|
|
const { readConfig, writeConfig } = require('./services/configStore');
|
|
const foodsharingClient = require('./services/foodsharingClient');
|
|
const {
|
|
scheduleConfig,
|
|
runStoreWatchCheck,
|
|
runImmediatePickupCheck,
|
|
runDormantMembershipCheck,
|
|
getRegularPickupSchedule,
|
|
scheduleRegularPickupRefresh
|
|
} = require('./services/pickupScheduler');
|
|
const adminConfig = require('./services/adminConfig');
|
|
const { readNotificationSettings, writeNotificationSettings } = require('./services/userSettingsStore');
|
|
const notificationService = require('./services/notificationService');
|
|
const { readStoreWatch, writeStoreWatch, listWatcherProfiles } = require('./services/storeWatchStore');
|
|
const { readPreferences, writePreferences, sanitizeLocation } = require('./services/userPreferencesStore');
|
|
const requestLogStore = require('./services/requestLogStore');
|
|
const {
|
|
readJournal,
|
|
writeJournal,
|
|
saveJournalImage,
|
|
deleteJournalImage,
|
|
getProfileImageDir
|
|
} = require('./services/journalStore');
|
|
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: '15mb' }));
|
|
app.use(express.static(path.join(__dirname, 'build')));
|
|
|
|
app.use((req, res, next) => {
|
|
const startedAt = Date.now();
|
|
let responseBodySnippet = null;
|
|
|
|
const captureBody = (body) => {
|
|
responseBodySnippet = body;
|
|
return body;
|
|
};
|
|
|
|
const originalJson = res.json.bind(res);
|
|
res.json = (body) => originalJson(captureBody(body));
|
|
|
|
const originalSend = res.send.bind(res);
|
|
res.send = (body) => originalSend(captureBody(body));
|
|
|
|
res.on('finish', () => {
|
|
try {
|
|
requestLogStore.add({
|
|
direction: 'incoming',
|
|
method: req.method,
|
|
path: req.originalUrl || req.url,
|
|
status: res.statusCode,
|
|
durationMs: Date.now() - startedAt,
|
|
sessionId: req.session?.id || null,
|
|
profileId: req.session?.profile?.id || null,
|
|
profileName: req.session?.profile?.name || null,
|
|
responseBody: responseBodySnippet
|
|
});
|
|
} catch (error) {
|
|
console.warn('[REQUEST-LOG] Schreiben fehlgeschlagen:', error.message);
|
|
}
|
|
});
|
|
next();
|
|
});
|
|
|
|
function isAdmin(profile) {
|
|
if (!adminEmail || !profile?.email) {
|
|
return false;
|
|
}
|
|
return profile.email.toLowerCase() === adminEmail;
|
|
}
|
|
|
|
async function fetchProfileWithCache(session, { force = false } = {}) {
|
|
if (!session?.id) {
|
|
return null;
|
|
}
|
|
const cached = session.profileDetailsCache;
|
|
const isFresh = cached?.fetchedAt && Date.now() - cached.fetchedAt <= PROFILE_DETAILS_TTL_MS;
|
|
if (!force && isFresh && cached?.data) {
|
|
return cached.data;
|
|
}
|
|
try {
|
|
const details = await withSessionRetry(
|
|
session,
|
|
() => foodsharingClient.fetchProfile(session.cookieHeader, { throwOnError: true }, session),
|
|
{ label: 'fetchProfile' }
|
|
);
|
|
sessionStore.update(session.id, {
|
|
profileDetailsCache: { data: details, fetchedAt: Date.now() }
|
|
});
|
|
return details;
|
|
} catch (error) {
|
|
if (cached?.data) {
|
|
return cached.data;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function buildRegularPickupMapForConfig(session, config) {
|
|
try {
|
|
const entries = Array.isArray(config) ? config : [];
|
|
const storeIds = Array.from(
|
|
new Set(entries.filter((entry) => entry?.id && !entry.hidden).map((entry) => String(entry.id)))
|
|
);
|
|
if (storeIds.length === 0) {
|
|
return {};
|
|
}
|
|
const results = await Promise.all(
|
|
storeIds.map(async (storeId) => {
|
|
const result = await getRegularPickupSchedule(session, storeId);
|
|
return [storeId, Array.isArray(result.rules) ? result.rules : []];
|
|
})
|
|
);
|
|
return results.reduce((acc, [storeId, rules]) => {
|
|
acc[storeId] = rules;
|
|
return acc;
|
|
}, {});
|
|
} catch (error) {
|
|
console.warn('[PICKUP] Regular-Pickup-Map konnte nicht geladen werden:', error.message);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function scheduleWithCurrentSettings(sessionId, config) {
|
|
const settings = adminConfig.readSettings();
|
|
scheduleConfig(sessionId, config, settings);
|
|
}
|
|
|
|
function rescheduleAllSessions() {
|
|
const settings = adminConfig.readSettings();
|
|
sessionStore.list().forEach((session) => {
|
|
if (!session?.profile?.id) {
|
|
return;
|
|
}
|
|
const config = readConfig(session.profile.id);
|
|
scheduleConfig(session.id, config, settings);
|
|
});
|
|
scheduleRegularPickupRefresh(settings);
|
|
}
|
|
|
|
function mergeStoresIntoConfig(config = [], stores = []) {
|
|
const entries = Array.isArray(config) ? config : [];
|
|
const 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 normalizeJournalReminder(reminder = {}) {
|
|
const unit = ['days', 'weeks', 'months'].includes(reminder.beforeUnit) ? reminder.beforeUnit : 'days';
|
|
const parsedBeforeValue = Number(reminder.beforeValue);
|
|
const parsedDaysBefore = Number(reminder.daysBefore);
|
|
const beforeValue = Number.isFinite(parsedBeforeValue)
|
|
? Math.max(0, parsedBeforeValue)
|
|
: Number.isFinite(parsedDaysBefore)
|
|
? Math.max(0, parsedDaysBefore)
|
|
: 6;
|
|
const daysBefore = unit === 'weeks' ? beforeValue * 7 : unit === 'months' ? beforeValue * 30 : beforeValue;
|
|
return {
|
|
enabled: !!reminder.enabled,
|
|
interval: ['monthly', 'quarterly', 'yearly'].includes(reminder.interval)
|
|
? reminder.interval
|
|
: 'yearly',
|
|
beforeUnit: unit,
|
|
beforeValue,
|
|
daysBefore
|
|
};
|
|
}
|
|
|
|
function parseDataUrl(dataUrl) {
|
|
if (typeof dataUrl !== 'string') {
|
|
return null;
|
|
}
|
|
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
const mimeType = match[1];
|
|
const buffer = Buffer.from(match[2], 'base64');
|
|
return { mimeType, buffer };
|
|
}
|
|
|
|
function resolveImageExtension(mimeType) {
|
|
if (mimeType === 'image/jpeg') {
|
|
return '.jpg';
|
|
}
|
|
if (mimeType === 'image/png') {
|
|
return '.png';
|
|
}
|
|
if (mimeType === 'image/gif') {
|
|
return '.gif';
|
|
}
|
|
if (mimeType === 'image/webp') {
|
|
return '.webp';
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function ingestStoreLocations(stores = []) {
|
|
let changed = false;
|
|
stores.forEach((store) => {
|
|
const lat = Number(store?.location?.lat);
|
|
const lon = Number(store?.location?.lon);
|
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
|
return;
|
|
}
|
|
const storeId = String(store.id);
|
|
const entry = {
|
|
storeId,
|
|
lat,
|
|
lon,
|
|
name: store.name || `Store ${storeId}`,
|
|
city: store.city || '',
|
|
regionName: store.region?.name || '',
|
|
label:
|
|
store.city && store.region?.name && store.city.toLowerCase() !== store.region.name.toLowerCase()
|
|
? `${store.city} • ${store.region.name}`
|
|
: store.city || store.region?.name || store.name || `Store ${storeId}`
|
|
};
|
|
storeLocationIndex.set(storeId, entry);
|
|
changed = true;
|
|
});
|
|
if (changed) {
|
|
storeLocationIndexUpdatedAt = Date.now();
|
|
}
|
|
}
|
|
|
|
async function ensureStoreLocationIndex(session, { force = false } = {}) {
|
|
if (!session?.cookieHeader) {
|
|
throw new Error('Keine gültige Session für Standortbestimmung verfügbar.');
|
|
}
|
|
const fresh = Date.now() - storeLocationIndexUpdatedAt < STORE_LOCATION_INDEX_TTL_MS;
|
|
if (!force && storeLocationIndex.size > 0 && fresh) {
|
|
return;
|
|
}
|
|
const details = await fetchProfileWithCache(session);
|
|
const regions = Array.isArray(details?.regions)
|
|
? details.regions.filter((region) => Number(region?.classification) === 1)
|
|
: [];
|
|
if (regions.length === 0) {
|
|
return;
|
|
}
|
|
for (const region of regions) {
|
|
let payload = getCachedRegionStores(region.id);
|
|
if (!payload) {
|
|
const result = await withSessionRetry(
|
|
session,
|
|
() => foodsharingClient.fetchRegionStores(region.id, session.cookieHeader, session),
|
|
{ label: 'fetchRegionStores' }
|
|
);
|
|
payload = {
|
|
total: Number(result?.total) || 0,
|
|
stores: Array.isArray(result?.stores) ? result.stores : []
|
|
};
|
|
setCachedRegionStores(region.id, payload);
|
|
}
|
|
const filtered = payload.stores
|
|
.filter((store) => Number(store.cooperationStatus) === 5)
|
|
.map((store) => ({ ...store, id: String(store.id) }));
|
|
ingestStoreLocations(filtered);
|
|
}
|
|
}
|
|
|
|
function findNearestStoreLocation(lat, lon) {
|
|
if (storeLocationIndex.size === 0) {
|
|
return null;
|
|
}
|
|
let closest = null;
|
|
storeLocationIndex.forEach((entry) => {
|
|
const distance = haversineDistanceKm(lat, lon, entry.lat, entry.lon);
|
|
if (distance === null) {
|
|
return;
|
|
}
|
|
if (!closest || distance < closest.distanceKm) {
|
|
closest = {
|
|
storeId: entry.storeId,
|
|
label: entry.label,
|
|
name: entry.name,
|
|
city: entry.city,
|
|
regionName: entry.regionName,
|
|
distanceKm: distance
|
|
};
|
|
}
|
|
});
|
|
return closest;
|
|
}
|
|
|
|
async function notifyWatchersForStatusChanges(changes = [], storeInfoMap = new Map()) {
|
|
if (!Array.isArray(changes) || changes.length === 0) {
|
|
return;
|
|
}
|
|
const changeMap = new Map();
|
|
changes.forEach((change) => {
|
|
if (!change) {
|
|
return;
|
|
}
|
|
changeMap.set(String(change.storeId), change);
|
|
});
|
|
if (changeMap.size === 0) {
|
|
return;
|
|
}
|
|
const profiles = listWatcherProfiles();
|
|
for (const profileId of profiles) {
|
|
const watchers = readStoreWatch(profileId);
|
|
if (!Array.isArray(watchers) || watchers.length === 0) {
|
|
continue;
|
|
}
|
|
let changed = false;
|
|
for (const watcher of watchers) {
|
|
const change = changeMap.get(String(watcher.storeId));
|
|
if (!change) {
|
|
continue;
|
|
}
|
|
if (watcher.lastTeamSearchStatus !== change.newStatus) {
|
|
if (change.newStatus === 1 && watcher.lastTeamSearchStatus !== 1) {
|
|
const details = storeInfoMap.get(String(watcher.storeId)) || {};
|
|
await notificationService.sendStoreWatchNotification({
|
|
profileId,
|
|
storeName: details.name || watcher.storeName,
|
|
storeId: watcher.storeId,
|
|
regionName: details.region?.name || watcher.regionName
|
|
});
|
|
}
|
|
watcher.lastTeamSearchStatus = change.newStatus;
|
|
watcher.lastStatusCheckAt = change.fetchedAt || Date.now();
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed) {
|
|
writeStoreWatch(profileId, watchers);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function refreshStoreStatus(
|
|
storeIds = [],
|
|
session,
|
|
{ force = false, storeInfoMap = new Map() } = {}
|
|
) {
|
|
if (!Array.isArray(storeIds) || storeIds.length === 0 || !session?.cookieHeader) {
|
|
return { refreshed: 0, changes: [] };
|
|
}
|
|
const now = Date.now();
|
|
let refreshed = 0;
|
|
const changes = [];
|
|
for (const id of storeIds) {
|
|
const storeId = String(id);
|
|
const entry = getCachedStoreStatus(storeId);
|
|
const ageOk = entry && now - (entry.fetchedAt || 0) <= STORE_STATUS_MAX_AGE_MS;
|
|
if (!force && ageOk) {
|
|
continue;
|
|
}
|
|
try {
|
|
const 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);
|
|
try {
|
|
await notificationService.sendAdminSessionErrorNotification({
|
|
profileId,
|
|
profileEmail: credentials?.email,
|
|
error: error?.message,
|
|
label: 'restore'
|
|
});
|
|
} catch (notifyError) {
|
|
console.error('[NOTIFY] Admin-Session-Fehler beim Restore konnte nicht gemeldet werden:', notifyError.message);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function requireAuth(req, res, next) {
|
|
const header = req.headers.authorization || '';
|
|
const [scheme, token] = header.split(' ');
|
|
if (scheme !== 'Bearer' || !token) {
|
|
return res.status(401).json({ error: 'Unautorisiert' });
|
|
}
|
|
|
|
const session = sessionStore.get(token);
|
|
if (!session) {
|
|
return res.status(401).json({ error: 'Session nicht gefunden oder abgelaufen' });
|
|
}
|
|
|
|
req.session = session;
|
|
next();
|
|
}
|
|
|
|
function requireAdmin(req, res, next) {
|
|
if (!req.session?.isAdmin) {
|
|
return res.status(403).json({ error: 'Nur für Admins verfügbar' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
app.post('/api/auth/login', async (req, res) => {
|
|
const { email, password } = req.body || {};
|
|
if (!email || !password) {
|
|
return res.status(400).json({ error: 'E-Mail und Passwort sind erforderlich' });
|
|
}
|
|
|
|
try {
|
|
const auth = await foodsharingClient.login(email, password);
|
|
const profile = {
|
|
id: String(auth.profile.id),
|
|
name: auth.profile.name,
|
|
email: auth.profile.email || email
|
|
};
|
|
const isAdminUser = isAdmin(profile);
|
|
const settings = adminConfig.readSettings();
|
|
let config = readConfig(profile.id);
|
|
|
|
const existingCredentials = credentialStore.get(profile.id);
|
|
const existingToken = existingCredentials?.token;
|
|
const previousSession = existingToken ? sessionStore.get(existingToken) : null;
|
|
if (existingToken) {
|
|
sessionStore.delete(existingToken);
|
|
}
|
|
|
|
const session = sessionStore.create({
|
|
cookieHeader: auth.cookieHeader,
|
|
csrfToken: auth.csrfToken,
|
|
profile,
|
|
credentials: { email, password },
|
|
isAdmin: isAdminUser
|
|
}, existingToken, ONE_YEAR_MS);
|
|
|
|
credentialStore.save(profile.id, { email, password, token: session.id });
|
|
scheduleConfig(session.id, config, settings);
|
|
if (previousSession?.storesCache) {
|
|
sessionStore.update(session.id, { storesCache: previousSession.storesCache });
|
|
} else if (cachedStoreSnapshots.has(profile.id)) {
|
|
sessionStore.update(session.id, { storesCache: cachedStoreSnapshots.get(profile.id) });
|
|
cachedStoreSnapshots.delete(profile.id);
|
|
}
|
|
const currentSession = sessionStore.get(session.id);
|
|
const needsRefresh = !isStoreCacheFresh(currentSession);
|
|
const refreshResult = needsRefresh ? triggerStoreRefresh(currentSession, { force: true, reason: 'login' }) : {};
|
|
|
|
return res.json({
|
|
token: session.id,
|
|
profile,
|
|
stores: sessionStore.get(session.id)?.storesCache?.data || [],
|
|
config,
|
|
isAdmin: isAdminUser,
|
|
adminSettings: isAdminUser ? settings : undefined,
|
|
storeRefreshJob: summarizeJob(refreshResult?.job),
|
|
storesFresh: isStoreCacheFresh(sessionStore.get(session.id))
|
|
});
|
|
} catch (error) {
|
|
console.error('Login fehlgeschlagen:', error.message);
|
|
return res.status(401).json({ error: 'Login fehlgeschlagen' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/auth/logout', requireAuth, (req, res) => {
|
|
if (req.session?.profile?.id && req.session?.storesCache) {
|
|
cachedStoreSnapshots.set(req.session.profile.id, req.session.storesCache);
|
|
}
|
|
sessionStore.update(req.session.id, { uiLoggedOutAt: Date.now() });
|
|
storeRefreshJobs.delete(req.session.id);
|
|
res.json({ success: true, backgroundJobsActive: true });
|
|
});
|
|
|
|
app.get('/api/auth/session', requireAuth, async (req, res) => {
|
|
const settings = adminConfig.readSettings();
|
|
const { stores } = await loadStoresForSession(req.session, settings, { reason: 'session-check' });
|
|
const config = readConfig(req.session.profile.id);
|
|
const regularPickupMap = await buildRegularPickupMapForConfig(req.session, config);
|
|
res.json({
|
|
profile: req.session.profile,
|
|
stores,
|
|
config,
|
|
regularPickupMap,
|
|
isAdmin: !!req.session.isAdmin,
|
|
adminSettings: req.session.isAdmin ? settings : undefined,
|
|
storeRefreshJob: summarizeJob(getStoreRefreshJob(req.session.id)),
|
|
storesFresh: isStoreCacheFresh(req.session)
|
|
});
|
|
});
|
|
|
|
app.get('/api/profile', requireAuth, async (req, res) => {
|
|
try {
|
|
const details = await fetchProfileWithCache(req.session);
|
|
res.json({
|
|
profile: details || req.session.profile
|
|
});
|
|
} catch (error) {
|
|
console.error('[PROFILE] Profil konnte nicht geladen werden:', error.message);
|
|
res.status(500).json({ error: 'Profil konnte nicht geladen werden' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/location/nearest-store', requireAuth, async (req, res) => {
|
|
const lat = Number(req.query.lat);
|
|
const lon = Number(req.query.lon);
|
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
|
return res.status(400).json({ error: 'Ungültige Koordinaten' });
|
|
}
|
|
try {
|
|
await ensureStoreLocationIndex(req.session);
|
|
const store = findNearestStoreLocation(lat, lon);
|
|
res.json({ store });
|
|
} catch (error) {
|
|
console.error('[LOCATION] Reverse Lookup fehlgeschlagen:', error.message);
|
|
res.status(500).json({ error: 'Ort konnte nicht bestimmt werden' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/store-watch/regions', requireAuth, async (req, res) => {
|
|
try {
|
|
const details = await fetchProfileWithCache(req.session);
|
|
const regions = Array.isArray(details?.regions)
|
|
? details.regions.filter((region) => Number(region?.classification) === 1)
|
|
: [];
|
|
res.json({ regions });
|
|
} catch (error) {
|
|
console.error('[STORE-WATCH] Regionen konnten nicht geladen werden:', error.message);
|
|
res.status(500).json({ error: 'Regionen konnten nicht geladen werden' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/store-watch/regions/:regionId/stores', requireAuth, async (req, res) => {
|
|
const { regionId } = req.params;
|
|
if (!regionId) {
|
|
return res.status(400).json({ error: 'Region-ID fehlt' });
|
|
}
|
|
const forceRegionRefresh = req.query.force === '1';
|
|
const forceStatusRefresh = req.query.forceStatus === '1';
|
|
|
|
let basePayload = null;
|
|
if (!forceRegionRefresh) {
|
|
const cached = getCachedRegionStores(regionId);
|
|
if (cached) {
|
|
basePayload = cached;
|
|
}
|
|
}
|
|
|
|
if (!basePayload) {
|
|
try {
|
|
const result = await withSessionRetry(
|
|
req.session,
|
|
() => foodsharingClient.fetchRegionStores(regionId, req.session.cookieHeader, req.session),
|
|
{ label: 'fetchRegionStores' }
|
|
);
|
|
basePayload = {
|
|
total: Number(result?.total) || 0,
|
|
stores: Array.isArray(result?.stores) ? result.stores : []
|
|
};
|
|
setCachedRegionStores(regionId, basePayload);
|
|
} catch (error) {
|
|
console.error(`[STORE-WATCH] Stores für Region ${regionId} konnten nicht geladen werden:`, error.message);
|
|
return res.status(500).json({ error: 'Betriebe konnten nicht geladen werden' });
|
|
}
|
|
}
|
|
|
|
const filteredStores = basePayload.stores
|
|
.filter((store) => Number(store.cooperationStatus) === 5)
|
|
.map((store) => ({ ...store, id: String(store.id) }));
|
|
|
|
ingestStoreLocations(filteredStores);
|
|
|
|
const { stores: enrichedStores, statusMeta } = await enrichStoresWithTeamStatus(
|
|
filteredStores,
|
|
req.session,
|
|
{ forceRefresh: forceStatusRefresh }
|
|
);
|
|
|
|
res.json({
|
|
total: filteredStores.length,
|
|
stores: enrichedStores,
|
|
statusMeta
|
|
});
|
|
});
|
|
|
|
app.get('/api/store-watch/subscriptions', requireAuth, (req, res) => {
|
|
const stores = readStoreWatch(req.session.profile.id);
|
|
res.json({ stores });
|
|
});
|
|
|
|
app.post('/api/store-watch/subscriptions', requireAuth, (req, res) => {
|
|
if (!req.body || !Array.isArray(req.body.stores)) {
|
|
return res.status(400).json({ error: 'Erwartet eine Liste von Betrieben' });
|
|
}
|
|
const previous = readStoreWatch(req.session.profile.id);
|
|
const previousMap = new Map(previous.map((entry) => [entry.storeId, entry]));
|
|
const normalized = [];
|
|
req.body.stores.forEach((store) => {
|
|
const storeId = store?.storeId || store?.id;
|
|
const regionId = store?.regionId || store?.region?.id;
|
|
if (!storeId || !regionId) {
|
|
return;
|
|
}
|
|
const entry = {
|
|
storeId: String(storeId),
|
|
storeName: store?.storeName || store?.name || `Store ${storeId}`,
|
|
regionId: String(regionId),
|
|
regionName: store?.regionName || store?.region?.name || '',
|
|
lastTeamSearchStatus: previousMap.get(String(storeId))?.lastTeamSearchStatus ?? null,
|
|
lastStatusCheckAt: previousMap.get(String(storeId))?.lastStatusCheckAt ?? null
|
|
};
|
|
normalized.push(entry);
|
|
});
|
|
|
|
const persisted = writeStoreWatch(req.session.profile.id, normalized);
|
|
const config = readConfig(req.session.profile.id);
|
|
scheduleWithCurrentSettings(req.session.id, config);
|
|
res.json({ success: true, stores: persisted });
|
|
});
|
|
|
|
app.post('/api/store-watch/check', requireAuth, async (req, res) => {
|
|
try {
|
|
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/journal', requireAuth, (req, res) => {
|
|
const entries = readJournal(req.session.profile.id);
|
|
const normalized = entries.map((entry) => ({
|
|
...entry,
|
|
images: Array.isArray(entry.images)
|
|
? entry.images.map((image) => ({
|
|
...image,
|
|
url: `/api/journal/images/${image.id}`
|
|
}))
|
|
: []
|
|
}));
|
|
res.json(normalized);
|
|
});
|
|
|
|
app.post('/api/journal', requireAuth, (req, res) => {
|
|
const profileId = req.session.profile.id;
|
|
const { storeId, storeName, pickupDate, note, reminder, images } = req.body || {};
|
|
if (!storeId || !pickupDate) {
|
|
return res.status(400).json({ error: 'Store und Abholdatum sind erforderlich' });
|
|
}
|
|
const parsedDate = new Date(`${pickupDate}T00:00:00`);
|
|
if (Number.isNaN(parsedDate.getTime())) {
|
|
return res.status(400).json({ error: 'Ungültiges Abholdatum' });
|
|
}
|
|
|
|
const entryId = uuid();
|
|
const timestamp = new Date().toISOString();
|
|
const normalizedReminder = normalizeJournalReminder(reminder);
|
|
const maxImages = 5;
|
|
const maxImageBytes = 5 * 1024 * 1024;
|
|
const collectedImages = [];
|
|
|
|
if (Array.isArray(images)) {
|
|
images.slice(0, maxImages).forEach((image) => {
|
|
const payload = parseDataUrl(image?.dataUrl);
|
|
if (!payload) {
|
|
return;
|
|
}
|
|
if (payload.buffer.length > maxImageBytes) {
|
|
return;
|
|
}
|
|
const imageId = uuid();
|
|
const ext = resolveImageExtension(payload.mimeType) || path.extname(image?.name || '');
|
|
const filename = `${imageId}${ext || ''}`;
|
|
const saved = saveJournalImage(profileId, {
|
|
id: imageId,
|
|
filename,
|
|
buffer: payload.buffer
|
|
});
|
|
collectedImages.push({
|
|
id: imageId,
|
|
filename: saved.filename,
|
|
originalName: image?.name || saved.filename,
|
|
mimeType: payload.mimeType,
|
|
uploadedAt: timestamp
|
|
});
|
|
});
|
|
}
|
|
|
|
const entry = {
|
|
id: entryId,
|
|
storeId: String(storeId),
|
|
storeName: storeName || `Store ${storeId}`,
|
|
pickupDate,
|
|
note: note || '',
|
|
reminder: normalizedReminder,
|
|
images: collectedImages,
|
|
createdAt: timestamp,
|
|
updatedAt: timestamp,
|
|
lastReminderAt: null
|
|
};
|
|
|
|
const entries = readJournal(profileId);
|
|
entries.push(entry);
|
|
writeJournal(profileId, entries);
|
|
|
|
res.json({
|
|
...entry,
|
|
images: collectedImages.map((image) => ({
|
|
...image,
|
|
url: `/api/journal/images/${image.id}`
|
|
}))
|
|
});
|
|
});
|
|
|
|
app.put('/api/journal/:id', requireAuth, (req, res) => {
|
|
const profileId = req.session.profile.id;
|
|
const { storeId, storeName, pickupDate, note, reminder, images, keepImageIds } = req.body || {};
|
|
if (!storeId || !pickupDate) {
|
|
return res.status(400).json({ error: 'Store und Abholdatum sind erforderlich' });
|
|
}
|
|
const parsedDate = new Date(`${pickupDate}T00:00:00`);
|
|
if (Number.isNaN(parsedDate.getTime())) {
|
|
return res.status(400).json({ error: 'Ungültiges Abholdatum' });
|
|
}
|
|
|
|
const entries = readJournal(profileId);
|
|
const index = entries.findIndex((entry) => entry.id === req.params.id);
|
|
if (index === -1) {
|
|
return res.status(404).json({ error: 'Eintrag nicht gefunden' });
|
|
}
|
|
|
|
const existing = entries[index];
|
|
const normalizedReminder = normalizeJournalReminder(reminder);
|
|
const maxImages = 5;
|
|
const maxImageBytes = 5 * 1024 * 1024;
|
|
const keepIds = Array.isArray(keepImageIds) ? new Set(keepImageIds.map(String)) : new Set();
|
|
const keptImages = (existing.images || []).filter((image) => keepIds.has(String(image.id)));
|
|
const removedImages = (existing.images || []).filter((image) => !keepIds.has(String(image.id)));
|
|
removedImages.forEach((image) => deleteJournalImage(profileId, image.filename));
|
|
|
|
const availableSlots = Math.max(0, maxImages - keptImages.length);
|
|
const collectedImages = [...keptImages];
|
|
|
|
if (Array.isArray(images) && availableSlots > 0) {
|
|
images.slice(0, availableSlots).forEach((image) => {
|
|
const payload = parseDataUrl(image?.dataUrl);
|
|
if (!payload) {
|
|
return;
|
|
}
|
|
if (payload.buffer.length > maxImageBytes) {
|
|
return;
|
|
}
|
|
const imageId = uuid();
|
|
const ext = resolveImageExtension(payload.mimeType) || path.extname(image?.name || '');
|
|
const filename = `${imageId}${ext || ''}`;
|
|
const saved = saveJournalImage(profileId, {
|
|
id: imageId,
|
|
filename,
|
|
buffer: payload.buffer
|
|
});
|
|
collectedImages.push({
|
|
id: imageId,
|
|
filename: saved.filename,
|
|
originalName: image?.name || saved.filename,
|
|
mimeType: payload.mimeType,
|
|
uploadedAt: new Date().toISOString()
|
|
});
|
|
});
|
|
}
|
|
|
|
const pickupDateChanged = existing.pickupDate !== pickupDate;
|
|
const updated = {
|
|
...existing,
|
|
storeId: String(storeId),
|
|
storeName: storeName || existing.storeName || `Store ${storeId}`,
|
|
pickupDate,
|
|
note: note || '',
|
|
reminder: normalizedReminder,
|
|
images: collectedImages,
|
|
updatedAt: new Date().toISOString(),
|
|
lastReminderAt: pickupDateChanged ? null : existing.lastReminderAt
|
|
};
|
|
|
|
entries[index] = updated;
|
|
writeJournal(profileId, entries);
|
|
|
|
res.json({
|
|
...updated,
|
|
images: collectedImages.map((image) => ({
|
|
...image,
|
|
url: `/api/journal/images/${image.id}`
|
|
}))
|
|
});
|
|
});
|
|
|
|
app.delete('/api/journal/:id', requireAuth, (req, res) => {
|
|
const profileId = req.session.profile.id;
|
|
const entries = readJournal(profileId);
|
|
const filtered = entries.filter((entry) => entry.id !== req.params.id);
|
|
if (filtered.length === entries.length) {
|
|
return res.status(404).json({ error: 'Eintrag nicht gefunden' });
|
|
}
|
|
const removed = entries.find((entry) => entry.id === req.params.id);
|
|
if (removed?.images) {
|
|
removed.images.forEach((image) => deleteJournalImage(profileId, image.filename));
|
|
}
|
|
writeJournal(profileId, filtered);
|
|
res.json({ success: true });
|
|
});
|
|
|
|
app.get('/api/journal/images/:imageId', requireAuth, (req, res) => {
|
|
const profileId = req.session.profile.id;
|
|
const entries = readJournal(profileId);
|
|
const imageEntry = entries
|
|
.flatMap((entry) => (Array.isArray(entry.images) ? entry.images : []))
|
|
.find((image) => image.id === req.params.imageId);
|
|
if (!imageEntry?.filename) {
|
|
return res.status(404).json({ error: 'Bild nicht gefunden' });
|
|
}
|
|
const baseDir = getProfileImageDir(profileId);
|
|
const filePath = path.join(baseDir, imageEntry.filename);
|
|
if (!filePath || !path.resolve(filePath).startsWith(path.resolve(baseDir))) {
|
|
return res.status(404).json({ error: 'Bild nicht gefunden' });
|
|
}
|
|
res.sendFile(filePath);
|
|
});
|
|
|
|
app.get('/api/config', requireAuth, (req, res) => {
|
|
const config = readConfig(req.session.profile.id);
|
|
res.json(config);
|
|
});
|
|
|
|
app.post('/api/config', requireAuth, (req, res) => {
|
|
if (!Array.isArray(req.body)) {
|
|
return res.status(400).json({ error: 'Konfiguration muss ein Array sein' });
|
|
}
|
|
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/:storeId/regular-pickup', requireAuth, async (req, res) => {
|
|
const { storeId } = req.params;
|
|
if (!storeId) {
|
|
return res.status(400).json({ error: 'Store-ID fehlt' });
|
|
}
|
|
try {
|
|
const result = await getRegularPickupSchedule(req.session, storeId);
|
|
res.json(result);
|
|
} catch (error) {
|
|
const message = error?.message || 'Regular-Pickup konnte nicht geladen werden';
|
|
return res.json({ rules: [], error: message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/stores', requireAuth, async (req, res) => {
|
|
const stores = req.session.storesCache?.data || [];
|
|
const config = readConfig(req.session.profile.id);
|
|
const regularPickupMap = await buildRegularPickupMapForConfig(req.session, config);
|
|
res.json({ stores, regularPickupMap });
|
|
});
|
|
|
|
app.post('/api/stores/refresh', requireAuth, (req, res) => {
|
|
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);
|
|
}
|
|
scheduleRegularPickupRefresh(adminConfig.readSettings());
|
|
startBackgroundStoreRefreshTicker();
|
|
app.listen(port, () => {
|
|
console.log(`Server läuft auf Port ${port}`);
|
|
});
|
|
}
|
|
|
|
startServer().catch((error) => {
|
|
console.error('[STARTUP] Unerwarteter Fehler:', error);
|
|
process.exit(1);
|
|
});
|