From 41ef5107aaf41590b75560c43bd368ce263e8609 Mon Sep 17 00:00:00 2001 From: Meik Date: Mon, 29 Dec 2025 19:51:45 +0100 Subject: [PATCH] aktueller stand --- server.js | 97 ++++++++++++++----- services/adminConfig.js | 6 +- services/foodsharingClient.js | 65 ++++++++----- services/notificationService.js | 36 +++++++- services/pickupScheduler.js | 133 ++++++++++++++++++--------- services/sessionRefresh.js | 80 ++++++++++++++++ src/components/AdminSettingsPanel.js | 7 ++ src/components/DashboardView.js | 106 ++++++++++++++++++++- src/utils/adminSettings.js | 6 +- 9 files changed, 438 insertions(+), 98 deletions(-) create mode 100644 services/sessionRefresh.js diff --git a/server.js b/server.js index fdd5894..5df453d 100644 --- a/server.js +++ b/server.js @@ -14,6 +14,7 @@ const notificationService = require('./services/notificationService'); const { readStoreWatch, writeStoreWatch, listWatcherProfiles } = require('./services/storeWatchStore'); const { readPreferences, writePreferences, sanitizeLocation } = require('./services/userPreferencesStore'); const requestLogStore = require('./services/requestLogStore'); +const { withSessionRetry } = require('./services/sessionRefresh'); const { getStoreStatus: getCachedStoreStatusEntry, setStoreStatus: setCachedStoreStatusEntry, @@ -23,6 +24,7 @@ const { 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; @@ -106,6 +108,33 @@ function isAdmin(profile) { 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 }), + { label: 'fetchProfile' } + ); + sessionStore.update(session.id, { + profileDetailsCache: { data: details, fetchedAt: Date.now() } + }); + return details; + } catch (error) { + if (cached?.data) { + return cached.data; + } + throw error; + } +} + function scheduleWithCurrentSettings(sessionId, config) { const settings = adminConfig.readSettings(); scheduleConfig(sessionId, config, settings); @@ -222,7 +251,7 @@ async function ensureStoreLocationIndex(session, { force = false } = {}) { if (!force && storeLocationIndex.size > 0 && fresh) { return; } - const details = await foodsharingClient.fetchProfile(session.cookieHeader); + const details = await fetchProfileWithCache(session); const regions = Array.isArray(details?.regions) ? details.regions.filter((region) => Number(region?.classification) === 1) : []; @@ -232,7 +261,11 @@ async function ensureStoreLocationIndex(session, { force = false } = {}) { for (const region of regions) { let payload = getCachedRegionStores(region.id); if (!payload) { - const result = await foodsharingClient.fetchRegionStores(region.id, session.cookieHeader); + const result = await withSessionRetry( + session, + () => foodsharingClient.fetchRegionStores(region.id, session.cookieHeader), + { label: 'fetchRegionStores' } + ); payload = { total: Number(result?.total) || 0, stores: Array.isArray(result?.stores) ? result.stores : [] @@ -319,10 +352,10 @@ async function notifyWatchersForStatusChanges(changes = [], storeInfoMap = new M async function refreshStoreStatus( storeIds = [], - cookieHeader, + session, { force = false, storeInfoMap = new Map() } = {} ) { - if (!Array.isArray(storeIds) || storeIds.length === 0 || !cookieHeader) { + if (!Array.isArray(storeIds) || storeIds.length === 0 || !session?.cookieHeader) { return { refreshed: 0, changes: [] }; } const now = Date.now(); @@ -336,7 +369,11 @@ async function refreshStoreStatus( continue; } try { - const details = await foodsharingClient.fetchStoreDetails(storeId, cookieHeader); + const details = await withSessionRetry( + session, + () => foodsharingClient.fetchStoreDetails(storeId, session.cookieHeader), + { label: 'fetchStoreDetails' } + ); const status = Number(details?.teamSearchStatus); const normalized = Number.isFinite(status) ? status : null; const previous = entry ? entry.teamSearchStatus : null; @@ -366,7 +403,7 @@ async function refreshStoreStatus( return { refreshed, changes }; } -async function enrichStoresWithTeamStatus(stores = [], cookieHeader, { forceRefresh = false } = {}) { +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 } }; } @@ -392,7 +429,7 @@ async function enrichStoresWithTeamStatus(stores = [], cookieHeader, { forceRefr let refreshed = 0; let changes = []; if (staleIds.length > 0) { - const result = await refreshStoreStatus(staleIds, cookieHeader, { + const result = await refreshStoreStatus(staleIds, session, { force: forceRefresh, storeInfoMap }); @@ -502,14 +539,19 @@ 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}`; - } - }); + 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}`; + } + }), + { label: 'fetchStores' } + ); job.processed = stores.length; job.total = stores.length; job.currentStore = null; @@ -726,10 +768,15 @@ app.get('/api/auth/session', requireAuth, async (req, res) => { }); app.get('/api/profile', requireAuth, async (req, res) => { - const details = await foodsharingClient.fetchProfile(req.session.cookieHeader); - res.json({ - profile: details || req.session.profile - }); + 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) => { @@ -750,7 +797,7 @@ app.get('/api/location/nearest-store', requireAuth, async (req, res) => { app.get('/api/store-watch/regions', requireAuth, async (req, res) => { try { - const details = await foodsharingClient.fetchProfile(req.session.cookieHeader); + const details = await fetchProfileWithCache(req.session); const regions = Array.isArray(details?.regions) ? details.regions.filter((region) => Number(region?.classification) === 1) : []; @@ -779,7 +826,11 @@ app.get('/api/store-watch/regions/:regionId/stores', requireAuth, async (req, re if (!basePayload) { try { - const result = await foodsharingClient.fetchRegionStores(regionId, req.session.cookieHeader); + const result = await withSessionRetry( + req.session, + () => foodsharingClient.fetchRegionStores(regionId, req.session.cookieHeader), + { label: 'fetchRegionStores' } + ); basePayload = { total: Number(result?.total) || 0, stores: Array.isArray(result?.stores) ? result.stores : [] @@ -799,7 +850,7 @@ app.get('/api/store-watch/regions/:regionId/stores', requireAuth, async (req, re const { stores: enrichedStores, statusMeta } = await enrichStoresWithTeamStatus( filteredStores, - req.session.cookieHeader, + req.session, { forceRefresh: forceStatusRefresh } ); @@ -863,7 +914,7 @@ app.get('/api/user/preferences', requireAuth, async (req, res) => { const preferences = readPreferences(req.session.profile.id); let location = preferences.location; try { - const details = await foodsharingClient.fetchProfile(req.session.cookieHeader); + const details = await fetchProfileWithCache(req.session); const coords = details?.coordinates; const sanitized = sanitizeLocation({ lat: coords?.lat, lon: coords?.lon }); if (sanitized) { diff --git a/services/adminConfig.js b/services/adminConfig.js index 39d547e..eb57267 100644 --- a/services/adminConfig.js +++ b/services/adminConfig.js @@ -31,7 +31,8 @@ const DEFAULT_SETTINGS = { }, telegram: { enabled: false, - botToken: '' + botToken: '', + chatId: '' } } }; @@ -84,7 +85,8 @@ function sanitizeNotifications(input = {}) { }, telegram: { enabled: !!(input?.telegram?.enabled), - botToken: sanitizeString(input?.telegram?.botToken || defaults.telegram.botToken) + botToken: sanitizeString(input?.telegram?.botToken || defaults.telegram.botToken), + chatId: sanitizeString(input?.telegram?.chatId || defaults.telegram.chatId) } }; } diff --git a/services/foodsharingClient.js b/services/foodsharingClient.js index 01b9a81..ce17746 100644 --- a/services/foodsharingClient.js +++ b/services/foodsharingClient.js @@ -55,15 +55,43 @@ client.interceptors.response.use( } ); +const CSRF_COOKIE_NAMES = ['CSRF_TOKEN', 'CSRF-TOKEN', 'XSRF-TOKEN', 'XSRF_TOKEN']; + +function extractCookieValue(cookies = [], name) { + if (!Array.isArray(cookies) || !name) { + return null; + } + const prefix = `${name}=`; + const match = cookies.find((cookie) => cookie.startsWith(prefix)); + if (!match) { + return null; + } + return match.split(';')[0].slice(prefix.length); +} + function extractCsrfToken(cookies = []) { - if (!Array.isArray(cookies)) { + for (const name of CSRF_COOKIE_NAMES) { + const value = extractCookieValue(cookies, name); + if (value) { + return value; + } + } + return null; +} + +function extractCsrfTokenFromCookieHeader(cookieHeader = '') { + if (!cookieHeader) { return null; } - const tokenCookie = cookies.find((cookie) => cookie.startsWith('CSRF_TOKEN=')); - if (!tokenCookie) { - return null; + const pairs = cookieHeader.split(';').map((part) => part.trim()); + for (const name of CSRF_COOKIE_NAMES) { + const prefix = `${name}=`; + const match = pairs.find((pair) => pair.startsWith(prefix)); + if (match) { + return match.slice(prefix.length); + } } - return tokenCookie.split(';')[0].split('=')[1]; + return null; } function serializeCookies(cookies = []) { @@ -78,8 +106,10 @@ function buildHeaders(cookieHeader, csrfToken) { if (cookieHeader) { headers.cookie = cookieHeader; } - if (csrfToken) { - headers['x-csrf-token'] = csrfToken; + const token = csrfToken || extractCsrfTokenFromCookieHeader(cookieHeader); + if (token) { + headers['x-csrf-token'] = token; + headers['x-xsrf-token'] = token; } return headers; } @@ -135,25 +165,13 @@ async function login(email, password) { }; } -async function checkSession(cookieHeader, profileId) { - if (!cookieHeader) { - return false; - } - - try { - await client.get(`/api/wall/foodsaver/${profileId}?limit=1`, { - headers: buildHeaders(cookieHeader) - }); - return true; - } catch { - return false; - } -} - -async function fetchProfile(cookieHeader) { +async function fetchProfile(cookieHeader, { throwOnError = false } = {}) { try { return await getCurrentUserDetails(cookieHeader); } catch (error) { + if (throwOnError) { + throw error; + } console.warn('Profil konnte nicht geladen werden:', error.message); return null; } @@ -296,7 +314,6 @@ async function bookSlot(storeId, utcDate, profileId, session) { module.exports = { login, - checkSession, fetchProfile, fetchStores, fetchPickups, diff --git a/services/notificationService.js b/services/notificationService.js index 5cf389b..fababbf 100644 --- a/services/notificationService.js +++ b/services/notificationService.js @@ -62,6 +62,19 @@ async function sendTelegramNotification(adminTelegram, userTelegram, payload) { ); } +async function sendAdminTelegramNotification(payload) { + const adminSettings = adminConfig.readSettings(); + const adminTelegram = adminSettings.notifications?.telegram; + if (!adminTelegram?.enabled || !adminTelegram.botToken || !adminTelegram.chatId) { + return; + } + await sendTelegramNotification( + adminTelegram, + { enabled: true, chatId: adminTelegram.chatId }, + payload + ); +} + async function notifyChannels(profileId, template) { const adminSettings = adminConfig.readSettings(); const userSettings = readNotificationSettings(profileId); @@ -217,11 +230,32 @@ async function sendDormantPickupWarning({ profileId, storeName, storeId, reasonL }); } +async function sendAdminBookingErrorNotification({ profileId, profileEmail, storeName, storeId, pickupDate, error }) { + const dateLabel = formatDateLabel(pickupDate); + const storeLabel = storeName || storeId || 'Unbekannter Store'; + const storeLink = storeId ? `https://foodsharing.de/store/${storeId}` : null; + const profileLabel = profileEmail || profileId || 'Unbekanntes Profil'; + const messageLines = [ + `Buchung fehlgeschlagen für ${storeLabel} am ${dateLabel}.`, + `Profil: ${profileLabel}.`, + `Fehler: ${error || 'Unbekannter Fehler'}.` + ]; + if (storeLink) { + messageLines.push(storeLink); + } + await sendAdminTelegramNotification({ + title: 'Fehler beim Slot-Buchen', + message: messageLines.join('\n'), + priority: 'high' + }); +} + module.exports = { sendSlotNotification, sendStoreWatchNotification, sendStoreWatchSummaryNotification, sendTestNotification, sendDesiredWindowMissedNotification, - sendDormantPickupWarning + sendDormantPickupWarning, + sendAdminBookingErrorNotification }; diff --git a/services/pickupScheduler.js b/services/pickupScheduler.js index 0b5b8cc..58209eb 100644 --- a/services/pickupScheduler.js +++ b/services/pickupScheduler.js @@ -7,6 +7,7 @@ const { readConfig, writeConfig } = require('./configStore'); const { readStoreWatch, writeStoreWatch } = require('./storeWatchStore'); const { setStoreStatus, persistStoreStatusCache } = require('./storeStatusCache'); const { sendDormantPickupWarning } = require('./notificationService'); +const { ensureSession, withSessionRetry } = require('./sessionRefresh'); function wait(ms) { if (!ms || ms <= 0) { @@ -122,43 +123,6 @@ function persistEntryDeactivation(profileId, entryId, options = {}) { } } -async function ensureSession(session) { - const profileId = session.profile?.id; - if (!profileId) { - return false; - } - - const stillValid = await foodsharingClient.checkSession(session.cookieHeader, profileId); - if (stillValid) { - return true; - } - - if (!session.credentials) { - console.warn(`Session ${session.id} kann nicht erneuert werden – keine Zugangsdaten gespeichert.`); - return false; - } - - try { - const refreshed = await foodsharingClient.login( - session.credentials.email, - session.credentials.password - ); - sessionStore.update(session.id, { - cookieHeader: refreshed.cookieHeader, - csrfToken: refreshed.csrfToken, - profile: { - ...session.profile, - ...refreshed.profile - } - }); - console.log(`Session ${session.id} wurde erfolgreich erneuert.`); - return true; - } catch (error) { - console.error(`Session ${session.id} konnte nicht erneuert werden:`, error.message); - return false; - } -} - function toDateValue(input) { if (!input) { return null; @@ -326,12 +290,20 @@ async function processBooking(session, entry, pickup) { const utcDate = new Date(pickup.date).toISOString(); try { - const allowed = await foodsharingClient.pickupRuleCheck(entry.id, utcDate, session.profile.id, session); + const allowed = await withSessionRetry( + session, + () => foodsharingClient.pickupRuleCheck(entry.id, utcDate, session.profile.id, session), + { label: 'pickupRuleCheck' } + ); if (!allowed) { console.warn(`[WARN] Rule-Check fehlgeschlagen für ${storeName} am ${readableDate}`); return; } - await foodsharingClient.bookSlot(entry.id, utcDate, session.profile.id, session); + await withSessionRetry( + session, + () => foodsharingClient.bookSlot(entry.id, utcDate, session.profile.id, session), + { label: 'bookSlot' } + ); console.log(`[SUCCESS] Slot gebucht für ${storeName} am ${readableDate}`); await notificationService.sendSlotNotification({ profileId: session.profile.id, @@ -345,6 +317,21 @@ async function processBooking(session, entry, pickup) { persistEntryDeactivation(session.profile.id, entry.id); } catch (error) { console.error(`[ERROR] Buchung für ${storeName} am ${readableDate} fehlgeschlagen:`, error.message); + try { + await notificationService.sendAdminBookingErrorNotification({ + profileId: session.profile.id, + profileEmail: session.profile.email, + storeName, + storeId: entry.id, + pickupDate: pickup.date, + error: error.message + }); + } catch (notifyError) { + console.error( + `[NOTIFY] Admin-Benachrichtigung für fehlgeschlagene Buchung bei ${storeName} fehlgeschlagen:`, + notifyError.message + ); + } } } @@ -368,7 +355,11 @@ async function checkEntry(sessionId, entry, settings) { } try { - const pickups = await foodsharingClient.fetchPickups(entry.id, session.cookieHeader); + const pickups = await withSessionRetry( + session, + () => foodsharingClient.fetchPickups(entry.id, session.cookieHeader), + { label: 'fetchPickups' } + ); let hasProfileId = false; let availablePickup = null; @@ -435,7 +426,11 @@ async function checkWatchedStores(sessionId, settings = DEFAULT_SETTINGS, option for (let index = 0; index < watchers.length; index += 1) { const watcher = watchers[index]; try { - const details = await foodsharingClient.fetchStoreDetails(watcher.storeId, session.cookieHeader); + const details = await withSessionRetry( + session, + () => foodsharingClient.fetchStoreDetails(watcher.storeId, session.cookieHeader), + { label: 'fetchStoreDetails' } + ); const status = details?.teamSearchStatus === 1 ? 1 : 0; const checkedAt = Date.now(); if (status === 1 && watcher.lastTeamSearchStatus !== 1) { @@ -589,6 +584,15 @@ function setMonthOffset(date, offset) { return copy; } +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)); +} + async function checkDormantMembers(sessionId) { const session = sessionStore.get(sessionId); if (!session?.profile?.id) { @@ -601,11 +605,15 @@ async function checkDormantMembers(sessionId) { } const config = readConfig(profileId); const skipMap = new Map(); - config.forEach((entry) => { + const configEntryMap = new Map(); + config.forEach((entry, index) => { if (entry?.id) { - skipMap.set(String(entry.id), !!entry.skipDormantCheck); + const id = String(entry.id); + skipMap.set(id, !!entry.skipDormantCheck); + configEntryMap.set(id, { entry, index }); } }); + let configChanged = false; const stores = Array.isArray(session.storesCache?.data) ? session.storesCache.data : []; if (stores.length === 0) { @@ -624,7 +632,11 @@ async function checkDormantMembers(sessionId) { } let members = []; try { - members = await foodsharingClient.fetchStoreMembers(storeId, session.cookieHeader); + members = await withSessionRetry( + session, + () => foodsharingClient.fetchStoreMembers(storeId, session.cookieHeader), + { label: 'fetchStoreMembers' } + ); } catch (error) { console.warn(`[DORMANT] Mitglieder von Store ${storeId} konnten nicht geladen werden:`, error.message); continue; @@ -635,6 +647,14 @@ async function checkDormantMembers(sessionId) { } const reasons = []; const lastFetchMs = memberEntry.last_fetch ? Number(memberEntry.last_fetch) * 1000 : null; + if (Number.isFinite(lastFetchMs)) { + const configEntry = configEntryMap.get(storeId)?.entry; + const lastPickupAt = new Date(lastFetchMs).toISOString(); + if (configEntry && configEntry.lastPickupAt !== lastPickupAt) { + configEntry.lastPickupAt = lastPickupAt; + configChanged = true; + } + } if (!lastFetchMs || lastFetchMs < fourMonthsAgo) { const lastFetchLabel = lastFetchMs ? new Date(lastFetchMs).toLocaleDateString('de-DE') : 'unbekannt'; reasons.push(`Letzte Abholung: ${lastFetchLabel} (älter als 4 Monate)`); @@ -660,6 +680,13 @@ async function checkDormantMembers(sessionId) { } } } + if (configChanged) { + try { + writeConfig(profileId, config); + } catch (error) { + console.error(`[DORMANT] Letzte Abholung für Profil ${profileId} konnte nicht gespeichert werden:`, error.message); + } + } } function scheduleDormantMembershipCheck(sessionId) { @@ -674,6 +701,24 @@ function scheduleDormantMembershipCheck(sessionId) { { timezone: 'Europe/Berlin' } ); sessionStore.attachJob(sessionId, job); + const session = sessionStore.get(sessionId); + const profileId = session?.profile?.id; + if (!profileId) { + return; + } + const config = readConfig(profileId); + const missingIds = getMissingLastPickupStoreIds(config); + if (missingIds.length === 0) { + if (session.dormantBootstrapSignature) { + sessionStore.update(sessionId, { dormantBootstrapSignature: null }); + } + return; + } + const signature = missingIds.sort().join(','); + if (session.dormantBootstrapSignature === signature) { + return; + } + sessionStore.update(sessionId, { dormantBootstrapSignature: signature }); setTimeout(() => checkDormantMembers(sessionId), randomDelayMs(30, 180)); } diff --git a/services/sessionRefresh.js b/services/sessionRefresh.js new file mode 100644 index 0000000..0292b2f --- /dev/null +++ b/services/sessionRefresh.js @@ -0,0 +1,80 @@ +const foodsharingClient = require('./foodsharingClient'); +const sessionStore = require('./sessionStore'); + +function isUnauthorizedError(error) { + const status = error?.response?.status; + return status === 401 || status === 403; +} + +async function refreshSession(session, { label } = {}) { + if (!session?.credentials?.email || !session?.credentials?.password) { + console.warn( + `[SESSION] Session ${session?.id || 'unbekannt'} kann nicht erneuert werden – keine Zugangsdaten gespeichert.` + ); + return false; + } + try { + const refreshed = await foodsharingClient.login( + session.credentials.email, + session.credentials.password + ); + sessionStore.update(session.id, { + cookieHeader: refreshed.cookieHeader, + csrfToken: refreshed.csrfToken, + profile: { + ...session.profile, + ...refreshed.profile + } + }); + console.log( + `[SESSION] Session ${session.id} wurde erfolgreich erneuert${label ? ` (${label})` : ''}.` + ); + return true; + } catch (error) { + console.error( + `[SESSION] Session ${session?.id || 'unbekannt'} konnte nicht erneuert werden${label ? ` (${label})` : ''}:`, + error.message + ); + return false; + } +} + +async function ensureSession(session) { + if (!session?.profile?.id) { + return false; + } + if (!session.cookieHeader) { + return refreshSession(session, { label: 'missing-cookie' }); + } + return true; +} + +async function withSessionRetry(session, action, { label } = {}) { + if (!session) { + throw new Error('Session fehlt'); + } + if (!session.cookieHeader && session.credentials) { + const refreshed = await refreshSession(session, { label }); + if (!refreshed) { + throw new Error('Session konnte nicht erneuert werden'); + } + } + try { + return await action(); + } catch (error) { + if (!isUnauthorizedError(error)) { + throw error; + } + const refreshed = await refreshSession(session, { label }); + if (!refreshed) { + throw error; + } + return action(); + } +} + +module.exports = { + ensureSession, + refreshSession, + withSessionRetry +}; diff --git a/src/components/AdminSettingsPanel.js b/src/components/AdminSettingsPanel.js index 00fba90..fca2472 100644 --- a/src/components/AdminSettingsPanel.js +++ b/src/components/AdminSettingsPanel.js @@ -347,6 +347,13 @@ const AdminSettingsPanel = ({ className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" placeholder="Bot-Token" /> + onNotificationChange('telegram', 'chatId', event.target.value)} + className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" + placeholder="Admin-Chat-ID" + /> diff --git a/src/components/DashboardView.js b/src/components/DashboardView.js index e23d732..0ac5000 100644 --- a/src/components/DashboardView.js +++ b/src/components/DashboardView.js @@ -1,4 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; +import { formatDistanceToNowStrict } from 'date-fns'; +import { de } from 'date-fns/locale'; import { createColumnHelper, flexRender, @@ -47,6 +49,57 @@ const ColumnSelectFilter = ({ column, options, placeholder = 'Alle' }) => { ); }; +const formatLastPickupLabel = (value) => { + if (!value) { + return 'Unbekannt'; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return 'Unbekannt'; + } + return date.toLocaleDateString('de-DE'); +}; + +const formatLastPickupRelative = (value) => { + if (!value) { + return 'Unbekannt'; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return 'Unbekannt'; + } + return formatDistanceToNowStrict(date, { addSuffix: true, locale: de }); +}; + +const getLastPickupTimestamp = (value) => { + if (!value) { + return -1; + } + const date = new Date(value); + const time = date.getTime(); + return Number.isNaN(time) ? -1 : time; +}; + +const getLastPickupStyle = (value) => { + const time = getLastPickupTimestamp(value); + if (time <= 0) { + return {}; + } + const ageDays = (Date.now() - time) / (1000 * 60 * 60 * 24); + if (ageDays < 90) { + return {}; + } + const minDays = 90; + const maxDays = 180; + const ratio = Math.min(Math.max((ageDays - minDays) / (maxDays - minDays), 0), 1); + const start = { r: 220, g: 38, b: 38 }; + const end = { r: 127, g: 29, b: 29 }; + const r = Math.round(start.r + (end.r - start.r) * ratio); + const g = Math.round(start.g + (end.g - start.g) * ratio); + const b = Math.round(start.b + (end.b - start.b) * ratio); + return { color: `rgb(${r}, ${g}, ${b})` }; +}; + function readConfigTableState() { if (typeof window === 'undefined') { return { sorting: [], columnFilters: [] }; @@ -222,11 +275,53 @@ const DashboardView = ({ sortingFn: 'alphanumeric', filterFn: 'includesString' }), + columnHelper.accessor('lastPickupAt', { + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const lastPickupAt = row.original.lastPickupAt; + const style = getLastPickupStyle(lastPickupAt); + return ( + + {formatLastPickupRelative(lastPickupAt)} + + ); + }, + sortingFn: (rowA, rowB, columnId) => { + const a = getLastPickupTimestamp(rowA.getValue(columnId)); + const b = getLastPickupTimestamp(rowB.getValue(columnId)); + return a - b; + }, + enableColumnFilter: false + }), columnHelper.display({ id: 'checkProfileId', header: () => Profil prüfen, cell: ({ row }) => ( -
+
Ruhe-Prüfung, cell: ({ row }) => ( -
+
{ }, telegram: { enabled: !!raw.notifications?.telegram?.enabled, - botToken: raw.notifications?.telegram?.botToken || '' + botToken: raw.notifications?.telegram?.botToken || '', + chatId: raw.notifications?.telegram?.chatId || '' } } }; @@ -72,7 +73,8 @@ export const serializeAdminSettings = (adminSettings) => { }, telegram: { enabled: !!adminSettings.notifications?.telegram?.enabled, - botToken: adminSettings.notifications?.telegram?.botToken || '' + botToken: adminSettings.notifications?.telegram?.botToken || '', + chatId: adminSettings.notifications?.telegram?.chatId || '' } } };