aktueller stand
This commit is contained in:
@@ -6,6 +6,8 @@ const SETTINGS_FILE = path.join(CONFIG_DIR, 'admin-settings.json');
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
scheduleCron: '*/10 7-22 * * *',
|
||||
pickupFallbackCron: '0 7,12,17,22 * * *',
|
||||
pickupWindowOffsetsMinutes: [-1, -0.5, 0, 0.5, 1, 1.5],
|
||||
randomDelayMinSeconds: 10,
|
||||
randomDelayMaxSeconds: 120,
|
||||
initialDelayMinSeconds: 5,
|
||||
@@ -53,6 +55,23 @@ function sanitizeNumber(value, 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 = []) {
|
||||
if (!Array.isArray(slots)) {
|
||||
return DEFAULT_SETTINGS.ignoredSlots;
|
||||
@@ -109,6 +128,11 @@ function readSettings() {
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
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),
|
||||
randomDelayMaxSeconds: sanitizeNumber(parsed.randomDelayMaxSeconds, DEFAULT_SETTINGS.randomDelayMaxSeconds),
|
||||
initialDelayMinSeconds: sanitizeNumber(parsed.initialDelayMinSeconds, DEFAULT_SETTINGS.initialDelayMinSeconds),
|
||||
@@ -147,6 +171,11 @@ function writeSettings(patch = {}) {
|
||||
const current = readSettings();
|
||||
const next = {
|
||||
scheduleCron: patch.scheduleCron || current.scheduleCron,
|
||||
pickupFallbackCron: patch.pickupFallbackCron || current.pickupFallbackCron,
|
||||
pickupWindowOffsetsMinutes: sanitizeNumberArray(
|
||||
patch.pickupWindowOffsetsMinutes,
|
||||
current.pickupWindowOffsetsMinutes
|
||||
),
|
||||
randomDelayMinSeconds: sanitizeNumber(patch.randomDelayMinSeconds, current.randomDelayMinSeconds),
|
||||
randomDelayMaxSeconds: sanitizeNumber(patch.randomDelayMaxSeconds, current.randomDelayMaxSeconds),
|
||||
initialDelayMinSeconds: sanitizeNumber(patch.initialDelayMinSeconds, current.initialDelayMinSeconds),
|
||||
|
||||
@@ -442,6 +442,17 @@ async function fetchStoreMembers(storeId, cookieHeader, context) {
|
||||
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) {
|
||||
await client.post(
|
||||
`/api/stores/${storeId}/pickups/${utcDate}/${profileId}`,
|
||||
@@ -464,6 +475,7 @@ module.exports = {
|
||||
fetchRegionStores,
|
||||
fetchStoreDetails,
|
||||
fetchStoreMembers,
|
||||
fetchRegularPickup,
|
||||
pickupRuleCheck,
|
||||
bookSlot
|
||||
};
|
||||
|
||||
@@ -22,6 +22,10 @@ const storeWatchInFlight = new Map();
|
||||
const pickupCheckInFlight = new Map();
|
||||
const pickupCheckLastRun = new Map();
|
||||
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 } = {}) {
|
||||
if (!storeId) {
|
||||
@@ -83,6 +87,104 @@ function randomDelayMs(minSeconds = 10, maxSeconds = 120) {
|
||||
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) {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
}
|
||||
@@ -131,6 +233,11 @@ function resolveSettings(settings) {
|
||||
}
|
||||
return {
|
||||
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)
|
||||
? 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) {
|
||||
if (entry) {
|
||||
entry.active = false;
|
||||
@@ -660,22 +868,73 @@ function scheduleStoreWatchers(sessionId, settings) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function scheduleEntry(sessionId, entry, settings) {
|
||||
const cronExpression = settings.scheduleCron || DEFAULT_SETTINGS.scheduleCron;
|
||||
function scheduleFallbackPickupChecks(sessionId, settings) {
|
||||
const cronExpression = settings.pickupFallbackCron || DEFAULT_SETTINGS.pickupFallbackCron;
|
||||
if (!cronExpression) {
|
||||
return null;
|
||||
}
|
||||
const job = cron.schedule(
|
||||
cronExpression,
|
||||
() => {
|
||||
const delay = randomDelayMs(
|
||||
settings.randomDelayMinSeconds,
|
||||
settings.randomDelayMaxSeconds
|
||||
);
|
||||
setTimeout(() => checkEntry(sessionId, entry, settings), delay);
|
||||
const session = sessionStore.get(sessionId);
|
||||
if (!session?.profile?.id) {
|
||||
return;
|
||||
}
|
||||
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: 'Europe/Berlin'
|
||||
}
|
||||
{ timezone: TIME_ZONE }
|
||||
);
|
||||
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) {
|
||||
@@ -683,6 +942,7 @@ function scheduleConfig(sessionId, config, settings) {
|
||||
sessionStore.clearJobs(sessionId);
|
||||
scheduleDormantMembershipCheck(sessionId);
|
||||
const watchScheduled = scheduleStoreWatchers(sessionId, resolvedSettings);
|
||||
scheduleFallbackPickupChecks(sessionId, resolvedSettings);
|
||||
scheduleJournalReminders(sessionId);
|
||||
const entries = Array.isArray(config) ? config : [];
|
||||
const activeEntries = entries.filter((entry) => entry.active);
|
||||
@@ -698,7 +958,7 @@ function scheduleConfig(sessionId, config, settings) {
|
||||
}
|
||||
activeEntries.forEach((entry) => scheduleEntry(sessionId, entry, resolvedSettings));
|
||||
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.`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user