From 89e7f77a4ed5561383eb03b254492b0a66d12514 Mon Sep 17 00:00:00 2001 From: Meik Date: Mon, 10 Nov 2025 16:44:54 +0100 Subject: [PATCH] refactoring --- server.js | 95 ++++++ services/adminConfig.js | 21 ++ services/foodsharingClient.js | 25 ++ services/notificationService.js | 15 + services/pickupScheduler.js | 92 +++++- services/storeWatchStore.js | 84 ++++++ src/App.js | 2 + src/components/AdminSettingsPanel.js | 44 +++ src/components/NavigationTabs.js | 19 +- src/components/StoreWatchPage.js | 414 +++++++++++++++++++++++++++ src/utils/adminSettings.js | 6 + 11 files changed, 807 insertions(+), 10 deletions(-) create mode 100644 services/storeWatchStore.js create mode 100644 src/components/StoreWatchPage.js diff --git a/server.js b/server.js index 7332049..3f4cf4c 100644 --- a/server.js +++ b/server.js @@ -11,6 +11,7 @@ const { scheduleConfig } = require('./services/pickupScheduler'); const adminConfig = require('./services/adminConfig'); const { readNotificationSettings, writeNotificationSettings } = require('./services/userSettingsStore'); const notificationService = require('./services/notificationService'); +const { readStoreWatch, writeStoreWatch } = require('./services/storeWatchStore'); const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000; const adminEmail = (process.env.ADMIN_EMAIL || '').toLowerCase(); @@ -20,6 +21,8 @@ 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; app.use(cors()); app.use(express.json({ limit: '1mb' })); @@ -88,6 +91,25 @@ function mergeStoresIntoConfig(config = [], stores = []) { 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 isStoreCacheFresh(session) { if (!session?.storesCache?.fetchedAt) { return false; @@ -368,6 +390,79 @@ app.get('/api/profile', requireAuth, async (req, res) => { }); }); +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 forceRefresh = req.query.force === '1'; + if (!forceRefresh) { + const cached = getCachedRegionStores(regionId); + if (cached) { + return res.json(cached); + } + } + try { + const result = await foodsharingClient.fetchRegionStores(regionId, req.session.cookieHeader); + const payload = { + total: Number(result?.total) || 0, + stores: Array.isArray(result?.stores) ? result.stores : [] + }; + setCachedRegionStores(regionId, payload); + res.json(payload); + } catch (error) { + console.error(`[STORE-WATCH] Stores für Region ${regionId} konnten nicht geladen werden:`, error.message); + res.status(500).json({ error: 'Betriebe konnten nicht geladen werden' }); + } +}); + +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/config', requireAuth, (req, res) => { const config = readConfig(req.session.profile.id); res.json(config); diff --git a/services/adminConfig.js b/services/adminConfig.js index 845f020..5a6a964 100644 --- a/services/adminConfig.js +++ b/services/adminConfig.js @@ -10,6 +10,9 @@ const DEFAULT_SETTINGS = { randomDelayMaxSeconds: 120, initialDelayMinSeconds: 5, initialDelayMaxSeconds: 30, + storeWatchCron: '*/30 * * * *', + storeWatchInitialDelayMinSeconds: 10, + storeWatchInitialDelayMaxSeconds: 60, storePickupCheckDelayMs: 400, ignoredSlots: [ { @@ -101,6 +104,15 @@ function readSettings() { randomDelayMaxSeconds: sanitizeNumber(parsed.randomDelayMaxSeconds, DEFAULT_SETTINGS.randomDelayMaxSeconds), initialDelayMinSeconds: sanitizeNumber(parsed.initialDelayMinSeconds, DEFAULT_SETTINGS.initialDelayMinSeconds), initialDelayMaxSeconds: sanitizeNumber(parsed.initialDelayMaxSeconds, DEFAULT_SETTINGS.initialDelayMaxSeconds), + storeWatchCron: parsed.storeWatchCron || DEFAULT_SETTINGS.storeWatchCron, + storeWatchInitialDelayMinSeconds: sanitizeNumber( + parsed.storeWatchInitialDelayMinSeconds, + DEFAULT_SETTINGS.storeWatchInitialDelayMinSeconds + ), + storeWatchInitialDelayMaxSeconds: sanitizeNumber( + parsed.storeWatchInitialDelayMaxSeconds, + DEFAULT_SETTINGS.storeWatchInitialDelayMaxSeconds + ), storePickupCheckDelayMs: sanitizeNumber( parsed.storePickupCheckDelayMs, DEFAULT_SETTINGS.storePickupCheckDelayMs @@ -122,6 +134,15 @@ function writeSettings(patch = {}) { randomDelayMaxSeconds: sanitizeNumber(patch.randomDelayMaxSeconds, current.randomDelayMaxSeconds), initialDelayMinSeconds: sanitizeNumber(patch.initialDelayMinSeconds, current.initialDelayMinSeconds), initialDelayMaxSeconds: sanitizeNumber(patch.initialDelayMaxSeconds, current.initialDelayMaxSeconds), + storeWatchCron: patch.storeWatchCron || current.storeWatchCron, + storeWatchInitialDelayMinSeconds: sanitizeNumber( + patch.storeWatchInitialDelayMinSeconds, + current.storeWatchInitialDelayMinSeconds + ), + storeWatchInitialDelayMaxSeconds: sanitizeNumber( + patch.storeWatchInitialDelayMaxSeconds, + current.storeWatchInitialDelayMaxSeconds + ), storePickupCheckDelayMs: sanitizeNumber( patch.storePickupCheckDelayMs, current.storePickupCheckDelayMs diff --git a/services/foodsharingClient.js b/services/foodsharingClient.js index 22acb69..e146962 100644 --- a/services/foodsharingClient.js +++ b/services/foodsharingClient.js @@ -200,6 +200,29 @@ async function fetchPickups(storeId, cookieHeader) { return response.data?.pickups || []; } +async function fetchRegionStores(regionId, cookieHeader) { + if (!regionId) { + return { total: 0, stores: [] }; + } + const response = await client.get(`/api/region/${regionId}/stores`, { + headers: buildHeaders(cookieHeader) + }); + return { + total: Number(response.data?.total) || 0, + stores: Array.isArray(response.data?.stores) ? response.data.stores : [] + }; +} + +async function fetchStoreDetails(storeId, cookieHeader) { + if (!storeId) { + return null; + } + const response = await client.get(`/api/map/stores/${storeId}`, { + headers: buildHeaders(cookieHeader) + }); + return response.data || null; +} + async function pickupRuleCheck(storeId, utcDate, profileId, session) { const response = await client.get(`/api/stores/${storeId}/pickupRuleCheck/${utcDate}/${profileId}`, { headers: buildHeaders(session.cookieHeader, session.csrfToken) @@ -223,6 +246,8 @@ module.exports = { fetchProfile, fetchStores, fetchPickups, + fetchRegionStores, + fetchStoreDetails, pickupRuleCheck, bookSlot }; diff --git a/services/notificationService.js b/services/notificationService.js index 5b14e3b..729cb28 100644 --- a/services/notificationService.js +++ b/services/notificationService.js @@ -99,6 +99,20 @@ async function sendSlotNotification({ profileId, storeName, pickupDate, onlyNoti }); } +async function sendStoreWatchNotification({ profileId, storeName, storeId, regionName }) { + const storeLink = storeId ? `https://foodsharing.de/store/${storeId}` : null; + const title = `Team sucht Verstärkung: ${storeName}`; + const regionText = regionName ? ` (${regionName})` : ''; + const messageBase = `Der Betrieb${regionText} sucht wieder aktiv neue Teammitglieder.`; + const message = storeLink ? `${messageBase}\n${storeLink}` : messageBase; + await notifyChannels(profileId, { + title, + message, + link: storeLink, + priority: 'high' + }); +} + async function sendTestNotification(profileId, channel) { const title = 'Pickup Benachrichtigung (Test)'; const message = 'Das ist eine Testnachricht. Bei Fragen wende dich bitte an den Admin.'; @@ -134,5 +148,6 @@ async function sendTestNotification(profileId, channel) { module.exports = { sendSlotNotification, + sendStoreWatchNotification, sendTestNotification }; diff --git a/services/pickupScheduler.js b/services/pickupScheduler.js index df0e5fa..6d0696b 100644 --- a/services/pickupScheduler.js +++ b/services/pickupScheduler.js @@ -4,6 +4,7 @@ const sessionStore = require('./sessionStore'); const { DEFAULT_SETTINGS } = require('./adminConfig'); const notificationService = require('./notificationService'); const { readConfig, writeConfig } = require('./configStore'); +const { readStoreWatch, writeStoreWatch } = require('./storeWatchStore'); const weekdayMap = { Montag: 'Monday', @@ -39,6 +40,13 @@ function resolveSettings(settings) { initialDelayMaxSeconds: Number.isFinite(settings.initialDelayMaxSeconds) ? settings.initialDelayMaxSeconds : DEFAULT_SETTINGS.initialDelayMaxSeconds, + storeWatchCron: settings.storeWatchCron || DEFAULT_SETTINGS.storeWatchCron, + storeWatchInitialDelayMinSeconds: Number.isFinite(settings.storeWatchInitialDelayMinSeconds) + ? settings.storeWatchInitialDelayMinSeconds + : DEFAULT_SETTINGS.storeWatchInitialDelayMinSeconds, + storeWatchInitialDelayMaxSeconds: Number.isFinite(settings.storeWatchInitialDelayMaxSeconds) + ? settings.storeWatchInitialDelayMaxSeconds + : DEFAULT_SETTINGS.storeWatchInitialDelayMaxSeconds, ignoredSlots: Array.isArray(settings.ignoredSlots) ? settings.ignoredSlots : DEFAULT_SETTINGS.ignoredSlots, notifications: { ntfy: { @@ -287,6 +295,78 @@ async function checkEntry(sessionId, entry, settings) { } } +async function checkWatchedStores(sessionId) { + const session = sessionStore.get(sessionId); + if (!session?.profile?.id) { + return; + } + const watchers = readStoreWatch(session.profile.id); + if (!Array.isArray(watchers) || watchers.length === 0) { + return; + } + + const ready = await ensureSession(session); + if (!ready) { + return; + } + + let changed = false; + for (const watcher of watchers) { + try { + const details = await foodsharingClient.fetchStoreDetails(watcher.storeId, session.cookieHeader); + const status = details?.teamSearchStatus === 1 ? 1 : 0; + if (status === 1 && watcher.lastTeamSearchStatus !== 1) { + await notificationService.sendStoreWatchNotification({ + profileId: session.profile.id, + storeName: watcher.storeName, + storeId: watcher.storeId, + regionName: watcher.regionName + }); + } + if (watcher.lastTeamSearchStatus !== status) { + watcher.lastTeamSearchStatus = status; + changed = true; + } + } catch (error) { + console.error(`[WATCH] Prüfung für Store ${watcher.storeId} fehlgeschlagen:`, error.message); + } + } + + if (changed) { + writeStoreWatch(session.profile.id, watchers); + } +} + +function scheduleStoreWatchers(sessionId, settings) { + const session = sessionStore.get(sessionId); + if (!session?.profile?.id) { + return false; + } + const watchers = readStoreWatch(session.profile.id); + if (!Array.isArray(watchers) || watchers.length === 0) { + return false; + } + const cronExpression = settings.storeWatchCron || DEFAULT_SETTINGS.storeWatchCron; + const job = cron.schedule( + cronExpression, + () => { + checkWatchedStores(sessionId).catch((error) => { + console.error('[WATCH] Regelmäßige Prüfung fehlgeschlagen:', error.message); + }); + }, + { timezone: 'Europe/Berlin' } + ); + sessionStore.attachJob(sessionId, job); + setTimeout( + () => checkWatchedStores(sessionId), + randomDelayMs(settings.storeWatchInitialDelayMinSeconds, settings.storeWatchInitialDelayMaxSeconds) + ); + console.log( + `[WATCH] Überwache ${watchers.length} Betriebe für Session ${sessionId} (Cron: ${cronExpression}).` + ); + return true; +} + function scheduleEntry(sessionId, entry, settings) { const cronExpression = settings.scheduleCron || DEFAULT_SETTINGS.scheduleCron; const job = cron.schedule( @@ -312,9 +392,17 @@ function scheduleEntry(sessionId, entry, settings) { function scheduleConfig(sessionId, config, settings) { const resolvedSettings = resolveSettings(settings); sessionStore.clearJobs(sessionId); - const activeEntries = config.filter((entry) => entry.active); + const watchScheduled = scheduleStoreWatchers(sessionId, resolvedSettings); + const entries = Array.isArray(config) ? config : []; + const activeEntries = entries.filter((entry) => entry.active); if (activeEntries.length === 0) { - console.log(`[INFO] Keine aktiven Einträge für Session ${sessionId} – Scheduler ruht.`); + if (watchScheduled) { + console.log( + `[INFO] Keine aktiven Pickup-Einträge für Session ${sessionId} – Store-Watch bleibt aktiv.` + ); + } else { + console.log(`[INFO] Keine aktiven Einträge für Session ${sessionId} – Scheduler ruht.`); + } return; } activeEntries.forEach((entry) => scheduleEntry(sessionId, entry, resolvedSettings)); diff --git a/services/storeWatchStore.js b/services/storeWatchStore.js new file mode 100644 index 0000000..86f4fe2 --- /dev/null +++ b/services/storeWatchStore.js @@ -0,0 +1,84 @@ +const fs = require('fs'); +const path = require('path'); + +const STORE_WATCH_DIR = path.join(__dirname, '..', 'config'); + +function ensureDir() { + if (!fs.existsSync(STORE_WATCH_DIR)) { + fs.mkdirSync(STORE_WATCH_DIR, { recursive: true }); + } +} + +function getStoreWatchPath(profileId = 'shared') { + return path.join(STORE_WATCH_DIR, `${profileId}-store-watch.json`); +} + +function sanitizeEntry(entry) { + if (!entry || !entry.storeId) { + return null; + } + const normalized = { + storeId: String(entry.storeId), + storeName: entry.storeName ? String(entry.storeName).trim() : `Store ${entry.storeId}`, + regionId: entry.regionId ? String(entry.regionId) : '', + regionName: entry.regionName ? String(entry.regionName).trim() : '', + lastTeamSearchStatus: + entry.lastTeamSearchStatus === 1 + ? 1 + : entry.lastTeamSearchStatus === 0 + ? 0 + : null + }; + if (!normalized.regionId) { + return null; + } + return normalized; +} + +function readStoreWatch(profileId) { + ensureDir(); + const filePath = getStoreWatchPath(profileId); + if (!fs.existsSync(filePath)) { + fs.writeFileSync(filePath, JSON.stringify([], null, 2)); + return []; + } + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + const unique = new Map(); + parsed.forEach((entry) => { + const sanitized = sanitizeEntry(entry); + if (sanitized) { + unique.set(sanitized.storeId, sanitized); + } + }); + return Array.from(unique.values()); + } catch (error) { + console.error(`[STORE-WATCH] Konnte Datei ${filePath} nicht lesen:`, error.message); + return []; + } +} + +function writeStoreWatch(profileId, entries = []) { + const sanitized = []; + const seen = new Set(); + entries.forEach((entry) => { + const normalized = sanitizeEntry(entry); + if (normalized && !seen.has(normalized.storeId)) { + sanitized.push(normalized); + seen.add(normalized.storeId); + } + }); + ensureDir(); + const filePath = getStoreWatchPath(profileId); + fs.writeFileSync(filePath, JSON.stringify(sanitized, null, 2)); + return sanitized; +} + +module.exports = { + readStoreWatch, + writeStoreWatch +}; diff --git a/src/App.js b/src/App.js index 261ac05..a66f424 100644 --- a/src/App.js +++ b/src/App.js @@ -21,6 +21,7 @@ import DirtyNavigationDialog from './components/DirtyNavigationDialog'; import ConfirmationDialog from './components/ConfirmationDialog'; import StoreSyncOverlay from './components/StoreSyncOverlay'; import RangePickerModal from './components/RangePickerModal'; +import StoreWatchPage from './components/StoreWatchPage'; function App() { const [credentials, setCredentials] = useState({ email: '', password: '' }); @@ -695,6 +696,7 @@ function App() { + } /> } /> diff --git a/src/components/AdminSettingsPanel.js b/src/components/AdminSettingsPanel.js index b0995af..b7a56ae 100644 --- a/src/components/AdminSettingsPanel.js +++ b/src/components/AdminSettingsPanel.js @@ -118,6 +118,50 @@ const AdminSettingsPanel = ({ +
+
+ + onSettingChange('storeWatchCron', 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="z. B. */30 * * * *" + /> +

+ Legt fest, wie häufig der Team-Status der überwachten Betriebe geprüft wird. +

+
+
+ +
+ + onSettingChange('storeWatchInitialDelayMinSeconds', event.target.value, true) + } + className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" + placeholder="Min" + /> + + onSettingChange('storeWatchInitialDelayMaxSeconds', event.target.value, true) + } + className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" + placeholder="Max" + /> +
+

+ Wird genutzt, um die ersten Prüfungen leicht zu verteilen. +

+
+
+

Ignorierte Slots

diff --git a/src/components/NavigationTabs.js b/src/components/NavigationTabs.js index e603e58..1a753dd 100644 --- a/src/components/NavigationTabs.js +++ b/src/components/NavigationTabs.js @@ -4,23 +4,26 @@ const NavigationTabs = ({ isAdmin, onProtectedNavigate }) => { const location = useLocation(); const navigate = useNavigate(); - if (!isAdmin) { - return null; - } - const tabs = [ { to: '/', label: 'Konfiguration' }, - { to: '/admin', label: 'Admin' } + { to: '/store-watch', label: 'Betriebs-Monitoring' } ]; + if (isAdmin) { + tabs.push({ to: '/admin', label: 'Admin' }); + } const handleClick = (event, to) => { event.preventDefault(); if (to === location.pathname) { return; } - onProtectedNavigate(`zur Seite "${tabs.find((tab) => tab.to === to)?.label || ''}" zu wechseln`, () => - navigate(to) - ); + const label = tabs.find((tab) => tab.to === to)?.label || ''; + const proceed = () => navigate(to); + if (onProtectedNavigate) { + onProtectedNavigate(`zur Seite "${label}" zu wechseln`, proceed); + return; + } + proceed(); }; return ( diff --git a/src/components/StoreWatchPage.js b/src/components/StoreWatchPage.js new file mode 100644 index 0000000..690ca08 --- /dev/null +++ b/src/components/StoreWatchPage.js @@ -0,0 +1,414 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +const StoreWatchPage = ({ authorizedFetch }) => { + const [regions, setRegions] = useState([]); + const [selectedRegionId, setSelectedRegionId] = useState(''); + const [storesByRegion, setStoresByRegion] = useState({}); + const [watchList, setWatchList] = useState([]); + const [regionLoading, setRegionLoading] = useState(false); + const [storesLoading, setStoresLoading] = useState(false); + const [subscriptionsLoading, setSubscriptionsLoading] = useState(false); + const [status, setStatus] = useState(''); + const [error, setError] = useState(''); + const [dirty, setDirty] = useState(false); + const [saving, setSaving] = useState(false); + + const watchedIds = useMemo( + () => new Set(watchList.map((entry) => String(entry.storeId))), + [watchList] + ); + + const selectedRegion = useMemo( + () => regions.find((region) => String(region.id) === String(selectedRegionId)) || null, + [regions, selectedRegionId] + ); + + const currentStores = useMemo(() => { + const regionEntry = storesByRegion[String(selectedRegionId)]; + if (!regionEntry || !Array.isArray(regionEntry.stores)) { + return []; + } + return regionEntry.stores; + }, [storesByRegion, selectedRegionId]); + + const eligibleStores = useMemo( + () => currentStores.filter((store) => Number(store.cooperationStatus) === 5), + [currentStores] + ); + + const loadRegions = useCallback(async () => { + if (!authorizedFetch) { + return; + } + setRegionLoading(true); + setError(''); + try { + const response = await authorizedFetch('/api/store-watch/regions'); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + const normalized = Array.isArray(data.regions) ? data.regions : []; + setRegions(normalized); + if (!selectedRegionId && normalized.length > 0) { + setSelectedRegionId(String(normalized[0].id)); + } + } catch (err) { + setError(`Regionen konnten nicht geladen werden: ${err.message}`); + } finally { + setRegionLoading(false); + } + }, [authorizedFetch, selectedRegionId]); + + const loadSubscriptions = useCallback(async () => { + if (!authorizedFetch) { + return; + } + setSubscriptionsLoading(true); + setError(''); + try { + const response = await authorizedFetch('/api/store-watch/subscriptions'); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + const normalized = Array.isArray(data.stores) ? data.stores : []; + setWatchList(normalized); + setDirty(false); + } catch (err) { + setError(`Überwachte Betriebe konnten nicht geladen werden: ${err.message}`); + } finally { + setSubscriptionsLoading(false); + } + }, [authorizedFetch]); + + const fetchStoresForRegion = useCallback( + async (regionId, { force } = {}) => { + if (!authorizedFetch || !regionId) { + return; + } + if (!force && storesByRegion[String(regionId)]) { + return; + } + setStoresLoading(true); + setError(''); + try { + const endpoint = force + ? `/api/store-watch/regions/${regionId}/stores?force=1` + : `/api/store-watch/regions/${regionId}/stores`; + const response = await authorizedFetch(endpoint); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + setStoresByRegion((prev) => ({ + ...prev, + [String(regionId)]: { + total: Number(data.total) || 0, + stores: Array.isArray(data.stores) ? data.stores : [], + fetchedAt: Date.now() + } + })); + } catch (err) { + setError(`Betriebe konnten nicht geladen werden: ${err.message}`); + } finally { + setStoresLoading(false); + } + }, + [authorizedFetch, storesByRegion] + ); + + useEffect(() => { + loadRegions(); + loadSubscriptions(); + }, [loadRegions, loadSubscriptions]); + + useEffect(() => { + if (selectedRegionId) { + fetchStoresForRegion(selectedRegionId); + } + }, [selectedRegionId, fetchStoresForRegion]); + + const handleToggleStore = useCallback( + (store, checked) => { + setWatchList((prev) => { + const storeId = String(store.id || store.storeId); + const existing = prev.find((entry) => entry.storeId === storeId); + if (checked) { + if (existing) { + return prev; + } + setDirty(true); + const regionName = + store.region?.name || selectedRegion?.name || existing?.regionName || ''; + return [ + ...prev, + { + storeId, + storeName: store.name || store.storeName || `Store ${storeId}`, + regionId: String(store.region?.id || selectedRegionId || existing?.regionId || ''), + regionName, + lastTeamSearchStatus: existing?.lastTeamSearchStatus ?? null + } + ]; + } + if (!existing) { + return prev; + } + setDirty(true); + return prev.filter((entry) => entry.storeId !== storeId); + }); + }, + [selectedRegion, selectedRegionId] + ); + + const handleRemoveWatch = useCallback((storeId) => { + setWatchList((prev) => { + const next = prev.filter((entry) => entry.storeId !== storeId); + if (next.length !== prev.length) { + setDirty(true); + } + return next; + }); + }, []); + + const handleSave = useCallback(async () => { + if (!authorizedFetch || saving || !dirty) { + return; + } + setSaving(true); + setStatus(''); + setError(''); + try { + const response = await authorizedFetch('/api/store-watch/subscriptions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ stores: watchList }) + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + setWatchList(Array.isArray(data.stores) ? data.stores : []); + setDirty(false); + setStatus('Überwachung gespeichert.'); + setTimeout(() => setStatus(''), 4000); + } catch (err) { + setError(`Speichern fehlgeschlagen: ${err.message}`); + } finally { + setSaving(false); + } + }, [authorizedFetch, dirty, saving, watchList]); + + const handleReset = useCallback(() => { + loadSubscriptions(); + }, [loadSubscriptions]); + + if (!authorizedFetch) { + return ( +
+

Keine Session aktiv.

+
+ ); + } + + return ( +
+
+

Betriebs-Monitoring

+

+ Wähle Betriebe aus, die bei offenem Team-Status automatisch gemeldet werden sollen. +

+
+ + {(error || status) && ( +
+ {error && ( +
{error}
+ )} + {status && ( +
+ {status} +
+ )} +
+ )} + +
+
+
+ + +
+
+ + +
+
+

+ Es werden nur Regionen angezeigt, in denen du Mitglied mit Klassifikation 1 bist. +

+
+ +
+
+

Betriebe in der Region

+ {storesByRegion[String(selectedRegionId)]?.fetchedAt && ( + + Aktualisiert:{' '} + {new Date(storesByRegion[String(selectedRegionId)].fetchedAt).toLocaleTimeString('de-DE')} + + )} +
+ {storesLoading &&

Lade Betriebe...

} + {!storesLoading && (!selectedRegionId || eligibleStores.length === 0) && ( +

+ {selectedRegionId + ? 'Keine geeigneten Betriebe (Status "aktiv") in dieser Region.' + : 'Bitte zuerst eine Region auswählen.'} +

+ )} + {!storesLoading && eligibleStores.length > 0 && ( +
+ + + + + + + + + + + {eligibleStores.map((store) => { + const checked = watchedIds.has(String(store.id)); + return ( + + + + + + + ); + })} + +
BetriebOrtKooperationÜberwachen
+

{store.name}

+

#{store.id}

+
+

{store.city || 'unbekannt'}

+

{store.street || ''}

+
+ Seit {store.createdAt ? new Date(store.createdAt).toLocaleDateString('de-DE') : 'n/a'} + + handleToggleStore(store, event.target.checked)} + /> +
+
+ )} +
+ +
+
+

+ Überwachte Betriebe ({watchList.length}) +

+
+ + +
+
+ {subscriptionsLoading &&

Lade aktuelle Auswahl...

} + {!subscriptionsLoading && watchList.length === 0 && ( +

Noch keine Betriebe ausgewählt.

+ )} + {watchList.length > 0 && ( +
+ {watchList.map((entry) => ( +
+
+
+

{entry.storeName}

+

+ #{entry.storeId} — {entry.regionName || 'Region unbekannt'} +

+
+ +
+

+ Letzter Status:{' '} + {entry.lastTeamSearchStatus === 1 + ? 'Suchend' + : entry.lastTeamSearchStatus === 0 + ? 'Nicht suchend' + : 'Unbekannt'} +

+
+ ))} +
+ )} +
+ + {dirty && ( +

+ Es gibt ungespeicherte Änderungen. Bitte "Speichern" klicken. +

+ )} +
+ ); +}; + +export default StoreWatchPage; diff --git a/src/utils/adminSettings.js b/src/utils/adminSettings.js index 3f93399..0eba3fe 100644 --- a/src/utils/adminSettings.js +++ b/src/utils/adminSettings.js @@ -9,6 +9,9 @@ export const normalizeAdminSettings = (raw) => { initialDelayMinSeconds: raw.initialDelayMinSeconds ?? '', initialDelayMaxSeconds: raw.initialDelayMaxSeconds ?? '', storePickupCheckDelayMs: raw.storePickupCheckDelayMs ?? '', + storeWatchCron: raw.storeWatchCron || '', + storeWatchInitialDelayMinSeconds: raw.storeWatchInitialDelayMinSeconds ?? '', + storeWatchInitialDelayMaxSeconds: raw.storeWatchInitialDelayMaxSeconds ?? '', ignoredSlots: Array.isArray(raw.ignoredSlots) ? raw.ignoredSlots.map((slot) => ({ storeId: slot?.storeId ? String(slot.storeId) : '', @@ -50,6 +53,9 @@ export const serializeAdminSettings = (adminSettings) => { initialDelayMinSeconds: toNumberOrUndefined(adminSettings.initialDelayMinSeconds), initialDelayMaxSeconds: toNumberOrUndefined(adminSettings.initialDelayMaxSeconds), storePickupCheckDelayMs: toNumberOrUndefined(adminSettings.storePickupCheckDelayMs), + storeWatchCron: adminSettings.storeWatchCron, + storeWatchInitialDelayMinSeconds: toNumberOrUndefined(adminSettings.storeWatchInitialDelayMinSeconds), + storeWatchInitialDelayMaxSeconds: toNumberOrUndefined(adminSettings.storeWatchInitialDelayMaxSeconds), ignoredSlots: (adminSettings.ignoredSlots || []).map((slot) => ({ storeId: slot.storeId || '', description: slot.description || ''