diff --git a/server.js b/server.js index c44c695..1388690 100644 --- a/server.js +++ b/server.js @@ -13,6 +13,7 @@ const { readNotificationSettings, writeNotificationSettings } = require('./servi const notificationService = require('./services/notificationService'); const { readStoreWatch, writeStoreWatch } = require('./services/storeWatchStore'); const { readPreferences, writePreferences } = require('./services/userPreferencesStore'); +const { readStoreStatus, writeStoreStatus } = require('./services/storeStatusStore'); const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000; const adminEmail = (process.env.ADMIN_EMAIL || '').toLowerCase(); @@ -24,6 +25,35 @@ const storeRefreshJobs = new Map(); const cachedStoreSnapshots = new Map(); const regionStoreCache = new Map(); const REGION_STORE_CACHE_MS = 15 * 60 * 1000; +const STORE_STATUS_MAX_AGE_MS = 60 * 24 * 60 * 60 * 1000; +const storeStatusCache = new Map(); + +(function bootstrapStoreStatusCache() { + try { + const cached = readStoreStatus(); + Object.entries(cached || {}).forEach(([storeId, entry]) => { + if (entry && typeof entry === 'object') { + storeStatusCache.set(String(storeId), { + teamSearchStatus: + entry.teamSearchStatus === null || entry.teamSearchStatus === undefined + ? null + : Number(entry.teamSearchStatus), + fetchedAt: Number(entry.fetchedAt) || 0 + }); + } + }); + } catch (error) { + console.error('[STORE-STATUS] Bootstrap fehlgeschlagen:', error.message); + } +})(); + +function persistStoreStatusCache() { + const payload = {}; + storeStatusCache.forEach((value, key) => { + payload[key] = value; + }); + writeStoreStatus(payload); +} app.use(cors()); app.use(express.json({ limit: '1mb' })); @@ -111,6 +141,90 @@ function setCachedRegionStores(regionId, payload) { }); } +function getCachedStoreStatus(storeId) { + return storeStatusCache.get(String(storeId)) || null; +} + +async function refreshStoreStatus(storeIds = [], cookieHeader, { force = false } = {}) { + if (!Array.isArray(storeIds) || storeIds.length === 0 || !cookieHeader) { + return { refreshed: 0 }; + } + const now = Date.now(); + let refreshed = 0; + for (const id of storeIds) { + const storeId = String(id); + const entry = storeStatusCache.get(storeId); + const ageOk = entry && now - (entry.fetchedAt || 0) <= STORE_STATUS_MAX_AGE_MS; + if (!force && ageOk) { + continue; + } + try { + const details = await foodsharingClient.fetchStoreDetails(storeId, cookieHeader); + const status = Number(details?.teamSearchStatus); + const normalized = Number.isFinite(status) ? status : null; + storeStatusCache.set(storeId, { + teamSearchStatus: normalized, + fetchedAt: now + }); + refreshed += 1; + } catch (error) { + console.error(`[STORE-STATUS] Status für Store ${storeId} konnte nicht aktualisiert werden:`, error.message); + } + } + if (refreshed > 0) { + persistStoreStatusCache(); + } + return { refreshed }; +} + +async function enrichStoresWithTeamStatus(stores = [], cookieHeader, { forceRefresh = false } = {}) { + if (!Array.isArray(stores) || stores.length === 0) { + return { stores, statusMeta: { total: 0, refreshed: 0, fromCache: 0, missing: 0 } }; + } + const now = Date.now(); + const staleIds = []; + let cacheHits = 0; + stores.forEach((store) => { + const entry = getCachedStoreStatus(store.id); + const fresh = entry && now - (entry.fetchedAt || 0) <= STORE_STATUS_MAX_AGE_MS; + if (entry && fresh && !forceRefresh) { + store.teamSearchStatus = entry.teamSearchStatus; + store.teamStatusUpdatedAt = entry.fetchedAt || null; + cacheHits += 1; + } else { + staleIds.push(store.id); + } + }); + + let refreshed = 0; + if (staleIds.length > 0) { + const result = await refreshStoreStatus(staleIds, cookieHeader, { force: forceRefresh }); + refreshed = result.refreshed || 0; + } + + stores.forEach((store) => { + if (store.teamSearchStatus === undefined) { + const entry = getCachedStoreStatus(store.id); + if (entry) { + store.teamSearchStatus = entry.teamSearchStatus; + store.teamStatusUpdatedAt = entry.fetchedAt || null; + } else { + store.teamSearchStatus = null; + store.teamStatusUpdatedAt = null; + } + } + }); + + const statusMeta = { + total: stores.length, + refreshed, + fromCache: cacheHits, + missing: stores.filter((store) => store.teamStatusUpdatedAt == null).length, + generatedAt: Date.now() + }; + return { stores, statusMeta }; +} + function isStoreCacheFresh(session) { if (!session?.storesCache?.fetchedAt) { return false; @@ -409,25 +523,46 @@ app.get('/api/store-watch/regions/:regionId/stores', requireAuth, async (req, re if (!regionId) { return res.status(400).json({ error: 'Region-ID fehlt' }); } - const forceRefresh = req.query.force === '1'; - if (!forceRefresh) { + const forceRegionRefresh = req.query.force === '1'; + const forceStatusRefresh = req.query.forceStatus === '1'; + + let basePayload = null; + if (!forceRegionRefresh) { const cached = getCachedRegionStores(regionId); if (cached) { - return res.json(cached); + basePayload = 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' }); + + if (!basePayload) { + try { + const result = await foodsharingClient.fetchRegionStores(regionId, req.session.cookieHeader); + basePayload = { + total: Number(result?.total) || 0, + stores: Array.isArray(result?.stores) ? result.stores : [] + }; + setCachedRegionStores(regionId, basePayload); + } catch (error) { + console.error(`[STORE-WATCH] Stores für Region ${regionId} konnten nicht geladen werden:`, error.message); + return res.status(500).json({ error: 'Betriebe konnten nicht geladen werden' }); + } } + + const filteredStores = basePayload.stores + .filter((store) => Number(store.cooperationStatus) === 5) + .map((store) => ({ ...store, id: String(store.id) })); + + const { stores: enrichedStores, statusMeta } = await enrichStoresWithTeamStatus( + filteredStores, + req.session.cookieHeader, + { forceRefresh: forceStatusRefresh } + ); + + res.json({ + total: filteredStores.length, + stores: enrichedStores, + statusMeta + }); }); app.get('/api/store-watch/subscriptions', requireAuth, (req, res) => { diff --git a/services/storeStatusStore.js b/services/storeStatusStore.js new file mode 100644 index 0000000..a846cf0 --- /dev/null +++ b/services/storeStatusStore.js @@ -0,0 +1,40 @@ +const fs = require('fs'); +const path = require('path'); + +const STORE_STATUS_FILE = path.join(__dirname, '..', 'config', 'store-watch-status.json'); + +function ensureDir() { + const dir = path.dirname(STORE_STATUS_FILE); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +function readStoreStatus() { + ensureDir(); + if (!fs.existsSync(STORE_STATUS_FILE)) { + fs.writeFileSync(STORE_STATUS_FILE, JSON.stringify({}, null, 2)); + return {}; + } + try { + const raw = fs.readFileSync(STORE_STATUS_FILE, 'utf8'); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + return parsed; + } + return {}; + } catch (error) { + console.error('[STORE-STATUS] Konnte Cache nicht lesen:', error.message); + return {}; + } +} + +function writeStoreStatus(cache = {}) { + ensureDir(); + fs.writeFileSync(STORE_STATUS_FILE, JSON.stringify(cache, null, 2)); +} + +module.exports = { + readStoreStatus, + writeStoreStatus +}; diff --git a/src/components/StoreWatchPage.js b/src/components/StoreWatchPage.js index e1956b8..75d72b3 100644 --- a/src/components/StoreWatchPage.js +++ b/src/components/StoreWatchPage.js @@ -144,6 +144,29 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) => const activeRegionLabel = selectedRegionId === 'all' ? 'allen Regionen' : selectedRegion?.name || 'dieser Region'; + const selectedStatusMeta = useMemo(() => { + if (selectedRegionId === 'all') { + const metas = regions + .map((region) => storesByRegion[String(region.id)]?.statusMeta) + .filter(Boolean); + if (metas.length === 0) { + return null; + } + const aggregated = metas.reduce( + (acc, meta) => ({ + total: acc.total + (meta.total || 0), + refreshed: acc.refreshed + (meta.refreshed || 0), + fromCache: acc.fromCache + (meta.fromCache || 0), + missing: acc.missing + (meta.missing || 0), + generatedAt: Math.max(acc.generatedAt, meta.generatedAt || 0) + }), + { total: 0, refreshed: 0, fromCache: 0, missing: 0, generatedAt: 0 } + ); + return aggregated; + } + return storesByRegion[String(selectedRegionId)]?.statusMeta || null; + }, [selectedRegionId, storesByRegion, regions]); + const lastUpdatedAt = useMemo(() => { if (selectedRegionId === 'all') { const timestamps = regions @@ -179,7 +202,27 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) => return regionEntry.stores; }, [regions, storesByRegion, selectedRegionId]); - const regionStores = useMemo(() => currentStores, [currentStores]); + const regionStores = useMemo( + () => currentStores.filter((store) => Number(store.cooperationStatus) === 5), + [currentStores] + ); + + const statusSummary = useMemo(() => { + if (!selectedStatusMeta) { + return 'Team-Status noch nicht geladen.'; + } + const parts = [ + `${selectedStatusMeta.refreshed || 0} aktualisiert`, + `${selectedStatusMeta.fromCache || 0} aus Cache` + ]; + if (selectedStatusMeta.missing) { + parts.push(`${selectedStatusMeta.missing} ohne Daten`); + } + const timestamp = selectedStatusMeta.generatedAt + ? new Date(selectedStatusMeta.generatedAt).toLocaleString('de-DE') + : null; + return `Team-Status: ${parts.join(', ')}${timestamp ? ` (Stand ${timestamp})` : ''}`; + }, [selectedStatusMeta]); const membershipMap = useMemo(() => { const map = new Map(); @@ -197,7 +240,7 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) => const storeId = String(store.id || store.storeId); const existing = prev.find((entry) => entry.storeId === storeId); if (checked) { - if (!store.isOpen || existing) { + if (existing) { return prev; } setDirty(true); @@ -239,15 +282,20 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) => const membership = membershipMap.has(String(store.id)); const lat = Number(store.location?.lat); const lon = Number(store.location?.lon); + const statusValue = store.teamSearchStatus === null || store.teamSearchStatus === undefined + ? null + : Number(store.teamSearchStatus); const distance = userLocation && Number.isFinite(lat) && Number.isFinite(lon) ? haversineDistanceKm(userLocation.lat, userLocation.lon, lat, lon) : null; - const isOpen = Number(store.cooperationStatus) === 5; + const isOpen = statusValue === 1; return { ...store, membership, distanceKm: distance, + teamStatusUpdatedAt: store.teamStatusUpdatedAt || null, + teamSearchStatus: statusValue, isOpen }; }), @@ -364,16 +412,25 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) => /> ), - cell: ({ getValue }) => { + cell: ({ row, getValue }) => { const value = getValue(); + const updatedAt = row.original.teamStatusUpdatedAt + ? new Date(row.original.teamStatusUpdatedAt).toLocaleDateString('de-DE') + : null; + if (value === null) { + return ; + } return ( - - {value ? 'Ja' : 'Nein'} - +
+ + {value ? 'Ja' : 'Nein'} + + {updatedAt &&

{updatedAt}

} +
); }, filterFn: (row, columnId, value) => { @@ -571,11 +628,11 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) => }, [authorizedFetch]); const fetchStoresForRegion = useCallback( - async (regionId, { force, silent } = {}) => { + async (regionId, { force, silent, forceStatus } = {}) => { if (!authorizedFetch || !regionId) { return; } - if (!force && storesByRegion[String(regionId)]) { + if (!force && !forceStatus && storesByRegion[String(regionId)]) { return; } if (!silent) { @@ -583,9 +640,15 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) => } setError(''); try { - const endpoint = force - ? `/api/store-watch/regions/${regionId}/stores?force=1` - : `/api/store-watch/regions/${regionId}/stores`; + const params = new URLSearchParams(); + if (force) { + params.append('force', '1'); + } + if (forceStatus) { + params.append('forceStatus', '1'); + } + const qs = params.toString(); + const endpoint = `/api/store-watch/regions/${regionId}/stores${qs ? `?${qs}` : ''}`; const response = await authorizedFetch(endpoint); if (!response.ok) { throw new Error(`HTTP ${response.status}`); @@ -596,7 +659,8 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) => [String(regionId)]: { total: Number(data.total) || 0, stores: Array.isArray(data.stores) ? data.stores : [], - fetchedAt: Date.now() + fetchedAt: Date.now(), + statusMeta: data.statusMeta || null } })); } catch (err) { @@ -611,12 +675,12 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) => ); const fetchAllRegions = useCallback( - async ({ force } = {}) => { + async ({ force, forceStatus } = {}) => { if (!authorizedFetch || regions.length === 0) { return; } const targets = regions.filter( - (region) => force || !storesByRegion[String(region.id)] + (region) => force || forceStatus || !storesByRegion[String(region.id)] ); if (targets.length === 0) { return; @@ -625,7 +689,7 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) => setError(''); try { for (const region of targets) { - await fetchStoresForRegion(region.id, { force, silent: true }); + await fetchStoresForRegion(region.id, { force, silent: true, forceStatus }); } } catch (err) { setError(`Betriebe konnten nicht geladen werden: ${err.message}`); @@ -684,6 +748,14 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) => loadSubscriptions(); }, [loadSubscriptions]); + const handleStatusRefresh = useCallback(() => { + if (selectedRegionId === 'all') { + fetchAllRegions({ force: true, forceStatus: true }); + } else if (selectedRegionId) { + fetchStoresForRegion(selectedRegionId, { forceStatus: true }); + } + }, [selectedRegionId, fetchAllRegions, fetchStoresForRegion]); + if (!authorizedFetch) { return (
@@ -735,7 +807,7 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) => ))}
-
+
+
-

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

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