aktueller stand
This commit is contained in:
702
config/request-logs.json
Normal file
702
config/request-logs.json
Normal file
File diff suppressed because one or more lines are too long
48
server.js
48
server.js
@@ -11,7 +11,9 @@ const {
|
|||||||
scheduleConfig,
|
scheduleConfig,
|
||||||
runStoreWatchCheck,
|
runStoreWatchCheck,
|
||||||
runImmediatePickupCheck,
|
runImmediatePickupCheck,
|
||||||
runDormantMembershipCheck
|
runDormantMembershipCheck,
|
||||||
|
getRegularPickupSchedule,
|
||||||
|
scheduleRegularPickupRefresh
|
||||||
} = require('./services/pickupScheduler');
|
} = 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');
|
||||||
@@ -162,6 +164,7 @@ function rescheduleAllSessions() {
|
|||||||
const config = readConfig(session.profile.id);
|
const config = readConfig(session.profile.id);
|
||||||
scheduleConfig(session.id, config, settings);
|
scheduleConfig(session.id, config, settings);
|
||||||
});
|
});
|
||||||
|
scheduleRegularPickupRefresh(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeStoresIntoConfig(config = [], stores = []) {
|
function mergeStoresIntoConfig(config = [], stores = []) {
|
||||||
@@ -1323,19 +1326,45 @@ app.get('/api/stores/:storeId/regular-pickup', requireAuth, async (req, res) =>
|
|||||||
return res.status(400).json({ error: 'Store-ID fehlt' });
|
return res.status(400).json({ error: 'Store-ID fehlt' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const rules = await withSessionRetry(
|
const result = await getRegularPickupSchedule(req.session, storeId);
|
||||||
req.session,
|
res.json(result);
|
||||||
() => foodsharingClient.fetchRegularPickup(storeId, req.session.cookieHeader, req.session),
|
|
||||||
{ label: 'fetchRegularPickup' }
|
|
||||||
);
|
|
||||||
res.json(Array.isArray(rules) ? rules : []);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({ error: error.message || 'Regular-Pickup konnte nicht geladen werden' });
|
const message = error?.message || 'Regular-Pickup konnte nicht geladen werden';
|
||||||
|
return res.json({ rules: [], error: message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stores', requireAuth, async (req, res) => {
|
app.get('/api/stores', requireAuth, async (req, res) => {
|
||||||
res.json(req.session.storesCache?.data || []);
|
const stores = req.session.storesCache?.data || [];
|
||||||
|
let regularPickupMap = {};
|
||||||
|
try {
|
||||||
|
const profileId = req.session.profile?.id;
|
||||||
|
if (profileId) {
|
||||||
|
const config = readConfig(profileId);
|
||||||
|
const storeIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
(Array.isArray(config) ? config : [])
|
||||||
|
.filter((entry) => entry?.id && !entry.hidden)
|
||||||
|
.map((entry) => String(entry.id))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (storeIds.length > 0) {
|
||||||
|
const results = await Promise.all(
|
||||||
|
storeIds.map(async (storeId) => {
|
||||||
|
const result = await getRegularPickupSchedule(req.session, storeId);
|
||||||
|
return [storeId, Array.isArray(result.rules) ? result.rules : []];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
regularPickupMap = results.reduce((acc, [storeId, rules]) => {
|
||||||
|
acc[storeId] = rules;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[PICKUP] Regular-Pickup-Map konnte nicht geladen werden:', error.message);
|
||||||
|
}
|
||||||
|
res.json({ stores, regularPickupMap });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/stores/refresh', requireAuth, (req, res) => {
|
app.post('/api/stores/refresh', requireAuth, (req, res) => {
|
||||||
@@ -1388,6 +1417,7 @@ async function startServer() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[RESTORE] Fehler bei der Session-Wiederherstellung:', error.message);
|
console.error('[RESTORE] Fehler bei der Session-Wiederherstellung:', error.message);
|
||||||
}
|
}
|
||||||
|
scheduleRegularPickupRefresh(adminConfig.readSettings());
|
||||||
startBackgroundStoreRefreshTicker();
|
startBackgroundStoreRefreshTicker();
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`Server läuft auf Port ${port}`);
|
console.log(`Server läuft auf Port ${port}`);
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ const DEFAULT_SETTINGS = {
|
|||||||
scheduleCron: '*/10 7-22 * * *',
|
scheduleCron: '*/10 7-22 * * *',
|
||||||
pickupFallbackCron: '0 7,12,17,22 * * *',
|
pickupFallbackCron: '0 7,12,17,22 * * *',
|
||||||
pickupWindowOffsetsMinutes: [-1, -0.5, 0, 0.5, 1, 1.5],
|
pickupWindowOffsetsMinutes: [-1, -0.5, 0, 0.5, 1, 1.5],
|
||||||
|
regularPickupRefreshCron: '0 3 * * *',
|
||||||
|
dormantMembershipCron: '0 4 */14 * *',
|
||||||
randomDelayMinSeconds: 10,
|
randomDelayMinSeconds: 10,
|
||||||
randomDelayMaxSeconds: 120,
|
randomDelayMaxSeconds: 120,
|
||||||
initialDelayMinSeconds: 5,
|
initialDelayMinSeconds: 5,
|
||||||
@@ -133,6 +135,8 @@ function readSettings() {
|
|||||||
parsed.pickupWindowOffsetsMinutes,
|
parsed.pickupWindowOffsetsMinutes,
|
||||||
DEFAULT_SETTINGS.pickupWindowOffsetsMinutes
|
DEFAULT_SETTINGS.pickupWindowOffsetsMinutes
|
||||||
),
|
),
|
||||||
|
regularPickupRefreshCron: parsed.regularPickupRefreshCron || DEFAULT_SETTINGS.regularPickupRefreshCron,
|
||||||
|
dormantMembershipCron: parsed.dormantMembershipCron || DEFAULT_SETTINGS.dormantMembershipCron,
|
||||||
randomDelayMinSeconds: sanitizeNumber(parsed.randomDelayMinSeconds, DEFAULT_SETTINGS.randomDelayMinSeconds),
|
randomDelayMinSeconds: sanitizeNumber(parsed.randomDelayMinSeconds, DEFAULT_SETTINGS.randomDelayMinSeconds),
|
||||||
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),
|
||||||
@@ -176,6 +180,8 @@ function writeSettings(patch = {}) {
|
|||||||
patch.pickupWindowOffsetsMinutes,
|
patch.pickupWindowOffsetsMinutes,
|
||||||
current.pickupWindowOffsetsMinutes
|
current.pickupWindowOffsetsMinutes
|
||||||
),
|
),
|
||||||
|
regularPickupRefreshCron: patch.regularPickupRefreshCron || current.regularPickupRefreshCron,
|
||||||
|
dormantMembershipCron: patch.dormantMembershipCron || current.dormantMembershipCron,
|
||||||
randomDelayMinSeconds: sanitizeNumber(patch.randomDelayMinSeconds, current.randomDelayMinSeconds),
|
randomDelayMinSeconds: sanitizeNumber(patch.randomDelayMinSeconds, current.randomDelayMinSeconds),
|
||||||
randomDelayMaxSeconds: sanitizeNumber(patch.randomDelayMaxSeconds, current.randomDelayMaxSeconds),
|
randomDelayMaxSeconds: sanitizeNumber(patch.randomDelayMaxSeconds, current.randomDelayMaxSeconds),
|
||||||
initialDelayMinSeconds: sanitizeNumber(patch.initialDelayMinSeconds, current.initialDelayMinSeconds),
|
initialDelayMinSeconds: sanitizeNumber(patch.initialDelayMinSeconds, current.initialDelayMinSeconds),
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
|
const http = require('http');
|
||||||
|
const https = require('https');
|
||||||
const requestLogStore = require('./requestLogStore');
|
const requestLogStore = require('./requestLogStore');
|
||||||
const sessionStore = require('./sessionStore');
|
const sessionStore = require('./sessionStore');
|
||||||
|
|
||||||
const BASE_URL = 'https://foodsharing.de';
|
const BASE_URL = 'https://foodsharing.de';
|
||||||
|
|
||||||
|
const keepAliveHttpAgent = new http.Agent({ keepAlive: true, maxSockets: 10 });
|
||||||
|
const keepAliveHttpsAgent = new https.Agent({ keepAlive: true, maxSockets: 10 });
|
||||||
|
|
||||||
const client = axios.create({
|
const client = axios.create({
|
||||||
baseURL: BASE_URL,
|
baseURL: BASE_URL,
|
||||||
timeout: 20000,
|
timeout: 20000,
|
||||||
|
httpAgent: keepAliveHttpAgent,
|
||||||
|
httpsAgent: keepAliveHttpsAgent,
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'pickup-config/1.0 (+https://foodsharing.de)',
|
'User-Agent': 'pickup-config/1.0 (+https://foodsharing.de)',
|
||||||
Accept: 'application/json, text/plain, */*'
|
Accept: 'application/json, text/plain, */*'
|
||||||
|
|||||||
@@ -37,6 +37,23 @@ function formatDateOnly(dateInput) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractFirstName(profileName) {
|
||||||
|
if (!profileName || typeof profileName !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = profileName.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (trimmed.includes(',')) {
|
||||||
|
const parts = trimmed.split(',').map((part) => part.trim()).filter(Boolean);
|
||||||
|
if (parts.length > 1) {
|
||||||
|
return parts[1].split(/\s+/)[0] || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return trimmed.split(/\s+/)[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
async function sendNtfyNotification(adminNtfy, userNtfy, payload) {
|
async function sendNtfyNotification(adminNtfy, userNtfy, payload) {
|
||||||
if (!adminNtfy?.enabled || !userNtfy?.enabled || !userNtfy.topic) {
|
if (!adminNtfy?.enabled || !userNtfy?.enabled || !userNtfy.topic) {
|
||||||
return;
|
return;
|
||||||
@@ -66,12 +83,18 @@ async function sendTelegramNotification(adminTelegram, userTelegram, payload) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const endpoint = `https://api.telegram.org/bot${adminTelegram.botToken}/sendMessage`;
|
const endpoint = `https://api.telegram.org/bot${adminTelegram.botToken}/sendMessage`;
|
||||||
|
const profileLabel = extractFirstName(payload?.profileName);
|
||||||
|
const messageParts = [
|
||||||
|
payload?.title ? `*${payload.title}*` : null,
|
||||||
|
profileLabel ? `Profil: ${profileLabel}` : null,
|
||||||
|
payload?.message || null
|
||||||
|
].filter(Boolean);
|
||||||
await axios.post(
|
await axios.post(
|
||||||
endpoint,
|
endpoint,
|
||||||
{
|
{
|
||||||
chat_id: userTelegram.chatId,
|
chat_id: userTelegram.chatId,
|
||||||
text: payload.title ? `*${payload.title}*\n${payload.message}` : payload.message,
|
text: messageParts.join('\n'),
|
||||||
parse_mode: payload.title ? 'Markdown' : undefined,
|
parse_mode: payload?.title ? 'Markdown' : undefined,
|
||||||
disable_web_page_preview: true
|
disable_web_page_preview: true
|
||||||
},
|
},
|
||||||
{ timeout: 15000 }
|
{ timeout: 15000 }
|
||||||
@@ -104,7 +127,7 @@ async function notifyChannels(profileId, template) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendSlotNotification({ profileId, storeName, pickupDate, onlyNotify, booked, storeId }) {
|
async function sendSlotNotification({ profileId, profileName, storeName, pickupDate, onlyNotify, booked, storeId }) {
|
||||||
const dateLabel = formatDateLabel(pickupDate);
|
const dateLabel = formatDateLabel(pickupDate);
|
||||||
const title = onlyNotify
|
const title = onlyNotify
|
||||||
? `Slot verfügbar bei ${storeName}`
|
? `Slot verfügbar bei ${storeName}`
|
||||||
@@ -123,7 +146,8 @@ async function sendSlotNotification({ profileId, storeName, pickupDate, onlyNoti
|
|||||||
title,
|
title,
|
||||||
message: fullMessage,
|
message: fullMessage,
|
||||||
link: storeLink,
|
link: storeLink,
|
||||||
priority: booked ? 'high' : 'default'
|
priority: booked ? 'high' : 'default',
|
||||||
|
profileName
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +161,7 @@ function formatStoreWatchStatus(status) {
|
|||||||
return 'Status unbekannt';
|
return 'Status unbekannt';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendStoreWatchNotification({ profileId, storeName, storeId, regionName }) {
|
async function sendStoreWatchNotification({ profileId, profileName, storeName, storeId, regionName }) {
|
||||||
const storeLink = storeId ? `https://foodsharing.de/store/${storeId}` : null;
|
const storeLink = storeId ? `https://foodsharing.de/store/${storeId}` : null;
|
||||||
const title = `Team sucht Verstärkung: ${storeName}`;
|
const title = `Team sucht Verstärkung: ${storeName}`;
|
||||||
const regionText = regionName ? ` (${regionName})` : '';
|
const regionText = regionName ? ` (${regionName})` : '';
|
||||||
@@ -147,11 +171,12 @@ async function sendStoreWatchNotification({ profileId, storeName, storeId, regio
|
|||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
link: storeLink,
|
link: storeLink,
|
||||||
priority: 'high'
|
priority: 'high',
|
||||||
|
profileName
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendStoreWatchSummaryNotification({ profileId, entries = [], triggeredBy = 'manual' }) {
|
async function sendStoreWatchSummaryNotification({ profileId, profileName, entries = [], triggeredBy = 'manual' }) {
|
||||||
if (!profileId || !Array.isArray(entries) || entries.length === 0) {
|
if (!profileId || !Array.isArray(entries) || entries.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -174,11 +199,12 @@ async function sendStoreWatchSummaryNotification({ profileId, entries = [], trig
|
|||||||
await notifyChannels(profileId, {
|
await notifyChannels(profileId, {
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
priority: 'default'
|
priority: 'default',
|
||||||
|
profileName
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendDesiredWindowMissedNotification({ profileId, storeName, desiredWindowLabel }) {
|
async function sendDesiredWindowMissedNotification({ profileId, profileName, storeName, desiredWindowLabel }) {
|
||||||
if (!profileId) {
|
if (!profileId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -191,7 +217,8 @@ async function sendDesiredWindowMissedNotification({ profileId, storeName, desir
|
|||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
link: null,
|
link: null,
|
||||||
priority: 'default'
|
priority: 'default',
|
||||||
|
profileName
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +255,7 @@ async function sendTestNotification(profileId, channel) {
|
|||||||
await Promise.all(tasks);
|
await Promise.all(tasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendDormantPickupWarning({ profileId, storeName, storeId, reasonLines = [] }) {
|
async function sendDormantPickupWarning({ profileId, profileName, storeName, storeId, reasonLines = [] }) {
|
||||||
if (!profileId || !Array.isArray(reasonLines) || reasonLines.length === 0) {
|
if (!profileId || !Array.isArray(reasonLines) || reasonLines.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -242,12 +269,14 @@ async function sendDormantPickupWarning({ profileId, storeName, storeId, reasonL
|
|||||||
await sendTelegramNotification(adminSettings.notifications?.telegram, userSettings.notifications?.telegram, {
|
await sendTelegramNotification(adminSettings.notifications?.telegram, userSettings.notifications?.telegram, {
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
priority: 'high'
|
priority: 'high',
|
||||||
|
profileName
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendJournalReminderNotification({
|
async function sendJournalReminderNotification({
|
||||||
profileId,
|
profileId,
|
||||||
|
profileName,
|
||||||
storeName,
|
storeName,
|
||||||
pickupDate,
|
pickupDate,
|
||||||
reminderDate,
|
reminderDate,
|
||||||
@@ -270,7 +299,8 @@ async function sendJournalReminderNotification({
|
|||||||
await sendTelegramNotification(adminSettings.notifications?.telegram, userSettings.notifications?.telegram, {
|
await sendTelegramNotification(adminSettings.notifications?.telegram, userSettings.notifications?.telegram, {
|
||||||
title,
|
title,
|
||||||
message: messageLines.join('\n'),
|
message: messageLines.join('\n'),
|
||||||
priority: 'default'
|
priority: 'default',
|
||||||
|
profileName
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
const cron = require('node-cron');
|
const cron = require('node-cron');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
const foodsharingClient = require('./foodsharingClient');
|
const foodsharingClient = require('./foodsharingClient');
|
||||||
const sessionStore = require('./sessionStore');
|
const sessionStore = require('./sessionStore');
|
||||||
const { DEFAULT_SETTINGS } = require('./adminConfig');
|
const { DEFAULT_SETTINGS } = require('./adminConfig');
|
||||||
@@ -23,7 +25,96 @@ const pickupCheckInFlight = new Map();
|
|||||||
const pickupCheckLastRun = new Map();
|
const pickupCheckLastRun = new Map();
|
||||||
const PICKUP_CHECK_DEDUP_MS = 30 * 1000;
|
const PICKUP_CHECK_DEDUP_MS = 30 * 1000;
|
||||||
const regularPickupCache = new Map();
|
const regularPickupCache = new Map();
|
||||||
|
const REGULAR_PICKUP_CACHE_FILE = path.join(__dirname, '..', 'config', 'regular-pickup-cache.json');
|
||||||
|
const REGULAR_PICKUP_CACHE_WRITE_DEBOUNCE_MS = 2000;
|
||||||
|
let regularPickupCacheWriteTimer = null;
|
||||||
|
let regularPickupCacheDirty = false;
|
||||||
const REGULAR_PICKUP_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
|
const REGULAR_PICKUP_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
|
||||||
|
const REGULAR_PICKUP_ERROR_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
const REGULAR_PICKUP_TRANSIENT_ERROR_TTL_MS = 30 * 1000;
|
||||||
|
const REGULAR_PICKUP_MAX_CONCURRENT = 3;
|
||||||
|
const REGULAR_PICKUP_MIN_DELAY_MS = 150;
|
||||||
|
const REGULAR_PICKUP_MAX_DELAY_MS = 350;
|
||||||
|
const regularPickupInFlight = new Map();
|
||||||
|
const regularPickupQueue = [];
|
||||||
|
let regularPickupActive = 0;
|
||||||
|
let regularPickupRefreshJob = null;
|
||||||
|
const dormantWarningCooldowns = new Map();
|
||||||
|
const DORMANT_WARNING_COOLDOWN_MS = 6 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function ensureRegularPickupCacheDir() {
|
||||||
|
const dir = path.dirname(REGULAR_PICKUP_CACHE_FILE);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistRegularPickupCache() {
|
||||||
|
if (!regularPickupCacheDirty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ensureRegularPickupCacheDir();
|
||||||
|
const now = Date.now();
|
||||||
|
const entries = {};
|
||||||
|
regularPickupCache.forEach((value, key) => {
|
||||||
|
if (value?.expiresAt && value.expiresAt < now) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entries[key] = value;
|
||||||
|
});
|
||||||
|
fs.writeFileSync(
|
||||||
|
REGULAR_PICKUP_CACHE_FILE,
|
||||||
|
JSON.stringify({ version: 1, entries }, null, 2)
|
||||||
|
);
|
||||||
|
regularPickupCacheDirty = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[PICKUP] Regular-Pickup-Cache konnte nicht geschrieben werden:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRegularPickupCachePersist() {
|
||||||
|
if (regularPickupCacheWriteTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
regularPickupCacheWriteTimer = setTimeout(() => {
|
||||||
|
regularPickupCacheWriteTimer = null;
|
||||||
|
persistRegularPickupCache();
|
||||||
|
}, REGULAR_PICKUP_CACHE_WRITE_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markRegularPickupCacheDirty() {
|
||||||
|
regularPickupCacheDirty = true;
|
||||||
|
scheduleRegularPickupCachePersist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRegularPickupCacheFromDisk() {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(REGULAR_PICKUP_CACHE_FILE)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const raw = fs.readFileSync(REGULAR_PICKUP_CACHE_FILE, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const entries = parsed?.entries && typeof parsed.entries === 'object' ? parsed.entries : {};
|
||||||
|
const now = Date.now();
|
||||||
|
Object.entries(entries).forEach(([key, value]) => {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value.expiresAt && value.expiresAt < now) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
regularPickupCache.set(String(key), {
|
||||||
|
fetchedAt: Number(value.fetchedAt) || Date.now(),
|
||||||
|
expiresAt: Number(value.expiresAt) || null,
|
||||||
|
rules: Array.isArray(value.rules) ? value.rules : [],
|
||||||
|
error: value.error || null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[PICKUP] Regular-Pickup-Cache konnte nicht geladen werden:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
const PICKUP_FALLBACK_RETRY_MS = 60 * 60 * 1000;
|
const PICKUP_FALLBACK_RETRY_MS = 60 * 60 * 1000;
|
||||||
const TIME_ZONE = 'Europe/Berlin';
|
const TIME_ZONE = 'Europe/Berlin';
|
||||||
|
|
||||||
@@ -87,6 +178,12 @@ function randomDelayMs(minSeconds = 10, maxSeconds = 120) {
|
|||||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function randomDelayMsBetween(minMs, maxMs) {
|
||||||
|
const min = Math.max(0, Number(minMs) || 0);
|
||||||
|
const max = Math.max(min, Number(maxMs) || 0);
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
|
||||||
function getTimeZoneParts(date, timeZone) {
|
function getTimeZoneParts(date, timeZone) {
|
||||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||||
timeZone,
|
timeZone,
|
||||||
@@ -250,6 +347,7 @@ function resolveSettings(settings) {
|
|||||||
initialDelayMaxSeconds: Number.isFinite(settings.initialDelayMaxSeconds)
|
initialDelayMaxSeconds: Number.isFinite(settings.initialDelayMaxSeconds)
|
||||||
? settings.initialDelayMaxSeconds
|
? settings.initialDelayMaxSeconds
|
||||||
: DEFAULT_SETTINGS.initialDelayMaxSeconds,
|
: DEFAULT_SETTINGS.initialDelayMaxSeconds,
|
||||||
|
dormantMembershipCron: settings.dormantMembershipCron || DEFAULT_SETTINGS.dormantMembershipCron,
|
||||||
storeWatchCron: settings.storeWatchCron || DEFAULT_SETTINGS.storeWatchCron,
|
storeWatchCron: settings.storeWatchCron || DEFAULT_SETTINGS.storeWatchCron,
|
||||||
storeWatchInitialDelayMinSeconds: Number.isFinite(settings.storeWatchInitialDelayMinSeconds)
|
storeWatchInitialDelayMinSeconds: Number.isFinite(settings.storeWatchInitialDelayMinSeconds)
|
||||||
? settings.storeWatchInitialDelayMinSeconds
|
? settings.storeWatchInitialDelayMinSeconds
|
||||||
@@ -280,29 +378,121 @@ function resolveSettings(settings) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchRegularPickupSchedule(session, storeId) {
|
function describeRegularPickupError(error) {
|
||||||
|
const status = error?.response?.status;
|
||||||
|
if (status) {
|
||||||
|
return `HTTP ${status}`;
|
||||||
|
}
|
||||||
|
const code = error?.code;
|
||||||
|
if (code) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
const message = error?.message;
|
||||||
|
if (message) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
return 'Unknown error';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRegularPickupErrorTtlMs(error) {
|
||||||
|
const status = error?.response?.status;
|
||||||
|
if (status === 403 || status === 404) {
|
||||||
|
return REGULAR_PICKUP_ERROR_CACHE_TTL_MS;
|
||||||
|
}
|
||||||
|
const code = error?.code;
|
||||||
|
if (code) {
|
||||||
|
return REGULAR_PICKUP_TRANSIENT_ERROR_TTL_MS;
|
||||||
|
}
|
||||||
|
return REGULAR_PICKUP_ERROR_CACHE_TTL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runWithRegularPickupLimiter(task) {
|
||||||
|
if (regularPickupActive < REGULAR_PICKUP_MAX_CONCURRENT) {
|
||||||
|
regularPickupActive += 1;
|
||||||
|
return Promise.resolve()
|
||||||
|
.then(async () => {
|
||||||
|
const delayMs = randomDelayMsBetween(REGULAR_PICKUP_MIN_DELAY_MS, REGULAR_PICKUP_MAX_DELAY_MS);
|
||||||
|
if (delayMs > 0) {
|
||||||
|
await wait(delayMs);
|
||||||
|
}
|
||||||
|
return task();
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
regularPickupActive -= 1;
|
||||||
|
const next = regularPickupQueue.shift();
|
||||||
|
if (next) {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
regularPickupQueue.push(() => {
|
||||||
|
runWithRegularPickupLimiter(task).then(resolve).catch(reject);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRegularPickupCacheEntry(storeId) {
|
||||||
|
const entry = regularPickupCache.get(String(storeId));
|
||||||
|
if (!entry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (entry.expiresAt && entry.expiresAt < Date.now()) {
|
||||||
|
regularPickupCache.delete(String(storeId));
|
||||||
|
markRegularPickupCacheDirty();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRegularPickupCacheEntry(storeId, rules, ttlMs, error) {
|
||||||
|
regularPickupCache.set(String(storeId), {
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
expiresAt: Date.now() + ttlMs,
|
||||||
|
rules: Array.isArray(rules) ? rules : [],
|
||||||
|
error: error || null
|
||||||
|
});
|
||||||
|
markRegularPickupCacheDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRegularPickupSchedule(session, storeId) {
|
||||||
if (!session?.profile?.id || !storeId) {
|
if (!session?.profile?.id || !storeId) {
|
||||||
return [];
|
return { rules: [], error: 'missing-session-or-store', fromCache: false };
|
||||||
}
|
}
|
||||||
const key = String(storeId);
|
const key = String(storeId);
|
||||||
const cached = regularPickupCache.get(key);
|
const cached = getRegularPickupCacheEntry(key);
|
||||||
if (cached && Date.now() - cached.fetchedAt <= REGULAR_PICKUP_CACHE_TTL_MS) {
|
if (cached) {
|
||||||
return cached.rules;
|
return { rules: cached.rules, error: cached.error, fromCache: true };
|
||||||
}
|
}
|
||||||
try {
|
const existing = regularPickupInFlight.get(key);
|
||||||
const rules = await withSessionRetry(
|
if (existing) {
|
||||||
session,
|
return existing;
|
||||||
() => foodsharingClient.fetchRegularPickup(storeId, session.cookieHeader, session),
|
}
|
||||||
{ label: 'fetchRegularPickup' }
|
const fetchPromise = runWithRegularPickupLimiter(async () => {
|
||||||
);
|
try {
|
||||||
const normalized = Array.isArray(rules) ? rules : [];
|
const rules = await withSessionRetry(
|
||||||
regularPickupCache.set(key, { fetchedAt: Date.now(), rules: normalized });
|
session,
|
||||||
return normalized;
|
() => foodsharingClient.fetchRegularPickup(storeId, session.cookieHeader, session),
|
||||||
} catch (error) {
|
{ label: 'fetchRegularPickup' }
|
||||||
if (cached?.rules) {
|
);
|
||||||
return cached.rules;
|
const normalized = Array.isArray(rules) ? rules : [];
|
||||||
|
setRegularPickupCacheEntry(key, normalized, REGULAR_PICKUP_CACHE_TTL_MS);
|
||||||
|
return { rules: normalized, error: null, fromCache: false };
|
||||||
|
} catch (error) {
|
||||||
|
const message = describeRegularPickupError(error);
|
||||||
|
const ttl = getRegularPickupErrorTtlMs(error);
|
||||||
|
setRegularPickupCacheEntry(key, [], ttl, message);
|
||||||
|
return { rules: [], error: message, fromCache: false };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
regularPickupInFlight.set(key, fetchPromise);
|
||||||
|
try {
|
||||||
|
return await fetchPromise;
|
||||||
|
} finally {
|
||||||
|
if (regularPickupInFlight.get(key) === fetchPromise) {
|
||||||
|
regularPickupInFlight.delete(key);
|
||||||
}
|
}
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,7 +517,7 @@ async function getNextPickupCheckTime(session, entry, settings) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rules = await fetchRegularPickupSchedule(session, entry.id);
|
const { rules } = await getRegularPickupSchedule(session, entry.id);
|
||||||
if (!Array.isArray(rules) || rules.length === 0) {
|
if (!Array.isArray(rules) || rules.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -518,6 +708,7 @@ async function handleExpiredDesiredWindow(session, entry) {
|
|||||||
try {
|
try {
|
||||||
await notificationService.sendDesiredWindowMissedNotification({
|
await notificationService.sendDesiredWindowMissedNotification({
|
||||||
profileId,
|
profileId,
|
||||||
|
profileName: session.profile?.name,
|
||||||
storeName,
|
storeName,
|
||||||
desiredWindowLabel: desiredLabel
|
desiredWindowLabel: desiredLabel
|
||||||
});
|
});
|
||||||
@@ -588,6 +779,7 @@ async function processBooking(session, entry, pickup) {
|
|||||||
console.log(`[INFO] Slot gefunden (nur Hinweis) für ${storeName} am ${readableDate}`);
|
console.log(`[INFO] Slot gefunden (nur Hinweis) für ${storeName} am ${readableDate}`);
|
||||||
await notificationService.sendSlotNotification({
|
await notificationService.sendSlotNotification({
|
||||||
profileId: session.profile.id,
|
profileId: session.profile.id,
|
||||||
|
profileName: session.profile?.name,
|
||||||
storeName,
|
storeName,
|
||||||
pickupDate: pickup.date,
|
pickupDate: pickup.date,
|
||||||
onlyNotify: true,
|
onlyNotify: true,
|
||||||
@@ -618,6 +810,7 @@ async function processBooking(session, entry, pickup) {
|
|||||||
console.log(`[SUCCESS] Slot gebucht für ${storeName} am ${readableDate}`);
|
console.log(`[SUCCESS] Slot gebucht für ${storeName} am ${readableDate}`);
|
||||||
await notificationService.sendSlotNotification({
|
await notificationService.sendSlotNotification({
|
||||||
profileId: session.profile.id,
|
profileId: session.profile.id,
|
||||||
|
profileName: session.profile?.name,
|
||||||
storeName,
|
storeName,
|
||||||
pickupDate: pickup.date,
|
pickupDate: pickup.date,
|
||||||
onlyNotify: false,
|
onlyNotify: false,
|
||||||
@@ -775,6 +968,7 @@ async function checkWatchedStores(sessionId, settings = DEFAULT_SETTINGS, option
|
|||||||
if (status === 1 && watcher.lastTeamSearchStatus !== 1) {
|
if (status === 1 && watcher.lastTeamSearchStatus !== 1) {
|
||||||
await notificationService.sendStoreWatchNotification({
|
await notificationService.sendStoreWatchNotification({
|
||||||
profileId: session.profile.id,
|
profileId: session.profile.id,
|
||||||
|
profileName: session.profile?.name,
|
||||||
storeName: watcher.storeName,
|
storeName: watcher.storeName,
|
||||||
storeId: watcher.storeId,
|
storeId: watcher.storeId,
|
||||||
regionName: watcher.regionName
|
regionName: watcher.regionName
|
||||||
@@ -824,6 +1018,7 @@ async function checkWatchedStores(sessionId, settings = DEFAULT_SETTINGS, option
|
|||||||
try {
|
try {
|
||||||
await notificationService.sendStoreWatchSummaryNotification({
|
await notificationService.sendStoreWatchSummaryNotification({
|
||||||
profileId: session.profile.id,
|
profileId: session.profile.id,
|
||||||
|
profileName: session.profile?.name,
|
||||||
entries: summary,
|
entries: summary,
|
||||||
triggeredBy: options.triggeredBy || 'manual'
|
triggeredBy: options.triggeredBy || 'manual'
|
||||||
});
|
});
|
||||||
@@ -868,6 +1063,51 @@ function scheduleStoreWatchers(sessionId, settings) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleRegularPickupRefresh(settings) {
|
||||||
|
if (regularPickupRefreshJob) {
|
||||||
|
regularPickupRefreshJob.stop();
|
||||||
|
regularPickupRefreshJob = null;
|
||||||
|
}
|
||||||
|
const cronExpression = settings.regularPickupRefreshCron || DEFAULT_SETTINGS.regularPickupRefreshCron;
|
||||||
|
if (!cronExpression) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
regularPickupRefreshJob = cron.schedule(
|
||||||
|
cronExpression,
|
||||||
|
async () => {
|
||||||
|
const sessions = sessionStore.list();
|
||||||
|
const storeSessionMap = new Map();
|
||||||
|
for (const session of sessions) {
|
||||||
|
if (!session?.profile?.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const config = readConfig(session.profile.id);
|
||||||
|
const entries = Array.isArray(config) ? config : [];
|
||||||
|
const storeIds = Array.from(
|
||||||
|
new Set(entries.filter((entry) => entry?.id && !entry.hidden).map((entry) => String(entry.id)))
|
||||||
|
);
|
||||||
|
const sessionUpdatedAt = Number(session.updatedAt) || 0;
|
||||||
|
storeIds.forEach((storeId) => {
|
||||||
|
const existing = storeSessionMap.get(storeId);
|
||||||
|
if (!existing || (Number(existing.updatedAt) || 0) < sessionUpdatedAt) {
|
||||||
|
storeSessionMap.set(storeId, session);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [storeId, session] of storeSessionMap.entries()) {
|
||||||
|
const ready = await ensureSession(session);
|
||||||
|
if (!ready) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await getRegularPickupSchedule(session, storeId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timezone: TIME_ZONE }
|
||||||
|
);
|
||||||
|
return regularPickupRefreshJob;
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleFallbackPickupChecks(sessionId, settings) {
|
function scheduleFallbackPickupChecks(sessionId, settings) {
|
||||||
const cronExpression = settings.pickupFallbackCron || DEFAULT_SETTINGS.pickupFallbackCron;
|
const cronExpression = settings.pickupFallbackCron || DEFAULT_SETTINGS.pickupFallbackCron;
|
||||||
if (!cronExpression) {
|
if (!cronExpression) {
|
||||||
@@ -940,7 +1180,7 @@ 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);
|
||||||
scheduleDormantMembershipCheck(sessionId);
|
scheduleDormantMembershipCheck(sessionId, resolvedSettings);
|
||||||
const watchScheduled = scheduleStoreWatchers(sessionId, resolvedSettings);
|
const watchScheduled = scheduleStoreWatchers(sessionId, resolvedSettings);
|
||||||
scheduleFallbackPickupChecks(sessionId, resolvedSettings);
|
scheduleFallbackPickupChecks(sessionId, resolvedSettings);
|
||||||
scheduleJournalReminders(sessionId);
|
scheduleJournalReminders(sessionId);
|
||||||
@@ -1101,13 +1341,20 @@ async function checkDormantMembers(sessionId, options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (reasons.length > 0) {
|
if (reasons.length > 0) {
|
||||||
|
const cooldownKey = `${profileId}:${storeId}`;
|
||||||
|
const lastSentAt = dormantWarningCooldowns.get(cooldownKey);
|
||||||
|
if (lastSentAt && Date.now() - lastSentAt < DORMANT_WARNING_COOLDOWN_MS) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await sendDormantPickupWarning({
|
await sendDormantPickupWarning({
|
||||||
profileId,
|
profileId,
|
||||||
|
profileName: session.profile?.name,
|
||||||
storeName: target.storeName,
|
storeName: target.storeName,
|
||||||
storeId,
|
storeId,
|
||||||
reasonLines: reasons
|
reasonLines: reasons
|
||||||
});
|
});
|
||||||
|
dormantWarningCooldowns.set(cooldownKey, Date.now());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[DORMANT] Warnung für Store ${storeId} konnte nicht versendet werden:`, error.message);
|
console.error(`[DORMANT] Warnung für Store ${storeId} konnte nicht versendet werden:`, error.message);
|
||||||
}
|
}
|
||||||
@@ -1122,8 +1369,9 @@ async function checkDormantMembers(sessionId, options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleDormantMembershipCheck(sessionId) {
|
function scheduleDormantMembershipCheck(sessionId, settings) {
|
||||||
const cronExpression = '0 4 */14 * *';
|
const cronExpression =
|
||||||
|
settings?.dormantMembershipCron || DEFAULT_SETTINGS.dormantMembershipCron || '0 4 */14 * *';
|
||||||
const job = cron.schedule(
|
const job = cron.schedule(
|
||||||
cronExpression,
|
cronExpression,
|
||||||
() => {
|
() => {
|
||||||
@@ -1165,6 +1413,7 @@ async function checkJournalReminders(sessionId) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const profileId = session.profile.id;
|
const profileId = session.profile.id;
|
||||||
|
const profileName = session.profile?.name;
|
||||||
const entries = readJournal(profileId);
|
const entries = readJournal(profileId);
|
||||||
if (!Array.isArray(entries) || entries.length === 0) {
|
if (!Array.isArray(entries) || entries.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -1197,6 +1446,7 @@ async function checkJournalReminders(sessionId) {
|
|||||||
}
|
}
|
||||||
await sendJournalReminderNotification({
|
await sendJournalReminderNotification({
|
||||||
profileId,
|
profileId,
|
||||||
|
profileName,
|
||||||
storeName: entry.storeName || `Store ${entry.storeId || ''}`,
|
storeName: entry.storeName || `Store ${entry.storeId || ''}`,
|
||||||
pickupDate: occurrence,
|
pickupDate: occurrence,
|
||||||
reminderDate,
|
reminderDate,
|
||||||
@@ -1235,5 +1485,9 @@ module.exports = {
|
|||||||
scheduleConfig,
|
scheduleConfig,
|
||||||
runStoreWatchCheck,
|
runStoreWatchCheck,
|
||||||
runImmediatePickupCheck,
|
runImmediatePickupCheck,
|
||||||
runDormantMembershipCheck
|
runDormantMembershipCheck,
|
||||||
|
getRegularPickupSchedule,
|
||||||
|
scheduleRegularPickupRefresh
|
||||||
};
|
};
|
||||||
|
|
||||||
|
loadRegularPickupCacheFromDisk();
|
||||||
|
|||||||
40
src/App.js
40
src/App.js
@@ -140,6 +140,7 @@ function App() {
|
|||||||
setError,
|
setError,
|
||||||
setLoading,
|
setLoading,
|
||||||
setStores,
|
setStores,
|
||||||
|
setRegularPickupMap,
|
||||||
setConfig,
|
setConfig,
|
||||||
normalizeConfigEntries,
|
normalizeConfigEntries,
|
||||||
setIsDirty,
|
setIsDirty,
|
||||||
@@ -552,45 +553,6 @@ function App() {
|
|||||||
|
|
||||||
const visibleConfig = useMemo(() => config.filter((item) => !item.hidden), [config]);
|
const visibleConfig = useMemo(() => config.filter((item) => !item.hidden), [config]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
if (!session?.token || !authorizedFetch || visibleConfig.length === 0) {
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const uniqueIds = Array.from(new Set(visibleConfig.map((item) => String(item.id))));
|
|
||||||
const missing = uniqueIds.filter((id) => regularPickupMap[id] === undefined);
|
|
||||||
if (missing.length === 0) {
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const fetchSchedules = async () => {
|
|
||||||
for (const id of missing) {
|
|
||||||
try {
|
|
||||||
const response = await authorizedFetch(`/api/stores/${id}/regular-pickup`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
const rules = Array.isArray(data) ? data : Array.isArray(data?.rules) ? data.rules : [];
|
|
||||||
if (!cancelled) {
|
|
||||||
setRegularPickupMap((prev) => ({ ...prev, [id]: rules }));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (!cancelled) {
|
|
||||||
setRegularPickupMap((prev) => ({ ...prev, [id]: [] }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchSchedules();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [authorizedFetch, regularPickupMap, session?.token, visibleConfig]);
|
|
||||||
|
|
||||||
const activeRangeEntry = useMemo(() => {
|
const activeRangeEntry = useMemo(() => {
|
||||||
if (!activeRangePicker) {
|
if (!activeRangePicker) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -103,6 +103,32 @@ const AdminSettingsPanel = ({
|
|||||||
/>
|
/>
|
||||||
</SettingField>
|
</SettingField>
|
||||||
|
|
||||||
|
<SettingField
|
||||||
|
label="Regular-Pickup Cache Refresh (Cron)"
|
||||||
|
description="Regelmäßiger Refresh der Regular-Pickup-Zeiten (serverseitiger Cache)."
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={adminSettings.regularPickupRefreshCron}
|
||||||
|
onChange={(event) => onSettingChange('regularPickupRefreshCron', 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. 0 3 * * *"
|
||||||
|
/>
|
||||||
|
</SettingField>
|
||||||
|
|
||||||
|
<SettingField
|
||||||
|
label="Letzte Abholungsprüfung (Cron)"
|
||||||
|
description="Wann die Info zur letzten Abholung (last_fetch) aktualisiert wird."
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={adminSettings.dormantMembershipCron}
|
||||||
|
onChange={(event) => onSettingChange('dormantMembershipCron', 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. 0 4 */14 * *"
|
||||||
|
/>
|
||||||
|
</SettingField>
|
||||||
|
|
||||||
<SettingField
|
<SettingField
|
||||||
label="Check-Fenster um Slot-Zeiten (Minuten)"
|
label="Check-Fenster um Slot-Zeiten (Minuten)"
|
||||||
description="Minuten relativ zur Slot-Zeit, kommagetrennt. Beispiel: -1,-0.5,0,0.5,1,1.5"
|
description="Minuten relativ zur Slot-Zeit, kommagetrennt. Beispiel: -1,-0.5,0,0.5,1,1.5"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const useStoreSync = ({
|
|||||||
setError,
|
setError,
|
||||||
setLoading,
|
setLoading,
|
||||||
setStores,
|
setStores,
|
||||||
|
setRegularPickupMap,
|
||||||
setConfig,
|
setConfig,
|
||||||
normalizeConfigEntries,
|
normalizeConfigEntries,
|
||||||
setIsDirty,
|
setIsDirty,
|
||||||
@@ -63,7 +64,14 @@ const useStoreSync = ({
|
|||||||
throw new Error(`HTTP ${response.status}`);
|
throw new Error(`HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setStores(Array.isArray(data) ? data : []);
|
if (Array.isArray(data)) {
|
||||||
|
setStores(data);
|
||||||
|
} else {
|
||||||
|
setStores(Array.isArray(data?.stores) ? data.stores : []);
|
||||||
|
if (data?.regularPickupMap && typeof data.regularPickupMap === 'object') {
|
||||||
|
setRegularPickupMap(data.regularPickupMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
setStatus('Betriebe aktualisiert.');
|
setStatus('Betriebe aktualisiert.');
|
||||||
setTimeout(() => setStatus(''), 3000);
|
setTimeout(() => setStatus(''), 3000);
|
||||||
@@ -72,7 +80,7 @@ const useStoreSync = ({
|
|||||||
setError(`Fehler beim Laden der Betriebe: ${err.message}`);
|
setError(`Fehler beim Laden der Betriebe: ${err.message}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[sessionToken, authorizedFetch, setStatus, setError, setStores]
|
[sessionToken, authorizedFetch, setStatus, setError, setStores, setRegularPickupMap]
|
||||||
);
|
);
|
||||||
|
|
||||||
const syncStoresWithProgress = useCallback(
|
const syncStoresWithProgress = useCallback(
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export const normalizeAdminSettings = (raw) => {
|
|||||||
pickupWindowOffsetsMinutes: Array.isArray(raw.pickupWindowOffsetsMinutes)
|
pickupWindowOffsetsMinutes: Array.isArray(raw.pickupWindowOffsetsMinutes)
|
||||||
? raw.pickupWindowOffsetsMinutes.join(', ')
|
? raw.pickupWindowOffsetsMinutes.join(', ')
|
||||||
: '',
|
: '',
|
||||||
|
regularPickupRefreshCron: raw.regularPickupRefreshCron || '',
|
||||||
|
dormantMembershipCron: raw.dormantMembershipCron || '',
|
||||||
randomDelayMinSeconds: raw.randomDelayMinSeconds ?? '',
|
randomDelayMinSeconds: raw.randomDelayMinSeconds ?? '',
|
||||||
randomDelayMaxSeconds: raw.randomDelayMaxSeconds ?? '',
|
randomDelayMaxSeconds: raw.randomDelayMaxSeconds ?? '',
|
||||||
initialDelayMinSeconds: raw.initialDelayMinSeconds ?? '',
|
initialDelayMinSeconds: raw.initialDelayMinSeconds ?? '',
|
||||||
@@ -74,6 +76,8 @@ export const serializeAdminSettings = (adminSettings) => {
|
|||||||
scheduleCron: adminSettings.scheduleCron,
|
scheduleCron: adminSettings.scheduleCron,
|
||||||
pickupFallbackCron: adminSettings.pickupFallbackCron,
|
pickupFallbackCron: adminSettings.pickupFallbackCron,
|
||||||
pickupWindowOffsetsMinutes: toNumberArray(adminSettings.pickupWindowOffsetsMinutes),
|
pickupWindowOffsetsMinutes: toNumberArray(adminSettings.pickupWindowOffsetsMinutes),
|
||||||
|
regularPickupRefreshCron: adminSettings.regularPickupRefreshCron,
|
||||||
|
dormantMembershipCron: adminSettings.dormantMembershipCron,
|
||||||
randomDelayMinSeconds: toNumberOrUndefined(adminSettings.randomDelayMinSeconds),
|
randomDelayMinSeconds: toNumberOrUndefined(adminSettings.randomDelayMinSeconds),
|
||||||
randomDelayMaxSeconds: toNumberOrUndefined(adminSettings.randomDelayMaxSeconds),
|
randomDelayMaxSeconds: toNumberOrUndefined(adminSettings.randomDelayMaxSeconds),
|
||||||
initialDelayMinSeconds: toNumberOrUndefined(adminSettings.initialDelayMinSeconds),
|
initialDelayMinSeconds: toNumberOrUndefined(adminSettings.initialDelayMinSeconds),
|
||||||
|
|||||||
Reference in New Issue
Block a user