chore: sync running docker state

Commit the app code currently running in Docker, including scheduler, store status, journal reminder, maintenance mode, and Foodsharing API check updates. Remove generated runtime logs from version control and ignore local runtime captures.
This commit is contained in:
2026-06-14 21:21:07 +02:00
parent 1e754023c3
commit bd41e8c079
46 changed files with 3010 additions and 7522 deletions

View File

@@ -1,5 +1,6 @@
const fs = require('fs');
const path = require('path');
const { readJsonFile, writeJsonFile } = require('./jsonFileStore');
const CONFIG_DIR = path.join(__dirname, '..', 'config');
const SETTINGS_FILE = path.join(CONFIG_DIR, 'admin-settings.json');
@@ -20,6 +21,16 @@ const DEFAULT_SETTINGS = {
storeWatchRequestDelayMs: 1000,
storeWatchStatusCacheMaxAgeMinutes: 120,
storePickupCheckDelayMs: 400,
messageWatcherSocketEnabled: true,
messageWatcherFallbackEnabled: true,
messageWatcherFallbackCron: '0 6-23 * * *',
messageWatcherDebugLogging: true,
maintenanceModeActive: false,
maintenanceModeAutoEnabled: true,
maintenanceModeActivatedAt: null,
maintenanceModeReason: '',
maintenanceModeErrorThreshold: 8,
maintenanceModeErrorWindowMinutes: 10,
ignoredSlots: [
{
storeId: '51450',
@@ -100,6 +111,11 @@ function sanitizeString(value) {
return String(value).trim();
}
function sanitizeNullableString(value, fallback = null) {
const sanitized = sanitizeString(value);
return sanitized || fallback;
}
function sanitizeNotifications(input = {}) {
const defaults = DEFAULT_SETTINGS.notifications;
return {
@@ -121,13 +137,16 @@ function sanitizeNotifications(input = {}) {
function readSettings() {
ensureDir();
if (!fs.existsSync(SETTINGS_FILE)) {
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(DEFAULT_SETTINGS, null, 2));
writeJsonFile(SETTINGS_FILE, DEFAULT_SETTINGS, { backup: false });
return { ...DEFAULT_SETTINGS };
}
try {
const raw = fs.readFileSync(SETTINGS_FILE, 'utf8');
const parsed = JSON.parse(raw);
const parsed = readJsonFile(
SETTINGS_FILE,
DEFAULT_SETTINGS,
(value) => value && typeof value === 'object' && !Array.isArray(value)
);
return {
scheduleCron: parsed.scheduleCron || DEFAULT_SETTINGS.scheduleCron,
pickupFallbackCron: parsed.pickupFallbackCron || DEFAULT_SETTINGS.pickupFallbackCron,
@@ -162,6 +181,35 @@ function readSettings() {
parsed.storePickupCheckDelayMs,
DEFAULT_SETTINGS.storePickupCheckDelayMs
),
messageWatcherSocketEnabled:
parsed.messageWatcherSocketEnabled === undefined
? DEFAULT_SETTINGS.messageWatcherSocketEnabled
: !!parsed.messageWatcherSocketEnabled,
messageWatcherFallbackEnabled:
parsed.messageWatcherFallbackEnabled === undefined
? DEFAULT_SETTINGS.messageWatcherFallbackEnabled
: !!parsed.messageWatcherFallbackEnabled,
messageWatcherFallbackCron:
parsed.messageWatcherFallbackCron || DEFAULT_SETTINGS.messageWatcherFallbackCron,
messageWatcherDebugLogging:
parsed.messageWatcherDebugLogging === undefined
? DEFAULT_SETTINGS.messageWatcherDebugLogging
: !!parsed.messageWatcherDebugLogging,
maintenanceModeActive: !!parsed.maintenanceModeActive,
maintenanceModeAutoEnabled:
parsed.maintenanceModeAutoEnabled === undefined
? DEFAULT_SETTINGS.maintenanceModeAutoEnabled
: !!parsed.maintenanceModeAutoEnabled,
maintenanceModeActivatedAt: sanitizeNullableString(parsed.maintenanceModeActivatedAt, null),
maintenanceModeReason: sanitizeString(parsed.maintenanceModeReason),
maintenanceModeErrorThreshold: sanitizeNumber(
parsed.maintenanceModeErrorThreshold,
DEFAULT_SETTINGS.maintenanceModeErrorThreshold
),
maintenanceModeErrorWindowMinutes: sanitizeNumber(
parsed.maintenanceModeErrorWindowMinutes,
DEFAULT_SETTINGS.maintenanceModeErrorWindowMinutes
),
ignoredSlots: sanitizeIgnoredSlots(parsed.ignoredSlots),
notifications: sanitizeNotifications(parsed.notifications)
};
@@ -207,6 +255,43 @@ function writeSettings(patch = {}) {
patch.storePickupCheckDelayMs,
current.storePickupCheckDelayMs
),
messageWatcherSocketEnabled:
patch.messageWatcherSocketEnabled === undefined
? current.messageWatcherSocketEnabled
: !!patch.messageWatcherSocketEnabled,
messageWatcherFallbackEnabled:
patch.messageWatcherFallbackEnabled === undefined
? current.messageWatcherFallbackEnabled
: !!patch.messageWatcherFallbackEnabled,
messageWatcherFallbackCron: patch.messageWatcherFallbackCron || current.messageWatcherFallbackCron,
messageWatcherDebugLogging:
patch.messageWatcherDebugLogging === undefined
? current.messageWatcherDebugLogging
: !!patch.messageWatcherDebugLogging,
maintenanceModeActive:
patch.maintenanceModeActive === undefined
? current.maintenanceModeActive
: !!patch.maintenanceModeActive,
maintenanceModeAutoEnabled:
patch.maintenanceModeAutoEnabled === undefined
? current.maintenanceModeAutoEnabled
: !!patch.maintenanceModeAutoEnabled,
maintenanceModeActivatedAt:
patch.maintenanceModeActivatedAt === undefined
? current.maintenanceModeActivatedAt
: sanitizeNullableString(patch.maintenanceModeActivatedAt, null),
maintenanceModeReason:
patch.maintenanceModeReason === undefined
? current.maintenanceModeReason
: sanitizeString(patch.maintenanceModeReason),
maintenanceModeErrorThreshold: sanitizeNumber(
patch.maintenanceModeErrorThreshold,
current.maintenanceModeErrorThreshold
),
maintenanceModeErrorWindowMinutes: sanitizeNumber(
patch.maintenanceModeErrorWindowMinutes,
current.maintenanceModeErrorWindowMinutes
),
ignoredSlots:
patch.ignoredSlots !== undefined
? sanitizeIgnoredSlots(patch.ignoredSlots)
@@ -217,8 +302,13 @@ function writeSettings(patch = {}) {
: current.notifications
};
if (!next.maintenanceModeActive) {
next.maintenanceModeActivatedAt = null;
next.maintenanceModeReason = '';
}
ensureDir();
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(next, null, 2));
writeJsonFile(SETTINGS_FILE, next);
return next;
}

View File

@@ -1,6 +1,7 @@
const fs = require('fs');
const path = require('path');
const defaultConfig = require('../data/defaultConfig');
const { readJsonFile, writeJsonFile } = require('./jsonFileStore');
const CONFIG_DIR = path.join(__dirname, '..', 'config');
@@ -18,7 +19,7 @@ function hydrateConfigFile(profileId) {
ensureDir();
const filePath = getConfigPath(profileId);
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, JSON.stringify(defaultConfig, null, 2));
writeJsonFile(filePath, defaultConfig, { backup: false });
}
return filePath;
}
@@ -26,8 +27,7 @@ function hydrateConfigFile(profileId) {
function readConfig(profileId) {
const filePath = hydrateConfigFile(profileId);
try {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw);
const parsed = readJsonFile(filePath, defaultConfig, Array.isArray);
return Array.isArray(parsed) ? parsed : [];
} catch (err) {
console.error(`Failed to read config for ${profileId}:`, err);
@@ -37,7 +37,7 @@ function readConfig(profileId) {
function writeConfig(profileId, payload) {
const filePath = hydrateConfigFile(profileId);
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2));
writeJsonFile(filePath, payload);
return filePath;
}

View File

@@ -1,5 +1,6 @@
const fs = require('fs');
const path = require('path');
const { readJsonFile, writeJsonFile } = require('./jsonFileStore');
const CONFIG_DIR = path.join(__dirname, '..', 'config');
const CREDENTIAL_FILE = path.join(CONFIG_DIR, 'credentials.json');
@@ -13,12 +14,15 @@ function ensureDir() {
function readStore() {
ensureDir();
if (!fs.existsSync(CREDENTIAL_FILE)) {
fs.writeFileSync(CREDENTIAL_FILE, JSON.stringify({}, null, 2));
writeJsonFile(CREDENTIAL_FILE, {}, { backup: false });
}
try {
const raw = fs.readFileSync(CREDENTIAL_FILE, 'utf8');
const parsed = JSON.parse(raw);
const parsed = readJsonFile(
CREDENTIAL_FILE,
{},
(value) => value && typeof value === 'object' && !Array.isArray(value)
);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (error) {
console.error('Konnte Credential-Store nicht lesen:', error.message);
@@ -28,7 +32,7 @@ function readStore() {
function writeStore(store) {
ensureDir();
fs.writeFileSync(CREDENTIAL_FILE, JSON.stringify(store, null, 2));
writeJsonFile(CREDENTIAL_FILE, store);
}
function save(profileId, credentials) {

View File

@@ -3,6 +3,7 @@ const http = require('http');
const https = require('https');
const requestLogStore = require('./requestLogStore');
const sessionStore = require('./sessionStore');
const maintenanceMode = require('./maintenanceMode');
const BASE_URL = 'https://foodsharing.de';
@@ -30,21 +31,23 @@ client.interceptors.response.use(
(response) => {
const startedAt = response?.config?.metadata?.startedAt || Date.now();
const metadata = response?.config?.metadata || {};
try {
requestLogStore.add({
direction: 'outgoing',
target: 'foodsharing.de',
method: (response.config?.method || 'GET').toUpperCase(),
path: response.config?.url || '',
status: response.status,
durationMs: Date.now() - startedAt,
sessionId: metadata.sessionId ?? null,
profileId: metadata.profileId ?? null,
profileName: metadata.profileName ?? null,
responseBody: response.data
});
} catch (error) {
console.warn('[REQUEST-LOG] Outgoing-Log fehlgeschlagen:', error.message);
if (!metadata.skipRequestLog) {
try {
requestLogStore.add({
direction: 'outgoing',
target: 'foodsharing.de',
method: (response.config?.method || 'GET').toUpperCase(),
path: response.config?.url || '',
status: response.status,
durationMs: Date.now() - startedAt,
sessionId: metadata.sessionId ?? null,
profileId: metadata.profileId ?? null,
profileName: metadata.profileName ?? null,
responseBody: response.data
});
} catch (error) {
console.warn('[REQUEST-LOG] Outgoing-Log fehlgeschlagen:', error.message);
}
}
updateSessionCookiesFromResponse(response);
return response;
@@ -52,22 +55,36 @@ client.interceptors.response.use(
(error) => {
const startedAt = error?.config?.metadata?.startedAt || Date.now();
const metadata = error?.config?.metadata || {};
try {
requestLogStore.add({
direction: 'outgoing',
target: 'foodsharing.de',
method: (error.config?.method || 'GET').toUpperCase(),
path: error.config?.url || '',
status: error?.response?.status || null,
durationMs: Date.now() - startedAt,
sessionId: metadata.sessionId ?? null,
profileId: metadata.profileId ?? null,
profileName: metadata.profileName ?? null,
error: error?.message || 'Unbekannter Fehler',
responseBody: error?.response?.data
});
} catch (logError) {
console.warn('[REQUEST-LOG] Outgoing-Error-Log fehlgeschlagen:', logError.message);
let logEntry = null;
if (!metadata.skipRequestLog) {
try {
logEntry = requestLogStore.add({
direction: 'outgoing',
target: 'foodsharing.de',
method: (error.config?.method || 'GET').toUpperCase(),
path: error.config?.url || '',
status: error?.response?.status || null,
durationMs: Date.now() - startedAt,
sessionId: metadata.sessionId ?? null,
profileId: metadata.profileId ?? null,
profileName: metadata.profileName ?? null,
error: error?.message || 'Unbekannter Fehler',
responseBody: error?.response?.data
});
} catch (logError) {
console.warn('[REQUEST-LOG] Outgoing-Error-Log fehlgeschlagen:', logError.message);
}
try {
maintenanceMode.recordOutboundFailure(logEntry || {
direction: 'outgoing',
target: 'foodsharing.de',
status: error?.response?.status || null,
error: error?.message || 'Unbekannter Fehler',
timestamp: Date.now()
});
} catch (maintenanceError) {
console.warn('[MAINTENANCE] Fehlerauswertung fehlgeschlagen:', maintenanceError.message);
}
}
if (error?.response) {
updateSessionCookiesFromResponse(error.response);
@@ -235,7 +252,7 @@ function updateSessionCookiesFromResponse(response) {
async function getCurrentUserDetails(cookieHeader, context, options = {}) {
const response = await client.get(
'/api/user/current/details',
'/api/users/current/details',
buildRequestConfig({ cookieHeader, context })
);
return options.raw ? response : response.data;
@@ -245,22 +262,23 @@ async function login(email, password) {
const payload = {
email,
password,
remember_me: true
code: '',
rememberMe: true
};
const headers = {
'sec-ch-ua': '"Chromium";v="128", "Not;A=Brand";v="24", "Google Chrome";v="128"',
Origin: BASE_URL,
Referer: BASE_URL,
DNT: '1',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Linux"',
'Cache-Control': 'no-cache, no-store, must-revalidate',
Pragma: 'no-cache',
Expires: '0',
'Content-Type': 'application/json; charset=utf-8'
};
const response = await client.post('/api/user/login', payload, { headers });
const response = await client.post('/api/login', payload, { headers });
const cookies = response.headers['set-cookie'] || [];
const csrfToken = extractCsrfToken(cookies);
let cookieHeader = serializeCookies(cookies);
let cookieHeader = mergeCookieHeaders('', cookies);
const detailsResponse = await getCurrentUserDetails(cookieHeader, null, { raw: true });
const detailsCookies = detailsResponse?.headers?.['set-cookie'] || [];
if (detailsCookies.length > 0) {
@@ -307,6 +325,20 @@ function wait(ms = 0) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function hasRegularStoreAccess(store) {
if (!store) {
return false;
}
if (store.isManaging) {
return true;
}
const membershipStatus = Number(store.membershipStatus);
if (!Number.isFinite(membershipStatus)) {
return true;
}
return membershipStatus === 1;
}
async function fetchStores(cookieHeader, profileId, options = {}, context) {
if (!profileId) {
return [];
@@ -320,8 +352,8 @@ async function fetchStores(cookieHeader, profileId, options = {}, context) {
: null;
try {
const response = await client.get(
`/api/user/${profileId}/stores`,
buildRequestConfig({ cookieHeader, params: { activeStores: 1 }, context })
`/api/users/${profileId}/stores`,
buildRequestConfig({ cookieHeader, params: { excludeInactive: true }, context })
);
const stores = Array.isArray(response.data) ? response.data : [];
const normalized = stores.map((store) => ({
@@ -332,7 +364,7 @@ async function fetchStores(cookieHeader, profileId, options = {}, context) {
isManaging: !!store.isManaging,
city: store.city || '',
street: store.street || '',
zip: store.zip || ''
zip: store.zipCode || store.zip || ''
}));
return annotateStoresWithPickupSlots(
@@ -375,6 +407,14 @@ async function annotateStoresWithPickupSlots(
await wait(delayMs);
}
let hasPickupSlots = null;
if (!hasRegularStoreAccess(store)) {
hasPickupSlots = false;
console.info(
`Pickups für Store ${store.id} werden wegen Mitgliedschaftsstatus ${store.membershipStatus} nicht abgefragt.`
);
annotated.push({ ...store, hasPickupSlots });
continue;
}
try {
const pickups = await fetchPickups(store.id, cookieHeader, context);
hasPickupSlots = Array.isArray(pickups) && pickups.length > 0;
@@ -398,6 +438,9 @@ async function fetchPickups(storeId, cookieHeader, context) {
`/api/stores/${storeId}/pickups`,
buildRequestConfig({ cookieHeader, context })
);
if (Array.isArray(response.data)) {
return response.data;
}
return response.data?.pickups || [];
}
@@ -406,21 +449,22 @@ async function fetchRegionStores(regionId, cookieHeader, context) {
return { total: 0, stores: [] };
}
const response = await client.get(
`/api/region/${regionId}/stores`,
`/api/regions/${regionId}/stores`,
buildRequestConfig({ cookieHeader, context })
);
const stores = Array.isArray(response.data) ? response.data : Array.isArray(response.data?.stores) ? response.data.stores : [];
return {
total: Number(response.data?.total) || 0,
stores: Array.isArray(response.data?.stores) ? response.data.stores : []
total: Number(response.data?.total) || stores.length,
stores
};
}
async function fetchStoreDetails(storeId, cookieHeader, context) {
async function fetchStoreMarker(storeId, cookieHeader, context) {
if (!storeId) {
return null;
}
const response = await client.get(
`/api/map/stores/${storeId}`,
`/api/map/markers/stores/${storeId}`,
buildRequestConfig({ cookieHeader, context })
);
return response.data || null;
@@ -428,14 +472,14 @@ async function fetchStoreDetails(storeId, cookieHeader, context) {
async function pickupRuleCheck(storeId, utcDate, profileId, session) {
const response = await client.get(
`/api/stores/${storeId}/pickupRuleCheck/${utcDate}/${profileId}`,
`/api/stores/${storeId}/pickups/${utcDate}/eligibility`,
buildRequestConfig({
cookieHeader: session.cookieHeader,
csrfToken: session.csrfToken,
context: session
})
);
return response.data?.result === true;
return response.data?.isEligible === true || response.data?.result === true;
}
async function fetchStoreMembers(storeId, cookieHeader, context) {
@@ -443,7 +487,7 @@ async function fetchStoreMembers(storeId, cookieHeader, context) {
return [];
}
const response = await client.get(
`/api/stores/${storeId}/member`,
`/api/stores/${storeId}/members`,
buildRequestConfig({ cookieHeader, context })
);
return Array.isArray(response.data) ? response.data : [];
@@ -454,15 +498,41 @@ async function fetchRegularPickup(storeId, cookieHeader, context) {
return [];
}
const response = await client.get(
`/api/stores/${storeId}/regularPickup`,
`/api/stores/${storeId}/regular-pickups`,
buildRequestConfig({ cookieHeader, context })
);
return Array.isArray(response.data) ? response.data : [];
}
async function fetchRegisteredPickups(userId, cookieHeader, context) {
const normalizedUserId = userId ? String(userId) : 'current';
const response = await client.get(
`/api/users/${normalizedUserId}/pickups/registered`,
buildRequestConfig({ cookieHeader, context })
);
return Array.isArray(response.data) ? response.data : [];
}
async function fetchConversations(cookieHeader, options = {}, context) {
const limit = Math.max(1, Math.min(Number(options.limit) || 20, 100));
const offset = Math.max(0, Number(options.offset) || 0);
const config = buildRequestConfig({ cookieHeader, params: { limit, offset }, context });
config.metadata = { ...(config.metadata || {}), skipRequestLog: true };
const response = await client.get('/api/conversations', config);
const conversations = Array.isArray(response.data?.conversations)
? response.data.conversations
: Array.isArray(response.data)
? response.data
: [];
return {
conversations,
profiles: Array.isArray(response.data?.profiles) ? response.data.profiles : []
};
}
async function bookSlot(storeId, utcDate, profileId, session) {
await client.post(
`/api/stores/${storeId}/pickups/${utcDate}/${profileId}`,
`/api/stores/${storeId}/pickups/${utcDate}/users/current`,
{},
{
...buildRequestConfig({
@@ -480,9 +550,11 @@ module.exports = {
fetchStores,
fetchPickups,
fetchRegionStores,
fetchStoreDetails,
fetchStoreMarker,
fetchStoreMembers,
fetchRegularPickup,
fetchRegisteredPickups,
fetchConversations,
pickupRuleCheck,
bookSlot
};

View File

@@ -0,0 +1,215 @@
const FIXED_REFERENCE_EVENTS = {
new_years_day: { label: 'Neujahr', month: 0, day: 1 },
epiphany: { label: 'Heilige Drei Könige', month: 0, day: 6 },
valentines_day: { label: 'Valentinstag', month: 1, day: 14 },
womens_day: { label: 'Internationaler Frauentag', month: 2, day: 8 },
may_day: { label: 'Tag der Arbeit', month: 4, day: 1 },
german_unity_day: { label: 'Tag der Deutschen Einheit', month: 9, day: 3 },
halloween: { label: 'Halloween', month: 9, day: 31 },
all_saints_day: { label: 'Allerheiligen', month: 10, day: 1 },
st_nicholas_day: { label: 'Nikolaus', month: 11, day: 6 },
christmas_eve: { label: 'Heiligabend', month: 11, day: 24 },
christmas_day: { label: '1. Weihnachtstag', month: 11, day: 25 },
boxing_day: { label: '2. Weihnachtstag', month: 11, day: 26 },
new_years_eve: { label: 'Silvester', month: 11, day: 31 }
};
const EASTER_REFERENCE_EVENTS = {
carnival_monday: { label: 'Rosenmontag', offsetDays: -48 },
ash_wednesday: { label: 'Aschermittwoch', offsetDays: -46 },
palm_sunday: { label: 'Palmsonntag', offsetDays: -7 },
maundy_thursday: { label: 'Gründonnerstag', offsetDays: -3 },
good_friday: { label: 'Karfreitag', offsetDays: -2 },
easter_sunday: { label: 'Ostersonntag', offsetDays: 0 },
easter_monday: { label: 'Ostermontag', offsetDays: 1 },
ascension_day: { label: 'Christi Himmelfahrt', offsetDays: 39 },
pentecost_sunday: { label: 'Pfingstsonntag', offsetDays: 49 },
pentecost_monday: { label: 'Pfingstmontag', offsetDays: 50 },
corpus_christi: { label: 'Fronleichnam', offsetDays: 60 }
};
const REFERENCE_EVENT_DEFINITIONS = {
...FIXED_REFERENCE_EVENTS,
...EASTER_REFERENCE_EVENTS
};
const REFERENCE_EVENTS = Object.entries(REFERENCE_EVENT_DEFINITIONS).map(([key, value]) => ({
key,
label: value.label
}));
function startOfDay(date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
function addDays(date, days) {
const copy = new Date(date.getTime());
copy.setDate(copy.getDate() + days);
return copy;
}
function addMonths(date, months) {
const copy = new Date(date.getTime());
copy.setMonth(copy.getMonth() + months);
return copy;
}
function applyOffset(date, unit, value = 0) {
const copy = new Date(date.getTime());
const numericValue = Number(value) || 0;
if (unit === 'weeks') {
copy.setDate(copy.getDate() + numericValue * 7);
return copy;
}
if (unit === 'months') {
copy.setMonth(copy.getMonth() + numericValue);
return copy;
}
copy.setDate(copy.getDate() + numericValue);
return copy;
}
function subtractOffset(date, unit, value = 0) {
return applyOffset(date, unit, -(Number(value) || 0));
}
function getIntervalMonths(interval) {
if (interval === 'monthly') {
return 1;
}
if (interval === 'quarterly') {
return 3;
}
return 12;
}
function getEasterSunday(year) {
const a = year % 19;
const b = Math.floor(year / 100);
const c = year % 100;
const d = Math.floor(b / 4);
const e = b % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const month = Math.floor((h + l - 7 * m + 114) / 31);
const day = ((h + l - 7 * m + 114) % 31) + 1;
return new Date(year, month - 1, day);
}
function getReferenceEventDate(eventKey, year) {
if (!eventKey || !Number.isFinite(year)) {
return null;
}
const fixedEvent = FIXED_REFERENCE_EVENTS[eventKey];
if (fixedEvent) {
return new Date(year, fixedEvent.month, fixedEvent.day);
}
const easterEvent = EASTER_REFERENCE_EVENTS[eventKey];
if (easterEvent) {
return addDays(getEasterSunday(year), easterEvent.offsetDays);
}
return null;
}
function getNextOccurrence(baseDate, intervalMonths, todayStart) {
if (!baseDate || Number.isNaN(baseDate.getTime())) {
return null;
}
let candidate = startOfDay(baseDate);
const guardYear = todayStart.getFullYear() + 200;
while (candidate < todayStart && candidate.getFullYear() < guardYear) {
candidate = startOfDay(addMonths(candidate, intervalMonths));
}
return candidate;
}
function getReminderReference(reminder = {}) {
const reference = reminder?.reference;
if (!reference || reference.mode !== 'event') {
return { mode: 'pickupDate' };
}
const unit = ['days', 'weeks', 'months'].includes(reference.occurrenceOffsetUnit)
? reference.occurrenceOffsetUnit
: 'days';
const value = Number.isFinite(Number(reference.occurrenceOffsetValue))
? Number(reference.occurrenceOffsetValue)
: 0;
if (!REFERENCE_EVENT_DEFINITIONS[reference.eventKey]) {
return { mode: 'pickupDate' };
}
return {
mode: 'event',
eventKey: reference.eventKey,
occurrenceOffsetUnit: unit,
occurrenceOffsetValue: value
};
}
function normalizeReminderReference(reference = {}) {
return getReminderReference({ reference });
}
function computeReminderSchedule(entry, today = new Date()) {
if (!entry?.reminder?.enabled) {
return null;
}
const todayStart = startOfDay(today);
const reminder = entry.reminder || {};
const beforeUnit = ['days', 'weeks', 'months'].includes(reminder.beforeUnit) ? reminder.beforeUnit : 'days';
const beforeValue = Number.isFinite(Number(reminder.beforeValue))
? Math.max(0, Number(reminder.beforeValue))
: Number.isFinite(Number(reminder.daysBefore))
? Math.max(0, Number(reminder.daysBefore))
: 6;
const reference = getReminderReference(reminder);
if (reference.mode === 'event') {
for (let year = todayStart.getFullYear(); year <= todayStart.getFullYear() + 2; year += 1) {
const eventDate = getReferenceEventDate(reference.eventKey, year);
if (!eventDate) {
return null;
}
const occurrence = startOfDay(
applyOffset(eventDate, reference.occurrenceOffsetUnit, reference.occurrenceOffsetValue)
);
const reminderDate = startOfDay(subtractOffset(occurrence, beforeUnit, beforeValue));
if (reminderDate >= todayStart) {
return { occurrence, reminderDate };
}
}
return null;
}
if (!entry.pickupDate) {
return null;
}
const baseDate = new Date(`${entry.pickupDate}T00:00:00`);
if (Number.isNaN(baseDate.getTime())) {
return null;
}
const intervalMonths = getIntervalMonths(reminder.interval);
let occurrence = getNextOccurrence(baseDate, intervalMonths, todayStart);
if (!occurrence) {
return null;
}
let reminderDate = startOfDay(subtractOffset(occurrence, beforeUnit, beforeValue));
if (reminderDate < todayStart) {
occurrence = startOfDay(addMonths(occurrence, intervalMonths));
reminderDate = startOfDay(subtractOffset(occurrence, beforeUnit, beforeValue));
}
return { occurrence, reminderDate };
}
module.exports = {
REFERENCE_EVENTS,
REFERENCE_EVENT_DEFINITIONS,
getReminderReference,
getReminderSchedule: computeReminderSchedule,
computeReminderSchedule,
normalizeReminderReference
};

View File

@@ -1,5 +1,6 @@
const fs = require('fs');
const path = require('path');
const { readJsonFile, writeJsonFile } = require('./jsonFileStore');
const CONFIG_DIR = path.join(__dirname, '..', 'config');
const IMAGE_ROOT = path.join(CONFIG_DIR, 'journal-images');
@@ -27,7 +28,7 @@ function hydrateJournalFile(profileId) {
ensureBaseDirs();
const filePath = getJournalPath(profileId);
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, JSON.stringify([], null, 2));
writeJsonFile(filePath, [], { backup: false });
}
return filePath;
}
@@ -35,8 +36,7 @@ function hydrateJournalFile(profileId) {
function readJournal(profileId) {
const filePath = hydrateJournalFile(profileId);
try {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw);
const parsed = readJsonFile(filePath, [], Array.isArray);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
console.error(`[JOURNAL] Konnte Journal für ${profileId} nicht lesen:`, error.message);
@@ -46,7 +46,7 @@ function readJournal(profileId) {
function writeJournal(profileId, entries = []) {
const filePath = hydrateJournalFile(profileId);
fs.writeFileSync(filePath, JSON.stringify(entries, null, 2));
writeJsonFile(filePath, entries);
return filePath;
}

89
services/jsonFileStore.js Normal file
View File

@@ -0,0 +1,89 @@
const fs = require('fs');
const path = require('path');
function ensureDirForFile(filePath) {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
function cloneDefault(defaultValue) {
return JSON.parse(JSON.stringify(defaultValue));
}
function fsyncPath(filePath) {
let fd = null;
try {
fd = fs.openSync(filePath, 'r');
fs.fsyncSync(fd);
} catch (error) {
if (error.code !== 'EINVAL' && error.code !== 'EBADF') {
throw error;
}
} finally {
if (fd !== null) {
fs.closeSync(fd);
}
}
}
function readJsonFile(filePath, defaultValue, validate = () => true) {
ensureDirForFile(filePath);
if (!fs.existsSync(filePath)) {
writeJsonFile(filePath, defaultValue, { backup: false });
return cloneDefault(defaultValue);
}
try {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw);
if (validate(parsed)) {
return parsed;
}
throw new Error('JSON shape validation failed');
} catch (error) {
const backupPath = `${filePath}.bak`;
if (fs.existsSync(backupPath)) {
try {
const backup = JSON.parse(fs.readFileSync(backupPath, 'utf8'));
if (validate(backup)) {
console.warn(`[JSON-STORE] Nutze Backup fuer ${filePath}: ${error.message}`);
return backup;
}
} catch (backupError) {
console.warn(`[JSON-STORE] Backup fuer ${filePath} ist unbrauchbar: ${backupError.message}`);
}
}
throw error;
}
}
function writeJsonFile(filePath, payload, { backup = true } = {}) {
ensureDirForFile(filePath);
const dir = path.dirname(filePath);
const tmpPath = path.join(
dir,
`.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`
);
const serialized = JSON.stringify(payload, null, 2);
fs.writeFileSync(tmpPath, serialized);
fsyncPath(tmpPath);
if (backup && fs.existsSync(filePath)) {
fs.copyFileSync(filePath, `${filePath}.bak`);
}
fs.renameSync(tmpPath, filePath);
try {
fsyncPath(dir);
} catch (error) {
console.warn(`[JSON-STORE] Directory fsync fuer ${dir} fehlgeschlagen: ${error.message}`);
}
}
module.exports = {
readJsonFile,
writeJsonFile
};

135
services/maintenanceMode.js Normal file
View File

@@ -0,0 +1,135 @@
const adminConfig = require('./adminConfig');
const requestLogStore = require('./requestLogStore');
const SIGNIFICANT_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
function createMaintenanceError(message) {
const error = new Error(message || 'Wartungsmodus aktiv');
error.code = 'MAINTENANCE_MODE_ACTIVE';
error.statusCode = 503;
return error;
}
function getSnapshot() {
const settings = adminConfig.readSettings();
return {
active: !!settings.maintenanceModeActive,
autoEnabled: !!settings.maintenanceModeAutoEnabled,
activatedAt: settings.maintenanceModeActivatedAt || null,
reason: settings.maintenanceModeReason || '',
errorThreshold: Number(settings.maintenanceModeErrorThreshold) || 8,
errorWindowMinutes: Number(settings.maintenanceModeErrorWindowMinutes) || 10
};
}
function isActive() {
return getSnapshot().active;
}
function isSignificantOutboundApiError(entry) {
if (entry?.direction !== 'outgoing' || entry?.target !== 'foodsharing.de') {
return false;
}
const status = Number(entry?.status);
if (Number.isFinite(status)) {
return SIGNIFICANT_STATUS_CODES.has(status);
}
return Boolean(entry?.error);
}
function activate({ reason } = {}) {
const snapshot = getSnapshot();
if (snapshot.active) {
return snapshot;
}
const next = adminConfig.writeSettings({
maintenanceModeActive: true,
maintenanceModeActivatedAt: new Date().toISOString(),
maintenanceModeReason: reason || 'Automatisch aktiviert'
});
console.warn(`[MAINTENANCE] Wartungsmodus aktiviert: ${next.maintenanceModeReason || 'ohne Grundangabe'}`);
return {
active: !!next.maintenanceModeActive,
autoEnabled: !!next.maintenanceModeAutoEnabled,
activatedAt: next.maintenanceModeActivatedAt || null,
reason: next.maintenanceModeReason || '',
errorThreshold: Number(next.maintenanceModeErrorThreshold) || 8,
errorWindowMinutes: Number(next.maintenanceModeErrorWindowMinutes) || 10
};
}
function activateAutomatically({ reason } = {}) {
const snapshot = activate({ reason });
if (!snapshot.active) {
return snapshot;
}
try {
const notificationService = require('./notificationService');
Promise.resolve(
notificationService.sendAdminMaintenanceModeNotification({
reason: snapshot.reason,
activatedAt: snapshot.activatedAt,
errorThreshold: snapshot.errorThreshold,
errorWindowMinutes: snapshot.errorWindowMinutes
})
).catch((error) => {
console.error('[MAINTENANCE] Admin-Benachrichtigung konnte nicht versendet werden:', error.message);
});
} catch (error) {
console.error('[MAINTENANCE] Notification-Service konnte nicht geladen werden:', error.message);
}
return snapshot;
}
function deactivate() {
const next = adminConfig.writeSettings({
maintenanceModeActive: false,
maintenanceModeActivatedAt: null,
maintenanceModeReason: ''
});
console.warn('[MAINTENANCE] Wartungsmodus deaktiviert.');
return next;
}
function ensureInactive(message) {
if (!isActive()) {
return;
}
throw createMaintenanceError(message);
}
function recordOutboundFailure(entry) {
if (!isSignificantOutboundApiError(entry)) {
return getSnapshot();
}
const snapshot = getSnapshot();
if (!snapshot.autoEnabled || snapshot.active) {
return snapshot;
}
const cutoff = Date.now() - snapshot.errorWindowMinutes * 60 * 1000;
const logs = requestLogStore.list();
const recentFailures = logs.filter(
(item) => Number(item?.timestamp) >= cutoff && isSignificantOutboundApiError(item)
);
if (recentFailures.length < snapshot.errorThreshold) {
return snapshot;
}
const statusSummary = recentFailures
.slice(0, 5)
.map((item) => item.status || item.error || 'unbekannt')
.join(', ');
return activateAutomatically({
reason: `${recentFailures.length} relevante Outbound-API-Fehler in ${snapshot.errorWindowMinutes} Minuten (z. B. ${statusSummary})`
});
}
module.exports = {
activate,
activateAutomatically,
createMaintenanceError,
deactivate,
ensureInactive,
getSnapshot,
isActive,
recordOutboundFailure
};

View File

@@ -351,6 +351,22 @@ async function sendAdminSessionErrorNotification({
});
}
async function sendAdminMaintenanceModeNotification({ reason, activatedAt, errorThreshold, errorWindowMinutes }) {
const messageLines = [
'Der Wartungsmodus wurde automatisch aktiviert.',
activatedAt ? `Aktiv seit: ${formatDateLabel(activatedAt)}.` : null,
Number.isFinite(errorThreshold) && Number.isFinite(errorWindowMinutes)
? `Schwelle: ${errorThreshold} relevante Fehler in ${errorWindowMinutes} Minuten.`
: null,
reason ? `Grund: ${reason}.` : null
].filter(Boolean);
await sendAdminTelegramNotification({
title: 'Wartungsmodus aktiviert',
message: messageLines.join('\n'),
priority: 'high'
});
}
module.exports = {
sendSlotNotification,
sendStoreWatchNotification,
@@ -360,5 +376,6 @@ module.exports = {
sendDormantPickupWarning,
sendJournalReminderNotification,
sendAdminBookingErrorNotification,
sendAdminSessionErrorNotification
sendAdminSessionErrorNotification,
sendAdminMaintenanceModeNotification
};

View File

@@ -11,6 +11,9 @@ const { readJournal, writeJournal } = require('./journalStore');
const { getStoreStatus, setStoreStatus, persistStoreStatusCache } = require('./storeStatusCache');
const { sendDormantPickupWarning, sendJournalReminderNotification } = require('./notificationService');
const { ensureSession, withSessionRetry } = require('./sessionRefresh');
const maintenanceMode = require('./maintenanceMode');
const { computeReminderSchedule } = require('./journalReminderUtils');
const { startStoreMessageWatcher } = require('./storeMessageWatcher');
function wait(ms) {
if (!ms || ms <= 0) {
@@ -19,6 +22,14 @@ function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function shouldPauseForMaintenance(scope) {
if (!maintenanceMode.isActive()) {
return false;
}
console.warn(`[MAINTENANCE] ${scope} übersprungen, weil Wartungsmodus aktiv ist.`);
return true;
}
const DEFAULT_STORE_WATCH_STATUS_MAX_AGE_MINUTES = 120;
const storeWatchInFlight = new Map();
const pickupCheckInFlight = new Map();
@@ -118,13 +129,37 @@ function loadRegularPickupCacheFromDisk() {
const PICKUP_FALLBACK_RETRY_MS = 60 * 60 * 1000;
const TIME_ZONE = 'Europe/Berlin';
function hasDormantMemberAccess(store) {
if (!store) {
return false;
}
if (store.isManaging) {
return true;
}
const membershipStatus = Number(store.membershipStatus);
if (!Number.isFinite(membershipStatus)) {
return true;
}
return membershipStatus === 1;
}
function resolveStoreTeamStatus(details) {
const status = Number(details?.teamStatus ?? details?.teamSearchStatus);
return Number.isFinite(status) ? status : null;
}
function isStoreStatusAccessError(error) {
const status = error?.response?.status;
return status === 403 || status === 404;
}
async function fetchSharedStoreStatus(session, storeId, { forceRefresh = false, maxAgeMs } = {}) {
if (!storeId) {
return { status: null, fetchedAt: null, fromCache: false };
}
const cacheEntry = getStoreStatus(storeId);
const cachedStatus = cacheEntry?.teamSearchStatus;
const hasCachedStatus = cachedStatus === 0 || cachedStatus === 1;
const hasCachedStatus = cacheEntry != null;
const cachedAt = Number(cacheEntry?.fetchedAt) || 0;
const effectiveMaxAge =
Number.isFinite(maxAgeMs) && maxAgeMs >= 0
@@ -141,15 +176,23 @@ async function fetchSharedStoreStatus(session, storeId, { forceRefresh = false,
}
const fetchPromise = (async () => {
const details = await withSessionRetry(
session,
() => foodsharingClient.fetchStoreDetails(storeId, session.cookieHeader, session),
{ label: 'fetchStoreDetails' }
);
const status = details?.teamSearchStatus === 1 ? 1 : 0;
const fetchedAt = Date.now();
setStoreStatus(storeId, { teamSearchStatus: status, fetchedAt });
return { status, fetchedAt, fromCache: false };
try {
const marker = await withSessionRetry(
session,
() => foodsharingClient.fetchStoreMarker(storeId, session.cookieHeader, session),
{ label: 'fetchStoreMarker' }
);
const status = resolveStoreTeamStatus(marker);
setStoreStatus(storeId, { teamSearchStatus: status, fetchedAt });
return { status, fetchedAt, fromCache: false };
} catch (error) {
if (!isStoreStatusAccessError(error)) {
throw error;
}
setStoreStatus(storeId, { teamSearchStatus: null, fetchedAt });
return { status: null, fetchedAt, fromCache: false };
}
})();
storeWatchInFlight.set(key, fetchPromise);
@@ -361,6 +404,20 @@ function resolveSettings(settings) {
storeWatchStatusCacheMaxAgeMinutes: Number.isFinite(settings.storeWatchStatusCacheMaxAgeMinutes)
? settings.storeWatchStatusCacheMaxAgeMinutes
: DEFAULT_SETTINGS.storeWatchStatusCacheMaxAgeMinutes,
messageWatcherSocketEnabled:
settings.messageWatcherSocketEnabled === undefined
? DEFAULT_SETTINGS.messageWatcherSocketEnabled
: !!settings.messageWatcherSocketEnabled,
messageWatcherFallbackEnabled:
settings.messageWatcherFallbackEnabled === undefined
? DEFAULT_SETTINGS.messageWatcherFallbackEnabled
: !!settings.messageWatcherFallbackEnabled,
messageWatcherFallbackCron:
settings.messageWatcherFallbackCron || DEFAULT_SETTINGS.messageWatcherFallbackCron,
messageWatcherDebugLogging:
settings.messageWatcherDebugLogging === undefined
? DEFAULT_SETTINGS.messageWatcherDebugLogging
: !!settings.messageWatcherDebugLogging,
ignoredSlots: Array.isArray(settings.ignoredSlots) ? settings.ignoredSlots : DEFAULT_SETTINGS.ignoredSlots,
notifications: {
ntfy: {
@@ -460,6 +517,9 @@ async function getRegularPickupSchedule(session, storeId) {
if (!session?.profile?.id || !storeId) {
return { rules: [], error: 'missing-session-or-store', fromCache: false };
}
if (shouldPauseForMaintenance(`Regular-Pickup-Refresh für Store ${storeId}`)) {
return { rules: [], error: 'maintenance-mode', fromCache: false };
}
const key = String(storeId);
const cached = getRegularPickupCacheEntry(key);
if (cached) {
@@ -570,7 +630,7 @@ function deactivateEntryInMemory(entry) {
function persistEntryDeactivation(profileId, entryId, options = {}) {
if (!profileId || !entryId) {
return;
return false;
}
try {
const config = readConfig(profileId);
@@ -603,11 +663,28 @@ function persistEntryDeactivation(profileId, entryId, options = {}) {
if (changed) {
writeConfig(profileId, updated);
}
return changed;
} catch (error) {
console.error(`[CONFIG] Konnte Eintrag ${entryId} für Profil ${profileId} nicht deaktivieren:`, error.message);
return false;
}
}
function deactivateEntry(session, entry, options = {}) {
if (!session?.profile?.id || !entry?.id) {
deactivateEntryInMemory(entry);
return false;
}
deactivateEntryInMemory(entry);
const changed = persistEntryDeactivation(session.profile.id, entry.id, options);
if (changed) {
const config = readConfig(session.profile.id);
const settings = resolveSettings();
scheduleConfig(session.id, config, settings);
}
return changed;
}
function isEntryActiveInConfig(profileId, entryId) {
if (!profileId || !entryId) {
return false;
@@ -692,9 +769,8 @@ async function handleExpiredDesiredWindow(session, entry) {
console.log(
`[INFO] Wunschzeitraum abgelaufen für ${storeName}${desiredLabel ? ` (${desiredLabel})` : ''}. Eintrag wird deaktiviert.`
);
deactivateEntryInMemory(entry);
resetDesiredWindow(entry);
persistEntryDeactivation(profileId, entry.id, { resetDesiredWindow: true });
deactivateEntry(session, entry, { resetDesiredWindow: true });
if (profileId) {
try {
await notificationService.sendDesiredWindowMissedNotification({
@@ -777,8 +853,7 @@ async function processBooking(session, entry, pickup) {
booked: false,
storeId: entry.id
});
deactivateEntryInMemory(entry);
persistEntryDeactivation(session.profile.id, entry.id);
deactivateEntry(session, entry);
return;
}
@@ -808,8 +883,7 @@ async function processBooking(session, entry, pickup) {
booked: true,
storeId: entry.id
});
deactivateEntryInMemory(entry);
persistEntryDeactivation(session.profile.id, entry.id);
deactivateEntry(session, entry);
} catch (error) {
console.error(`[ERROR] Buchung für ${storeName} am ${readableDate} fehlgeschlagen:`, error.message);
try {
@@ -831,6 +905,9 @@ async function processBooking(session, entry, pickup) {
}
async function checkEntry(sessionId, entry, settings) {
if (shouldPauseForMaintenance(`Pickup-Check für Store ${entry?.id || 'unbekannt'}`)) {
return;
}
if (entry?.active === false) {
return;
}
@@ -927,6 +1004,9 @@ async function checkEntry(sessionId, entry, settings) {
}
async function checkWatchedStores(sessionId, settings = DEFAULT_SETTINGS, options = {}) {
if (shouldPauseForMaintenance(`Store-Watch für Session ${sessionId}`)) {
return [];
}
const session = sessionStore.get(sessionId);
if (!session?.profile?.id) {
return [];
@@ -1066,6 +1146,9 @@ function scheduleRegularPickupRefresh(settings) {
regularPickupRefreshJob = cron.schedule(
cronExpression,
async () => {
if (shouldPauseForMaintenance('Regular-Pickup-Cronlauf')) {
return;
}
const sessions = sessionStore.list();
const storeSessionMap = new Map();
for (const session of sessions) {
@@ -1170,12 +1253,15 @@ function scheduleEntry(sessionId, entry, settings) {
function scheduleConfig(sessionId, config, settings) {
const resolvedSettings = resolveSettings(settings);
const entries = Array.isArray(config) ? config : [];
sessionStore.clearJobs(sessionId);
scheduleJournalReminders(sessionId);
if (shouldPauseForMaintenance(`Scheduler für Session ${sessionId}`)) {
return;
}
scheduleDormantMembershipCheck(sessionId, resolvedSettings);
const watchScheduled = scheduleStoreWatchers(sessionId, resolvedSettings);
scheduleFallbackPickupChecks(sessionId, resolvedSettings);
scheduleJournalReminders(sessionId);
const entries = Array.isArray(config) ? config : [];
const activeEntries = entries.filter((entry) => entry.active);
if (activeEntries.length === 0) {
if (watchScheduled) {
@@ -1187,6 +1273,15 @@ function scheduleConfig(sessionId, config, settings) {
}
return;
}
sessionStore.attachJob(
sessionId,
startStoreMessageWatcher({
sessionId,
config: entries,
settings: resolvedSettings,
runPickupCheck: runImmediatePickupCheck
})
);
activeEntries.forEach((entry) => scheduleEntry(sessionId, entry, resolvedSettings));
console.log(
`[INFO] Scheduler für Session ${sessionId} mit ${activeEntries.length} Jobs aktiv.`
@@ -1194,11 +1289,13 @@ function scheduleConfig(sessionId, config, settings) {
}
async function runStoreWatchCheck(sessionId, settings, options = {}) {
maintenanceMode.ensureInactive('Store-Watch ist während des Wartungsmodus deaktiviert.');
const resolvedSettings = resolveSettings(settings);
return checkWatchedStores(sessionId, resolvedSettings, options);
}
async function runImmediatePickupCheck(sessionId, config, settings) {
maintenanceMode.ensureInactive('Pickup-Prüfungen sind während des Wartungsmodus deaktiviert.');
const resolvedSettings = resolveSettings(settings);
const entries = Array.isArray(config) ? config : [];
const activeEntries = entries.filter((entry) => entry?.active);
@@ -1227,6 +1324,9 @@ function getMissingLastPickupStoreIds(config = []) {
}
async function checkDormantMembers(sessionId, options = {}) {
if (shouldPauseForMaintenance(`Dormant-Check für Session ${sessionId}`)) {
return;
}
const session = sessionStore.get(sessionId);
if (!session?.profile?.id) {
return;
@@ -1272,20 +1372,49 @@ async function checkDormantMembers(sessionId, options = {}) {
const stores = Array.isArray(session.storesCache?.data) ? session.storesCache.data : [];
if (stores.length === 0) {
console.warn(`[DORMANT] Keine Stores für Session ${sessionId} im Cache gefunden.`);
} else {
stores.forEach((store) => {
const storeId = store?.id ? String(store.id) : null;
if (!storeId || !storeTargets.has(storeId)) {
return;
}
const target = storeTargets.get(storeId);
storeTargets.set(storeId, {
...target,
storeName: store.name || target.storeName
});
});
return;
}
const activeStoreIds = new Set();
const activeStoresById = new Map();
stores.forEach((store) => {
const storeId = store?.id ? String(store.id) : null;
if (!storeId) {
return;
}
activeStoreIds.add(storeId);
activeStoresById.set(storeId, store);
if (!storeTargets.has(storeId)) {
return;
}
const target = storeTargets.get(storeId);
storeTargets.set(storeId, {
...target,
storeName: store.name || target.storeName
});
});
Array.from(storeTargets.keys()).forEach((storeId) => {
if (activeStoreIds.has(storeId)) {
return;
}
console.info(
`[DORMANT] Store ${storeId} wird für Profil ${profileId} übersprungen, weil er nicht mehr in den aktuellen Store-Mitgliedschaften enthalten ist.`
);
storeTargets.delete(storeId);
});
Array.from(storeTargets.keys()).forEach((storeId) => {
const store = activeStoresById.get(storeId);
if (hasDormantMemberAccess(store)) {
return;
}
console.info(
`[DORMANT] Store ${storeId} wird für Profil ${profileId} übersprungen, weil die aktuelle Mitgliedschaft (${store?.membershipStatus ?? 'unbekannt'}) keinen Zugriff auf die Mitgliederliste erlaubt.`
);
storeTargets.delete(storeId);
});
if (storeTargets.size === 0) {
return;
}
@@ -1302,6 +1431,13 @@ async function checkDormantMembers(sessionId, options = {}) {
{ label: 'fetchStoreMembers' }
);
} catch (error) {
const status = error?.response?.status;
if (status === 403 || status === 404) {
console.warn(
`[DORMANT] Mitglieder von Store ${storeId} konnten für Profil ${profileId} nicht geladen werden (${status}). Der Store wird für diese Session übersprungen.`
);
continue;
}
console.warn(`[DORMANT] Mitglieder von Store ${storeId} konnten nicht geladen werden:`, error.message);
continue;
}
@@ -1310,7 +1446,17 @@ async function checkDormantMembers(sessionId, options = {}) {
continue;
}
const reasons = [];
const lastFetchMs = memberEntry.last_fetch ? Number(memberEntry.last_fetch) * 1000 : null;
const lastFetchRaw = memberEntry.lastFetch ?? memberEntry.last_fetch ?? null;
let lastFetchMs = null;
if (typeof lastFetchRaw === 'string') {
const parsedLastFetch = new Date(lastFetchRaw);
if (!Number.isNaN(parsedLastFetch.getTime())) {
lastFetchMs = parsedLastFetch.getTime();
}
} else if (Number.isFinite(Number(lastFetchRaw))) {
const numericLastFetch = Number(lastFetchRaw);
lastFetchMs = numericLastFetch > 1e12 ? numericLastFetch : numericLastFetch * 1000;
}
if (Number.isFinite(lastFetchMs)) {
const configEntry = configEntryMap.get(storeId)?.entry;
const lastPickupAt = new Date(lastFetchMs).toISOString();
@@ -1323,8 +1469,10 @@ async function checkDormantMembers(sessionId, options = {}) {
const lastFetchLabel = lastFetchMs ? new Date(lastFetchMs).toLocaleDateString('de-DE') : 'unbekannt';
reasons.push(`Letzte Abholung: ${lastFetchLabel} (älter als 4 Monate)`);
}
if (memberEntry.hygiene_certificate_until) {
const expiry = new Date(memberEntry.hygiene_certificate_until.replace(' ', 'T'));
const hygieneCertificateUntil =
memberEntry.hygieneCertificateUntil ?? memberEntry.hygiene_certificate_until ?? null;
if (hygieneCertificateUntil) {
const expiry = new Date(String(hygieneCertificateUntil).replace(' ', 'T'));
if (!Number.isNaN(expiry.getTime()) && expiry.getTime() < hygieneCutoff) {
reasons.push(
`Hygiene-Nachweis läuft bald ab: ${expiry.toLocaleDateString('de-DE')} (unter 6 Wochen)`
@@ -1395,6 +1543,7 @@ function scheduleDormantMembershipCheck(sessionId, settings) {
}
async function runDormantMembershipCheck(sessionId, options = {}) {
maintenanceMode.ensureInactive('Dormant-Prüfungen sind während des Wartungsmodus deaktiviert.');
await checkDormantMembers(sessionId, options);
}
@@ -1417,17 +1566,11 @@ async function checkJournalReminders(sessionId) {
if (!entry?.reminder?.enabled || !entry.pickupDate) {
continue;
}
const intervalMonths = getIntervalMonths(entry.reminder.interval);
const baseDate = new Date(`${entry.pickupDate}T00:00:00`);
const occurrence = getNextOccurrence(baseDate, intervalMonths, todayStart);
if (!occurrence) {
const schedule = computeReminderSchedule(entry, todayStart);
if (!schedule?.occurrence || !schedule?.reminderDate) {
continue;
}
const daysBefore = Number.isFinite(entry.reminder.daysBefore)
? Math.max(0, entry.reminder.daysBefore)
: 42;
const reminderDate = new Date(occurrence.getTime());
reminderDate.setDate(reminderDate.getDate() - daysBefore);
const { occurrence, reminderDate } = schedule;
if (!isSameDay(reminderDate, todayStart)) {
continue;

View File

@@ -4,7 +4,7 @@ const { v4: uuid } = require('uuid');
const CONFIG_DIR = path.join(__dirname, '..', 'config');
const LOG_FILE = path.join(CONFIG_DIR, 'request-logs.json');
const TTL_MS = 14 * 24 * 60 * 60 * 1000;
const TTL_MS = 28 * 24 * 60 * 60 * 1000;
const MAX_BODY_CHARS = 10000;
function ensureDir() {
@@ -79,10 +79,13 @@ function add(entry = {}) {
return record;
}
function list(limit = 500) {
const sanitizedLimit = Math.max(1, Math.min(Number(limit) || 500, 2000));
function list(limit) {
const logs = prune(readLogs());
persistLogs(logs);
if (limit == null) {
return logs.slice().reverse();
}
const sanitizedLimit = Math.max(1, Math.min(Number(limit) || 500, 10000));
return logs.slice(-sanitizedLimit).reverse();
}

View File

@@ -20,7 +20,20 @@ function shouldNotifyAdminSessionError(key) {
function isUnauthorizedError(error) {
const status = error?.response?.status;
return status === 401 || status === 403;
if (status === 401) {
return true;
}
if (status !== 403) {
return false;
}
const data = error?.response?.data;
const message =
typeof data === 'string'
? data
: typeof data?.message === 'string'
? data.message
: '';
return message.toLowerCase().includes('not logged in');
}
function isCsrfError(error) {

View File

@@ -0,0 +1,72 @@
const fs = require('fs');
const path = require('path');
const { readJsonFile, writeJsonFile } = require('./jsonFileStore');
const CONFIG_DIR = path.join(__dirname, '..', 'config');
function ensureDir() {
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
}
}
function getStoreCachePath(profileId = 'shared') {
return path.join(CONFIG_DIR, `${profileId}-stores-cache.json`);
}
function sanitizeStoreList(stores) {
return Array.isArray(stores) ? stores.filter(Boolean) : [];
}
function readStoresCache(profileId) {
ensureDir();
const filePath = getStoreCachePath(profileId);
if (!fs.existsSync(filePath)) {
return null;
}
try {
const parsed = readJsonFile(
filePath,
null,
(value) =>
value === null ||
(
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
Array.isArray(value.data) &&
Number.isFinite(Number(value.fetchedAt))
)
);
if (!parsed) {
return null;
}
return {
data: sanitizeStoreList(parsed.data),
fetchedAt: Number(parsed.fetchedAt)
};
} catch (error) {
console.error(`[STORE-CACHE] Konnte Cache fuer ${profileId} nicht lesen:`, error.message);
return null;
}
}
function writeStoresCache(profileId, storesCache) {
if (!profileId || !storesCache) {
return null;
}
const payload = {
data: sanitizeStoreList(storesCache.data),
fetchedAt: Number(storesCache.fetchedAt) || Date.now()
};
const filePath = getStoreCachePath(profileId);
writeJsonFile(filePath, payload);
return payload;
}
module.exports = {
readStoresCache,
writeStoresCache,
getStoreCachePath
};

View File

@@ -0,0 +1,421 @@
const { io } = require('socket.io-client');
const cron = require('node-cron');
const foodsharingClient = require('./foodsharingClient');
const requestLogStore = require('./requestLogStore');
const sessionStore = require('./sessionStore');
const FOODSHARING_URL = 'https://foodsharing.de';
const DEFAULT_FALLBACK_CRON = '0 6-23 * * *';
const STORE_DEBOUNCE_MS = 2 * 60 * 1000;
function extractStoreIdFromHref(href = '') {
if (typeof href !== 'string' || !href) {
return null;
}
const directMatch = href.match(/\/store\/(\d+)/);
if (directMatch) {
return directMatch[1];
}
const queryMatch = href.match(/[?&]id=(\d+)/);
if (queryMatch && href.includes('fsbetrieb')) {
return queryMatch[1];
}
return null;
}
function getMatchingActiveEntries(config, storeId) {
if (!Array.isArray(config) || !storeId) {
return [];
}
return config.filter((entry) => entry?.active && String(entry.id) === String(storeId));
}
function summarizePayload(payload, depth = 0) {
if (payload === null || payload === undefined) {
return payload;
}
if (typeof payload !== 'object') {
return typeof payload === 'string' && payload.length > 240 ? `${payload.slice(0, 240)}` : payload;
}
if (depth >= 2) {
return Array.isArray(payload) ? `[array:${payload.length}]` : '[object]';
}
if (Array.isArray(payload)) {
return payload.slice(0, 5).map((entry) => summarizePayload(entry, depth + 1));
}
return Object.fromEntries(
Object.entries(payload)
.slice(0, 20)
.map(([key, value]) => [key, summarizePayload(value, depth + 1)])
);
}
function sortConversationsAscending(conversations = []) {
return conversations.slice().sort((a, b) => {
const left = new Date(a?.lastMessage?.sentAt || 0).getTime() || 0;
const right = new Date(b?.lastMessage?.sentAt || 0).getTime() || 0;
if (left !== right) {
return left - right;
}
return Number(a?.lastMessage?.id || 0) - Number(b?.lastMessage?.id || 0);
});
}
function collectStoreIds(value, ids = new Set(), depth = 0) {
if (value === null || value === undefined || depth > 5) {
return ids;
}
if (typeof value === 'string') {
const hrefId = extractStoreIdFromHref(value);
if (hrefId) {
ids.add(hrefId);
}
return ids;
}
if (typeof value !== 'object') {
return ids;
}
if (Array.isArray(value)) {
value.forEach((entry) => collectStoreIds(entry, ids, depth + 1));
return ids;
}
const directStoreId = value.storeId ?? value.store_id ?? value.store?.id ?? value.store?.storeId;
if (directStoreId) {
ids.add(String(directStoreId));
}
if (value.href || value.url || value.link) {
collectStoreIds(value.href || value.url || value.link, ids, depth + 1);
}
Object.values(value).forEach((entry) => collectStoreIds(entry, ids, depth + 1));
return ids;
}
function getMessageWatcherSettings(settings = {}) {
const fallbackCron = settings.messageWatcherFallbackCron || DEFAULT_FALLBACK_CRON;
return {
socketEnabled: settings.messageWatcherSocketEnabled !== false,
fallbackEnabled: settings.messageWatcherFallbackEnabled !== false,
fallbackCron: cron.validate(fallbackCron) ? fallbackCron : DEFAULT_FALLBACK_CRON,
debugLogging: settings.messageWatcherDebugLogging !== false
};
}
function isPotentialMessageSocketEvent(eventName = '') {
return /message|conversation|bell|notification|wall|post/i.test(String(eventName));
}
function startStoreMessageWatcher({ sessionId, config, settings, runPickupCheck }) {
const watcherSettings = getMessageWatcherSettings(settings);
let stopped = false;
let fallbackJob = null;
let socket = null;
let socketConnected = false;
let conversationsBaselineReady = false;
const lastMessageByConversation = new Map();
const lastTriggerByStore = new Map();
const inFlightStores = new Set();
function logWatcherEvent(type, data = {}, options = {}) {
const session = sessionStore.get(sessionId);
const payload = {
event: type,
...data
};
const consoleMessage = `[MESSAGES] ${type}: ${JSON.stringify(payload)}`;
const level = options.level || 'info';
if (level === 'warn') {
console.warn(consoleMessage);
} else if (level === 'error') {
console.error(consoleMessage);
} else {
console.log(consoleMessage);
}
if (!watcherSettings.debugLogging && !options.forceLog) {
return;
}
try {
requestLogStore.add({
direction: 'incoming',
target: 'foodsharing.de',
method: 'MESSAGE_WATCHER',
path: type,
status: null,
sessionId,
profileId: session?.profile?.id ?? null,
profileName: session?.profile?.name ?? null,
responseBody: payload
});
} catch (error) {
console.warn('[MESSAGES] Watcher-Log konnte nicht geschrieben werden:', error.message);
}
}
async function triggerStoreCheck(storeId, source = {}) {
const matchingEntries = getMatchingActiveEntries(config, storeId);
if (matchingEntries.length === 0) {
logWatcherEvent('trigger-skipped-no-active-entry', { storeId, source: source.type });
return;
}
const now = Date.now();
const lastTrigger = lastTriggerByStore.get(storeId) || 0;
if (now - lastTrigger < STORE_DEBOUNCE_MS || inFlightStores.has(storeId)) {
logWatcherEvent('trigger-skipped-debounce', { storeId, source: source.type });
return;
}
lastTriggerByStore.set(storeId, now);
inFlightStores.add(storeId);
const storeName = source.storeName || matchingEntries[0]?.name || `Store ${storeId}`;
logWatcherEvent(
'store-message-pickup-check',
{
storeId,
storeName,
checkedEntries: matchingEntries.length,
source
},
{ forceLog: true }
);
try {
await runPickupCheck(sessionId, matchingEntries, settings);
logWatcherEvent('store-message-pickup-check-complete', {
storeId,
checkedEntries: matchingEntries.length,
source: source.type
});
} catch (error) {
logWatcherEvent(
'store-message-pickup-check-error',
{ storeId, source: source.type, error: error.message },
{ level: 'error', forceLog: true }
);
} finally {
inFlightStores.delete(storeId);
}
}
async function processSocketEvent(eventName, args = []) {
const summarizedArgs = summarizePayload(args);
const storeIds = Array.from(collectStoreIds(args));
logWatcherEvent('socket-event', {
eventName,
storeIds,
payload: summarizedArgs
});
if (!isPotentialMessageSocketEvent(eventName)) {
return;
}
for (const storeId of storeIds) {
await triggerStoreCheck(storeId, {
type: 'socket',
eventName
});
}
}
async function pollConversations(reason) {
const session = sessionStore.get(sessionId);
if (!session?.cookieHeader) {
return;
}
const { conversations } = await foodsharingClient.fetchConversations(
session.cookieHeader,
{ limit: 20, offset: 0 },
session
);
const sorted = sortConversationsAscending(conversations);
let newMessages = 0;
if (!conversationsBaselineReady) {
sorted.forEach((conversation) => {
if (conversation?.id && conversation?.lastMessage?.id) {
lastMessageByConversation.set(String(conversation.id), String(conversation.lastMessage.id));
}
});
conversationsBaselineReady = true;
logWatcherEvent('fallback-conversations-baseline', {
reason,
conversations: sorted.length
});
return;
}
for (const conversation of sorted) {
const conversationId = conversation?.id ? String(conversation.id) : null;
const lastMessageId = conversation?.lastMessage?.id ? String(conversation.lastMessage.id) : null;
if (!conversationId || !lastMessageId) {
continue;
}
const previousMessageId = lastMessageByConversation.get(conversationId);
lastMessageByConversation.set(conversationId, lastMessageId);
if (!previousMessageId || previousMessageId === lastMessageId) {
continue;
}
newMessages += 1;
if (!conversation.storeId) {
logWatcherEvent('conversation-message-without-store', {
conversationId,
lastMessageId
});
continue;
}
await triggerStoreCheck(String(conversation.storeId), {
type: 'conversation-fallback',
conversationId,
messageId: lastMessageId,
storeName: conversation.title || null,
sentAt: conversation.lastMessage?.sentAt || null,
body: summarizePayload(conversation.lastMessage?.body || '')
});
}
logWatcherEvent('fallback-conversations-polled', {
reason,
conversations: sorted.length,
newMessages
});
}
async function runFallbackPoll(reason) {
if (stopped || !watcherSettings.fallbackEnabled) {
return;
}
try {
await pollConversations(reason);
} catch (error) {
logWatcherEvent(
'fallback-poll-error',
{ reason, error: error.message },
{ level: 'warn', forceLog: true }
);
}
}
function scheduleFallbackPoll() {
if (stopped || !watcherSettings.fallbackEnabled) {
return;
}
fallbackJob = cron.schedule(watcherSettings.fallbackCron, async () => {
if (!socketConnected) {
await runFallbackPoll('socket-disconnected');
} else {
logWatcherEvent('fallback-poll-skipped-socket-connected', {
cron: watcherSettings.fallbackCron
});
}
});
}
function startSocket() {
if (!watcherSettings.socketEnabled) {
logWatcherEvent('socket-disabled');
return;
}
const session = sessionStore.get(sessionId);
if (!session?.cookieHeader) {
logWatcherEvent('socket-skipped-missing-cookie', {}, { level: 'warn', forceLog: true });
return;
}
socket = io(FOODSHARING_URL, {
path: '/websocket/socket.io',
transports: ['websocket', 'polling'],
extraHeaders: {
Cookie: session.cookieHeader,
Origin: FOODSHARING_URL,
Referer: `${FOODSHARING_URL}/dashboard`
},
reconnection: true,
reconnectionDelay: 10000,
reconnectionDelayMax: 60000,
timeout: 20000,
forceNew: true
});
socket.on('connect', () => {
socketConnected = true;
logWatcherEvent('socket-connected', {
socketId: socket.id,
transport: socket.io?.engine?.transport?.name || null
}, { forceLog: true });
});
socket.on('disconnect', (reason) => {
socketConnected = false;
logWatcherEvent('socket-disconnected', { reason }, { level: 'warn', forceLog: true });
});
socket.on('connect_error', (error) => {
socketConnected = false;
logWatcherEvent(
'socket-connect-error',
{ error: error.message },
{ level: 'warn', forceLog: true }
);
});
socket.io.on('reconnect_attempt', (attempt) => {
logWatcherEvent('socket-reconnect-attempt', { attempt });
});
socket.io.on('upgrade', (transport) => {
logWatcherEvent('socket-transport-upgrade', {
transport: transport?.name || null
});
});
socket.onAny((eventName, ...args) => {
processSocketEvent(eventName, args).catch((error) => {
logWatcherEvent(
'socket-event-error',
{ eventName, error: error.message },
{ level: 'error', forceLog: true }
);
});
});
}
logWatcherEvent('watcher-started', {
socketEnabled: watcherSettings.socketEnabled,
fallbackEnabled: watcherSettings.fallbackEnabled,
fallbackCron: watcherSettings.fallbackCron,
debugLogging: watcherSettings.debugLogging,
activeEntries: Array.isArray(config) ? config.filter((entry) => entry?.active).length : 0
}, { forceLog: true });
startSocket();
if (watcherSettings.fallbackEnabled) {
runFallbackPoll('initial-baseline').finally(scheduleFallbackPoll);
}
return {
stop() {
stopped = true;
if (fallbackJob) {
fallbackJob.stop();
}
if (socket) {
socket.disconnect();
}
logWatcherEvent('watcher-stopped', {}, { forceLog: true });
}
};
}
module.exports = {
collectStoreIds,
extractStoreIdFromHref,
getMatchingActiveEntries,
getMessageWatcherSettings,
isPotentialMessageSocketEvent,
startStoreMessageWatcher
};

View File

@@ -1,5 +1,6 @@
const fs = require('fs');
const path = require('path');
const { readJsonFile, writeJsonFile } = require('./jsonFileStore');
const STORE_STATUS_FILE = path.join(__dirname, '..', 'config', 'store-watch-status.json');
@@ -13,12 +14,15 @@ function ensureDir() {
function readStoreStatus() {
ensureDir();
if (!fs.existsSync(STORE_STATUS_FILE)) {
fs.writeFileSync(STORE_STATUS_FILE, JSON.stringify({}, null, 2));
writeJsonFile(STORE_STATUS_FILE, {}, { backup: false });
return {};
}
try {
const raw = fs.readFileSync(STORE_STATUS_FILE, 'utf8');
const parsed = JSON.parse(raw);
const parsed = readJsonFile(
STORE_STATUS_FILE,
{},
(value) => value && typeof value === 'object' && !Array.isArray(value)
);
if (parsed && typeof parsed === 'object') {
return parsed;
}
@@ -31,7 +35,7 @@ function readStoreStatus() {
function writeStoreStatus(cache = {}) {
ensureDir();
fs.writeFileSync(STORE_STATUS_FILE, JSON.stringify(cache, null, 2));
writeJsonFile(STORE_STATUS_FILE, cache);
}
module.exports = {

View File

@@ -1,5 +1,6 @@
const fs = require('fs');
const path = require('path');
const { readJsonFile, writeJsonFile } = require('./jsonFileStore');
const STORE_WATCH_DIR = path.join(__dirname, '..', 'config');
@@ -41,12 +42,11 @@ function readStoreWatch(profileId) {
ensureDir();
const filePath = getStoreWatchPath(profileId);
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, JSON.stringify([], null, 2));
writeJsonFile(filePath, [], { backup: false });
return [];
}
try {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw);
const parsed = readJsonFile(filePath, [], Array.isArray);
if (!Array.isArray(parsed)) {
return [];
}
@@ -76,7 +76,7 @@ function writeStoreWatch(profileId, entries = []) {
});
ensureDir();
const filePath = getStoreWatchPath(profileId);
fs.writeFileSync(filePath, JSON.stringify(sanitized, null, 2));
writeJsonFile(filePath, sanitized);
return sanitized;
}

View File

@@ -1,5 +1,6 @@
const fs = require('fs');
const path = require('path');
const { readJsonFile, writeJsonFile } = require('./jsonFileStore');
const PREF_DIR = path.join(__dirname, '..', 'config');
@@ -40,12 +41,15 @@ function readPreferences(profileId) {
ensureDir();
const filePath = getPreferencesPath(profileId);
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, JSON.stringify(DEFAULT_PREFERENCES, null, 2));
writeJsonFile(filePath, DEFAULT_PREFERENCES, { backup: false });
return { ...DEFAULT_PREFERENCES };
}
try {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw);
const parsed = readJsonFile(
filePath,
DEFAULT_PREFERENCES,
(value) => value && typeof value === 'object' && !Array.isArray(value)
);
return {
location: sanitizeLocation(parsed.location) || null
};
@@ -65,7 +69,7 @@ function writePreferences(profileId, patch = {}) {
};
ensureDir();
const filePath = getPreferencesPath(profileId);
fs.writeFileSync(filePath, JSON.stringify(next, null, 2));
writeJsonFile(filePath, next);
return next;
}

View File

@@ -1,5 +1,6 @@
const fs = require('fs');
const path = require('path');
const { readJsonFile, writeJsonFile } = require('./jsonFileStore');
const SETTINGS_DIR = path.join(__dirname, '..', 'config');
@@ -45,7 +46,7 @@ function hydrateSettingsFile(profileId) {
ensureDir();
const filePath = getSettingsPath(profileId);
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, JSON.stringify(DEFAULT_USER_SETTINGS, null, 2));
writeJsonFile(filePath, DEFAULT_USER_SETTINGS, { backup: false });
}
return filePath;
}
@@ -53,8 +54,11 @@ function hydrateSettingsFile(profileId) {
function readNotificationSettings(profileId) {
const filePath = hydrateSettingsFile(profileId);
try {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw);
const parsed = readJsonFile(
filePath,
DEFAULT_USER_SETTINGS,
(value) => value && typeof value === 'object' && !Array.isArray(value)
);
return {
notifications: {
ntfy: {
@@ -97,7 +101,7 @@ function writeNotificationSettings(profileId, patch = {}) {
};
const filePath = hydrateSettingsFile(profileId);
fs.writeFileSync(filePath, JSON.stringify(next, null, 2));
writeJsonFile(filePath, next);
return next;
}