aktueller stand
This commit is contained in:
17
server.js
17
server.js
@@ -1317,6 +1317,23 @@ app.post('/api/notifications/test', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/stores/:storeId/regular-pickup', requireAuth, async (req, res) => {
|
||||||
|
const { storeId } = req.params;
|
||||||
|
if (!storeId) {
|
||||||
|
return res.status(400).json({ error: 'Store-ID fehlt' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const rules = await withSessionRetry(
|
||||||
|
req.session,
|
||||||
|
() => foodsharingClient.fetchRegularPickup(storeId, req.session.cookieHeader, req.session),
|
||||||
|
{ label: 'fetchRegularPickup' }
|
||||||
|
);
|
||||||
|
res.json(Array.isArray(rules) ? rules : []);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: error.message || 'Regular-Pickup konnte nicht geladen werden' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/stores', requireAuth, async (req, res) => {
|
app.get('/api/stores', requireAuth, async (req, res) => {
|
||||||
res.json(req.session.storesCache?.data || []);
|
res.json(req.session.storesCache?.data || []);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ const SETTINGS_FILE = path.join(CONFIG_DIR, 'admin-settings.json');
|
|||||||
|
|
||||||
const DEFAULT_SETTINGS = {
|
const DEFAULT_SETTINGS = {
|
||||||
scheduleCron: '*/10 7-22 * * *',
|
scheduleCron: '*/10 7-22 * * *',
|
||||||
|
pickupFallbackCron: '0 7,12,17,22 * * *',
|
||||||
|
pickupWindowOffsetsMinutes: [-1, -0.5, 0, 0.5, 1, 1.5],
|
||||||
randomDelayMinSeconds: 10,
|
randomDelayMinSeconds: 10,
|
||||||
randomDelayMaxSeconds: 120,
|
randomDelayMaxSeconds: 120,
|
||||||
initialDelayMinSeconds: 5,
|
initialDelayMinSeconds: 5,
|
||||||
@@ -53,6 +55,23 @@ function sanitizeNumber(value, fallback) {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeNumberArray(value, fallback) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const cleaned = value
|
||||||
|
.map((entry) => Number(entry))
|
||||||
|
.filter((entry) => Number.isFinite(entry));
|
||||||
|
return cleaned.length > 0 ? cleaned : fallback;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const cleaned = value
|
||||||
|
.split(',')
|
||||||
|
.map((entry) => Number(entry.trim()))
|
||||||
|
.filter((entry) => Number.isFinite(entry));
|
||||||
|
return cleaned.length > 0 ? cleaned : fallback;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeIgnoredSlots(slots = []) {
|
function sanitizeIgnoredSlots(slots = []) {
|
||||||
if (!Array.isArray(slots)) {
|
if (!Array.isArray(slots)) {
|
||||||
return DEFAULT_SETTINGS.ignoredSlots;
|
return DEFAULT_SETTINGS.ignoredSlots;
|
||||||
@@ -109,6 +128,11 @@ function readSettings() {
|
|||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
return {
|
return {
|
||||||
scheduleCron: parsed.scheduleCron || DEFAULT_SETTINGS.scheduleCron,
|
scheduleCron: parsed.scheduleCron || DEFAULT_SETTINGS.scheduleCron,
|
||||||
|
pickupFallbackCron: parsed.pickupFallbackCron || DEFAULT_SETTINGS.pickupFallbackCron,
|
||||||
|
pickupWindowOffsetsMinutes: sanitizeNumberArray(
|
||||||
|
parsed.pickupWindowOffsetsMinutes,
|
||||||
|
DEFAULT_SETTINGS.pickupWindowOffsetsMinutes
|
||||||
|
),
|
||||||
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),
|
||||||
@@ -147,6 +171,11 @@ function writeSettings(patch = {}) {
|
|||||||
const current = readSettings();
|
const current = readSettings();
|
||||||
const next = {
|
const next = {
|
||||||
scheduleCron: patch.scheduleCron || current.scheduleCron,
|
scheduleCron: patch.scheduleCron || current.scheduleCron,
|
||||||
|
pickupFallbackCron: patch.pickupFallbackCron || current.pickupFallbackCron,
|
||||||
|
pickupWindowOffsetsMinutes: sanitizeNumberArray(
|
||||||
|
patch.pickupWindowOffsetsMinutes,
|
||||||
|
current.pickupWindowOffsetsMinutes
|
||||||
|
),
|
||||||
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),
|
||||||
|
|||||||
@@ -442,6 +442,17 @@ async function fetchStoreMembers(storeId, cookieHeader, context) {
|
|||||||
return Array.isArray(response.data) ? response.data : [];
|
return Array.isArray(response.data) ? response.data : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchRegularPickup(storeId, cookieHeader, context) {
|
||||||
|
if (!storeId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const response = await client.get(
|
||||||
|
`/api/stores/${storeId}/regularPickup`,
|
||||||
|
buildRequestConfig({ cookieHeader, context })
|
||||||
|
);
|
||||||
|
return Array.isArray(response.data) ? response.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
async function bookSlot(storeId, utcDate, profileId, session) {
|
async function bookSlot(storeId, utcDate, profileId, session) {
|
||||||
await client.post(
|
await client.post(
|
||||||
`/api/stores/${storeId}/pickups/${utcDate}/${profileId}`,
|
`/api/stores/${storeId}/pickups/${utcDate}/${profileId}`,
|
||||||
@@ -464,6 +475,7 @@ module.exports = {
|
|||||||
fetchRegionStores,
|
fetchRegionStores,
|
||||||
fetchStoreDetails,
|
fetchStoreDetails,
|
||||||
fetchStoreMembers,
|
fetchStoreMembers,
|
||||||
|
fetchRegularPickup,
|
||||||
pickupRuleCheck,
|
pickupRuleCheck,
|
||||||
bookSlot
|
bookSlot
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ const storeWatchInFlight = new Map();
|
|||||||
const pickupCheckInFlight = new Map();
|
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 REGULAR_PICKUP_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
|
||||||
|
const PICKUP_FALLBACK_RETRY_MS = 60 * 60 * 1000;
|
||||||
|
const TIME_ZONE = 'Europe/Berlin';
|
||||||
|
|
||||||
async function fetchSharedStoreStatus(session, storeId, { forceRefresh = false, maxAgeMs } = {}) {
|
async function fetchSharedStoreStatus(session, storeId, { forceRefresh = false, maxAgeMs } = {}) {
|
||||||
if (!storeId) {
|
if (!storeId) {
|
||||||
@@ -83,6 +87,104 @@ 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 getTimeZoneParts(date, timeZone) {
|
||||||
|
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
weekday: 'short',
|
||||||
|
hour12: false
|
||||||
|
});
|
||||||
|
const parts = formatter.formatToParts(date);
|
||||||
|
const values = {};
|
||||||
|
parts.forEach((part) => {
|
||||||
|
if (part.type !== 'literal') {
|
||||||
|
values[part.type] = part.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const weekdayMap = {
|
||||||
|
Sun: 0,
|
||||||
|
Mon: 1,
|
||||||
|
Tue: 2,
|
||||||
|
Wed: 3,
|
||||||
|
Thu: 4,
|
||||||
|
Fri: 5,
|
||||||
|
Sat: 6
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
year: Number(values.year),
|
||||||
|
month: Number(values.month),
|
||||||
|
day: Number(values.day),
|
||||||
|
hour: Number(values.hour),
|
||||||
|
minute: Number(values.minute),
|
||||||
|
second: Number(values.second),
|
||||||
|
weekday: weekdayMap[values.weekday] ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimeZoneOffsetMinutes(timeZone, date) {
|
||||||
|
const parts = getTimeZoneParts(date, timeZone);
|
||||||
|
const utcFromParts = Date.UTC(
|
||||||
|
parts.year,
|
||||||
|
parts.month - 1,
|
||||||
|
parts.day,
|
||||||
|
parts.hour,
|
||||||
|
parts.minute,
|
||||||
|
parts.second
|
||||||
|
);
|
||||||
|
return (utcFromParts - date.getTime()) / 60000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDateInTimeZone(timeZone, year, month, day, hour, minute, second = 0) {
|
||||||
|
const utcGuess = new Date(Date.UTC(year, month - 1, day, hour, minute, second));
|
||||||
|
const offset = getTimeZoneOffsetMinutes(timeZone, utcGuess);
|
||||||
|
return new Date(utcGuess.getTime() - offset * 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStartOfDayInTimeZone(date, timeZone) {
|
||||||
|
const parts = getTimeZoneParts(date, timeZone);
|
||||||
|
if (!parts.year || !parts.month || !parts.day) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return makeDateInTimeZone(timeZone, parts.year, parts.month, parts.day, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRegularPickupWeekday(value) {
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isFinite(num)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (num === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (num >= 1 && num <= 7) {
|
||||||
|
return num % 7;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimeString(value) {
|
||||||
|
if (!value || typeof value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const [hourText, minuteText, secondText] = value.split(':');
|
||||||
|
const hour = Number(hourText);
|
||||||
|
const minute = Number(minuteText);
|
||||||
|
const second = Number(secondText || 0);
|
||||||
|
if (![hour, minute, second].every((entry) => Number.isFinite(entry))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { hour, minute, second };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(date, days) {
|
||||||
|
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
function startOfDay(date) {
|
function startOfDay(date) {
|
||||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||||
}
|
}
|
||||||
@@ -131,6 +233,11 @@ function resolveSettings(settings) {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
scheduleCron: settings.scheduleCron || DEFAULT_SETTINGS.scheduleCron,
|
scheduleCron: settings.scheduleCron || DEFAULT_SETTINGS.scheduleCron,
|
||||||
|
pickupFallbackCron: settings.pickupFallbackCron || DEFAULT_SETTINGS.pickupFallbackCron,
|
||||||
|
pickupWindowOffsetsMinutes:
|
||||||
|
Array.isArray(settings.pickupWindowOffsetsMinutes) && settings.pickupWindowOffsetsMinutes.length > 0
|
||||||
|
? settings.pickupWindowOffsetsMinutes
|
||||||
|
: DEFAULT_SETTINGS.pickupWindowOffsetsMinutes,
|
||||||
randomDelayMinSeconds: Number.isFinite(settings.randomDelayMinSeconds)
|
randomDelayMinSeconds: Number.isFinite(settings.randomDelayMinSeconds)
|
||||||
? settings.randomDelayMinSeconds
|
? settings.randomDelayMinSeconds
|
||||||
: DEFAULT_SETTINGS.randomDelayMinSeconds,
|
: DEFAULT_SETTINGS.randomDelayMinSeconds,
|
||||||
@@ -173,6 +280,107 @@ function resolveSettings(settings) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchRegularPickupSchedule(session, storeId) {
|
||||||
|
if (!session?.profile?.id || !storeId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const key = String(storeId);
|
||||||
|
const cached = regularPickupCache.get(key);
|
||||||
|
if (cached && Date.now() - cached.fetchedAt <= REGULAR_PICKUP_CACHE_TTL_MS) {
|
||||||
|
return cached.rules;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const rules = await withSessionRetry(
|
||||||
|
session,
|
||||||
|
() => foodsharingClient.fetchRegularPickup(storeId, session.cookieHeader, session),
|
||||||
|
{ label: 'fetchRegularPickup' }
|
||||||
|
);
|
||||||
|
const normalized = Array.isArray(rules) ? rules : [];
|
||||||
|
regularPickupCache.set(key, { fetchedAt: Date.now(), rules: normalized });
|
||||||
|
return normalized;
|
||||||
|
} catch (error) {
|
||||||
|
if (cached?.rules) {
|
||||||
|
return cached.rules;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEffectiveNow(entry, now) {
|
||||||
|
const startValue = toDateValue(entry?.desiredDateRange?.start || entry?.desiredDate);
|
||||||
|
if (startValue && startValue > now.getTime()) {
|
||||||
|
return new Date(startValue);
|
||||||
|
}
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getNextPickupCheckTime(session, entry, settings) {
|
||||||
|
const now = new Date();
|
||||||
|
const effectiveNow = resolveEffectiveNow(entry, now);
|
||||||
|
const offsets = (settings.pickupWindowOffsetsMinutes || [])
|
||||||
|
.map((value) => Number(value))
|
||||||
|
.filter((value) => Number.isFinite(value))
|
||||||
|
.map((value) => value * 60 * 1000)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
if (offsets.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules = await fetchRegularPickupSchedule(session, entry.id);
|
||||||
|
if (!Array.isArray(rules) || rules.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowParts = getTimeZoneParts(effectiveNow, TIME_ZONE);
|
||||||
|
if (nowParts.weekday === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const todayStart = getStartOfDayInTimeZone(effectiveNow, TIME_ZONE);
|
||||||
|
if (!todayStart) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let best = null;
|
||||||
|
|
||||||
|
rules.forEach((rule) => {
|
||||||
|
const weekday = normalizeRegularPickupWeekday(rule.weekday);
|
||||||
|
const timeParts = parseTimeString(rule.startTimeOfPickup);
|
||||||
|
if (weekday === null || !timeParts) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let daysAhead = (weekday - nowParts.weekday + 7) % 7;
|
||||||
|
let candidateStart = addDays(todayStart, daysAhead);
|
||||||
|
const candidateParts = getTimeZoneParts(candidateStart, TIME_ZONE);
|
||||||
|
let slotDate = makeDateInTimeZone(
|
||||||
|
TIME_ZONE,
|
||||||
|
candidateParts.year,
|
||||||
|
candidateParts.month,
|
||||||
|
candidateParts.day,
|
||||||
|
timeParts.hour,
|
||||||
|
timeParts.minute,
|
||||||
|
timeParts.second
|
||||||
|
);
|
||||||
|
|
||||||
|
let candidate = offsets
|
||||||
|
.map((offset) => new Date(slotDate.getTime() + offset))
|
||||||
|
.find((date) => date.getTime() > effectiveNow.getTime());
|
||||||
|
|
||||||
|
if (!candidate) {
|
||||||
|
slotDate = addDays(slotDate, 7);
|
||||||
|
candidate = offsets
|
||||||
|
.map((offset) => new Date(slotDate.getTime() + offset))
|
||||||
|
.find((date) => date.getTime() > effectiveNow.getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate && (!best || candidate.getTime() < best.getTime())) {
|
||||||
|
best = candidate;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
function deactivateEntryInMemory(entry) {
|
function deactivateEntryInMemory(entry) {
|
||||||
if (entry) {
|
if (entry) {
|
||||||
entry.active = false;
|
entry.active = false;
|
||||||
@@ -660,22 +868,73 @@ function scheduleStoreWatchers(sessionId, settings) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleEntry(sessionId, entry, settings) {
|
function scheduleFallbackPickupChecks(sessionId, settings) {
|
||||||
const cronExpression = settings.scheduleCron || DEFAULT_SETTINGS.scheduleCron;
|
const cronExpression = settings.pickupFallbackCron || DEFAULT_SETTINGS.pickupFallbackCron;
|
||||||
|
if (!cronExpression) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const job = cron.schedule(
|
const job = cron.schedule(
|
||||||
cronExpression,
|
cronExpression,
|
||||||
() => {
|
() => {
|
||||||
const delay = randomDelayMs(
|
const session = sessionStore.get(sessionId);
|
||||||
settings.randomDelayMinSeconds,
|
if (!session?.profile?.id) {
|
||||||
settings.randomDelayMaxSeconds
|
return;
|
||||||
);
|
|
||||||
setTimeout(() => checkEntry(sessionId, entry, settings), delay);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
timezone: 'Europe/Berlin'
|
|
||||||
}
|
}
|
||||||
|
const delay = randomDelayMs(settings.randomDelayMinSeconds, settings.randomDelayMaxSeconds);
|
||||||
|
setTimeout(() => {
|
||||||
|
const config = readConfig(session.profile.id);
|
||||||
|
runImmediatePickupCheck(sessionId, config, settings).catch((error) => {
|
||||||
|
console.error('[PICKUP] Fallback-Check fehlgeschlagen:', error.message);
|
||||||
|
});
|
||||||
|
}, delay);
|
||||||
|
},
|
||||||
|
{ timezone: TIME_ZONE }
|
||||||
);
|
);
|
||||||
sessionStore.attachJob(sessionId, job);
|
sessionStore.attachJob(sessionId, job);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleEntry(sessionId, entry, settings) {
|
||||||
|
let timeoutId = null;
|
||||||
|
let stopped = false;
|
||||||
|
|
||||||
|
const scheduleNext = async () => {
|
||||||
|
if (stopped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const session = sessionStore.get(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let nextTime = null;
|
||||||
|
try {
|
||||||
|
nextTime = await getNextPickupCheckTime(session, entry, settings);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[PICKUP] Konnte nächste Slot-Zeit für ${entry?.id} nicht berechnen:`, error.message);
|
||||||
|
}
|
||||||
|
const delayMs = nextTime
|
||||||
|
? Math.max(0, nextTime.getTime() - Date.now())
|
||||||
|
: PICKUP_FALLBACK_RETRY_MS;
|
||||||
|
timeoutId = setTimeout(async () => {
|
||||||
|
timeoutId = null;
|
||||||
|
if (stopped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await checkEntry(sessionId, entry, settings);
|
||||||
|
scheduleNext();
|
||||||
|
}, delayMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
scheduleNext();
|
||||||
|
const job = {
|
||||||
|
stop: () => {
|
||||||
|
stopped = true;
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
sessionStore.attachJob(sessionId, job);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleConfig(sessionId, config, settings) {
|
function scheduleConfig(sessionId, config, settings) {
|
||||||
@@ -683,6 +942,7 @@ function scheduleConfig(sessionId, config, settings) {
|
|||||||
sessionStore.clearJobs(sessionId);
|
sessionStore.clearJobs(sessionId);
|
||||||
scheduleDormantMembershipCheck(sessionId);
|
scheduleDormantMembershipCheck(sessionId);
|
||||||
const watchScheduled = scheduleStoreWatchers(sessionId, resolvedSettings);
|
const watchScheduled = scheduleStoreWatchers(sessionId, resolvedSettings);
|
||||||
|
scheduleFallbackPickupChecks(sessionId, resolvedSettings);
|
||||||
scheduleJournalReminders(sessionId);
|
scheduleJournalReminders(sessionId);
|
||||||
const entries = Array.isArray(config) ? config : [];
|
const entries = Array.isArray(config) ? config : [];
|
||||||
const activeEntries = entries.filter((entry) => entry.active);
|
const activeEntries = entries.filter((entry) => entry.active);
|
||||||
@@ -698,7 +958,7 @@ function scheduleConfig(sessionId, config, settings) {
|
|||||||
}
|
}
|
||||||
activeEntries.forEach((entry) => scheduleEntry(sessionId, entry, resolvedSettings));
|
activeEntries.forEach((entry) => scheduleEntry(sessionId, entry, resolvedSettings));
|
||||||
console.log(
|
console.log(
|
||||||
`[INFO] Scheduler für Session ${sessionId} mit ${activeEntries.length} Jobs aktiv (Cron: ${resolvedSettings.scheduleCron}).`
|
`[INFO] Scheduler für Session ${sessionId} mit ${activeEntries.length} Jobs aktiv.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
41
src/App.js
41
src/App.js
@@ -39,6 +39,7 @@ function App() {
|
|||||||
const [notificationPanelOpen, setNotificationPanelOpen] = useState(false);
|
const [notificationPanelOpen, setNotificationPanelOpen] = useState(false);
|
||||||
const [focusedStoreId, setFocusedStoreId] = useState(null);
|
const [focusedStoreId, setFocusedStoreId] = useState(null);
|
||||||
const [nearestStoreLabel, setNearestStoreLabel] = useState(null);
|
const [nearestStoreLabel, setNearestStoreLabel] = useState(null);
|
||||||
|
const [regularPickupMap, setRegularPickupMap] = useState({});
|
||||||
const minSelectableDate = useMemo(() => startOfDay(new Date()), []);
|
const minSelectableDate = useMemo(() => startOfDay(new Date()), []);
|
||||||
|
|
||||||
const weekdays = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
|
const weekdays = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
|
||||||
@@ -551,6 +552,45 @@ 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;
|
||||||
@@ -736,6 +776,7 @@ function App() {
|
|||||||
onToggleStores={() => setAvailableCollapsed((prev) => !prev)}
|
onToggleStores={() => setAvailableCollapsed((prev) => !prev)}
|
||||||
onStoreSelect={handleStoreSelection}
|
onStoreSelect={handleStoreSelection}
|
||||||
configMap={configMap}
|
configMap={configMap}
|
||||||
|
regularPickupMap={regularPickupMap}
|
||||||
error={error}
|
error={error}
|
||||||
onDismissError={() => setError('')}
|
onDismissError={() => setError('')}
|
||||||
status={status}
|
status={status}
|
||||||
|
|||||||
@@ -91,15 +91,28 @@ const AdminSettingsPanel = ({
|
|||||||
subtitle="Bestimmt, wann der Bot freie Slots sucht und wie stark er Anfragen verteilt."
|
subtitle="Bestimmt, wann der Bot freie Slots sucht und wie stark er Anfragen verteilt."
|
||||||
>
|
>
|
||||||
<SettingField
|
<SettingField
|
||||||
label="Cron-Ausdruck"
|
label="Fallback-Cron"
|
||||||
description="Legt fest, zu welchen Zeiten die reine Slot-Suche läuft. Nutzt die klassische Cron-Syntax (Serverzeit)."
|
description="Zeitplan für die seltene Komplettprüfung (Fallback), falls keine Slot-Zeiten bekannt sind oder Sonder-Slots auftauchen."
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={adminSettings.scheduleCron}
|
value={adminSettings.pickupFallbackCron}
|
||||||
onChange={(event) => onSettingChange('scheduleCron', event.target.value)}
|
onChange={(event) => onSettingChange('pickupFallbackCron', event.target.value)}
|
||||||
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||||
placeholder="z. B. */10 7-22 * * *"
|
placeholder="z. B. 0 7,12,17,22 * * *"
|
||||||
|
/>
|
||||||
|
</SettingField>
|
||||||
|
|
||||||
|
<SettingField
|
||||||
|
label="Check-Fenster um Slot-Zeiten (Minuten)"
|
||||||
|
description="Minuten relativ zur Slot-Zeit, kommagetrennt. Beispiel: -1,-0.5,0,0.5,1,1.5"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={adminSettings.pickupWindowOffsetsMinutes}
|
||||||
|
onChange={(event) => onSettingChange('pickupWindowOffsetsMinutes', 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="-1,-0.5,0,0.5,1,1.5"
|
||||||
/>
|
/>
|
||||||
</SettingField>
|
</SettingField>
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ const DashboardView = ({
|
|||||||
onToggleStores,
|
onToggleStores,
|
||||||
onStoreSelect,
|
onStoreSelect,
|
||||||
configMap,
|
configMap,
|
||||||
|
regularPickupMap,
|
||||||
error,
|
error,
|
||||||
onDismissError,
|
onDismissError,
|
||||||
status,
|
status,
|
||||||
@@ -214,6 +215,76 @@ const DashboardView = ({
|
|||||||
[weekdays]
|
[weekdays]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const formatRegularPickup = useCallback((rules) => {
|
||||||
|
if (!Array.isArray(rules) || rules.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const weekdayLabels = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
||||||
|
const normalizeWeekday = (value) => {
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isFinite(num)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (num === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (num >= 1 && num <= 7) {
|
||||||
|
return num % 7;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const byTime = new Map();
|
||||||
|
rules.forEach((rule) => {
|
||||||
|
const day = normalizeWeekday(rule.weekday);
|
||||||
|
const time = typeof rule.startTimeOfPickup === 'string' ? rule.startTimeOfPickup.slice(0, 5) : '';
|
||||||
|
if (day === null || !time) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!byTime.has(time)) {
|
||||||
|
byTime.set(time, new Set());
|
||||||
|
}
|
||||||
|
byTime.get(time).add(day);
|
||||||
|
});
|
||||||
|
if (byTime.size === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const buildRanges = (days) => {
|
||||||
|
if (days.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const ranges = [];
|
||||||
|
let start = days[0];
|
||||||
|
let last = days[0];
|
||||||
|
for (let i = 1; i < days.length; i += 1) {
|
||||||
|
const current = days[i];
|
||||||
|
if (current === last + 1) {
|
||||||
|
last = current;
|
||||||
|
} else {
|
||||||
|
ranges.push([start, last]);
|
||||||
|
start = current;
|
||||||
|
last = current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ranges.push([start, last]);
|
||||||
|
return ranges;
|
||||||
|
};
|
||||||
|
const formatRange = ([start, end]) => {
|
||||||
|
if (start === end) {
|
||||||
|
return weekdayLabels[start];
|
||||||
|
}
|
||||||
|
return `${weekdayLabels[start]}-${weekdayLabels[end]}`;
|
||||||
|
};
|
||||||
|
const parts = Array.from(byTime.entries())
|
||||||
|
.sort(([timeA], [timeB]) => timeA.localeCompare(timeB))
|
||||||
|
.map(([time, daysSet]) => {
|
||||||
|
const days = Array.from(daysSet).sort((a, b) => a - b);
|
||||||
|
const ranges = buildRanges(days).map(formatRange).join(',');
|
||||||
|
return `${ranges} ${time}`;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
return parts.length > 0 ? parts.join(' | ') : null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const configColumns = useMemo(
|
const configColumns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
columnHelper.display({
|
columnHelper.display({
|
||||||
@@ -250,12 +321,17 @@ const DashboardView = ({
|
|||||||
<ColumnTextFilter column={column} placeholder="Name / ID" />
|
<ColumnTextFilter column={column} placeholder="Name / ID" />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
|
const scheduleLabel = formatRegularPickup(regularPickupMap?.[row.original.id]);
|
||||||
|
return (
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">{row.original.label}</span>
|
<span className="font-medium">{row.original.label}</span>
|
||||||
{row.original.hidden && <span className="ml-2 text-xs text-gray-400">(ausgeblendet)</span>}
|
{row.original.hidden && <span className="ml-2 text-xs text-gray-400">(ausgeblendet)</span>}
|
||||||
<p className="text-xs text-gray-500">#{row.original.id}</p>
|
<p className="text-xs text-gray-500">#{row.original.id}</p>
|
||||||
|
{scheduleLabel && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Slots: {scheduleLabel}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href={`https://foodsharing.de/store/${row.original.id}`}
|
href={`https://foodsharing.de/store/${row.original.id}`}
|
||||||
@@ -271,7 +347,8 @@ const DashboardView = ({
|
|||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
sortingFn: 'alphanumeric',
|
sortingFn: 'alphanumeric',
|
||||||
filterFn: 'includesString'
|
filterFn: 'includesString'
|
||||||
}),
|
}),
|
||||||
@@ -493,6 +570,8 @@ const DashboardView = ({
|
|||||||
onWeekdayChange,
|
onWeekdayChange,
|
||||||
formatRangeLabel,
|
formatRangeLabel,
|
||||||
canDelete,
|
canDelete,
|
||||||
|
formatRegularPickup,
|
||||||
|
regularPickupMap,
|
||||||
weekdays,
|
weekdays,
|
||||||
weekdaysOptions
|
weekdaysOptions
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ export const normalizeAdminSettings = (raw) => {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
scheduleCron: raw.scheduleCron || '',
|
scheduleCron: raw.scheduleCron || '',
|
||||||
|
pickupFallbackCron: raw.pickupFallbackCron || '',
|
||||||
|
pickupWindowOffsetsMinutes: Array.isArray(raw.pickupWindowOffsetsMinutes)
|
||||||
|
? raw.pickupWindowOffsetsMinutes.join(', ')
|
||||||
|
: '',
|
||||||
randomDelayMinSeconds: raw.randomDelayMinSeconds ?? '',
|
randomDelayMinSeconds: raw.randomDelayMinSeconds ?? '',
|
||||||
randomDelayMaxSeconds: raw.randomDelayMaxSeconds ?? '',
|
randomDelayMaxSeconds: raw.randomDelayMaxSeconds ?? '',
|
||||||
initialDelayMinSeconds: raw.initialDelayMinSeconds ?? '',
|
initialDelayMinSeconds: raw.initialDelayMinSeconds ?? '',
|
||||||
@@ -46,12 +50,30 @@ const toNumberOrUndefined = (value) => {
|
|||||||
return Number.isFinite(parsed) ? parsed : undefined;
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toNumberArray = (value) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.map((entry) => Number(entry))
|
||||||
|
.filter((entry) => Number.isFinite(entry));
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const parsed = value
|
||||||
|
.split(',')
|
||||||
|
.map((entry) => Number(entry.trim()))
|
||||||
|
.filter((entry) => Number.isFinite(entry));
|
||||||
|
return parsed.length > 0 ? parsed : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export const serializeAdminSettings = (adminSettings) => {
|
export const serializeAdminSettings = (adminSettings) => {
|
||||||
if (!adminSettings) {
|
if (!adminSettings) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
scheduleCron: adminSettings.scheduleCron,
|
scheduleCron: adminSettings.scheduleCron,
|
||||||
|
pickupFallbackCron: adminSettings.pickupFallbackCron,
|
||||||
|
pickupWindowOffsetsMinutes: toNumberArray(adminSettings.pickupWindowOffsetsMinutes),
|
||||||
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