aktueller stand

This commit is contained in:
2026-01-29 17:00:24 +01:00
parent 132b571798
commit 916ca1dbc2
10 changed files with 256 additions and 55 deletions

View File

@@ -14,11 +14,13 @@ const DEFAULT_SETTINGS = {
storeWatchInitialDelayMinSeconds: 10, storeWatchInitialDelayMinSeconds: 10,
storeWatchInitialDelayMaxSeconds: 60, storeWatchInitialDelayMaxSeconds: 60,
storeWatchRequestDelayMs: 1000, storeWatchRequestDelayMs: 1000,
storeWatchStatusCacheMaxAgeMinutes: 120,
storePickupCheckDelayMs: 400, storePickupCheckDelayMs: 400,
ignoredSlots: [ ignoredSlots: [
{ {
storeId: '51450', storeId: '51450',
description: 'TVS' slotName: 'TVS',
info: ''
} }
], ],
notifications: { notifications: {
@@ -56,11 +58,15 @@ function sanitizeIgnoredSlots(slots = []) {
return DEFAULT_SETTINGS.ignoredSlots; return DEFAULT_SETTINGS.ignoredSlots;
} }
return slots return slots
.map((slot) => ({ .map((slot) => {
const slotName = slot?.slotName ?? slot?.description;
return {
storeId: slot?.storeId ? String(slot.storeId) : '', storeId: slot?.storeId ? String(slot.storeId) : '',
description: slot?.description ? String(slot.description) : '' slotName: slotName ? String(slotName) : '',
})) info: slot?.info ? String(slot.info) : ''
.filter((slot) => slot.storeId); };
})
.filter((slot) => slot.storeId && slot.slotName);
} }
function sanitizeString(value) { function sanitizeString(value) {
@@ -120,6 +126,10 @@ function readSettings() {
parsed.storeWatchRequestDelayMs, parsed.storeWatchRequestDelayMs,
DEFAULT_SETTINGS.storeWatchRequestDelayMs DEFAULT_SETTINGS.storeWatchRequestDelayMs
), ),
storeWatchStatusCacheMaxAgeMinutes: sanitizeNumber(
parsed.storeWatchStatusCacheMaxAgeMinutes,
DEFAULT_SETTINGS.storeWatchStatusCacheMaxAgeMinutes
),
storePickupCheckDelayMs: sanitizeNumber( storePickupCheckDelayMs: sanitizeNumber(
parsed.storePickupCheckDelayMs, parsed.storePickupCheckDelayMs,
DEFAULT_SETTINGS.storePickupCheckDelayMs DEFAULT_SETTINGS.storePickupCheckDelayMs
@@ -154,6 +164,10 @@ function writeSettings(patch = {}) {
patch.storeWatchRequestDelayMs, patch.storeWatchRequestDelayMs,
current.storeWatchRequestDelayMs current.storeWatchRequestDelayMs
), ),
storeWatchStatusCacheMaxAgeMinutes: sanitizeNumber(
patch.storeWatchStatusCacheMaxAgeMinutes,
current.storeWatchStatusCacheMaxAgeMinutes
),
storePickupCheckDelayMs: sanitizeNumber( storePickupCheckDelayMs: sanitizeNumber(
patch.storePickupCheckDelayMs, patch.storePickupCheckDelayMs,
current.storePickupCheckDelayMs current.storePickupCheckDelayMs

View File

@@ -6,7 +6,7 @@ const notificationService = require('./notificationService');
const { readConfig, writeConfig } = require('./configStore'); const { readConfig, writeConfig } = require('./configStore');
const { readStoreWatch, writeStoreWatch } = require('./storeWatchStore'); const { readStoreWatch, writeStoreWatch } = require('./storeWatchStore');
const { readJournal, writeJournal } = require('./journalStore'); const { readJournal, writeJournal } = require('./journalStore');
const { setStoreStatus, persistStoreStatusCache } = require('./storeStatusCache'); const { getStoreStatus, setStoreStatus, persistStoreStatusCache } = require('./storeStatusCache');
const { sendDormantPickupWarning, sendJournalReminderNotification } = require('./notificationService'); const { sendDormantPickupWarning, sendJournalReminderNotification } = require('./notificationService');
const { ensureSession, withSessionRetry } = require('./sessionRefresh'); const { ensureSession, withSessionRetry } = require('./sessionRefresh');
@@ -17,6 +17,53 @@ function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
const DEFAULT_STORE_WATCH_STATUS_MAX_AGE_MINUTES = 120;
const storeWatchInFlight = new Map();
async function fetchSharedStoreStatus(session, storeId, { forceRefresh = false, maxAgeMs } = {}) {
if (!storeId) {
return { status: null, fetchedAt: null, fromCache: false };
}
const cacheEntry = getStoreStatus(storeId);
const cachedStatus = cacheEntry?.teamSearchStatus;
const hasCachedStatus = cachedStatus === 0 || cachedStatus === 1;
const cachedAt = Number(cacheEntry?.fetchedAt) || 0;
const effectiveMaxAge =
Number.isFinite(maxAgeMs) && maxAgeMs >= 0
? maxAgeMs
: DEFAULT_STORE_WATCH_STATUS_MAX_AGE_MINUTES * 60 * 1000;
const cacheFresh = hasCachedStatus && Date.now() - cachedAt <= effectiveMaxAge;
if (cacheFresh && !forceRefresh) {
return { status: cachedStatus, fetchedAt: cachedAt, fromCache: true };
}
const key = String(storeId);
if (!forceRefresh && storeWatchInFlight.has(key)) {
return storeWatchInFlight.get(key);
}
const fetchPromise = (async () => {
const details = await withSessionRetry(
session,
() => foodsharingClient.fetchStoreDetails(storeId, session.cookieHeader, session),
{ label: 'fetchStoreDetails' }
);
const status = details?.teamSearchStatus === 1 ? 1 : 0;
const fetchedAt = Date.now();
setStoreStatus(storeId, { teamSearchStatus: status, fetchedAt });
return { status, fetchedAt, fromCache: false };
})();
storeWatchInFlight.set(key, fetchPromise);
try {
return await fetchPromise;
} finally {
if (storeWatchInFlight.get(key) === fetchPromise) {
storeWatchInFlight.delete(key);
}
}
}
const weekdayMap = { const weekdayMap = {
Montag: 'Monday', Montag: 'Monday',
Dienstag: 'Tuesday', Dienstag: 'Tuesday',
@@ -103,6 +150,9 @@ function resolveSettings(settings) {
storeWatchRequestDelayMs: Number.isFinite(settings.storeWatchRequestDelayMs) storeWatchRequestDelayMs: Number.isFinite(settings.storeWatchRequestDelayMs)
? settings.storeWatchRequestDelayMs ? settings.storeWatchRequestDelayMs
: DEFAULT_SETTINGS.storeWatchRequestDelayMs, : DEFAULT_SETTINGS.storeWatchRequestDelayMs,
storeWatchStatusCacheMaxAgeMinutes: Number.isFinite(settings.storeWatchStatusCacheMaxAgeMinutes)
? settings.storeWatchStatusCacheMaxAgeMinutes
: DEFAULT_SETTINGS.storeWatchStatusCacheMaxAgeMinutes,
ignoredSlots: Array.isArray(settings.ignoredSlots) ? settings.ignoredSlots : DEFAULT_SETTINGS.ignoredSlots, ignoredSlots: Array.isArray(settings.ignoredSlots) ? settings.ignoredSlots : DEFAULT_SETTINGS.ignoredSlots,
notifications: { notifications: {
ntfy: { ntfy: {
@@ -305,10 +355,8 @@ function shouldIgnoreSlot(entry, pickup, settings) {
if (String(rule.storeId) !== entry.id) { if (String(rule.storeId) !== entry.id) {
return false; return false;
} }
if (rule.description) { const slotName = rule.slotName || rule.description;
return pickup.description === rule.description; return slotName ? pickup.description === slotName : true;
}
return true;
}); });
} }
@@ -469,12 +517,13 @@ async function checkWatchedStores(sessionId, settings = DEFAULT_SETTINGS, option
for (let index = 0; index < watchers.length; index += 1) { for (let index = 0; index < watchers.length; index += 1) {
const watcher = watchers[index]; const watcher = watchers[index];
try { try {
const details = await withSessionRetry( const cacheMaxAgeMs = Math.max(
session, 0,
() => foodsharingClient.fetchStoreDetails(watcher.storeId, session.cookieHeader, session), Number(settings?.storeWatchStatusCacheMaxAgeMinutes ?? DEFAULT_STORE_WATCH_STATUS_MAX_AGE_MINUTES)
{ label: 'fetchStoreDetails' } ) * 60 * 1000;
); const { status, fromCache } = await fetchSharedStoreStatus(session, watcher.storeId, {
const status = details?.teamSearchStatus === 1 ? 1 : 0; maxAgeMs: cacheMaxAgeMs
});
const checkedAt = Date.now(); const checkedAt = Date.now();
if (status === 1 && watcher.lastTeamSearchStatus !== 1) { if (status === 1 && watcher.lastTeamSearchStatus !== 1) {
await notificationService.sendStoreWatchNotification({ await notificationService.sendStoreWatchNotification({
@@ -490,8 +539,9 @@ async function checkWatchedStores(sessionId, settings = DEFAULT_SETTINGS, option
} }
watcher.lastStatusCheckAt = checkedAt; watcher.lastStatusCheckAt = checkedAt;
changed = true; changed = true;
setStoreStatus(watcher.storeId, { teamSearchStatus: status, fetchedAt: checkedAt }); if (!fromCache) {
statusCacheUpdated = true; statusCacheUpdated = true;
}
summary.push({ summary.push({
storeId: watcher.storeId, storeId: watcher.storeId,
storeName: watcher.storeName, storeName: watcher.storeName,

View File

@@ -784,7 +784,7 @@ function App() {
<Router> <Router>
<> <>
<div className="min-h-screen bg-gray-100 py-6"> <div className="min-h-screen bg-gray-100 py-6">
<div className="max-w-7xl mx-auto px-4"> <div className="max-w-6xl mx-auto px-4">
<NavigationTabs isAdmin={session?.isAdmin} onProtectedNavigate={requestNavigation} /> <NavigationTabs isAdmin={session?.isAdmin} onProtectedNavigate={requestNavigation} />
<Routes> <Routes>
<Route path="/" element={dashboardContent} /> <Route path="/" element={dashboardContent} />

View File

@@ -1,7 +1,7 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
const AdminAccessMessage = () => ( const AdminAccessMessage = () => (
<div className="p-6 max-w-lg mx-auto bg-white shadow rounded-lg mt-8 text-center"> <div className="p-6 max-w-6xl w-full mx-auto bg-white shadow rounded-lg mt-8 text-center">
<h1 className="text-2xl font-semibold text-gray-800 mb-2">Kein Zugriff</h1> <h1 className="text-2xl font-semibold text-gray-800 mb-2">Kein Zugriff</h1>
<p className="text-gray-600 mb-4">Dieser Bereich ist nur für Administratoren verfügbar.</p> <p className="text-gray-600 mb-4">Dieser Bereich ist nur für Administratoren verfügbar.</p>
<Link <Link

View File

@@ -49,7 +49,7 @@ const AdminSettingsPanel = ({
onSave onSave
}) => { }) => {
return ( return (
<div className="p-4 max-w-4xl mx-auto bg-white shadow-lg rounded-lg mt-4"> <div className="p-4 max-w-6xl w-full mx-auto bg-white shadow-lg rounded-lg mt-4">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-4"> <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-4">
<div> <div>
<h1 className="text-2xl font-bold text-purple-900">Admin-Einstellungen</h1> <h1 className="text-2xl font-bold text-purple-900">Admin-Einstellungen</h1>
@@ -224,6 +224,22 @@ const AdminSettingsPanel = ({
placeholder="z. B. 1000" placeholder="z. B. 1000"
/> />
</SettingField> </SettingField>
<SettingField
label="Store-Watch Status-Cache (Minuten)"
description="Wie lange Ergebnisse pro Store wiederverwendet werden, bevor erneut abgefragt wird."
>
<input
type="number"
min="0"
value={adminSettings.storeWatchStatusCacheMaxAgeMinutes}
onChange={(event) =>
onSettingChange('storeWatchStatusCacheMaxAgeMinutes', 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="z. B. 120"
/>
</SettingField>
</SettingSection> </SettingSection>
<SettingSection <SettingSection
@@ -233,23 +249,50 @@ const AdminSettingsPanel = ({
{(!adminSettings.ignoredSlots || adminSettings.ignoredSlots.length === 0) && ( {(!adminSettings.ignoredSlots || adminSettings.ignoredSlots.length === 0) && (
<p className="text-sm text-gray-500">Keine Regeln definiert.</p> <p className="text-sm text-gray-500">Keine Regeln definiert.</p>
)} )}
{adminSettings.ignoredSlots?.length > 0 && (
<div className="hidden md:grid md:grid-cols-6 gap-2 text-xs font-semibold text-gray-500 px-1">
<span>Store-ID</span>
<span className="md:col-span-2">Slotname</span>
<span className="md:col-span-2">Info</span>
<span>Aktion</span>
</div>
)}
{adminSettings.ignoredSlots?.map((slot, index) => ( {adminSettings.ignoredSlots?.map((slot, index) => (
(() => {
const storeIdMissing = !slot.storeId;
const slotNameMissing = !slot.slotName;
const showErrors = storeIdMissing || slotNameMissing;
return (
<div <div
key={`${index}-${slot.storeId}`} key={`${index}-${slot.storeId}`}
className="grid grid-cols-1 md:grid-cols-5 gap-2 items-center border border-purple-100 rounded p-3" className={`grid grid-cols-1 md:grid-cols-6 gap-2 items-center rounded p-3 ${
showErrors ? 'border border-red-200 bg-red-50/30' : 'border border-purple-100'
}`}
> >
<input <input
type="text" type="text"
value={slot.storeId} value={slot.storeId}
onChange={(event) => onIgnoredSlotChange(index, 'storeId', event.target.value)} onChange={(event) => onIgnoredSlotChange(index, 'storeId', event.target.value)}
placeholder="Store-ID" placeholder="Store-ID"
className="md:col-span-2 border rounded p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" className={`border rounded p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 ${
storeIdMissing ? 'border-red-400 focus:ring-red-500 focus:border-red-500' : ''
}`}
/> />
<input <input
type="text" type="text"
value={slot.description} value={slot.slotName}
onChange={(event) => onIgnoredSlotChange(index, 'description', event.target.value)} onChange={(event) => onIgnoredSlotChange(index, 'slotName', event.target.value)}
placeholder="Beschreibung (optional)" placeholder="Slotname"
required
className={`md:col-span-2 border rounded p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 ${
slotNameMissing ? 'border-red-400 focus:ring-red-500 focus:border-red-500' : ''
}`}
/>
<input
type="text"
value={slot.info}
onChange={(event) => onIgnoredSlotChange(index, 'info', event.target.value)}
placeholder="Info (optional)"
className="md:col-span-2 border rounded p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" className="md:col-span-2 border rounded p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/> />
<button <button
@@ -259,7 +302,14 @@ const AdminSettingsPanel = ({
> >
Entfernen Entfernen
</button> </button>
{showErrors && (
<p className="md:col-span-6 text-xs text-red-600">
Store-ID und Slotname sind Pflichtfelder.
</p>
)}
</div> </div>
);
})()
))} ))}
<button <button
type="button" type="button"

View File

@@ -335,7 +335,7 @@ const DashboardView = ({
}), }),
columnHelper.display({ columnHelper.display({
id: 'skipDormantCheck', id: 'skipDormantCheck',
header: () => <span>Ruhe-Prüfung</span>, header: () => <span>Inaktivitätswarnung</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<div <div
className="text-center" className="text-center"
@@ -348,9 +348,9 @@ const DashboardView = ({
<input <input
type="checkbox" type="checkbox"
className="h-5 w-5" className="h-5 w-5"
checked={!!row.original.skipDormantCheck} checked={!row.original.skipDormantCheck}
onChange={() => onToggleDormantSkip(row.original.id)} onChange={() => onToggleDormantSkip(row.original.id)}
title="Warnung bei ausbleibenden Abholungen/Hygiene für diesen Betrieb deaktivieren" title="Warnung bei ausbleibenden Abholungen/Hygiene für diesen Betrieb aktivieren"
/> />
</div> </div>
), ),
@@ -670,11 +670,11 @@ const DashboardView = ({
<input <input
type="checkbox" type="checkbox"
className="h-4 w-4" className="h-4 w-4"
checked={!!entry?.skipDormantCheck} checked={!entry?.skipDormantCheck}
onChange={() => onToggleDormantSkip(storeId)} onChange={() => onToggleDormantSkip(storeId)}
title="Warnung bei ausbleibenden Abholungen/Hygiene für diesen Betrieb deaktivieren" title="Warnung bei ausbleibenden Abholungen/Hygiene für diesen Betrieb aktivieren"
/> />
<span>Ruhe-Prüfung</span> <span>Inaktivitätswarnung</span>
</label> </label>
<button <button
onClick={() => onStoreSelect(store)} onClick={() => onStoreSelect(store)}

View File

@@ -1,8 +1,10 @@
import { useEffect, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom'; import { Link, useLocation, useNavigate } from 'react-router-dom';
const NavigationTabs = ({ isAdmin, onProtectedNavigate }) => { const NavigationTabs = ({ isAdmin, onProtectedNavigate }) => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
const tabs = [ const tabs = [
{ to: '/', label: 'Slots buchen' }, { to: '/', label: 'Slots buchen' },
@@ -28,25 +30,99 @@ const NavigationTabs = ({ isAdmin, onProtectedNavigate }) => {
proceed(); proceed();
}; };
return ( useEffect(() => {
<nav className="mb-4 flex gap-2" aria-label="Navigation"> setMobileOpen(false);
{tabs.map((tab) => { }, [location.pathname]);
const renderLink = (tab, className, options = {}) => {
const isActive = location.pathname === tab.to; const isActive = location.pathname === tab.to;
const combinedClassName = typeof className === 'function' ? className(isActive) : className;
return ( return (
<Link <Link
key={tab.to} key={tab.to}
to={tab.to} to={tab.to}
onClick={(event) => handleClick(event, tab.to)} onClick={(event) => handleClick(event, tab.to)}
className={`px-4 py-2 rounded-md border transition-colors ${ className={combinedClassName}
aria-current={isActive ? 'page' : undefined}
>
<span className="relative z-10">{tab.label}</span>
{options.showUnderline ? (
<span
className={`absolute inset-x-3 -bottom-1 h-0.5 rounded-full transition-all ${
isActive ? 'bg-blue-500 opacity-100' : 'bg-blue-200 opacity-0 group-hover:opacity-60'
}`}
/>
) : null}
</Link>
);
};
return (
<nav className="mb-4" aria-label="Navigation">
<div className="flex items-center justify-between sm:hidden">
<span className="text-sm font-semibold text-gray-600">Navigation</span>
<button
type="button"
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-3 py-2 text-gray-700 shadow-sm"
aria-expanded={mobileOpen}
aria-controls="mobile-navigation"
onClick={() => setMobileOpen((open) => !open)}
>
<svg
className="h-5 w-5"
viewBox="0 0 24 24"
stroke="currentColor"
fill="none"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{mobileOpen ? (
<>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</>
) : (
<>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</>
)}
</svg>
</button>
</div>
<div
id="mobile-navigation"
className={`mt-3 flex flex-col gap-2 sm:hidden ${mobileOpen ? '' : 'hidden'}`}
>
{tabs.map((tab) =>
renderLink(
tab,
(isActive) =>
`px-4 py-3 rounded-md border text-left transition-colors ${
isActive isActive
? 'bg-blue-500 text-white border-blue-600' ? 'bg-blue-500 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:border-blue-400' : 'bg-white text-gray-700 border-gray-300 hover:border-blue-400'
}`} }`
> )
{tab.label} )}
</Link> </div>
); <div className="hidden items-center gap-6 rounded-xl border border-gray-200 bg-white/80 px-3 py-2 shadow-sm sm:flex">
})} {tabs.map((tab) =>
renderLink(
tab,
(isActive) =>
`group relative rounded-md px-2 pb-2 pt-1 text-sm font-semibold tracking-tight transition-colors ${
isActive
? 'text-blue-600 bg-blue-50/70'
: 'text-gray-600 hover:bg-gray-100/80 hover:text-gray-900'
}`,
{ showUnderline: true }
)
)}
</div>
</nav> </nav>
); );
}; };

View File

@@ -1167,14 +1167,14 @@ const StoreWatchPage = ({
if (!authorizedFetch) { if (!authorizedFetch) {
return ( return (
<div className="p-4 max-w-4xl mx-auto"> <div className="p-4 max-w-6xl w-full mx-auto">
<p className="text-red-600">Keine Session aktiv.</p> <p className="text-red-600">Keine Session aktiv.</p>
</div> </div>
); );
} }
return ( return (
<div className="p-4 max-w-5xl mx-auto bg-white shadow rounded-lg mt-4"> <div className="p-4 max-w-6xl w-full mx-auto bg-white shadow rounded-lg mt-4">
<div className="flex flex-col gap-2 mb-4"> <div className="flex flex-col gap-2 mb-4">
<h1 className="text-2xl font-bold text-blue-900">Betriebs-Monitoring</h1> <h1 className="text-2xl font-bold text-blue-900">Betriebs-Monitoring</h1>
<p className="text-gray-600 text-sm"> <p className="text-gray-600 text-sm">

View File

@@ -126,7 +126,7 @@ const useAdminSettings = ({ session, authorizedFetch, setStatus, setError }) =>
} }
return { return {
...prev, ...prev,
ignoredSlots: [...(prev.ignoredSlots || []), { storeId: '', description: '' }] ignoredSlots: [...(prev.ignoredSlots || []), { storeId: '', slotName: '', info: '' }]
}; };
}); });
}, []); }, []);
@@ -149,6 +149,13 @@ const useAdminSettings = ({ session, authorizedFetch, setStatus, setError }) =>
if (!session?.token || !session.isAdmin || !adminSettings) { if (!session?.token || !session.isAdmin || !adminSettings) {
return; return;
} }
const invalidIgnoredSlot = (adminSettings.ignoredSlots || []).find(
(slot) => !slot?.storeId || !slot?.slotName
);
if (invalidIgnoredSlot) {
setError('Ignorierte Slots: Store-ID und Slotname sind Pflichtfelder.');
return;
}
setStatus('Admin-Einstellungen werden gespeichert...'); setStatus('Admin-Einstellungen werden gespeichert...');
setError(''); setError('');

View File

@@ -13,10 +13,12 @@ export const normalizeAdminSettings = (raw) => {
storeWatchInitialDelayMinSeconds: raw.storeWatchInitialDelayMinSeconds ?? '', storeWatchInitialDelayMinSeconds: raw.storeWatchInitialDelayMinSeconds ?? '',
storeWatchInitialDelayMaxSeconds: raw.storeWatchInitialDelayMaxSeconds ?? '', storeWatchInitialDelayMaxSeconds: raw.storeWatchInitialDelayMaxSeconds ?? '',
storeWatchRequestDelayMs: raw.storeWatchRequestDelayMs ?? '', storeWatchRequestDelayMs: raw.storeWatchRequestDelayMs ?? '',
storeWatchStatusCacheMaxAgeMinutes: raw.storeWatchStatusCacheMaxAgeMinutes ?? '',
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) : '',
description: slot?.description || '' slotName: slot?.slotName || slot?.description || '',
info: slot?.info || ''
})) }))
: [], : [],
notifications: { notifications: {
@@ -59,9 +61,11 @@ export const serializeAdminSettings = (adminSettings) => {
storeWatchInitialDelayMinSeconds: toNumberOrUndefined(adminSettings.storeWatchInitialDelayMinSeconds), storeWatchInitialDelayMinSeconds: toNumberOrUndefined(adminSettings.storeWatchInitialDelayMinSeconds),
storeWatchInitialDelayMaxSeconds: toNumberOrUndefined(adminSettings.storeWatchInitialDelayMaxSeconds), storeWatchInitialDelayMaxSeconds: toNumberOrUndefined(adminSettings.storeWatchInitialDelayMaxSeconds),
storeWatchRequestDelayMs: toNumberOrUndefined(adminSettings.storeWatchRequestDelayMs), storeWatchRequestDelayMs: toNumberOrUndefined(adminSettings.storeWatchRequestDelayMs),
storeWatchStatusCacheMaxAgeMinutes: toNumberOrUndefined(adminSettings.storeWatchStatusCacheMaxAgeMinutes),
ignoredSlots: (adminSettings.ignoredSlots || []).map((slot) => ({ ignoredSlots: (adminSettings.ignoredSlots || []).map((slot) => ({
storeId: slot.storeId || '', storeId: slot.storeId || '',
description: slot.description || '' slotName: slot.slotName || '',
info: slot.info || ''
})), })),
notifications: { notifications: {
ntfy: { ntfy: {