refactoring

This commit is contained in:
2025-11-10 16:44:54 +01:00
parent c2710f0a67
commit 89e7f77a4e
11 changed files with 807 additions and 10 deletions

View File

@@ -11,6 +11,7 @@ const { scheduleConfig } = require('./services/pickupScheduler');
const adminConfig = require('./services/adminConfig'); const adminConfig = require('./services/adminConfig');
const { readNotificationSettings, writeNotificationSettings } = require('./services/userSettingsStore'); const { readNotificationSettings, writeNotificationSettings } = require('./services/userSettingsStore');
const notificationService = require('./services/notificationService'); const notificationService = require('./services/notificationService');
const { readStoreWatch, writeStoreWatch } = require('./services/storeWatchStore');
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000; const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
const adminEmail = (process.env.ADMIN_EMAIL || '').toLowerCase(); const adminEmail = (process.env.ADMIN_EMAIL || '').toLowerCase();
@@ -20,6 +21,8 @@ const app = express();
const port = process.env.PORT || 3000; const port = process.env.PORT || 3000;
const storeRefreshJobs = new Map(); const storeRefreshJobs = new Map();
const cachedStoreSnapshots = new Map(); const cachedStoreSnapshots = new Map();
const regionStoreCache = new Map();
const REGION_STORE_CACHE_MS = 15 * 60 * 1000;
app.use(cors()); app.use(cors());
app.use(express.json({ limit: '1mb' })); app.use(express.json({ limit: '1mb' }));
@@ -88,6 +91,25 @@ function mergeStoresIntoConfig(config = [], stores = []) {
return { merged: Array.from(map.values()), changed }; 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) { function isStoreCacheFresh(session) {
if (!session?.storesCache?.fetchedAt) { if (!session?.storesCache?.fetchedAt) {
return false; 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) => { app.get('/api/config', requireAuth, (req, res) => {
const config = readConfig(req.session.profile.id); const config = readConfig(req.session.profile.id);
res.json(config); res.json(config);

View File

@@ -10,6 +10,9 @@ const DEFAULT_SETTINGS = {
randomDelayMaxSeconds: 120, randomDelayMaxSeconds: 120,
initialDelayMinSeconds: 5, initialDelayMinSeconds: 5,
initialDelayMaxSeconds: 30, initialDelayMaxSeconds: 30,
storeWatchCron: '*/30 * * * *',
storeWatchInitialDelayMinSeconds: 10,
storeWatchInitialDelayMaxSeconds: 60,
storePickupCheckDelayMs: 400, storePickupCheckDelayMs: 400,
ignoredSlots: [ ignoredSlots: [
{ {
@@ -101,6 +104,15 @@ function readSettings() {
randomDelayMaxSeconds: sanitizeNumber(parsed.randomDelayMaxSeconds, DEFAULT_SETTINGS.randomDelayMaxSeconds), randomDelayMaxSeconds: sanitizeNumber(parsed.randomDelayMaxSeconds, DEFAULT_SETTINGS.randomDelayMaxSeconds),
initialDelayMinSeconds: sanitizeNumber(parsed.initialDelayMinSeconds, DEFAULT_SETTINGS.initialDelayMinSeconds), initialDelayMinSeconds: sanitizeNumber(parsed.initialDelayMinSeconds, DEFAULT_SETTINGS.initialDelayMinSeconds),
initialDelayMaxSeconds: sanitizeNumber(parsed.initialDelayMaxSeconds, DEFAULT_SETTINGS.initialDelayMaxSeconds), 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( storePickupCheckDelayMs: sanitizeNumber(
parsed.storePickupCheckDelayMs, parsed.storePickupCheckDelayMs,
DEFAULT_SETTINGS.storePickupCheckDelayMs DEFAULT_SETTINGS.storePickupCheckDelayMs
@@ -122,6 +134,15 @@ function writeSettings(patch = {}) {
randomDelayMaxSeconds: sanitizeNumber(patch.randomDelayMaxSeconds, current.randomDelayMaxSeconds), randomDelayMaxSeconds: sanitizeNumber(patch.randomDelayMaxSeconds, current.randomDelayMaxSeconds),
initialDelayMinSeconds: sanitizeNumber(patch.initialDelayMinSeconds, current.initialDelayMinSeconds), initialDelayMinSeconds: sanitizeNumber(patch.initialDelayMinSeconds, current.initialDelayMinSeconds),
initialDelayMaxSeconds: sanitizeNumber(patch.initialDelayMaxSeconds, current.initialDelayMaxSeconds), 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( storePickupCheckDelayMs: sanitizeNumber(
patch.storePickupCheckDelayMs, patch.storePickupCheckDelayMs,
current.storePickupCheckDelayMs current.storePickupCheckDelayMs

View File

@@ -200,6 +200,29 @@ async function fetchPickups(storeId, cookieHeader) {
return response.data?.pickups || []; 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) { async function pickupRuleCheck(storeId, utcDate, profileId, session) {
const response = await client.get(`/api/stores/${storeId}/pickupRuleCheck/${utcDate}/${profileId}`, { const response = await client.get(`/api/stores/${storeId}/pickupRuleCheck/${utcDate}/${profileId}`, {
headers: buildHeaders(session.cookieHeader, session.csrfToken) headers: buildHeaders(session.cookieHeader, session.csrfToken)
@@ -223,6 +246,8 @@ module.exports = {
fetchProfile, fetchProfile,
fetchStores, fetchStores,
fetchPickups, fetchPickups,
fetchRegionStores,
fetchStoreDetails,
pickupRuleCheck, pickupRuleCheck,
bookSlot bookSlot
}; };

View File

@@ -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) { async function sendTestNotification(profileId, channel) {
const title = 'Pickup Benachrichtigung (Test)'; const title = 'Pickup Benachrichtigung (Test)';
const message = 'Das ist eine Testnachricht. Bei Fragen wende dich bitte an den Admin.'; 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 = { module.exports = {
sendSlotNotification, sendSlotNotification,
sendStoreWatchNotification,
sendTestNotification sendTestNotification
}; };

View File

@@ -4,6 +4,7 @@ const sessionStore = require('./sessionStore');
const { DEFAULT_SETTINGS } = require('./adminConfig'); const { DEFAULT_SETTINGS } = require('./adminConfig');
const notificationService = require('./notificationService'); const notificationService = require('./notificationService');
const { readConfig, writeConfig } = require('./configStore'); const { readConfig, writeConfig } = require('./configStore');
const { readStoreWatch, writeStoreWatch } = require('./storeWatchStore');
const weekdayMap = { const weekdayMap = {
Montag: 'Monday', Montag: 'Monday',
@@ -39,6 +40,13 @@ function resolveSettings(settings) {
initialDelayMaxSeconds: Number.isFinite(settings.initialDelayMaxSeconds) initialDelayMaxSeconds: Number.isFinite(settings.initialDelayMaxSeconds)
? settings.initialDelayMaxSeconds ? settings.initialDelayMaxSeconds
: DEFAULT_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, ignoredSlots: Array.isArray(settings.ignoredSlots) ? settings.ignoredSlots : DEFAULT_SETTINGS.ignoredSlots,
notifications: { notifications: {
ntfy: { 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) { function scheduleEntry(sessionId, entry, settings) {
const cronExpression = settings.scheduleCron || DEFAULT_SETTINGS.scheduleCron; const cronExpression = settings.scheduleCron || DEFAULT_SETTINGS.scheduleCron;
const job = cron.schedule( const job = cron.schedule(
@@ -312,9 +392,17 @@ function scheduleEntry(sessionId, entry, settings) {
function scheduleConfig(sessionId, config, settings) { function scheduleConfig(sessionId, config, settings) {
const resolvedSettings = resolveSettings(settings); const resolvedSettings = resolveSettings(settings);
sessionStore.clearJobs(sessionId); 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) { if (activeEntries.length === 0) {
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.`); console.log(`[INFO] Keine aktiven Einträge für Session ${sessionId} Scheduler ruht.`);
}
return; return;
} }
activeEntries.forEach((entry) => scheduleEntry(sessionId, entry, resolvedSettings)); activeEntries.forEach((entry) => scheduleEntry(sessionId, entry, resolvedSettings));

View File

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

View File

@@ -21,6 +21,7 @@ import DirtyNavigationDialog from './components/DirtyNavigationDialog';
import ConfirmationDialog from './components/ConfirmationDialog'; import ConfirmationDialog from './components/ConfirmationDialog';
import StoreSyncOverlay from './components/StoreSyncOverlay'; import StoreSyncOverlay from './components/StoreSyncOverlay';
import RangePickerModal from './components/RangePickerModal'; import RangePickerModal from './components/RangePickerModal';
import StoreWatchPage from './components/StoreWatchPage';
function App() { function App() {
const [credentials, setCredentials] = useState({ email: '', password: '' }); const [credentials, setCredentials] = useState({ email: '', password: '' });
@@ -695,6 +696,7 @@ function App() {
<NavigationTabs isAdmin={session?.isAdmin} onProtectedNavigate={requestNavigation} /> <NavigationTabs isAdmin={session?.isAdmin} onProtectedNavigate={requestNavigation} />
<Routes> <Routes>
<Route path="/" element={dashboardContent} /> <Route path="/" element={dashboardContent} />
<Route path="/store-watch" element={<StoreWatchPage authorizedFetch={authorizedFetch} />} />
<Route path="/admin" element={adminPageContent} /> <Route path="/admin" element={adminPageContent} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>

View File

@@ -118,6 +118,50 @@ const AdminSettingsPanel = ({
</div> </div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Store-Watch Cron</label>
<input
type="text"
value={adminSettings.storeWatchCron || ''}
onChange={(event) => 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 * * * *"
/>
<p className="text-xs text-gray-500 mt-1">
Legt fest, wie häufig der Team-Status der überwachten Betriebe geprüft wird.
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Store-Watch Startverzögerung (Sek.)</label>
<div className="grid grid-cols-2 gap-2">
<input
type="number"
min="0"
value={adminSettings.storeWatchInitialDelayMinSeconds}
onChange={(event) =>
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"
/>
<input
type="number"
min="0"
value={adminSettings.storeWatchInitialDelayMaxSeconds}
onChange={(event) =>
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"
/>
</div>
<p className="text-xs text-gray-500 mt-1">
Wird genutzt, um die ersten Prüfungen leicht zu verteilen.
</p>
</div>
</div>
<div className="mb-6"> <div className="mb-6">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<h2 className="text-lg font-semibold text-purple-900">Ignorierte Slots</h2> <h2 className="text-lg font-semibold text-purple-900">Ignorierte Slots</h2>

View File

@@ -4,23 +4,26 @@ const NavigationTabs = ({ isAdmin, onProtectedNavigate }) => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
if (!isAdmin) {
return null;
}
const tabs = [ const tabs = [
{ to: '/', label: 'Konfiguration' }, { 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) => { const handleClick = (event, to) => {
event.preventDefault(); event.preventDefault();
if (to === location.pathname) { if (to === location.pathname) {
return; return;
} }
onProtectedNavigate(`zur Seite "${tabs.find((tab) => tab.to === to)?.label || ''}" zu wechseln`, () => const label = tabs.find((tab) => tab.to === to)?.label || '';
navigate(to) const proceed = () => navigate(to);
); if (onProtectedNavigate) {
onProtectedNavigate(`zur Seite "${label}" zu wechseln`, proceed);
return;
}
proceed();
}; };
return ( return (

View File

@@ -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 (
<div className="p-4 max-w-4xl mx-auto">
<p className="text-red-600">Keine Session aktiv.</p>
</div>
);
}
return (
<div className="p-4 max-w-5xl mx-auto bg-white shadow rounded-lg mt-4">
<div className="flex flex-col gap-2 mb-4">
<h1 className="text-2xl font-bold text-blue-900">Betriebs-Monitoring</h1>
<p className="text-gray-600 text-sm">
Wähle Betriebe aus, die bei offenem Team-Status automatisch gemeldet werden sollen.
</p>
</div>
{(error || status) && (
<div className="mb-4 space-y-2">
{error && (
<div className="bg-red-100 border border-red-300 text-red-700 px-4 py-2 rounded">{error}</div>
)}
{status && (
<div className="bg-green-100 border border-green-300 text-green-700 px-4 py-2 rounded">
{status}
</div>
)}
</div>
)}
<div className="mb-6 border border-gray-200 rounded-lg p-4 bg-gray-50">
<div className="flex flex-col md:flex-row md:items-end gap-3">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1" htmlFor="region-select">
Region
</label>
<select
id="region-select"
value={selectedRegionId}
onChange={(event) => setSelectedRegionId(event.target.value)}
className="border rounded-md p-2 w-full"
disabled={regionLoading}
>
<option value="">Region auswählen</option>
{regions.map((region) => (
<option key={region.id} value={region.id}>
{region.name}
</option>
))}
</select>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => loadRegions()}
className="px-4 py-2 border rounded-md text-sm bg-white hover:bg-gray-100"
disabled={regionLoading}
>
Regionen neu laden
</button>
<button
type="button"
onClick={() => fetchStoresForRegion(selectedRegionId, { force: true })}
className="px-4 py-2 border rounded-md text-sm bg-white hover:bg-gray-100"
disabled={!selectedRegionId || storesLoading}
>
Betriebe aktualisieren
</button>
</div>
</div>
<p className="text-xs text-gray-500 mt-2">
Es werden nur Regionen angezeigt, in denen du Mitglied mit Klassifikation 1 bist.
</p>
</div>
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<h2 className="text-lg font-semibold text-gray-800">Betriebe in der Region</h2>
{storesByRegion[String(selectedRegionId)]?.fetchedAt && (
<span className="text-xs text-gray-500">
Aktualisiert:{' '}
{new Date(storesByRegion[String(selectedRegionId)].fetchedAt).toLocaleTimeString('de-DE')}
</span>
)}
</div>
{storesLoading && <p className="text-sm text-gray-600">Lade Betriebe...</p>}
{!storesLoading && (!selectedRegionId || eligibleStores.length === 0) && (
<p className="text-sm text-gray-500">
{selectedRegionId
? 'Keine geeigneten Betriebe (Status "aktiv") in dieser Region.'
: 'Bitte zuerst eine Region auswählen.'}
</p>
)}
{!storesLoading && eligibleStores.length > 0 && (
<div className="overflow-x-auto border border-gray-200 rounded-lg">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-100">
<tr>
<th className="px-4 py-2 text-left">Betrieb</th>
<th className="px-4 py-2 text-left">Ort</th>
<th className="px-4 py-2 text-left">Kooperation</th>
<th className="px-4 py-2 text-center">Überwachen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{eligibleStores.map((store) => {
const checked = watchedIds.has(String(store.id));
return (
<tr key={store.id} className="bg-white">
<td className="px-4 py-2">
<p className="font-medium text-gray-900">{store.name}</p>
<p className="text-xs text-gray-500">#{store.id}</p>
</td>
<td className="px-4 py-2">
<p className="text-gray-800 text-sm">{store.city || 'unbekannt'}</p>
<p className="text-xs text-gray-500">{store.street || ''}</p>
</td>
<td className="px-4 py-2 text-sm text-gray-600">
Seit {store.createdAt ? new Date(store.createdAt).toLocaleDateString('de-DE') : 'n/a'}
</td>
<td className="px-4 py-2 text-center">
<input
type="checkbox"
className="h-5 w-5"
checked={checked}
onChange={(event) => handleToggleStore(store, event.target.checked)}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-gray-800">
Überwachte Betriebe ({watchList.length})
</h2>
<div className="flex gap-2">
<button
type="button"
className="px-4 py-2 border rounded-md text-sm bg-white hover:bg-gray-100"
onClick={handleReset}
disabled={subscriptionsLoading}
>
Änderungen verwerfen
</button>
<button
type="button"
className="px-4 py-2 rounded-md text-sm text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-60"
onClick={handleSave}
disabled={!dirty || saving}
>
{saving ? 'Speichere...' : 'Speichern'}
</button>
</div>
</div>
{subscriptionsLoading && <p className="text-sm text-gray-600">Lade aktuelle Auswahl...</p>}
{!subscriptionsLoading && watchList.length === 0 && (
<p className="text-sm text-gray-500">Noch keine Betriebe ausgewählt.</p>
)}
{watchList.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{watchList.map((entry) => (
<div key={entry.storeId} className="border border-gray-200 rounded-lg p-3 bg-gray-50">
<div className="flex justify-between items-start">
<div>
<p className="font-semibold text-gray-900">{entry.storeName}</p>
<p className="text-xs text-gray-500">
#{entry.storeId} {entry.regionName || 'Region unbekannt'}
</p>
</div>
<button
type="button"
className="text-xs text-red-600 hover:underline"
onClick={() => handleRemoveWatch(entry.storeId)}
>
Entfernen
</button>
</div>
<p className="text-xs text-gray-600 mt-2">
Letzter Status:{' '}
{entry.lastTeamSearchStatus === 1
? 'Suchend'
: entry.lastTeamSearchStatus === 0
? 'Nicht suchend'
: 'Unbekannt'}
</p>
</div>
))}
</div>
)}
</div>
{dirty && (
<p className="text-xs text-amber-600">
Es gibt ungespeicherte Änderungen. Bitte "Speichern" klicken.
</p>
)}
</div>
);
};
export default StoreWatchPage;

View File

@@ -9,6 +9,9 @@ export const normalizeAdminSettings = (raw) => {
initialDelayMinSeconds: raw.initialDelayMinSeconds ?? '', initialDelayMinSeconds: raw.initialDelayMinSeconds ?? '',
initialDelayMaxSeconds: raw.initialDelayMaxSeconds ?? '', initialDelayMaxSeconds: raw.initialDelayMaxSeconds ?? '',
storePickupCheckDelayMs: raw.storePickupCheckDelayMs ?? '', storePickupCheckDelayMs: raw.storePickupCheckDelayMs ?? '',
storeWatchCron: raw.storeWatchCron || '',
storeWatchInitialDelayMinSeconds: raw.storeWatchInitialDelayMinSeconds ?? '',
storeWatchInitialDelayMaxSeconds: raw.storeWatchInitialDelayMaxSeconds ?? '',
ignoredSlots: Array.isArray(raw.ignoredSlots) ignoredSlots: Array.isArray(raw.ignoredSlots)
? raw.ignoredSlots.map((slot) => ({ ? raw.ignoredSlots.map((slot) => ({
storeId: slot?.storeId ? String(slot.storeId) : '', storeId: slot?.storeId ? String(slot.storeId) : '',
@@ -50,6 +53,9 @@ export const serializeAdminSettings = (adminSettings) => {
initialDelayMinSeconds: toNumberOrUndefined(adminSettings.initialDelayMinSeconds), initialDelayMinSeconds: toNumberOrUndefined(adminSettings.initialDelayMinSeconds),
initialDelayMaxSeconds: toNumberOrUndefined(adminSettings.initialDelayMaxSeconds), initialDelayMaxSeconds: toNumberOrUndefined(adminSettings.initialDelayMaxSeconds),
storePickupCheckDelayMs: toNumberOrUndefined(adminSettings.storePickupCheckDelayMs), storePickupCheckDelayMs: toNumberOrUndefined(adminSettings.storePickupCheckDelayMs),
storeWatchCron: adminSettings.storeWatchCron,
storeWatchInitialDelayMinSeconds: toNumberOrUndefined(adminSettings.storeWatchInitialDelayMinSeconds),
storeWatchInitialDelayMaxSeconds: toNumberOrUndefined(adminSettings.storeWatchInitialDelayMaxSeconds),
ignoredSlots: (adminSettings.ignoredSlots || []).map((slot) => ({ ignoredSlots: (adminSettings.ignoredSlots || []).map((slot) => ({
storeId: slot.storeId || '', storeId: slot.storeId || '',
description: slot.description || '' description: slot.description || ''