From dc951567937d16dea01919e0c5bd00c08d112bb3 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 9 Nov 2025 13:50:17 +0100 Subject: [PATCH] aktueller Stand --- .env | 6 +- config/839246-pickup-config.json | 170 +++++ config/admin-settings.json | 13 + config/credentials.json | 7 + data/defaultConfig.js | 1 + iobrokerSkript.js | 390 ----------- package-lock.json | 546 +++------------ package.json | 4 +- server.js | 348 +++++++--- services/adminConfig.js | 94 +++ services/configStore.js | 48 ++ services/credentialStore.js | 71 ++ services/foodsharingClient.js | 179 +++++ services/pickupScheduler.js | 236 +++++++ services/sessionStore.js | 98 +++ src/App.js | 1089 ++++++++++++++++++++++++------ 16 files changed, 2152 insertions(+), 1148 deletions(-) create mode 100644 config/839246-pickup-config.json create mode 100644 config/admin-settings.json create mode 100644 config/credentials.json create mode 100644 data/defaultConfig.js delete mode 100644 iobrokerSkript.js create mode 100644 services/adminConfig.js create mode 100644 services/configStore.js create mode 100644 services/credentialStore.js create mode 100644 services/foodsharingClient.js create mode 100644 services/pickupScheduler.js create mode 100644 services/sessionStore.js diff --git a/.env b/.env index 41a5f72..674608e 100644 --- a/.env +++ b/.env @@ -1,9 +1,5 @@ # MQTT-Konfiguration -MQTT_BROKER=mqtt://iobroker:1884 -MQTT_TOPIC=foodsharing/pickupCheck/config/meik -MQTT_USER=mqtt -MQTT_PASSWORD=mqtt!1884! - +ADMIN_EMAIL=meikdre@gmx.de # Server-Konfiguration PORT=3000 NODE_ENV=production diff --git a/config/839246-pickup-config.json b/config/839246-pickup-config.json new file mode 100644 index 0000000..8c8afa4 --- /dev/null +++ b/config/839246-pickup-config.json @@ -0,0 +1,170 @@ +[ + { + "id": "44972", + "label": "Aldi Süd RA Biblisweg", + "active": false, + "checkProfileId": true, + "onlyNotify": true + }, + { + "id": "44975", + "label": "Aldi Süd RA Kuppenheim", + "active": false, + "checkProfileId": true, + "onlyNotify": false + }, + { + "id": "44971", + "label": "Aldi Süd RA LützowerStr.", + "active": false, + "checkProfileId": true, + "onlyNotify": false + }, + { + "id": "63367", + "label": "Arena Balkan Bäckerei", + "active": false, + "checkProfileId": true, + "onlyNotify": false + }, + { + "id": "59378", + "label": "Backwaren Kaufland", + "active": false, + "checkProfileId": true, + "onlyNotify": false + }, + { + "id": "49712", + "label": "Baden-Baden foodsharing", + "active": false, + "checkProfileId": true, + "onlyNotify": false + }, + { + "id": "42264", + "label": "Bildungshaus St. Bernhard, Rastatt", + "active": false, + "checkProfileId": true, + "onlyNotify": false + }, + { + "id": "43191", + "label": "Café Böckeler Baden-Baden", + "active": false, + "checkProfileId": true, + "onlyNotify": false + }, + { + "id": "33875", + "label": "CAP-Markt Sandweier", + "active": false, + "checkProfileId": true, + "onlyNotify": false + }, + { + "id": "28513", + "label": "denn's Biomarkt", + "active": false, + "checkProfileId": true, + "onlyNotify": false + }, + { + "id": "42322", + "label": "Edeka Fischer Haueneberstein", + "active": false, + "checkProfileId": true, + "onlyNotify": false + }, + { + "id": "40082", + "label": "Ernteaktionen rund um Baden-Baden", + "active": false, + "checkProfileId": true, + "onlyNotify": false + }, + { + "id": "40261", + "label": "Eventbetrieb Rastatt/Baden-Baden", + "active": false, + "checkProfileId": true, + "onlyNotify": false + }, + { + "id": "51450", + "label": "Hornbach KA-Grünwinkel", + "active": false, + "checkProfileId": true, + "onlyNotify": false + }, + { + "id": "62551", + "label": "Koordinationsbetrieb Fairteiler Sandweier ", + "active": false, + "checkProfileId": true, + "onlyNotify": false + }, + { + "id": "53552", + "label": "Koordinationsbetrieb Fairteiler Spitalkirche", + "active": false, + "checkProfileId": true, + "onlyNotify": false + }, + { + "id": "43754", + "label": "Koordinationsbetrieb Lebensmittelspenden für Geflüchtete bei Sinzheim", + "active": false, + "checkProfileId": true, + "onlyNotify": false + }, + { + "id": "66125", + "label": "Murgtal Foodsharing", + "active": false, + "checkProfileId": true, + "onlyNotify": false + }, + { + "id": "62533", + "label": "New Pop-Festival", + "active": false, + "checkProfileId": true, + "onlyNotify": false + }, + { + "id": "31602", + "label": "Notfallteam Rastatt/Baden-Baden / spontane Abholungen", + "active": false, + "checkProfileId": true, + "onlyNotify": false + }, + { + "id": "63448", + "label": "Penny Baden-Oos", + "active": false, + "checkProfileId": true, + "onlyNotify": false + }, + { + "id": "41393", + "label": "Weihnachtsmarkt Baden-Baden", + "active": false, + "checkProfileId": true, + "onlyNotify": false + }, + { + "id": "22787", + "label": "Weihnachtsmarkt Rastatt", + "active": false, + "checkProfileId": true, + "onlyNotify": false + }, + { + "id": "36100", + "label": "Bäckerei Späth", + "active": false, + "checkProfileId": true, + "onlyNotify": false + } +] \ No newline at end of file diff --git a/config/admin-settings.json b/config/admin-settings.json new file mode 100644 index 0000000..af162f6 --- /dev/null +++ b/config/admin-settings.json @@ -0,0 +1,13 @@ +{ + "scheduleCron": "*/10 7-22 * * *", + "randomDelayMinSeconds": 10, + "randomDelayMaxSeconds": 120, + "initialDelayMinSeconds": 5, + "initialDelayMaxSeconds": 30, + "ignoredSlots": [ + { + "storeId": "51450", + "description": "TVS" + } + ] +} \ No newline at end of file diff --git a/config/credentials.json b/config/credentials.json new file mode 100644 index 0000000..c7589de --- /dev/null +++ b/config/credentials.json @@ -0,0 +1,7 @@ +{ + "839246": { + "email": "meikdre@gmx.de", + "password": "R67aJUj2-wWVfP8", + "token": "1fdccfbe-2182-4749-9f42-ac79345c143d" + } +} \ No newline at end of file diff --git a/data/defaultConfig.js b/data/defaultConfig.js new file mode 100644 index 0000000..e0a30c5 --- /dev/null +++ b/data/defaultConfig.js @@ -0,0 +1 @@ +module.exports = []; diff --git a/iobrokerSkript.js b/iobrokerSkript.js deleted file mode 100644 index 8b35527..0000000 --- a/iobrokerSkript.js +++ /dev/null @@ -1,390 +0,0 @@ -const weekdayMap={Montag:"Monday",Dienstag:"Tuesday",Mittwoch:"Wednesday",Donnerstag:"Thursday",Freitag:"Friday",Samstag:"Saturday",Sonntag:"Sunday"}; - -const configState = 'mqtt.0.foodsharing.pickupCheck.config.meik'; -let scheduledJobs = []; - -// Zeitpläne aufräumen -function clearAllScheduledJobs() { - scheduledJobs.forEach(job => clearSchedule(job)); - scheduledJobs = []; -} - -// Neue Funktion: Config aus dem Datenpunkt parsen -function parseConfigState() { - try { - const raw = getState(configState)?.val; - if (!raw) throw new Error("Leerer Konfigurationswert"); - - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) throw new Error("Konfiguration ist kein Array"); - - return parsed; - } catch (e) { - console.error("Fehler beim Parsen der Konfiguration:", e.message); - sendTelegramMessage(telegramInstance, `? Fehler beim Parsen der Pickup-Konfiguration: ${e.message}`); - return []; - } -} - -// Pickup-Checks ausführen mit aktueller Konfiguration -function setupPickupCheckSchedules() { - clearAllScheduledJobs(); // Vorherige Tasks stoppen - const pickupCheckConfig = parseConfigState(); - - pickupCheckConfig.forEach(({ id, active, onlyNotify, label, desiredWeekday, desiredDate, checkProfileId }) => { - if (active) { - const storeLabel = label || masterStores[id] || "Unbekannter Store"; - const translatedWeekday = desiredWeekday && weekdayMap[desiredWeekday] ? weekdayMap[desiredWeekday] : desiredWeekday; - - const job = scheduleWithRandomDelay('*/10 7-22 * * *', () => - handleLoginAndRequests( - (storeIds, session) => { - console.log(`Prüfung für ${storeLabel} (${id}) – Nur Benachrichtigung: ${onlyNotify}` + - (translatedWeekday ? ` – nur an ${translatedWeekday}` : '') + - ` – checkProfileId: ${checkProfileId}`); - checkPickupData(id, !!checkProfileId, session, onlyNotify, translatedWeekday, desiredDate); - }, - null - ) - ); - scheduledJobs.push(job); // Job merken - } - }); -} -// Zentrale Definition der Stores mit ID und sprechendem Namen -const masterStores = { - "51485": "Hornbach KA - Hagsfeld", - "43694": "Blumen Deniz Rastatt", - "32807": "Claus Reformwaren", - "47510": "BIOLAND GÄRTNEREI LUTZ", - "42141": "Restaurant Poké You", - "37245": "Erdbeerland Enderle GBR", - "35401": "Kalinka - Internationale Lebensmittel", - "31080": "Gemüsebau Gabelmann", - "28513": "denn's Biomarkt", - // Weitere Stores, die nur in Pickup-Checks genutzt werden: - "63448": "Penny Baden-Oos", - "42264": "Bildungshaus St. Bernhard, Rastatt", - "51450": "Hornbach KA - Grünwinkel", // Für diese StoreID gilt die TVS-Ausnahme - "42322": "Edeka Fischer Haueneberstein", - "33875": "Cap Markt", - "44972": "Aldi Biblisweg", - "44975": "Aldi Kuppenheim" - -}; - -// Extrahierte Store-IDs als Array für sendFoodSharingRequests -const storeIds = [ - //"51485", //Hornbach KA -Hagsfeld - //"51450", //Hornbach KA-Grünwinkel - "43694", //Blumen Deniz Rastatt - "32807", //Claus Reformwaren - "47510", //BIOLAND GÄRTNEREI LUTZ - "42141", //Restaurant Poké You - "37245", //Erdbeerland Enderle GBR - "35401", //Kalinka - Internationale Lebensmittel - "31080", //Gemüsebau Gabelmann - "28513", //denn's Biomarkt - //"29374", //Rastatter Wochenmarkt -]; - -// Anmeldedaten -const userEmail = "ddda@gmx.de"; -const userPassword = "a"; - -// Telegram-Bot-Instanz und Profil-ID (für Pickup-Prüfung) -const telegramInstance = 'telegram.0'; -const profileId = 839246; - -// Globale Variablen für Session und CSRF-Token -let sessionCookies = null; -let csrfToken = null; - -// Funktion zum Extrahieren des CSRF-Tokens aus den Cookies -function extractCsrfToken(cookies) { - let token = null; - cookies.forEach(cookieStr => { - if (cookieStr.includes("CSRF_TOKEN=")) { - // Beispiel-Cookie: "CSRF_TOKEN=wert; Path=/; Domain=foodsharing.de; ..." - token = cookieStr.split(';')[0].split('=')[1]; - } - }); - return token; -} - -// Funktion zum zufällig verzögerten Start des Skripts -function startScriptWithRandomDelay(random = false) { - const maxDelay = random ? 10 * 60 * 1000 : 1000; // bis zu 10 Minuten Verzögerung - const delay = Math.floor(Math.random() * maxDelay); - console.log(`Das Skript wird in ${Math.round(delay / 1000)} Sekunden gestartet.`); - setTimeout(() => { - // sendFoodSharingRequests nutzt hier die zentrale masterStores-Liste - handleLoginAndRequests(sendFoodSharingRequests, storeIds); - }, delay); -} - -// Session-Handling: Login und anschließende Requests ausführen -function handleLoginAndRequests(callback, storeIds = null) { - checkSessionValidity().then(isValid => { - if (!isValid) { - login(userEmail, userPassword).then(() => { - callback(storeIds, sessionCookies); - }).catch(error => { - console.error(`Login-Fehler: ${error}`); - sendTelegramMessage(telegramInstance, "Login fehlgeschlagen."); - }); - } else { - callback(storeIds, sessionCookies); - } - }); -} - -// Überprüfung der Session-Gültigkeit -async function checkSessionValidity() { - if (!sessionCookies) { - return false; - } - const axios = require('axios'); - const headers = { - "cookie": sessionCookies - }; - try { - const response = await axios.get('https://foodsharing.de/api/wall/foodsaver/839246?limit=1', { headers }); - return response.status === 200; - } catch (error) { - return false; - } -} - -// Login-Funktion inkl. CSRF-Token-Extraktion -async function login(userEmail, userPassword) { - const axios = require('axios'); - const loginData = { - email: userEmail, - password: userPassword, - remember_me: true - }; - const headers = { - "sec-ch-ua": "\"Chromium\";v=\"128\", \"Not;A=Brand\";v=\"24\", \"Google Chrome\";v=\"128\"", - "Referer": "https://foodsharing.de/", - "DNT": "1", - "sec-ch-ua-mobile": "?0", - "sec-ch-ua-platform": "\"Windows\"", - "Content-Type": "application/json; charset=utf-8" - }; - - const response = await axios.post('https://foodsharing.de/api/user/login', loginData, { headers }); - if (response.status === 200) { - sessionCookies = response.headers['set-cookie']; // Alle Cookies speichern - csrfToken = extractCsrfToken(sessionCookies); // CSRF-Token extrahieren - console.log("Login erfolgreich. CSRF Token: " + csrfToken); - } else { - throw new Error("Login fehlgeschlagen."); - } -} - -// Funktion zum Senden der FoodSharing-Anfragen (zeigt Ergebnisse per Telegram an) -function sendFoodSharingRequests(storeIds, session) { - const axios = require('axios'); - let results = []; - let errors = []; - let shouldSendMessage = false; - - storeIds.forEach(storeId => { - const storeUrl = `https://foodsharing.de/api/map/stores/${storeId}`; - const storeHeaders = { - "cookie": session - }; - - axios.get(storeUrl, { headers: storeHeaders }) - .then(storeResponse => { - const storeData = storeResponse.data; - if (storeData.maySendRequest) { - shouldSendMessage = true; - } - results.push({ - ID: storeId, - Name: masterStores[storeId] || storeData.name, - AnmeldungOffen: storeData.maySendRequest ? "Ja" : "Nein" - }); - - if (results.length + errors.length === storeIds.length) { - if (shouldSendMessage || errors.length > 0) { - sendResultsViaTelegram(results, errors); - } - } - }) - .catch(error => { - errors.push(`Fehler bei der Store-Anfrage für Store ${storeId} (${masterStores[storeId] || "unbekannt"}): ${error.message}`); - if (results.length + errors.length === storeIds.length) { - sendResultsViaTelegram(results, errors); - } - }); - }); -} - -// Erweiterte Funktion zur Prüfung der Pickup-Daten mit automatischer Buchung -function checkPickupData(storeId, checkProfileId, session, onlyNotify = false, desiredWeekday = null, desiredDate = null) { - const axios = require('axios'); - const storeUrl = `https://foodsharing.de/api/stores/${storeId}/pickups`; - const headers = { "cookie": session }; - - axios.get(storeUrl, { headers }) - .then(response => { - const pickups = response.data.pickups; - let hasProfileId = false; - let availablePickup = null; - - pickups.forEach(pickup => { - const pickupDate = new Date(pickup.date); - const weekday = pickupDate.toLocaleDateString("en-US", { weekday: 'long' }); - - let matchesFilter = true; - - if (desiredDate) { - const desired = new Date(desiredDate); - matchesFilter = pickupDate.getFullYear() === desired.getFullYear() - && pickupDate.getMonth() === desired.getMonth() - && pickupDate.getDate() === desired.getDate(); - } - - if (desiredWeekday && !matchesFilter) { - matchesFilter = weekday === desiredWeekday; - } - - if (!matchesFilter) return; - - if (checkProfileId && pickup.occupiedSlots.some(slot => slot.profile.id === profileId)) { - hasProfileId = true; - } else if (pickup.isAvailable && !availablePickup) { - availablePickup = pickup; - } - }); - - // Sonderfall TVS für StoreID 51450 - if (storeId === "51450" && availablePickup && availablePickup.description === "TVS") { - console.log(`Slot mit Beschreibung "TVS" gefunden für Store ${storeId}. Buchung wird übersprungen.`); - return; - } - - if ((!checkProfileId || !hasProfileId) && availablePickup) { - const localDate = availablePickup.date; - const utcDate = new Date(localDate).toISOString(); - const readableDate = new Date(localDate).toLocaleString("de-DE"); - - if (onlyNotify) { - console.log(`Benachrichtigung: Freier Slot für ${masterStores[storeId] || storeId} am ${readableDate}`); - sendTelegramMessage(telegramInstance, `?? Freier Slot gefunden für ${masterStores[storeId] || storeId} am ${readableDate} (nur Benachrichtigung).`); - } else { - bookSlot(storeId, utcDate, localDate); - } - } else { - console.log(`Kein passender Slot für Store ${storeId} (${masterStores[storeId] || "unbekannt"}) gefunden.`); - } - }) - .catch(error => { - console.error(`Fehler bei Pickup-Abfrage für Store ${storeId}: ${error.message}`); - }); -} - - -// Funktion zum automatischen Buchen eines freien Slots -function bookSlot(storeId, utcDate, localDate) { - const axios = require('axios'); - console.log(`Starte Buchung für Store ${storeId} (${masterStores[storeId] || "unbekannt"}) am ${localDate} (UTC: ${utcDate})...`); - - // 1. Pickup-Rule Check durchführen - axios.get(`https://foodsharing.de/api/stores/${storeId}/pickupRuleCheck/${utcDate}/${profileId}`, { - headers: { - "cookie": sessionCookies, - "x-csrf-token": csrfToken - } - }) - .then(ruleResponse => { - if (ruleResponse.data.result === true) { - // 2. Slot buchen, wenn Rule Check erfolgreich war - axios.post(`https://foodsharing.de/api/stores/${storeId}/pickups/${utcDate}/${profileId}`, {}, { - headers: { - "cookie": sessionCookies, - "Content-Type": "application/json; charset=utf-8", - "x-csrf-token": csrfToken - } - }) - .then(bookingResponse => { - console.log("Buchung Response:", bookingResponse.data); - sendTelegramMessage(telegramInstance, `Slot gebucht für Store ${storeId} (${masterStores[storeId] || "unbekannt"}) am ${localDate}`); - }) - .catch(bookingError => { - console.error("Buchung Fehler:", bookingError.message); - sendTelegramMessage(telegramInstance, `Buchungsfehler für Store ${storeId} (${masterStores[storeId] || "unbekannt"}): ${bookingError.message}`); - }); - } else { - console.log("Pickup Rule Check fehlgeschlagen."); - sendTelegramMessage(telegramInstance, `Pickup Rule Check fehlgeschlagen für Store ${storeId} (${masterStores[storeId] || "unbekannt"}) am ${localDate}`); - } - }) - .catch(ruleError => { - console.error("Fehler beim Pickup Rule Check:", ruleError.message); - sendTelegramMessage(telegramInstance, `Fehler beim Pickup Rule Check für Store ${storeId} (${masterStores[storeId] || "unbekannt"}): ${ruleError.message}`); - }); -} - -// Funktion zum Senden der Ergebnisse und Fehler per Telegram -function sendResultsViaTelegram(results, errors) { - let message = "Ergebnisse der FoodSharing-Anfragen:\n\n"; - - if (results.length > 0) { - message += "Erfolg:\n
ID      | Name                               | Anmeldung offen\n";
-        message += "--------|------------------------------------|-----------------\n";
-        results.forEach(result => {
-            message += `${result.ID.padEnd(8)}| ${result.Name.padEnd(36)}| ${result.AnmeldungOffen}\n`;
-        });
-        message += "
\n"; - } else { - message += "Keine erfolgreichen Anfragen.\n"; - } - - if (errors.length > 0) { - message += "\nFehler:\n"; - errors.forEach(error => { - message += `${error}\n`; - }); - } - - sendTelegramMessage(telegramInstance, message, true); -} - -// Funktion zum Senden einer Nachricht per Telegram -function sendTelegramMessage(instance, message, isHtml = false) { - const options = { text: message }; - if (isHtml) { - options.parse_mode = 'HTML'; - } - sendTo(instance, options); -} - -// Funktion zur Planung mit zufälliger Verzögerung -function scheduleWithRandomDelay(cronExpression, callback) { - const job = schedule(cronExpression, function () { - let delay = Math.floor(Math.random() * 110 * 1000) + 10 * 1000; // 10s bis 120s - setTimeout(callback, delay); - }); - return job; -} - -// Zufälliger Start des Skripts -startScriptWithRandomDelay(); - -// Zeitplan: Täglich um 7 Uhr morgens bzw. 15 Uhr (hier per Cron-Ausdruck) -schedule('0 7,15 * * *', function () { - startScriptWithRandomDelay(true); -}); - -// Initialisierung -setupPickupCheckSchedules(); - -// Trigger bei Änderungen des Datenpunkts -on({ id: configState, change: "any" }, () => { - console.log("?? Konfiguration geändert. Pickup-Checks werden neu geladen."); - setupPickupCheckSchedules(); -}); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3c975e8..d754262 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,12 +12,14 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^13.5.0", + "axios": "^1.7.7", "body-parser": "^1.20.2", "cors": "^2.8.5", "express": "^4.18.2", - "mqtt": "^5.3.0", + "node-cron": "^3.0.3", "react": "^19.1.0", "react-dom": "^19.1.0", + "uuid": "^11.0.3", "web-vitals": "^2.1.4" }, "devDependencies": { @@ -87,6 +89,7 @@ "integrity": "sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -831,6 +834,7 @@ "integrity": "sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1753,6 +1757,7 @@ "integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-module-imports": "^7.27.1", @@ -3804,6 +3809,7 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4167,6 +4173,7 @@ "version": "22.15.18", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.18.tgz", "integrity": "sha512-v1DKRfUdyW+jJhZNEI1PYy29S2YRxMV5AOO/x/SjKmW0acCIOqmbj6Haf9eHAhsPmrhlHSxEhv/1WszcLWV4cg==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -4217,22 +4224,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/readable-stream": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.18.tgz", - "integrity": "sha512-21jK/1j+Wg+7jVw1xnSwy/2Q1VgVjWuFssbYGTREPUBeZ+rqVFl2udq0IkxzPC0ZhOzVceUbyIACFZKLqKEBlA==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "safe-buffer": "~5.1.1" - } - }, - "node_modules/@types/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, "node_modules/@types/resolve": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", @@ -4871,18 +4862,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -4902,6 +4881,7 @@ "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5021,6 +5001,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5429,7 +5410,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/at-least-node": { @@ -5506,6 +5486,33 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axios/node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -5793,26 +5800,6 @@ "dev": true, "license": "MIT" }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/batch": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", @@ -5860,18 +5847,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/bl": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.0.tgz", - "integrity": "sha512-ClDyJGQkc8ZtzdAAbAwBmhMSpwN/sC9HA8jxdYm6nVUbCfZbe2mgza4qh7AuEYyEPB/c4Kznf9s66bnsKMQDjw==", - "license": "MIT", - "dependencies": { - "@types/readable-stream": "^4.0.0", - "buffer": "^6.0.3", - "inherits": "^2.0.4", - "readable-stream": "^4.2.0" - } - }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -5972,6 +5947,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001716", "electron-to-chromium": "^1.5.149", @@ -5995,34 +5971,11 @@ "node-int64": "^0.4.0" } }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, "license": "MIT" }, "node_modules/builtin-modules": { @@ -6469,7 +6422,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -6488,12 +6440,6 @@ "node": ">= 12" } }, - "node_modules/commist": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz", - "integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==", - "license": "MIT" - }, "node_modules/common-tags": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", @@ -6560,35 +6506,6 @@ "dev": true, "license": "MIT" }, - "node_modules/concat-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", - "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", - "engines": [ - "node >= 6.0" - ], - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.0.2", - "typedarray": "^0.0.6" - } - }, - "node_modules/concat-stream/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/confusing-browser-globals": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", @@ -7327,7 +7244,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -7868,7 +7784,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7980,6 +7895,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -8704,15 +8620,6 @@ "node": ">= 0.6" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -8724,6 +8631,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.8.x" @@ -8875,19 +8783,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-unique-numbers": { - "version": "8.0.13", - "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-8.0.13.tgz", - "integrity": "sha512-7OnTFAVPefgw2eBJ1xj2PGGR9FwYzSUso9decayHgCDX4sJkHLdcsYTytTg+tYv+wKF3U8gJuSBz2jJpQV4u/g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.23.8", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.1.0" - } - }, "node_modules/fast-uri": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", @@ -9123,7 +9018,6 @@ "version": "1.15.9", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", - "dev": true, "funding": [ { "type": "individual", @@ -9779,7 +9673,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -9813,12 +9706,6 @@ "he": "bin/he" } }, - "node_modules/help-me": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", - "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", - "license": "MIT" - }, "node_modules/hoopy": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz", @@ -10198,26 +10085,6 @@ "node": ">=4" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -10345,25 +10212,6 @@ "node": ">= 0.4" } }, - "node_modules/ip-address": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", - "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", - "license": "MIT", - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - }, - "engines": { - "node": ">= 12" - } - }, - "node_modules/ip-address/node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "license": "BSD-3-Clause" - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -11112,6 +10960,7 @@ "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^27.5.1", "import-local": "^3.0.2", @@ -12065,16 +11914,6 @@ "jiti": "bin/jiti.js" } }, - "node_modules/js-sdsl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz", - "integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/js-sdsl" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -12095,12 +11934,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsbn": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", - "license": "MIT" - }, "node_modules/jsdom": { "version": "16.7.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz", @@ -12513,6 +12346,7 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, "license": "ISC" }, "node_modules/lz-string": { @@ -12754,6 +12588,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -12782,93 +12617,6 @@ "mkdirp": "bin/cmd.js" } }, - "node_modules/mqtt": { - "version": "5.13.0", - "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.13.0.tgz", - "integrity": "sha512-pR+z+ChxFl3n8AKLQbTONVOOg/jl4KiKQRBAi78tjd6PksOWvl1nl9L8ZHOZ3MiavZfrUOjok2ddwc1VymGWRg==", - "license": "MIT", - "dependencies": { - "commist": "^3.2.0", - "concat-stream": "^2.0.0", - "debug": "^4.4.0", - "help-me": "^5.0.0", - "lru-cache": "^10.4.3", - "minimist": "^1.2.8", - "mqtt-packet": "^9.0.2", - "number-allocator": "^1.0.14", - "readable-stream": "^4.7.0", - "rfdc": "^1.4.1", - "socks": "^2.8.3", - "split2": "^4.2.0", - "worker-timers": "^7.1.8", - "ws": "^8.18.0" - }, - "bin": { - "mqtt": "build/bin/mqtt.js", - "mqtt_pub": "build/bin/pub.js", - "mqtt_sub": "build/bin/sub.js" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/mqtt-packet": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz", - "integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==", - "license": "MIT", - "dependencies": { - "bl": "^6.0.8", - "debug": "^4.3.4", - "process-nextick-args": "^2.0.1" - } - }, - "node_modules/mqtt-packet/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/mqtt-packet/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/mqtt/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/mqtt/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -12961,6 +12709,27 @@ "tslib": "^2.0.3" } }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "license": "ISC", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-cron/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/node-forge": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", @@ -13044,39 +12813,6 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, - "node_modules/number-allocator": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz", - "integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.1", - "js-sdsl": "4.3.0" - } - }, - "node_modules/number-allocator/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/number-allocator/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/nwsapi": { "version": "2.2.20", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", @@ -13698,6 +13434,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", @@ -14956,6 +14693,7 @@ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -15127,19 +14865,11 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, "license": "MIT" }, "node_modules/promise": { @@ -15198,6 +14928,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -15325,6 +15061,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -15463,6 +15200,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -15489,6 +15227,7 @@ "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -15577,22 +15316,6 @@ "pify": "^2.3.0" } }, - "node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -15958,12 +15681,6 @@ "node": ">=0.10.0" } }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "license": "MIT" - }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -15987,6 +15704,7 @@ "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -16242,6 +15960,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -16627,16 +16346,6 @@ "node": ">=8" } }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, "node_modules/sockjs": { "version": "0.3.24", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", @@ -16649,18 +16358,14 @@ "websocket-driver": "^0.7.4" } }, - "node_modules/socks": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", - "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", + "node_modules/sockjs/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, "license": "MIT", - "dependencies": { - "ip-address": "^9.0.5", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" + "bin": { + "uuid": "dist/bin/uuid" } }, "node_modules/source-list-map": { @@ -16851,15 +16556,6 @@ "dev": true, "license": "MIT" }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -17026,6 +16722,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -18008,6 +17705,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD" }, "node_modules/tsutils": { @@ -18062,6 +17760,7 @@ "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", "dev": true, "license": "(MIT OR CC0-1.0)", + "peer": true, "engines": { "node": ">=10" }, @@ -18160,12 +17859,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "license": "MIT" - }, "node_modules/typedarray-to-buffer": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", @@ -18177,9 +17870,9 @@ } }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true, "license": "Apache-2.0", "peer": true, @@ -18188,7 +17881,7 @@ "tsserver": "bin/tsserver" }, "engines": { - "node": ">=14.17" + "node": ">=4.2.0" } }, "node_modules/unbox-primitive": { @@ -18221,6 +17914,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -18373,6 +18067,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, "license": "MIT" }, "node_modules/util.promisify": { @@ -18408,13 +18103,16 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/v8-to-istanbul": { @@ -18528,6 +18226,7 @@ "integrity": "sha512-lQ3CPiSTpfOnrEGeXDwoq5hIGzSjmwD72GdfVzF7CQAI7t47rJG9eDWvcEkEn3CUQymAElVvDg3YNTlCYj+qUQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", @@ -18600,6 +18299,7 @@ "integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/bonjour": "^3.5.9", "@types/connect-history-api-fallback": "^1.3.5", @@ -19012,6 +18712,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -19268,40 +18969,6 @@ "workbox-core": "6.6.0" } }, - "node_modules/worker-timers": { - "version": "7.1.8", - "resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-7.1.8.tgz", - "integrity": "sha512-R54psRKYVLuzff7c1OTFcq/4Hue5Vlz4bFtNEIarpSiCYhpifHU3aIQI29S84o1j87ePCYqbmEJPqwBTf+3sfw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.24.5", - "tslib": "^2.6.2", - "worker-timers-broker": "^6.1.8", - "worker-timers-worker": "^7.0.71" - } - }, - "node_modules/worker-timers-broker": { - "version": "6.1.8", - "resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-6.1.8.tgz", - "integrity": "sha512-FUCJu9jlK3A8WqLTKXM9E6kAmI/dR1vAJ8dHYLMisLNB/n3GuaFIjJ7pn16ZcD1zCOf7P6H62lWIEBi+yz/zQQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.24.5", - "fast-unique-numbers": "^8.0.13", - "tslib": "^2.6.2", - "worker-timers-worker": "^7.0.71" - } - }, - "node_modules/worker-timers-worker": { - "version": "7.0.71", - "resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-7.0.71.tgz", - "integrity": "sha512-ks/5YKwZsto1c2vmljroppOKCivB/ma97g9y77MAAz2TBBjPPgpoOiS1qYQKIgvGTr2QYPT3XhJWIB6Rj2MVPQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.24.5", - "tslib": "^2.6.2" - } - }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -19363,6 +19030,7 @@ "version": "8.18.2", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 62d1c2c..896ef77 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { + "axios": "^1.7.7", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", @@ -10,9 +11,10 @@ "body-parser": "^1.20.2", "cors": "^2.8.5", "express": "^4.18.2", - "mqtt": "^5.3.0", + "node-cron": "^3.0.3", "react": "^19.1.0", "react-dom": "^19.1.0", + "uuid": "^11.0.3", "web-vitals": "^2.1.4" }, "scripts": { diff --git a/server.js b/server.js index bcfabc8..38afb89 100644 --- a/server.js +++ b/server.js @@ -1,126 +1,290 @@ const express = require('express'); const path = require('path'); const cors = require('cors'); -const fs = require('fs'); -const bodyParser = require('body-parser'); -const mqtt = require('mqtt'); + +const sessionStore = require('./services/sessionStore'); +const credentialStore = require('./services/credentialStore'); +const { readConfig, writeConfig } = require('./services/configStore'); +const foodsharingClient = require('./services/foodsharingClient'); +const { scheduleConfig } = require('./services/pickupScheduler'); +const adminConfig = require('./services/adminConfig'); + +const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000; +const adminEmail = (process.env.ADMIN_EMAIL || '').toLowerCase(); const app = express(); const port = process.env.PORT || 3000; -const mqttBroker = process.env.MQTT_BROKER || 'mqtt://192.168.1.100:1883'; -const mqttTopic = process.env.MQTT_TOPIC || 'iobroker/pickupCheck/config'; -const mqttUser = process.env.MQTT_USER || 'iobroker'; -const mqttPassword = process.env.MQTT_PASSWORD || 'password'; -const configPath = './config/pickup-config.json'; -// Logger mit Timestamp -function logWithTimestamp(...args) { - const timestamp = new Date().toISOString(); - console.log(`[${timestamp}]`, ...args); +app.use(cors()); +app.use(express.json({ limit: '1mb' })); +app.use(express.static(path.join(__dirname, 'build'))); + +function isAdmin(profile) { + if (!adminEmail || !profile?.email) { + return false; + } + return profile.email.toLowerCase() === adminEmail; } -function errorWithTimestamp(...args) { - const timestamp = new Date().toISOString(); - console.error(`[${timestamp}]`, ...args); +function scheduleWithCurrentSettings(sessionId, config) { + const settings = adminConfig.readSettings(); + scheduleConfig(sessionId, config, settings); } -// MQTT-Client mit Authentifizierung initialisieren -const mqttClient = mqtt.connect(mqttBroker, { - clientId: 'pickup-config-web-' + Math.random().toString(16).substring(2, 8), - clean: true, - username: mqttUser, - password: mqttPassword +function rescheduleAllSessions() { + const settings = adminConfig.readSettings(); + sessionStore.list().forEach((session) => { + if (!session?.profile?.id) { + return; + } + const config = readConfig(session.profile.id); + scheduleConfig(session.id, config, settings); + }); +} + +function mergeStoresIntoConfig(config = [], stores = []) { + const entries = Array.isArray(config) ? config : []; + const map = new Map(); + entries.forEach((entry) => { + if (!entry || !entry.id) { + return; + } + map.set(String(entry.id), { ...entry, id: String(entry.id) }); + }); + + let changed = false; + stores.forEach((store) => { + if (!store?.id) { + return; + } + const id = String(store.id); + if (!map.has(id)) { + map.set(id, { + id, + label: store.name || `Store ${id}`, + active: false, + checkProfileId: true, + onlyNotify: false, + hidden: false + }); + changed = true; + return; + } + + const existing = map.get(id); + if (!existing.label && store.name) { + existing.label = store.name; + changed = true; + } + }); + + return { merged: Array.from(map.values()), changed }; +} + +async function restoreSessionsFromDisk() { + const saved = credentialStore.loadAll(); + const entries = Object.entries(saved); + if (entries.length === 0) { + return; + } + + console.log(`[RESTORE] Versuche ${entries.length} gespeicherte Anmeldung(en) zu laden...`); + const schedulerSettings = adminConfig.readSettings(); + + for (const [profileId, credentials] of entries) { + if (!credentials?.email || !credentials?.password) { + continue; + } + try { + const auth = await foodsharingClient.login(credentials.email, credentials.password); + const profile = { + id: String(auth.profile.id), + name: auth.profile.name, + email: auth.profile.email || credentials.email + }; + const isAdminUser = isAdmin(profile); + let config = readConfig(profile.id); + const stores = await foodsharingClient.fetchStores(auth.cookieHeader, profile.id); + const { merged, changed } = mergeStoresIntoConfig(config, stores); + if (changed) { + config = merged; + writeConfig(profile.id, config); + } + + const session = sessionStore.create({ + cookieHeader: auth.cookieHeader, + csrfToken: auth.csrfToken, + profile, + credentials, + isAdmin: isAdminUser + }, credentials.token, ONE_YEAR_MS); + credentialStore.save(profile.id, { + email: credentials.email, + password: credentials.password, + token: session.id + }); + scheduleConfig(session.id, config, schedulerSettings); + console.log(`[RESTORE] Session fuer Profil ${profile.id} (${profile.name}) reaktiviert.`); + } catch (error) { + console.error(`[RESTORE] Login fuer Profil ${profileId} fehlgeschlagen:`, error.message); + } + } +} + +function requireAuth(req, res, next) { + const header = req.headers.authorization || ''; + const [scheme, token] = header.split(' '); + if (scheme !== 'Bearer' || !token) { + return res.status(401).json({ error: 'Unautorisiert' }); + } + + const session = sessionStore.get(token); + if (!session) { + return res.status(401).json({ error: 'Session nicht gefunden oder abgelaufen' }); + } + + req.session = session; + next(); +} + +function requireAdmin(req, res, next) { + if (!req.session?.isAdmin) { + return res.status(403).json({ error: 'Nur für Admins verfügbar' }); + } + next(); +} + +app.post('/api/auth/login', async (req, res) => { + const { email, password } = req.body || {}; + if (!email || !password) { + return res.status(400).json({ error: 'E-Mail und Passwort sind erforderlich' }); + } + + try { + const auth = await foodsharingClient.login(email, password); + const profile = { + id: String(auth.profile.id), + name: auth.profile.name, + email: auth.profile.email || email + }; + const isAdminUser = isAdmin(profile); + + let config = readConfig(profile.id); + const stores = await foodsharingClient.fetchStores(auth.cookieHeader, profile.id); + const { merged, changed } = mergeStoresIntoConfig(config, stores); + if (changed) { + config = merged; + writeConfig(profile.id, config); + } + + const existingCredentials = credentialStore.get(profile.id); + const existingToken = existingCredentials?.token; + if (existingToken) { + sessionStore.delete(existingToken); + } + + const session = sessionStore.create({ + cookieHeader: auth.cookieHeader, + csrfToken: auth.csrfToken, + profile, + credentials: { email, password }, + isAdmin: isAdminUser + }, existingToken, ONE_YEAR_MS); + + credentialStore.save(profile.id, { email, password, token: session.id }); + const settings = adminConfig.readSettings(); + scheduleConfig(session.id, config, settings); + + return res.json({ + token: session.id, + profile, + stores, + config, + isAdmin: isAdminUser, + adminSettings: isAdminUser ? settings : undefined + }); + } catch (error) { + console.error('Login fehlgeschlagen:', error.message); + return res.status(401).json({ error: 'Login fehlgeschlagen' }); + } }); -// MQTT-Events -mqttClient.on('connect', () => { - logWithTimestamp('Verbunden mit MQTT-Broker:', mqttBroker); +app.post('/api/auth/logout', requireAuth, (req, res) => { + sessionStore.delete(req.session.id); + credentialStore.remove(req.session.profile.id); + res.json({ success: true }); +}); - mqttClient.subscribe(mqttTopic, (err) => { - if (!err) { - logWithTimestamp('Abonniert auf Topic:', mqttTopic); - mqttClient.publish(mqttTopic + '/get', 'true'); - } +app.get('/api/auth/session', requireAuth, async (req, res) => { + const stores = await foodsharingClient.fetchStores(req.session.cookieHeader, req.session.profile.id); + let config = readConfig(req.session.profile.id); + const { merged, changed } = mergeStoresIntoConfig(config, stores); + if (changed) { + config = merged; + writeConfig(req.session.profile.id, config); + } + res.json({ + profile: req.session.profile, + stores, + isAdmin: !!req.session.isAdmin, + adminSettings: req.session.isAdmin ? adminConfig.readSettings() : undefined }); }); -mqttClient.on('error', (error) => { - errorWithTimestamp('MQTT-Fehler:', error); +app.get('/api/profile', requireAuth, async (req, res) => { + const details = await foodsharingClient.fetchProfile(req.session.cookieHeader); + res.json({ + profile: details || req.session.profile + }); }); -mqttClient.on('message', (topic, message) => { - logWithTimestamp('Nachricht erhalten auf Topic:', topic); +app.get('/api/config', requireAuth, (req, res) => { + const config = readConfig(req.session.profile.id); + res.json(config); +}); - if (topic === mqttTopic) { - try { - const config = JSON.parse(message.toString()); - logWithTimestamp('Konfiguration vom MQTT-Broker erhalten'); - fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); - } catch (error) { - errorWithTimestamp('Fehler beim Verarbeiten der MQTT-Nachricht:', error); - } +app.post('/api/config', requireAuth, (req, res) => { + if (!Array.isArray(req.body)) { + return res.status(400).json({ error: 'Konfiguration muss ein Array sein' }); } + writeConfig(req.session.profile.id, req.body); + scheduleWithCurrentSettings(req.session.id, req.body); + res.json({ success: true }); }); -// Middleware -app.use(cors()); -app.use(bodyParser.json()); -app.use(express.static(path.join(__dirname, 'build'))); - -// Sicherstellen, dass Konfigurationsordner existiert -const configDir = path.dirname(configPath); -if (!fs.existsSync(configDir)) { - fs.mkdirSync(configDir, { recursive: true }); -} - -// Initiale Konfigurationsdatei erstellen, falls nicht vorhanden -if (!fs.existsSync(configPath)) { - const initialConfig = [ - { id: "63448", active: false, checkProfileId: true, onlyNotify: true, label: "Penny Baden-Oos" }, - { id: "44975", active: false, checkProfileId: true, onlyNotify: false, label: "Aldi Kuppenheim", desiredWeekday: "Samstag" }, - { id: "44972", active: false, checkProfileId: true, onlyNotify: false, label: "Aldi Biblisweg", desiredWeekday: "Dienstag" }, - { id: "44975", active: false, checkProfileId: true, onlyNotify: false, label: "Aldi Kuppenheim", desiredDate: "2025-05-18" }, - { id: "33875", active: false, checkProfileId: true, onlyNotify: false, label: "Cap Markt", desiredWeekday: "Donnerstag" }, - { id: "42322", active: false, checkProfileId: false, onlyNotify: false, label: "Edeka Haueneberstein" }, - { id: "51450", active: false, checkProfileId: true, onlyNotify: false, label: "Hornbach Grünwinkel" } - ]; - - fs.writeFileSync(configPath, JSON.stringify(initialConfig, null, 2)); - mqttClient.publish(mqttTopic, JSON.stringify(initialConfig)); - logWithTimestamp('Initiale Konfiguration erstellt und an MQTT gesendet'); -} - -// API: Konfiguration abrufen -app.get('/api/iobroker/pickup-config', (req, res) => { - try { - const configData = fs.readFileSync(configPath, 'utf8'); - res.json(JSON.parse(configData)); - } catch (error) { - errorWithTimestamp('Error reading configuration:', error); - res.status(500).json({ error: 'Failed to read configuration' }); +app.get('/api/stores', requireAuth, async (req, res) => { + const stores = await foodsharingClient.fetchStores(req.session.cookieHeader, req.session.profile.id); + let config = readConfig(req.session.profile.id); + const { merged, changed } = mergeStoresIntoConfig(config, stores); + if (changed) { + config = merged; + writeConfig(req.session.profile.id, config); } + res.json(stores); }); -// API: Konfiguration speichern -app.post('/api/iobroker/pickup-config', (req, res) => { - try { - const newConfig = req.body; - fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2)); - mqttClient.publish(mqttTopic, JSON.stringify(newConfig)); - logWithTimestamp('Konfiguration über MQTT gesendet'); - res.json({ success: true }); - } catch (error) { - errorWithTimestamp('Error saving configuration:', error); - res.status(500).json({ error: 'Failed to save configuration' }); - } +app.get('/api/admin/settings', requireAuth, requireAdmin, (_req, res) => { + res.json(adminConfig.readSettings()); +}); + +app.post('/api/admin/settings', requireAuth, requireAdmin, (req, res) => { + const updated = adminConfig.writeSettings(req.body || {}); + rescheduleAllSessions(); + res.json(updated); +}); + +app.get('/api/health', (_req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); -// React-App ausliefern app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'build', 'index.html')); }); -// Server starten app.listen(port, () => { - logWithTimestamp(`Server läuft auf Port ${port}`); + console.log(`Server läuft auf Port ${port}`); +}); + +restoreSessionsFromDisk().catch((error) => { + console.error('[RESTORE] Fehler bei der Session-Wiederherstellung:', error.message); }); diff --git a/services/adminConfig.js b/services/adminConfig.js new file mode 100644 index 0000000..4bfaf8d --- /dev/null +++ b/services/adminConfig.js @@ -0,0 +1,94 @@ +const fs = require('fs'); +const path = require('path'); + +const CONFIG_DIR = path.join(__dirname, '..', 'config'); +const SETTINGS_FILE = path.join(CONFIG_DIR, 'admin-settings.json'); + +const DEFAULT_SETTINGS = { + scheduleCron: '*/10 7-22 * * *', + randomDelayMinSeconds: 10, + randomDelayMaxSeconds: 120, + initialDelayMinSeconds: 5, + initialDelayMaxSeconds: 30, + ignoredSlots: [ + { + storeId: '51450', + description: 'TVS' + } + ] +}; + +function ensureDir() { + if (!fs.existsSync(CONFIG_DIR)) { + fs.mkdirSync(CONFIG_DIR, { recursive: true }); + } +} + +function sanitizeNumber(value, fallback) { + const num = Number(value); + if (Number.isFinite(num) && num >= 0) { + return num; + } + return fallback; +} + +function sanitizeIgnoredSlots(slots = []) { + if (!Array.isArray(slots)) { + return DEFAULT_SETTINGS.ignoredSlots; + } + return slots + .map((slot) => ({ + storeId: slot?.storeId ? String(slot.storeId) : '', + description: slot?.description ? String(slot.description) : '' + })) + .filter((slot) => slot.storeId); +} + +function readSettings() { + ensureDir(); + if (!fs.existsSync(SETTINGS_FILE)) { + fs.writeFileSync(SETTINGS_FILE, JSON.stringify(DEFAULT_SETTINGS, null, 2)); + return { ...DEFAULT_SETTINGS }; + } + + try { + const raw = fs.readFileSync(SETTINGS_FILE, 'utf8'); + const parsed = JSON.parse(raw); + return { + scheduleCron: parsed.scheduleCron || DEFAULT_SETTINGS.scheduleCron, + randomDelayMinSeconds: sanitizeNumber(parsed.randomDelayMinSeconds, DEFAULT_SETTINGS.randomDelayMinSeconds), + randomDelayMaxSeconds: sanitizeNumber(parsed.randomDelayMaxSeconds, DEFAULT_SETTINGS.randomDelayMaxSeconds), + initialDelayMinSeconds: sanitizeNumber(parsed.initialDelayMinSeconds, DEFAULT_SETTINGS.initialDelayMinSeconds), + initialDelayMaxSeconds: sanitizeNumber(parsed.initialDelayMaxSeconds, DEFAULT_SETTINGS.initialDelayMaxSeconds), + ignoredSlots: sanitizeIgnoredSlots(parsed.ignoredSlots) + }; + } catch (error) { + console.error('Konnte Admin-Einstellungen nicht lesen:', error.message); + return { ...DEFAULT_SETTINGS }; + } +} + +function writeSettings(patch = {}) { + const current = readSettings(); + const next = { + scheduleCron: patch.scheduleCron || current.scheduleCron, + randomDelayMinSeconds: sanitizeNumber(patch.randomDelayMinSeconds, current.randomDelayMinSeconds), + randomDelayMaxSeconds: sanitizeNumber(patch.randomDelayMaxSeconds, current.randomDelayMaxSeconds), + initialDelayMinSeconds: sanitizeNumber(patch.initialDelayMinSeconds, current.initialDelayMinSeconds), + initialDelayMaxSeconds: sanitizeNumber(patch.initialDelayMaxSeconds, current.initialDelayMaxSeconds), + ignoredSlots: + patch.ignoredSlots !== undefined + ? sanitizeIgnoredSlots(patch.ignoredSlots) + : current.ignoredSlots + }; + + ensureDir(); + fs.writeFileSync(SETTINGS_FILE, JSON.stringify(next, null, 2)); + return next; +} + +module.exports = { + DEFAULT_SETTINGS, + readSettings, + writeSettings +}; diff --git a/services/configStore.js b/services/configStore.js new file mode 100644 index 0000000..210f257 --- /dev/null +++ b/services/configStore.js @@ -0,0 +1,48 @@ +const fs = require('fs'); +const path = require('path'); +const defaultConfig = require('../data/defaultConfig'); + +const CONFIG_DIR = path.join(__dirname, '..', 'config'); + +function ensureDir() { + if (!fs.existsSync(CONFIG_DIR)) { + fs.mkdirSync(CONFIG_DIR, { recursive: true }); + } +} + +function getConfigPath(profileId = 'shared') { + return path.join(CONFIG_DIR, `${profileId}-pickup-config.json`); +} + +function hydrateConfigFile(profileId) { + ensureDir(); + const filePath = getConfigPath(profileId); + if (!fs.existsSync(filePath)) { + fs.writeFileSync(filePath, JSON.stringify(defaultConfig, null, 2)); + } + return filePath; +} + +function readConfig(profileId) { + const filePath = hydrateConfigFile(profileId); + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch (err) { + console.error(`Failed to read config for ${profileId}:`, err); + return []; + } +} + +function writeConfig(profileId, payload) { + const filePath = hydrateConfigFile(profileId); + fs.writeFileSync(filePath, JSON.stringify(payload, null, 2)); + return filePath; +} + +module.exports = { + readConfig, + writeConfig, + getConfigPath +}; diff --git a/services/credentialStore.js b/services/credentialStore.js new file mode 100644 index 0000000..5969aa1 --- /dev/null +++ b/services/credentialStore.js @@ -0,0 +1,71 @@ +const fs = require('fs'); +const path = require('path'); + +const CONFIG_DIR = path.join(__dirname, '..', 'config'); +const CREDENTIAL_FILE = path.join(CONFIG_DIR, 'credentials.json'); + +function ensureDir() { + if (!fs.existsSync(CONFIG_DIR)) { + fs.mkdirSync(CONFIG_DIR, { recursive: true }); + } +} + +function readStore() { + ensureDir(); + if (!fs.existsSync(CREDENTIAL_FILE)) { + fs.writeFileSync(CREDENTIAL_FILE, JSON.stringify({}, null, 2)); + } + + try { + const raw = fs.readFileSync(CREDENTIAL_FILE, 'utf8'); + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch (error) { + console.error('Konnte Credential-Store nicht lesen:', error.message); + return {}; + } +} + +function writeStore(store) { + ensureDir(); + fs.writeFileSync(CREDENTIAL_FILE, JSON.stringify(store, null, 2)); +} + +function save(profileId, credentials) { + if (!profileId || !credentials?.email || !credentials?.password) { + return; + } + const store = readStore(); + store[profileId] = credentials; + writeStore(store); +} + +function remove(profileId) { + if (!profileId) { + return; + } + const store = readStore(); + if (store[profileId]) { + delete store[profileId]; + writeStore(store); + } +} + +function loadAll() { + return readStore(); +} + +function get(profileId) { + if (!profileId) { + return null; + } + const store = readStore(); + return store[profileId] || null; +} + +module.exports = { + save, + remove, + loadAll, + get +}; diff --git a/services/foodsharingClient.js b/services/foodsharingClient.js new file mode 100644 index 0000000..4db2a44 --- /dev/null +++ b/services/foodsharingClient.js @@ -0,0 +1,179 @@ +const axios = require('axios'); + +const BASE_URL = 'https://foodsharing.de'; + +const client = axios.create({ + baseURL: BASE_URL, + timeout: 20000, + headers: { + 'User-Agent': 'pickup-config/1.0 (+https://foodsharing.de)', + Accept: 'application/json, text/plain, */*' + } +}); + +function extractCsrfToken(cookies = []) { + if (!Array.isArray(cookies)) { + return null; + } + const tokenCookie = cookies.find((cookie) => cookie.startsWith('CSRF_TOKEN=')); + if (!tokenCookie) { + return null; + } + return tokenCookie.split(';')[0].split('=')[1]; +} + +function serializeCookies(cookies = []) { + if (!Array.isArray(cookies)) { + return ''; + } + return cookies.map((c) => c.split(';')[0]).join('; '); +} + +function buildHeaders(cookieHeader, csrfToken) { + const headers = {}; + if (cookieHeader) { + headers.cookie = cookieHeader; + } + if (csrfToken) { + headers['x-csrf-token'] = csrfToken; + } + return headers; +} + +async function getCurrentUserDetails(cookieHeader) { + const response = await client.get('/api/user/current/details', { + headers: buildHeaders(cookieHeader) + }); + return response.data; +} + +async function login(email, password) { + const payload = { + email, + password, + remember_me: true + }; + + const headers = { + 'sec-ch-ua': '"Chromium";v="128", "Not;A=Brand";v="24", "Google Chrome";v="128"', + Referer: BASE_URL, + DNT: '1', + 'sec-ch-ua-mobile': '?0', + 'sec-ch-ua-platform': '"Linux"', + 'Content-Type': 'application/json; charset=utf-8' + }; + + const response = await client.post('/api/user/login', payload, { headers }); + const cookies = response.headers['set-cookie'] || []; + const csrfToken = extractCsrfToken(cookies); + const cookieHeader = serializeCookies(cookies); + const details = await getCurrentUserDetails(cookieHeader); + if (!details?.id) { + throw new Error('Profil-ID konnte nicht ermittelt werden.'); + } + + const nameParts = []; + if (details.firstname) { + nameParts.push(details.firstname); + } + if (details.lastname) { + nameParts.push(details.lastname); + } + + return { + csrfToken, + cookieHeader, + profile: { + id: String(details.id), + name: nameParts.length > 0 ? nameParts.join(' ') : details.email || email, + email: details.email || email + } + }; +} + +async function checkSession(cookieHeader, profileId) { + if (!cookieHeader) { + return false; + } + + try { + await client.get(`/api/wall/foodsaver/${profileId}?limit=1`, { + headers: buildHeaders(cookieHeader) + }); + return true; + } catch { + return false; + } +} + +async function fetchProfile(cookieHeader) { + try { + return await getCurrentUserDetails(cookieHeader); + } catch (error) { + console.warn('Profil konnte nicht geladen werden:', error.message); + return null; + } +} + +async function fetchStores(cookieHeader, profileId) { + if (!profileId) { + return []; + } + try { + const response = await client.get(`/api/user/${profileId}/stores`, { + headers: buildHeaders(cookieHeader), + params: { activeStores: 1 } + }); + const stores = response.data || []; + if (!Array.isArray(stores)) { + return []; + } + return stores.map((store) => ({ + id: String(store.id), + name: store.name || `Store ${store.id}`, + pickupStatus: store.pickupStatus, + membershipStatus: store.membershipStatus, + isManaging: !!store.isManaging, + city: store.city || '', + street: store.street || '', + zip: store.zip || '' + })); + } catch (error) { + console.warn('Stores konnten nicht geladen werden:', error.message); + return []; + } +} + +async function fetchPickups(storeId, cookieHeader) { + const response = await client.get(`/api/stores/${storeId}/pickups`, { + headers: buildHeaders(cookieHeader) + }); + return response.data?.pickups || []; +} + +async function pickupRuleCheck(storeId, utcDate, profileId, session) { + const response = await client.get(`/api/stores/${storeId}/pickupRuleCheck/${utcDate}/${profileId}`, { + headers: buildHeaders(session.cookieHeader, session.csrfToken) + }); + return response.data?.result === true; +} + +async function bookSlot(storeId, utcDate, profileId, session) { + await client.post( + `/api/stores/${storeId}/pickups/${utcDate}/${profileId}`, + {}, + { + headers: buildHeaders(session.cookieHeader, session.csrfToken) + } + ); +} + +module.exports = { + login, + checkSession, + fetchProfile, + fetchStores, + fetchPickups, + pickupRuleCheck, + bookSlot +}; diff --git a/services/pickupScheduler.js b/services/pickupScheduler.js new file mode 100644 index 0000000..b96b0d3 --- /dev/null +++ b/services/pickupScheduler.js @@ -0,0 +1,236 @@ +const cron = require('node-cron'); +const foodsharingClient = require('./foodsharingClient'); +const sessionStore = require('./sessionStore'); +const { DEFAULT_SETTINGS } = require('./adminConfig'); + +const weekdayMap = { + Montag: 'Monday', + Dienstag: 'Tuesday', + Mittwoch: 'Wednesday', + Donnerstag: 'Thursday', + Freitag: 'Friday', + Samstag: 'Saturday', + Sonntag: 'Sunday' +}; + +function randomDelayMs(minSeconds = 10, maxSeconds = 120) { + const min = minSeconds * 1000; + const max = maxSeconds * 1000; + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +function resolveSettings(settings) { + if (!settings) { + return { ...DEFAULT_SETTINGS }; + } + return { + scheduleCron: settings.scheduleCron || DEFAULT_SETTINGS.scheduleCron, + randomDelayMinSeconds: Number.isFinite(settings.randomDelayMinSeconds) + ? settings.randomDelayMinSeconds + : DEFAULT_SETTINGS.randomDelayMinSeconds, + randomDelayMaxSeconds: Number.isFinite(settings.randomDelayMaxSeconds) + ? settings.randomDelayMaxSeconds + : DEFAULT_SETTINGS.randomDelayMaxSeconds, + initialDelayMinSeconds: Number.isFinite(settings.initialDelayMinSeconds) + ? settings.initialDelayMinSeconds + : DEFAULT_SETTINGS.initialDelayMinSeconds, + initialDelayMaxSeconds: Number.isFinite(settings.initialDelayMaxSeconds) + ? settings.initialDelayMaxSeconds + : DEFAULT_SETTINGS.initialDelayMaxSeconds, + ignoredSlots: Array.isArray(settings.ignoredSlots) ? settings.ignoredSlots : DEFAULT_SETTINGS.ignoredSlots + }; +} + +async function ensureSession(session) { + const profileId = session.profile?.id; + if (!profileId) { + return false; + } + + const stillValid = await foodsharingClient.checkSession(session.cookieHeader, profileId); + if (stillValid) { + return true; + } + + if (!session.credentials) { + console.warn(`Session ${session.id} kann nicht erneuert werden – keine Zugangsdaten gespeichert.`); + return false; + } + + try { + const refreshed = await foodsharingClient.login( + session.credentials.email, + session.credentials.password + ); + sessionStore.update(session.id, { + cookieHeader: refreshed.cookieHeader, + csrfToken: refreshed.csrfToken, + profile: { + ...session.profile, + ...refreshed.profile + } + }); + console.log(`Session ${session.id} wurde erfolgreich erneuert.`); + return true; + } catch (error) { + console.error(`Session ${session.id} konnte nicht erneuert werden:`, error.message); + return false; + } +} + +function matchesDesiredDate(pickupDate, desiredDate) { + if (!desiredDate) { + return true; + } + + const desired = new Date(desiredDate); + return ( + pickupDate.getFullYear() === desired.getFullYear() && + pickupDate.getMonth() === desired.getMonth() && + pickupDate.getDate() === desired.getDate() + ); +} + +function matchesDesiredWeekday(pickupDate, desiredWeekday) { + if (!desiredWeekday) { + return true; + } + const weekday = pickupDate.toLocaleDateString('en-US', { weekday: 'long' }); + return weekday === desiredWeekday; +} + +function shouldIgnoreSlot(entry, pickup, settings) { + const rules = settings.ignoredSlots || []; + return rules.some((rule) => { + if (!rule?.storeId) { + return false; + } + if (String(rule.storeId) !== entry.id) { + return false; + } + if (rule.description) { + return pickup.description === rule.description; + } + return true; + }); +} + +async function processBooking(session, entry, pickup) { + const readableDate = new Date(pickup.date).toLocaleString('de-DE'); + const storeName = entry.label || entry.id; + + if (entry.onlyNotify) { + console.log(`[INFO] Slot gefunden (nur Hinweis) für ${storeName} am ${readableDate}`); + return; + } + + const utcDate = new Date(pickup.date).toISOString(); + try { + const allowed = await foodsharingClient.pickupRuleCheck(entry.id, utcDate, session.profile.id, session); + if (!allowed) { + console.warn(`[WARN] Rule-Check fehlgeschlagen für ${storeName} am ${readableDate}`); + return; + } + await foodsharingClient.bookSlot(entry.id, utcDate, session.profile.id, session); + console.log(`[SUCCESS] Slot gebucht für ${storeName} am ${readableDate}`); + } catch (error) { + console.error(`[ERROR] Buchung für ${storeName} am ${readableDate} fehlgeschlagen:`, error.message); + } +} + +async function checkEntry(sessionId, entry, settings) { + const session = sessionStore.get(sessionId); + if (!session) { + return; + } + + const ready = await ensureSession(session); + if (!ready) { + return; + } + + try { + const pickups = await foodsharingClient.fetchPickups(entry.id, session.cookieHeader); + let hasProfileId = false; + let availablePickup = null; + + const desiredWeekday = entry.desiredWeekday ? weekdayMap[entry.desiredWeekday] || entry.desiredWeekday : null; + + pickups.forEach((pickup) => { + const pickupDate = new Date(pickup.date); + if (!matchesDesiredDate(pickupDate, entry.desiredDate)) { + return; + } + if (!matchesDesiredWeekday(pickupDate, desiredWeekday)) { + return; + } + if (entry.checkProfileId && pickup.occupiedSlots?.some((slot) => slot.profile?.id === session.profile.id)) { + hasProfileId = true; + return; + } + if (pickup.isAvailable && !availablePickup) { + availablePickup = pickup; + } + }); + + if (!availablePickup) { + console.log( + `[INFO] Kein freier Slot für ${entry.label || entry.id} in dieser Runde gefunden. Profil bereits eingetragen: ${ + hasProfileId ? 'ja' : 'nein' + }` + ); + return; + } + + if (shouldIgnoreSlot(entry, availablePickup, settings)) { + console.log(`[INFO] Slot für ${entry.id} aufgrund einer Admin-Regel ignoriert.`); + return; + } + + if (!entry.checkProfileId || !hasProfileId) { + await processBooking(session, entry, availablePickup); + } + } catch (error) { + console.error(`[ERROR] Pickup-Check für Store ${entry.id} fehlgeschlagen:`, error.message); + } +} + +function scheduleEntry(sessionId, entry, settings) { + const cronExpression = settings.scheduleCron || DEFAULT_SETTINGS.scheduleCron; + const job = cron.schedule( + cronExpression, + () => { + const delay = randomDelayMs( + settings.randomDelayMinSeconds, + settings.randomDelayMaxSeconds + ); + setTimeout(() => checkEntry(sessionId, entry, settings), delay); + }, + { + timezone: 'Europe/Berlin' + } + ); + sessionStore.attachJob(sessionId, job); + setTimeout( + () => checkEntry(sessionId, entry, settings), + randomDelayMs(settings.initialDelayMinSeconds, settings.initialDelayMaxSeconds) + ); +} + +function scheduleConfig(sessionId, config, settings) { + const resolvedSettings = resolveSettings(settings); + sessionStore.clearJobs(sessionId); + const activeEntries = config.filter((entry) => entry.active); + if (activeEntries.length === 0) { + console.log(`[INFO] Keine aktiven Einträge für Session ${sessionId} – Scheduler ruht.`); + return; + } + activeEntries.forEach((entry) => scheduleEntry(sessionId, entry, resolvedSettings)); + console.log( + `[INFO] Scheduler für Session ${sessionId} mit ${activeEntries.length} Jobs aktiv (Cron: ${resolvedSettings.scheduleCron}).` + ); +} + +module.exports = { + scheduleConfig +}; diff --git a/services/sessionStore.js b/services/sessionStore.js new file mode 100644 index 0000000..338dd8f --- /dev/null +++ b/services/sessionStore.js @@ -0,0 +1,98 @@ +const { v4: uuid } = require('uuid'); + +class SessionStore { + constructor() { + this.sessions = new Map(); + this.profileIndex = new Map(); + } + + create(payload, customId, ttlMs) { + const id = customId || uuid(); + const profileId = payload?.profile?.id ? String(payload.profile.id) : null; + + if (profileId && this.profileIndex.has(profileId)) { + const previousId = this.profileIndex.get(profileId); + if (previousId && previousId !== id) { + this.delete(previousId); + } + } + + const session = { + id, + createdAt: Date.now(), + updatedAt: Date.now(), + expiresAt: ttlMs ? Date.now() + ttlMs : null, + jobs: [], + ...payload + }; + this.sessions.set(id, session); + + if (profileId) { + this.profileIndex.set(profileId, id); + } + + return session; + } + + get(id) { + const session = this.sessions.get(id); + if (!session) { + return null; + } + + if (session.expiresAt && session.expiresAt < Date.now()) { + this.delete(id); + return null; + } + + return session; + } + + update(id, patch) { + const session = this.get(id); + if (!session) { + return null; + } + Object.assign(session, patch, { updatedAt: Date.now() }); + return session; + } + + attachJob(id, job) { + const session = this.get(id); + if (!session) { + return; + } + session.jobs.push(job); + } + + clearJobs(id) { + const session = this.get(id); + if (!session || !Array.isArray(session.jobs)) { + return; + } + session.jobs.forEach((job) => { + if (job && typeof job.stop === 'function') { + job.stop(); + } + }); + session.jobs = []; + } + + delete(id) { + const session = this.sessions.get(id); + if (session) { + this.clearJobs(id); + const profileId = session.profile?.id ? String(session.profile.id) : null; + if (profileId && this.profileIndex.get(profileId) === id) { + this.profileIndex.delete(profileId); + } + } + this.sessions.delete(id); + } + + list() { + return Array.from(this.sessions.values()); + } +} + +module.exports = new SessionStore(); diff --git a/src/App.js b/src/App.js index 8d7be52..9ea6cb8 100644 --- a/src/App.js +++ b/src/App.js @@ -1,251 +1,776 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import './App.css'; +const emptyEntry = { + id: '', + label: '', + active: false, + checkProfileId: true, + onlyNotify: false +}; + +const TOKEN_STORAGE_KEY = 'pickupConfigToken'; + function App() { + const [session, setSession] = useState(null); + const [credentials, setCredentials] = useState({ email: '', password: '' }); const [config, setConfig] = useState([]); - const [loading, setLoading] = useState(true); + const [stores, setStores] = useState([]); + const [loading, setLoading] = useState(false); const [status, setStatus] = useState(''); const [error, setError] = useState(''); - const [newEntry, setNewEntry] = useState({ - id: "", - label: "", - active: false, - checkProfileId: true, - onlyNotify: false - }); + const [newEntry, setNewEntry] = useState(emptyEntry); const [showNewEntryForm, setShowNewEntryForm] = useState(false); + const [availableCollapsed, setAvailableCollapsed] = useState(true); + const [adminSettings, setAdminSettings] = useState(null); + const [adminSettingsLoading, setAdminSettingsLoading] = useState(false); - // API-URL für Server-Endpunkte - const API_URL = '/api/iobroker/pickup-config'; + const weekdays = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']; - // Beim Laden der Komponente die aktuelle Konfiguration abrufen - useEffect(() => { - fetchConfig(); + const normalizeAdminSettings = useCallback((raw) => { + if (!raw) { + return null; + } + return { + scheduleCron: raw.scheduleCron || '', + randomDelayMinSeconds: raw.randomDelayMinSeconds ?? '', + randomDelayMaxSeconds: raw.randomDelayMaxSeconds ?? '', + initialDelayMinSeconds: raw.initialDelayMinSeconds ?? '', + initialDelayMaxSeconds: raw.initialDelayMaxSeconds ?? '', + ignoredSlots: Array.isArray(raw.ignoredSlots) + ? raw.ignoredSlots.map((slot) => ({ + storeId: slot?.storeId ? String(slot.storeId) : '', + description: slot?.description || '' + })) + : [] + }; }, []); - // Konfiguration vom Server abrufen - const fetchConfig = async () => { + const resetSessionState = useCallback(() => { + setSession(null); + setConfig([]); + setStores([]); + setStatus(''); + setError(''); + setShowNewEntryForm(false); + setNewEntry(emptyEntry); + setAdminSettings(null); + setAdminSettingsLoading(false); + setAvailableCollapsed(true); + }, []); + + const handleUnauthorized = useCallback(() => { + resetSessionState(); + try { + localStorage.removeItem(TOKEN_STORAGE_KEY); + } catch (storageError) { + console.warn('Konnte Token nicht aus dem Speicher entfernen:', storageError); + } + }, [resetSessionState]); + + const bootstrapSession = useCallback( + async (token) => { + setLoading(true); + setError(''); + try { + const response = await fetch('/api/auth/session', { + headers: { Authorization: `Bearer ${token}` } + }); + if (response.status === 401) { + handleUnauthorized(); + return; + } + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + setSession({ token, profile: data.profile, isAdmin: data.isAdmin }); + setStores(Array.isArray(data.stores) ? data.stores : []); + setAdminSettings(data.isAdmin ? normalizeAdminSettings(data.adminSettings) : null); + + const configResponse = await fetch('/api/config', { + headers: { Authorization: `Bearer ${token}` } + }); + if (configResponse.status === 401) { + handleUnauthorized(); + return; + } + if (!configResponse.ok) { + throw new Error(`HTTP ${configResponse.status}`); + } + const configData = await configResponse.json(); + setConfig(Array.isArray(configData) ? configData : []); + } catch (err) { + setError(`Session konnte nicht wiederhergestellt werden: ${err.message}`); + } finally { + setLoading(false); + } + }, + [handleUnauthorized, normalizeAdminSettings] + ); + + useEffect(() => { + try { + const storedToken = localStorage.getItem(TOKEN_STORAGE_KEY); + if (storedToken) { + bootstrapSession(storedToken); + } + } catch (err) { + console.warn('Konnte gespeicherten Token nicht lesen:', err); + } + }, [bootstrapSession]); + + const authorizedFetch = useCallback( + async (url, options = {}, tokenOverride) => { + const activeToken = tokenOverride || session?.token; + if (!activeToken) { + throw new Error('Keine aktive Session'); + } + const headers = { + Authorization: `Bearer ${activeToken}`, + ...(options.headers || {}) + }; + const response = await fetch(url, { ...options, headers }); + if (response.status === 401) { + handleUnauthorized(); + throw new Error('Nicht autorisiert'); + } + return response; + }, + [handleUnauthorized, session?.token] + ); + + useEffect(() => { + if (!session?.token || !session.isAdmin) { + setAdminSettings(null); + setAdminSettingsLoading(false); + return; + } + + let cancelled = false; + setAdminSettingsLoading(true); + + (async () => { + try { + const response = await authorizedFetch('/api/admin/settings'); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + if (!cancelled) { + setAdminSettings(normalizeAdminSettings(data)); + } + } catch (err) { + if (!cancelled) { + setError(`Admin-Einstellungen konnten nicht geladen werden: ${err.message}`); + } + } finally { + if (!cancelled) { + setAdminSettingsLoading(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [session?.token, session?.isAdmin, authorizedFetch, normalizeAdminSettings]); + + const handleLogin = async (event) => { + event.preventDefault(); setLoading(true); setError(''); - + setStatus(''); + try { - const response = await fetch(API_URL); + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(credentials) + }); + if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + throw new Error(`HTTP ${response.status}`); } - + const data = await response.json(); - setConfig(data); - setLoading(false); + try { + localStorage.setItem(TOKEN_STORAGE_KEY, data.token); + } catch (storageError) { + console.warn('Konnte Token nicht speichern:', storageError); + } + setSession({ token: data.token, profile: data.profile, isAdmin: data.isAdmin }); + setConfig(Array.isArray(data.config) ? data.config : []); + setStores(Array.isArray(data.stores) ? data.stores : []); + setAdminSettings(data.isAdmin ? normalizeAdminSettings(data.adminSettings) : null); + setStatus('Anmeldung erfolgreich. Konfiguration geladen.'); + setTimeout(() => setStatus(''), 3000); } catch (err) { - console.error("Fehler beim Laden der Konfiguration:", err); - setError(`Fehler beim Laden der Konfiguration: ${err.message}`); + setError(`Login fehlgeschlagen: ${err.message}`); + } finally { setLoading(false); - - // Fallback zur statischen Konfiguration bei Fehler - setConfig([ - { id: "63448", active: false, checkProfileId: true, onlyNotify: true, label: "Penny Baden-Oos" }, - { id: "44975", active: false, checkProfileId: true, onlyNotify: false, label: "Aldi Kuppenheim", desiredWeekday: "Samstag" }, - { id: "44972", active: false, checkProfileId: true, onlyNotify: false, label: "Aldi Biblisweg", desiredWeekday: "Dienstag" }, - { id: "44975", active: false, checkProfileId: true, onlyNotify: false, label: "Aldi Kuppenheim", desiredDate: "2025-05-18" }, - { id: "33875", active: false, checkProfileId: true, onlyNotify: false, label: "Cap Markt", desiredWeekday: "Donnerstag" }, - { id: "42322", active: false, checkProfileId: false, onlyNotify: false, label: "Edeka Haueneberstein" }, - { id: "51450", active: false, checkProfileId: true, onlyNotify: false, label: "Hornbach Grünwinkel" } - ]); } }; - // Konfiguration auf dem Server speichern + const handleLogout = async () => { + if (!session?.token) { + handleUnauthorized(); + return; + } + try { + await authorizedFetch('/api/auth/logout', { method: 'POST' }); + } catch (err) { + console.warn('Logout fehlgeschlagen:', err); + } finally { + handleUnauthorized(); + } + }; + + const fetchConfig = async (tokenOverride, { silent = false } = {}) => { + const tokenToUse = tokenOverride || session?.token; + if (!tokenToUse) { + return; + } + if (!silent) { + setStatus(''); + } + setLoading(true); + setError(''); + try { + const response = await authorizedFetch('/api/config', {}, tokenToUse); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + setConfig(Array.isArray(data) ? data : []); + if (!silent) { + setStatus('Konfiguration aktualisiert.'); + setTimeout(() => setStatus(''), 3000); + } + } catch (err) { + setError(`Fehler beim Laden der Konfiguration: ${err.message}`); + } finally { + setLoading(false); + } + }; + + const fetchStoresList = async () => { + if (!session?.token) { + return; + } + setStatus(''); + setError(''); + try { + const response = await authorizedFetch('/api/stores'); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + setStores(Array.isArray(data) ? data : []); + await fetchConfig(undefined, { silent: true }); + setStatus('Betriebe aktualisiert.'); + setTimeout(() => setStatus(''), 3000); + } catch (err) { + setError(`Fehler beim Laden der Betriebe: ${err.message}`); + } + }; + const saveConfig = async () => { + if (!session?.token) { + return; + } setStatus('Speichere...'); setError(''); - try { - const response = await fetch(API_URL, { + const response = await authorizedFetch('/api/config', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(config), + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config) }); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + throw new Error(`HTTP ${response.status}`); } - const result = await response.json(); - if (result.success) { setStatus('Konfiguration erfolgreich gespeichert!'); - // Status nach 3 Sekunden wieder zurücksetzen setTimeout(() => setStatus(''), 3000); } else { throw new Error(result.error || 'Unbekannter Fehler beim Speichern'); } } catch (err) { - console.error("Fehler beim Speichern:", err); + setError(`Fehler beim Speichern: ${err.message}`); + setStatus(''); + } + }; + + const persistConfigUpdate = async (updater, successMessage) => { + if (!session?.token) { + return; + } + let nextConfigState; + setConfig((prev) => { + nextConfigState = typeof updater === 'function' ? updater(prev) : updater; + return nextConfigState; + }); + if (!nextConfigState) { + return; + } + setStatus('Speichere...'); + setError(''); + try { + const response = await authorizedFetch('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(nextConfigState) + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const result = await response.json(); + if (!result.success) { + throw new Error(result.error || 'Unbekannter Fehler beim Speichern'); + } + const message = successMessage || 'Konfiguration gespeichert.'; + setStatus(message); + setTimeout(() => setStatus(''), 3000); + } catch (err) { setError(`Fehler beim Speichern: ${err.message}`); } }; - // Eintrag hinzufügen const addEntry = () => { - // Validierung if (!newEntry.id || !newEntry.label) { setError('ID und Bezeichnung müssen ausgefüllt werden!'); return; } - - // Neuen Eintrag zur Konfiguration hinzufügen - const updatedConfig = [...config, newEntry]; + + const normalized = { + ...newEntry, + id: String(newEntry.id), + hidden: false + }; + + const updatedConfig = [...config.filter((item) => item.id !== normalized.id), normalized]; setConfig(updatedConfig); - - // Formular zurücksetzen - setNewEntry({ - id: "", - label: "", - active: false, - checkProfileId: true, - onlyNotify: false - }); - - // Formular ausblenden + setNewEntry(emptyEntry); setShowNewEntryForm(false); - - // Erfolgsmeldung anzeigen setStatus('Neuer Eintrag hinzugefügt!'); setTimeout(() => setStatus(''), 3000); }; - // Eintrag löschen - const deleteEntry = (index) => { - if (window.confirm('Sind Sie sicher, dass Sie diesen Eintrag löschen möchten?')) { - const updatedConfig = [...config]; - updatedConfig.splice(index, 1); + const deleteEntry = (entryId) => { + if (window.confirm('Soll dieser Eintrag dauerhaft gelöscht werden?')) { + const updatedConfig = config.filter((item) => item.id !== entryId); setConfig(updatedConfig); - setStatus('Eintrag gelöscht!'); setTimeout(() => setStatus(''), 3000); } }; - // Event-Handler für verschiedene Änderungen an der Konfiguration - const handleToggleActive = (index) => { - const newConfig = [...config]; - newConfig[index].active = !newConfig[index].active; - setConfig(newConfig); - }; - - const handleToggleProfileCheck = (index) => { - const newConfig = [...config]; - newConfig[index].checkProfileId = !newConfig[index].checkProfileId; - setConfig(newConfig); - }; - - const handleToggleOnlyNotify = (index) => { - const newConfig = [...config]; - newConfig[index].onlyNotify = !newConfig[index].onlyNotify; - setConfig(newConfig); - }; - - const handleWeekdayChange = (index, value) => { - const newConfig = [...config]; - newConfig[index].desiredWeekday = value || null; - - // Wenn ein Wochentag gesetzt wird, entfernen wir das spezifische Datum - if (value && newConfig[index].desiredDate) { - delete newConfig[index].desiredDate; + const hideEntry = async (entryId) => { + if (!window.confirm('Soll dieser Betrieb ausgeblendet werden?')) { + return; } - - setConfig(newConfig); - }; - - const handleDateChange = (index, value) => { - const newConfig = [...config]; - newConfig[index].desiredDate = value || null; - - // Wenn ein spezifisches Datum gesetzt wird, entfernen wir den Wochentag - if (value && newConfig[index].desiredWeekday) { - delete newConfig[index].desiredWeekday; - } - - setConfig(newConfig); + await persistConfigUpdate( + (prev) => prev.map((item) => (item.id === entryId ? { ...item, hidden: true } : item)), + 'Betrieb ausgeblendet.' + ); }; - // Neue Eintragsdaten verwalten - const handleNewEntryChange = (e) => { - const { name, value, type, checked } = e.target; + const handleToggleActive = (entryId) => { + setConfig((prev) => + prev.map((item) => + item.id === entryId ? { ...item, active: !item.active } : item + ) + ); + }; + + const handleToggleProfileCheck = (entryId) => { + setConfig((prev) => + prev.map((item) => + item.id === entryId ? { ...item, checkProfileId: !item.checkProfileId } : item + ) + ); + }; + + const handleToggleOnlyNotify = (entryId) => { + setConfig((prev) => + prev.map((item) => + item.id === entryId ? { ...item, onlyNotify: !item.onlyNotify } : item + ) + ); + }; + + const handleWeekdayChange = (entryId, value) => { + setConfig((prev) => + prev.map((item) => { + if (item.id !== entryId) { + return item; + } + const updated = { ...item, desiredWeekday: value || null }; + if (value && updated.desiredDate) { + delete updated.desiredDate; + } + return updated; + }) + ); + }; + + const handleDateChange = (entryId, value) => { + setConfig((prev) => + prev.map((item) => { + if (item.id !== entryId) { + return item; + } + const updated = { ...item, desiredDate: value || null }; + if (value && updated.desiredWeekday) { + delete updated.desiredWeekday; + } + return updated; + }) + ); + }; + + const handleNewEntryChange = (event) => { + const { name, value, type, checked } = event.target; setNewEntry({ ...newEntry, [name]: type === 'checkbox' ? checked : value }); }; - // Lade-Indikator anzeigen, während die Daten geladen werden + const configMap = useMemo(() => { + const map = new Map(); + config.forEach((item) => { + if (item?.id) { + map.set(String(item.id), item); + } + }); + return map; + }, [config]); + + const visibleConfig = useMemo(() => config.filter((item) => !item.hidden), [config]); + + const handleStoreSelection = async (store) => { + const storeId = String(store.id); + const existing = configMap.get(storeId); + if (existing && !existing.hidden) { + return; + } + if (!window.confirm(`Soll der Betrieb "${store.name}" zur Liste hinzugefügt werden?`)) { + return; + } + const message = existing ? 'Betrieb wieder eingeblendet.' : 'Betrieb zur Liste hinzugefügt.'; + await persistConfigUpdate( + (prev) => { + const already = prev.find((item) => item.id === storeId); + if (already) { + return prev.map((item) => + item.id === storeId + ? { + ...item, + hidden: false, + label: item.label || store.name || `Store ${storeId}` + } + : item + ); + } + return [ + ...prev, + { + id: storeId, + label: store.name || `Store ${storeId}`, + active: false, + checkProfileId: true, + onlyNotify: false, + hidden: false + } + ]; + }, + message + ); + }; + + const handleAdminSettingChange = (field, value, isNumber = false) => { + setAdminSettings((prev) => { + if (!prev) { + return prev; + } + let nextValue = value; + if (isNumber) { + nextValue = value === '' ? '' : Number(value); + } + return { + ...prev, + [field]: nextValue + }; + }); + }; + + const handleIgnoredSlotChange = (index, field, value) => { + setAdminSettings((prev) => { + if (!prev) { + return prev; + } + const slots = [...(prev.ignoredSlots || [])]; + slots[index] = { + ...slots[index], + [field]: field === 'storeId' ? value : value + }; + return { + ...prev, + ignoredSlots: slots + }; + }); + }; + + const addIgnoredSlot = () => { + setAdminSettings((prev) => { + if (!prev) { + return prev; + } + return { + ...prev, + ignoredSlots: [...(prev.ignoredSlots || []), { storeId: '', description: '' }] + }; + }); + }; + + const removeIgnoredSlot = (index) => { + setAdminSettings((prev) => { + if (!prev) { + return prev; + } + const slots = [...(prev.ignoredSlots || [])]; + slots.splice(index, 1); + return { + ...prev, + ignoredSlots: slots + }; + }); + }; + + const saveAdminSettings = async () => { + if (!session?.token || !session.isAdmin || !adminSettings) { + return; + } + setStatus('Admin-Einstellungen werden gespeichert...'); + setError(''); + const toNumber = (value) => { + if (value === '' || value === null || value === undefined) { + return undefined; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + }; + try { + const payload = { + scheduleCron: adminSettings.scheduleCron, + randomDelayMinSeconds: toNumber(adminSettings.randomDelayMinSeconds), + randomDelayMaxSeconds: toNumber(adminSettings.randomDelayMaxSeconds), + initialDelayMinSeconds: toNumber(adminSettings.initialDelayMinSeconds), + initialDelayMaxSeconds: toNumber(adminSettings.initialDelayMaxSeconds), + ignoredSlots: (adminSettings.ignoredSlots || []).map((slot) => ({ + storeId: slot.storeId || '', + description: slot.description || '' + })) + }; + + const response = await authorizedFetch('/api/admin/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + setAdminSettings(normalizeAdminSettings(data)); + setStatus('Admin-Einstellungen gespeichert.'); + setTimeout(() => setStatus(''), 3000); + } catch (err) { + setError(`Speichern der Admin-Einstellungen fehlgeschlagen: ${err.message}`); + } + }; + + if (!session?.token) { + return ( +
+
+
+

Pickup Config Login

+

+ Die Anwendung authentifiziert sich direkt bei foodsharing.de. Bitte verwende deine persönlichen Login-Daten. +

+
+ {error && ( +
+ {error} +
+ )} +
+ + setCredentials({ ...credentials, email: e.target.value })} + className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + required + /> +
+
+ + setCredentials({ ...credentials, password: e.target.value })} + className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + required + /> +
+ +
+
+ ); + } + if (loading) { return (
-

Lade Konfiguration...

+

Lade Daten...

); } - // Definiere die Wochentage - const weekdays = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']; - - // Hauptkomponente rendern return (
-

Foodsharing Abholung-Konfiguration

- - {/* Externe Links */} +

Foodsharing Pickup Manager

+
+
+
+

Angemeldet

+

{session.profile.name}

+

Profil-ID: {session.profile.id}

+
+
+ + +
+
+
+ +
+ + {!availableCollapsed && ( +
+ {stores.length === 0 && ( +
Noch keine Betriebe geladen. Aktualisiere nach dem Login.
+ )} + {stores.map((store) => { + const storeId = String(store.id); + const entry = configMap.get(storeId); + const isVisible = entry && !entry.hidden; + const needsRestore = entry && entry.hidden; + let statusLabel = 'Hinzufügen'; + if (isVisible) { + statusLabel = 'Bereits in Konfiguration'; + } else if (needsRestore) { + statusLabel = 'Ausgeblendet – erneut hinzufügen'; + } + return ( + + ); + })} +
+ )} +
+
- - Foodsharing - - - - - - ioBroker Admin + Foodsharing Dashboard
- {/* Fehler- und Status-Meldungen */} + {error && (
{error} -
)} - + {status && (
{status}
)} - - {/* Konfigurationstabelle */} +
@@ -260,17 +785,22 @@ function App() { - {config.map((item, index) => ( - + {visibleConfig.length === 0 && ( + + + + )} + {visibleConfig.map((item, index) => ( + - ))}
+ Keine sichtbaren Einträge. Nutze „Verfügbare Betriebe“, um Betriebe hinzuzufügen oder ausgeblendete Einträge zurückzuholen. +
- + handleToggleActive(item.id)} + className="h-5 w-5 text-blue-600 rounded focus:ring-2 focus:ring-blue-500" + />
@@ -280,65 +810,191 @@ function App() {
- + handleToggleProfileCheck(item.id)} + className="h-5 w-5 text-blue-600 rounded focus:ring-2 focus:ring-blue-500" + /> - + handleToggleOnlyNotify(item.id)} + className="h-5 w-5 text-blue-600 rounded focus:ring-2 focus:ring-blue-500" + /> - - handleDateChange(index, e.target.value)} + onChange={(e) => handleDateChange(item.id, e.target.value)} className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" disabled={item.desiredWeekday} /> - + +
+ + +
- - {/* Neuer Eintrag Formular */} + + {session?.isAdmin && ( +
+

Admin-Einstellungen

+ {adminSettingsLoading &&

Lade Admin-Einstellungen...

} + {!adminSettingsLoading && !adminSettings && ( +

Keine Admin-Einstellungen verfügbar.

+ )} + {adminSettings && ( + <> +
+
+ + handleAdminSettingChange('scheduleCron', e.target.value)} + className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" + /> +
+
+ +
+ handleAdminSettingChange('initialDelayMinSeconds', e.target.value, true)} + className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" + placeholder="Min" + /> + handleAdminSettingChange('initialDelayMaxSeconds', e.target.value, true)} + className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" + placeholder="Max" + /> +
+
+
+ +
+ handleAdminSettingChange('randomDelayMinSeconds', e.target.value, true)} + className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" + placeholder="Min" + /> + handleAdminSettingChange('randomDelayMaxSeconds', e.target.value, true)} + className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" + placeholder="Max" + /> +
+
+
+ +
+
+

Ignorierte Slots

+ +
+ {(!adminSettings.ignoredSlots || adminSettings.ignoredSlots.length === 0) && ( +

Keine Regeln definiert.

+ )} + {adminSettings.ignoredSlots?.map((slot, index) => ( +
+ handleIgnoredSlotChange(index, 'storeId', e.target.value)} + placeholder="Store-ID" + className="md:col-span-2 border rounded p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" + /> + handleIgnoredSlotChange(index, 'description', e.target.value)} + placeholder="Beschreibung (optional)" + className="md:col-span-2 border rounded p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" + /> + +
+ ))} +
+ +
+ +
+ + )} +
+ )} + {showNewEntryForm ? (

Neuen Eintrag hinzufügen

@@ -366,9 +1022,9 @@ function App() { />
- +
-
+
- -
+ Aktiv + +
- -
+ Profil prüfen + +
+ Nur benachrichtigen +
- +
@@ -413,7 +1067,9 @@ function App() { > {weekdays.map((day) => ( - + ))}
@@ -429,7 +1085,7 @@ function App() { />
- +
)} - - {/* Aktionsbuttons */} +
- -
- - {/* JSON Vorschau -
-

Aktuelle JSON-Konfiguration:

-
-          {JSON.stringify(config, null, 2)}
-        
-
*/}
); } -export default App; \ No newline at end of file +export default App;