chore: sync running docker state

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

3
.commitmessage Normal file
View File

@@ -0,0 +1,3 @@
chore: sync running docker state
Commit the app code currently running in Docker, including scheduler, store status, journal reminder, maintenance mode, and Foodsharing API check updates. Remove generated runtime logs from version control and ignore local runtime captures.

3
.gitignore vendored
View File

@@ -13,10 +13,13 @@
# misc # misc
.DS_Store .DS_Store
.codex
.env.local .env.local
.env.development.local .env.development.local
.env.test.local .env.test.local
.env.production.local .env.production.local
/config/*.json
/demo requests/*.har
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*

View File

@@ -13,9 +13,18 @@
- `npm start` launch the CRA dev server on port 3000 with live reload. - `npm start` launch the CRA dev server on port 3000 with live reload.
- `npm run build` emit the production bundle into `build/`; run before `node server.js` or container builds. - `npm run build` emit the production bundle into `build/`; run before `node server.js` or container builds.
- `npm test` run React Testing Library suites in watch mode; append `-- --watch=false` in CI. - `npm test` run React Testing Library suites in watch mode; append `-- --watch=false` in CI.
- `npm run check:foodsharing-api` verify the Foodsharing endpoints used by the app against `https://foodsharing.de/api/doc`.
- `node server.js` serve the prebuilt UI and REST API using values from `.env` (e.g., `PICKUP_TOPIC`, credentials, ports). - `node server.js` serve the prebuilt UI and REST API using values from `.env` (e.g., `PICKUP_TOPIC`, credentials, ports).
- `docker-compose up --build` rebuild and start the containerized service, syncing the bundled UI and server. - `docker-compose up --build` rebuild and start the containerized service, syncing the bundled UI and server.
## Required Close-Out Steps
- After changes to `server.js`, `services/`, `src/`, `Dockerfile`, `docker-compose.yml`, or the Foodsharing API integration, always run this sequence completely:
- `CI=true npm test -- --watch=false`
- `npm run build`
- `npm run check:foodsharing-api` when Foodsharing endpoints, payloads, or response mappings are affected
- `docker compose up -d --build`
- `curl http://localhost:3005/api/health` and confirm the response contains `"status":"ok"`
## Coding Style & Naming Conventions ## Coding Style & Naming Conventions
- Use 2-space indentation and Standard/Prettier-compatible formatting; rely on the CRA ESLint config (`react-app`, `react-app/jest`) for feedback. - Use 2-space indentation and Standard/Prettier-compatible formatting; rely on the CRA ESLint config (`react-app`, `react-app/jest`) for feedback.
- Favor functional React components with PascalCase filenames (`PickupConfigEditor.js`) and camelCase props/state keys. - Favor functional React components with PascalCase filenames (`PickupConfigEditor.js`) and camelCase props/state keys.

View File

@@ -85,6 +85,7 @@ Weitere Laufzeitdaten:
| `npm start` | CRA-Devserver inkl. Live-Reload. | | `npm start` | CRA-Devserver inkl. Live-Reload. |
| `npm run build` | Produktionsbundle (CRA). | | `npm run build` | Produktionsbundle (CRA). |
| `npm test` | Jest/RTL-Tests (Watch-Mode). Für CI `CI=true npm test`. | | `npm test` | Jest/RTL-Tests (Watch-Mode). Für CI `CI=true npm test`. |
| `npm run check:foodsharing-api` | Prüft die genutzten Foodsharing-Endpunkte gegen `https://foodsharing.de/api/doc`. |
| `node server.js` | Express-Server (liefert `build/`, API & Scheduler). | | `node server.js` | Express-Server (liefert `build/`, API & Scheduler). |
Docker: Docker:
@@ -137,6 +138,40 @@ CI=true npm test
docker-compose up --build docker-compose up --build
``` ```
- Container läuft mit dem gebauten Bundle & Express-Server. - Container läuft mit dem gebauten Bundle & Express-Server.
- Nach relevanten Änderungen an `server.js`, `services/`, `src/`, `Dockerfile` oder `docker-compose.yml` den Container immer neu bauen und neu starten:
```bash
docker compose up -d --build
```
oder alternativ:
```bash
./rebuildContainer.sh
```
### Abschluss-Checkliste nach relevanten Änderungen
Bei Änderungen an `server.js`, `services/`, `src/`, `Dockerfile`, `docker-compose.yml` oder der Foodsharing-API-Integration immer vollständig ausführen:
1. Tests:
```bash
CI=true npm test -- --watch=false
```
2. Produktions-Build:
```bash
npm run build
```
3. Falls Foodsharing-Endpunkte oder Payloads betroffen sind:
```bash
npm run check:foodsharing-api
```
4. Container neu bauen und starten:
```bash
docker compose up -d --build
```
5. Laufenden Dienst prüfen:
```bash
curl http://localhost:3005/api/health
```
Erwartet wird ein JSON mit `\"status\":\"ok\"`.
Beachte: `config/` muss beschreibbar sein, da dort u.a. Session- und Watcher-Infos landen. Beachte: `config/` muss beschreibbar sein, da dort u.a. Session- und Watcher-Infos landen.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,188 +0,0 @@
{
"log": {
"version": "1.2",
"creator": {
"name": "Firefox",
"version": "146.0.1"
},
"browser": {
"name": "Firefox",
"version": "146.0.1"
},
"pages": [
{
"id": "page_2",
"pageTimings": {
"onContentLoad": 721,
"onLoad": 988
},
"startedDateTime": "2026-01-02T20:03:09.325+01:00",
"title": "https://foodsharing.de/store/44975"
}
],
"entries": [
{
"startedDateTime": "2026-01-02T20:03:09.325+01:00",
"request": {
"bodySize": 0,
"method": "GET",
"url": "https://foodsharing.de/api/foodsaver/839246/agenda/2026-01-21T18:55:00.000Z",
"httpVersion": "HTTP/2",
"headers": [
{
"name": "Host",
"value": "foodsharing.de"
},
{
"name": "User-Agent",
"value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:146.0) Gecko/20100101 Firefox/146.0"
},
{
"name": "Accept",
"value": "application/json, text/plain, */*"
},
{
"name": "Accept-Language",
"value": "de,en-US;q=0.7,en;q=0.3"
},
{
"name": "Accept-Encoding",
"value": "gzip, deflate, br, zstd"
},
{
"name": "Referer",
"value": "https://foodsharing.de/store/44975"
},
{
"name": "Cache-Control",
"value": "no-cache, no-store, must-revalidate"
},
{
"name": "Pragma",
"value": "no-cache"
},
{
"name": "Expires",
"value": "0"
},
{
"name": "X-CSRF-Token",
"value": "ee0a04d48f80a5abae3be7b25da22164"
},
{
"name": "sentry-trace",
"value": "437169c7d1dc4761850ba492b55401de-9f89ecfc36ed2bc4-0"
},
{
"name": "baggage",
"value": "sentry-environment=production,sentry-release=ad5e1f003156978d9ba2914bc8b58c75acfb7742,sentry-public_key=88f1f6fc30d10dba9f9459eecd9d3099,sentry-trace_id=437169c7d1dc4761850ba492b55401de,sentry-sampled=false,sentry-sample_rand=0.04415874010402121,sentry-sample_rate=0.01"
},
{
"name": "Sec-Fetch-Dest",
"value": "empty"
},
{
"name": "Sec-Fetch-Mode",
"value": "cors"
},
{
"name": "Sec-Fetch-Site",
"value": "same-origin"
},
{
"name": "Connection",
"value": "keep-alive"
},
{
"name": "Cookie",
"value": "FS_SESSID=h63ree5vn0sip5rdkdkdiisva5; FS_CSRF_TOKEN=ee0a04d48f80a5abae3be7b25da22164"
},
{
"name": "Priority",
"value": "u=0"
},
{
"name": "TE",
"value": "trailers"
}
],
"cookies": [
{
"name": "FS_SESSID",
"value": "h63ree5vn0sip5rdkdkdiisva5"
},
{
"name": "FS_CSRF_TOKEN",
"value": "ee0a04d48f80a5abae3be7b25da22164"
}
],
"queryString": [],
"headersSize": 1042
},
"response": {
"status": 200,
"statusText": "",
"httpVersion": "HTTP/2",
"headers": [
{
"name": "server",
"value": "nginx"
},
{
"name": "content-type",
"value": "application/json"
},
{
"name": "cache-control",
"value": "max-age=0, private, must-revalidate"
},
{
"name": "cache-control",
"value": "no-cache, private"
},
{
"name": "date",
"value": "Fri, 02 Jan 2026 19:03:09 GMT"
},
{
"name": "x-nginx-cache",
"value": "BYPASS"
},
{
"name": "content-encoding",
"value": "gzip"
},
{
"name": "X-Firefox-Spdy",
"value": "h2"
}
],
"cookies": [],
"content": {
"mimeType": "application/json",
"size": 90,
"text": "[{\"name\":null,\"id\":-1,\"isConfirmed\":false,\"date\":\"2026-01-21 19:55:00\",\"type\":\"proposal\"}]"
},
"redirectURL": "",
"headersSize": 237,
"bodySize": 341
},
"cache": {},
"timings": {
"blocked": 0,
"dns": 0,
"connect": 0,
"ssl": 0,
"send": 0,
"wait": 42,
"receive": 0
},
"time": 42,
"_securityState": "secure",
"serverIPAddress": "89.238.64.239",
"connection": "443",
"pageref": "page_2"
}
]
}
}

View File

@@ -1,188 +0,0 @@
{
"log": {
"version": "1.2",
"creator": {
"name": "Firefox",
"version": "146.0.1"
},
"browser": {
"name": "Firefox",
"version": "146.0.1"
},
"pages": [
{
"id": "page_4",
"pageTimings": {
"onContentLoad": 721,
"onLoad": 988
},
"startedDateTime": "2026-01-02T20:03:09.328+01:00",
"title": "https://foodsharing.de/store/44975"
}
],
"entries": [
{
"startedDateTime": "2026-01-02T20:03:09.328+01:00",
"request": {
"bodySize": 0,
"method": "GET",
"url": "https://foodsharing.de/api/stores/44975/pickupRuleCheck/2026-01-21T18:55:00.000Z/839246",
"httpVersion": "HTTP/2",
"headers": [
{
"name": "Host",
"value": "foodsharing.de"
},
{
"name": "User-Agent",
"value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:146.0) Gecko/20100101 Firefox/146.0"
},
{
"name": "Accept",
"value": "application/json, text/plain, */*"
},
{
"name": "Accept-Language",
"value": "de,en-US;q=0.7,en;q=0.3"
},
{
"name": "Accept-Encoding",
"value": "gzip, deflate, br, zstd"
},
{
"name": "Referer",
"value": "https://foodsharing.de/store/44975"
},
{
"name": "Cache-Control",
"value": "no-cache, no-store, must-revalidate"
},
{
"name": "Pragma",
"value": "no-cache"
},
{
"name": "Expires",
"value": "0"
},
{
"name": "X-CSRF-Token",
"value": "ee0a04d48f80a5abae3be7b25da22164"
},
{
"name": "sentry-trace",
"value": "437169c7d1dc4761850ba492b55401de-ace60c0491b458b9-0"
},
{
"name": "baggage",
"value": "sentry-environment=production,sentry-release=ad5e1f003156978d9ba2914bc8b58c75acfb7742,sentry-public_key=88f1f6fc30d10dba9f9459eecd9d3099,sentry-trace_id=437169c7d1dc4761850ba492b55401de,sentry-sampled=false,sentry-sample_rand=0.04415874010402121,sentry-sample_rate=0.01"
},
{
"name": "Sec-Fetch-Dest",
"value": "empty"
},
{
"name": "Sec-Fetch-Mode",
"value": "cors"
},
{
"name": "Sec-Fetch-Site",
"value": "same-origin"
},
{
"name": "Connection",
"value": "keep-alive"
},
{
"name": "Cookie",
"value": "FS_SESSID=h63ree5vn0sip5rdkdkdiisva5; FS_CSRF_TOKEN=ee0a04d48f80a5abae3be7b25da22164"
},
{
"name": "Priority",
"value": "u=0"
},
{
"name": "TE",
"value": "trailers"
}
],
"cookies": [
{
"name": "FS_SESSID",
"value": "h63ree5vn0sip5rdkdkdiisva5"
},
{
"name": "FS_CSRF_TOKEN",
"value": "ee0a04d48f80a5abae3be7b25da22164"
}
],
"queryString": [],
"headersSize": 1054
},
"response": {
"status": 200,
"statusText": "",
"httpVersion": "HTTP/2",
"headers": [
{
"name": "server",
"value": "nginx"
},
{
"name": "content-type",
"value": "application/json"
},
{
"name": "cache-control",
"value": "max-age=0, private, must-revalidate"
},
{
"name": "cache-control",
"value": "no-cache, private"
},
{
"name": "date",
"value": "Fri, 02 Jan 2026 19:03:09 GMT"
},
{
"name": "x-nginx-cache",
"value": "BYPASS"
},
{
"name": "content-encoding",
"value": "gzip"
},
{
"name": "X-Firefox-Spdy",
"value": "h2"
}
],
"cookies": [],
"content": {
"mimeType": "application/json",
"size": 15,
"text": "{\"result\":true}"
},
"redirectURL": "",
"headersSize": 237,
"bodySize": 272
},
"cache": {},
"timings": {
"blocked": 0,
"dns": 0,
"connect": 0,
"ssl": 0,
"send": 0,
"wait": 42,
"receive": 0
},
"time": 42,
"_securityState": "secure",
"serverIPAddress": "89.238.64.239",
"connection": "443",
"pageref": "page_4"
}
]
}
}

View File

@@ -1,192 +0,0 @@
{
"log": {
"version": "1.2",
"creator": {
"name": "Firefox",
"version": "146.0.1"
},
"browser": {
"name": "Firefox",
"version": "146.0.1"
},
"pages": [
{
"id": "page_5",
"pageTimings": {
"onContentLoad": 721,
"onLoad": 988
},
"startedDateTime": "2026-01-02T20:03:12.592+01:00",
"title": "https://foodsharing.de/store/44975"
}
],
"entries": [
{
"startedDateTime": "2026-01-02T20:03:12.592+01:00",
"request": {
"bodySize": 0,
"method": "POST",
"url": "https://foodsharing.de/api/stores/44975/pickups/2026-01-21T18:55:00.000Z/839246",
"httpVersion": "HTTP/2",
"headers": [
{
"name": "Host",
"value": "foodsharing.de"
},
{
"name": "User-Agent",
"value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:146.0) Gecko/20100101 Firefox/146.0"
},
{
"name": "Accept",
"value": "application/json, text/plain, */*"
},
{
"name": "Accept-Language",
"value": "de,en-US;q=0.7,en;q=0.3"
},
{
"name": "Accept-Encoding",
"value": "gzip, deflate, br, zstd"
},
{
"name": "Referer",
"value": "https://foodsharing.de/store/44975"
},
{
"name": "Cache-Control",
"value": "no-cache, no-store, must-revalidate"
},
{
"name": "Pragma",
"value": "no-cache"
},
{
"name": "Expires",
"value": "0"
},
{
"name": "X-CSRF-Token",
"value": "ee0a04d48f80a5abae3be7b25da22164"
},
{
"name": "sentry-trace",
"value": "437169c7d1dc4761850ba492b55401de-a1069f647575db89-0"
},
{
"name": "baggage",
"value": "sentry-environment=production,sentry-release=ad5e1f003156978d9ba2914bc8b58c75acfb7742,sentry-public_key=88f1f6fc30d10dba9f9459eecd9d3099,sentry-trace_id=437169c7d1dc4761850ba492b55401de,sentry-sampled=false,sentry-sample_rand=0.04415874010402121,sentry-sample_rate=0.01"
},
{
"name": "Origin",
"value": "https://foodsharing.de"
},
{
"name": "Sec-Fetch-Dest",
"value": "empty"
},
{
"name": "Sec-Fetch-Mode",
"value": "cors"
},
{
"name": "Sec-Fetch-Site",
"value": "same-origin"
},
{
"name": "Connection",
"value": "keep-alive"
},
{
"name": "Cookie",
"value": "FS_SESSID=h63ree5vn0sip5rdkdkdiisva5; FS_CSRF_TOKEN=ee0a04d48f80a5abae3be7b25da22164"
},
{
"name": "Priority",
"value": "u=0"
},
{
"name": "Content-Length",
"value": "0"
},
{
"name": "TE",
"value": "trailers"
}
],
"cookies": [
{
"name": "FS_SESSID",
"value": "h63ree5vn0sip5rdkdkdiisva5"
},
{
"name": "FS_CSRF_TOKEN",
"value": "ee0a04d48f80a5abae3be7b25da22164"
}
],
"queryString": [],
"headersSize": 1098
},
"response": {
"status": 200,
"statusText": "",
"httpVersion": "HTTP/2",
"headers": [
{
"name": "server",
"value": "nginx"
},
{
"name": "content-type",
"value": "application/json"
},
{
"name": "cache-control",
"value": "max-age=0, private, must-revalidate"
},
{
"name": "cache-control",
"value": "no-cache, private"
},
{
"name": "date",
"value": "Fri, 02 Jan 2026 19:03:13 GMT"
},
{
"name": "content-encoding",
"value": "gzip"
},
{
"name": "X-Firefox-Spdy",
"value": "h2"
}
],
"cookies": [],
"content": {
"mimeType": "application/json",
"size": 21,
"text": "{\"isConfirmed\":false}"
},
"redirectURL": "",
"headersSize": 214,
"bodySize": 255
},
"cache": {},
"timings": {
"blocked": -1,
"dns": 0,
"connect": 0,
"ssl": 0,
"send": 0,
"wait": 120,
"receive": 0
},
"time": 120,
"_securityState": "secure",
"serverIPAddress": "89.238.64.239",
"connection": "443",
"pageref": "page_5"
}
]
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

177
package-lock.json generated
View File

@@ -24,6 +24,7 @@
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-router-dom": "^7.9.5", "react-router-dom": "^7.9.5",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"socket.io-client": "^4.8.3",
"uuid": "^11.0.3", "uuid": "^11.0.3",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
}, },
@@ -77,7 +78,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@@ -773,7 +773,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.27.1.tgz",
"integrity": "sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA==", "integrity": "sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -1657,7 +1656,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.27.1.tgz",
"integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==", "integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-annotate-as-pure": "^7.27.1",
"@babel/helper-module-imports": "^7.27.1", "@babel/helper-module-imports": "^7.27.1",
@@ -3318,6 +3316,12 @@
"@sinonjs/commons": "^1.7.0" "@sinonjs/commons": "^1.7.0"
} }
}, },
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@surma/rollup-plugin-off-main-thread": { "node_modules/@surma/rollup-plugin-off-main-thread": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
@@ -3589,7 +3593,6 @@
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.10.4", "@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
@@ -4572,7 +4575,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -4694,7 +4696,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0", "fast-json-stable-stringify": "^2.0.0",
@@ -5586,7 +5587,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.19", "baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751", "caniuse-lite": "^1.0.30001751",
@@ -6728,7 +6728,6 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.21.0" "@babel/runtime": "^7.21.0"
}, },
@@ -7163,6 +7162,72 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/engine.io-client": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-client/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io-client/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/engine.io-client/node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
"version": "5.18.3", "version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
@@ -7448,7 +7513,6 @@
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1", "@eslint-community/regexpp": "^4.6.1",
@@ -10314,7 +10378,6 @@
"resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz", "resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz",
"integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==", "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jest/core": "^27.5.1", "@jest/core": "^27.5.1",
"import-local": "^3.0.2", "import-local": "^3.0.2",
@@ -11200,7 +11263,6 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
@@ -12621,7 +12683,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -13756,7 +13817,6 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cssesc": "^3.0.0", "cssesc": "^3.0.0",
"util-deprecate": "^1.0.2" "util-deprecate": "^1.0.2"
@@ -14089,7 +14149,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -14240,7 +14299,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.0" "scheduler": "^0.23.0"
@@ -14275,7 +14333,6 @@
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
"integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==", "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -14769,7 +14826,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz",
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"rollup": "dist/bin/rollup" "rollup": "dist/bin/rollup"
}, },
@@ -15015,7 +15071,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -15392,6 +15447,80 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-client/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-client/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/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-parser/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/sockjs": { "node_modules/sockjs": {
"version": "0.3.24", "version": "0.3.24",
"resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz",
@@ -16744,7 +16873,6 @@
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"license": "(MIT OR CC0-1.0)", "license": "(MIT OR CC0-1.0)",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@@ -17178,7 +17306,6 @@
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz",
"integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/eslint-scope": "^3.7.7", "@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8", "@types/estree": "^1.0.8",
@@ -17250,7 +17377,6 @@
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz", "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz",
"integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==", "integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/bonjour": "^3.5.9", "@types/bonjour": "^3.5.9",
"@types/connect-history-api-fallback": "^1.3.5", "@types/connect-history-api-fallback": "^1.3.5",
@@ -17660,7 +17786,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -18012,6 +18137,14 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/y18n": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@@ -19,6 +19,7 @@
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-router-dom": "^7.9.5", "react-router-dom": "^7.9.5",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"socket.io-client": "^4.8.3",
"uuid": "^11.0.3", "uuid": "^11.0.3",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
}, },
@@ -29,6 +30,7 @@
"start": "react-scripts start", "start": "react-scripts start",
"build": "react-scripts build", "build": "react-scripts build",
"test": "react-scripts test", "test": "react-scripts test",
"check:foodsharing-api": "node scripts/check-foodsharing-endpoints.js",
"eject": "react-scripts eject" "eject": "react-scripts eject"
}, },
"eslintConfig": { "eslintConfig": {

View File

@@ -1,8 +1,6 @@
#/bin/bash #!/bin/bash
# Bauen Sie die React-App neu set -euo pipefail
npm run build
# Erzwingen Sie einen Neubau des Docker-Images # Nach relevanten Backend-, API-, Dockerfile-, Compose- oder Frontend-Änderungen
docker compose down # den Container mit dem aktuellen Stand neu bauen und starten.
docker compose build --no-cache docker compose up -d --build
docker compose up -d

View File

@@ -0,0 +1,172 @@
#!/usr/bin/env node
const https = require('https');
const API_DOC_URL = 'https://foodsharing.de/api/doc';
const EXPECTED_ENDPOINTS = [
{
path: '/api/login',
method: 'post',
requestRef: '#/components/schemas/LoginRequest',
responseCodes: ['200', '401', '403', '409']
},
{
path: '/api/users/current/details',
method: 'get',
responseRef: '#/components/schemas/ProfileDetails',
responseCodes: ['200', '401']
},
{
path: '/api/users/{userId}/stores',
method: 'get',
responseCodes: ['200', '400', '401', '403', '404']
},
{
path: '/api/stores/{storeId}/pickups',
method: 'get',
responseCodes: ['200', '401', '403']
},
{
path: '/api/regions/{regionId}/stores',
method: 'get',
responseCodes: ['200', '401', '403']
},
{
path: '/api/stores/{storeId}/details',
method: 'get',
responseRef: '#/components/schemas/Store',
responseCodes: ['200', '401', '403', '404']
},
{
path: '/api/stores/{storeId}/members',
method: 'get',
responseCodes: ['200', '401', '403', '404']
},
{
path: '/api/stores/{storeId}/regular-pickups',
method: 'get',
responseCodes: ['200', '401', '403', '404']
},
{
path: '/api/users/{userId}/pickups/registered',
method: 'get',
responseCodes: ['200', '401', '403']
},
{
path: '/api/stores/{storeId}/pickups/{pickupDate}/eligibility',
method: 'get',
responseCodes: ['200', '401']
},
{
path: '/api/stores/{storeId}/pickups/{pickupDate}/users/current',
method: 'post',
responseCodes: ['200', '401', '403']
},
{
path: '/api/conversations',
method: 'get',
responseCodes: ['200', '401']
}
];
function fetchText(url) {
return new Promise((resolve, reject) => {
https
.get(url, (res) => {
if (res.statusCode && res.statusCode >= 400) {
reject(new Error(`HTTP ${res.statusCode} for ${url}`));
res.resume();
return;
}
let data = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => resolve(data));
})
.on('error', reject);
});
}
function extractSpec(html) {
const match = html.match(/<script id="swagger-data" type="application\/json">([\s\S]*?)<\/script>/);
if (!match) {
throw new Error('Swagger spec konnte in /api/doc nicht gefunden werden.');
}
const payload = JSON.parse(match[1]);
if (!payload?.spec?.paths) {
throw new Error('OpenAPI-Spec in /api/doc ist unvollständig.');
}
return payload.spec;
}
function schemaRefFromResponse(operation) {
return operation?.responses?.['200']?.content?.['application/json']?.schema?.$ref || null;
}
function schemaRefFromRequest(operation) {
return operation?.requestBody?.content?.['application/json']?.schema?.$ref || null;
}
function validateOperation(spec, expectation) {
const operation = spec.paths?.[expectation.path]?.[expectation.method];
if (!operation) {
return [`Fehlt: ${expectation.method.toUpperCase()} ${expectation.path}`];
}
const issues = [];
if (expectation.requestRef) {
const actualRef = schemaRefFromRequest(operation);
if (actualRef !== expectation.requestRef) {
issues.push(
`Unerwartetes Request-Schema bei ${expectation.method.toUpperCase()} ${expectation.path}: ${actualRef || 'keins'}`
);
}
}
if (expectation.responseRef) {
const actualRef = schemaRefFromResponse(operation);
if (actualRef !== expectation.responseRef) {
issues.push(
`Unerwartetes 200-Schema bei ${expectation.method.toUpperCase()} ${expectation.path}: ${actualRef || 'keins'}`
);
}
}
const actualCodes = Object.keys(operation.responses || {}).sort();
const expectedCodes = [...expectation.responseCodes].sort();
if (actualCodes.join(',') !== expectedCodes.join(',')) {
issues.push(
`Unerwartete Response-Codes bei ${expectation.method.toUpperCase()} ${expectation.path}: erwartet ${expectedCodes.join(
','
)}, bekommen ${actualCodes.join(',')}`
);
}
return issues;
}
async function main() {
const html = await fetchText(API_DOC_URL);
const spec = extractSpec(html);
const issues = EXPECTED_ENDPOINTS.flatMap((endpoint) => validateOperation(spec, endpoint));
if (issues.length > 0) {
console.error('Foodsharing-API-Doku stimmt nicht mit den erwarteten Endpunkten ueberein:');
issues.forEach((issue) => console.error(`- ${issue}`));
process.exit(1);
}
console.log(`Geprueft: ${EXPECTED_ENDPOINTS.length} Foodsharing-Endpunkte laut ${API_DOC_URL}`);
EXPECTED_ENDPOINTS.forEach((endpoint) => {
console.log(`- ${endpoint.method.toUpperCase()} ${endpoint.path}`);
});
}
main().catch((error) => {
console.error(error.message);
process.exit(1);
});

149
server.js
View File

@@ -20,6 +20,7 @@ const { readNotificationSettings, writeNotificationSettings } = require('./servi
const notificationService = require('./services/notificationService'); const notificationService = require('./services/notificationService');
const { readStoreWatch, writeStoreWatch, listWatcherProfiles } = require('./services/storeWatchStore'); const { readStoreWatch, writeStoreWatch, listWatcherProfiles } = require('./services/storeWatchStore');
const { readPreferences, writePreferences, sanitizeLocation } = require('./services/userPreferencesStore'); const { readPreferences, writePreferences, sanitizeLocation } = require('./services/userPreferencesStore');
const { readStoresCache, writeStoresCache } = require('./services/storeCacheStore');
const requestLogStore = require('./services/requestLogStore'); const requestLogStore = require('./services/requestLogStore');
const { const {
readJournal, readJournal,
@@ -28,12 +29,14 @@ const {
deleteJournalImage, deleteJournalImage,
getProfileImageDir getProfileImageDir
} = require('./services/journalStore'); } = require('./services/journalStore');
const { normalizeReminderReference } = require('./services/journalReminderUtils');
const { withSessionRetry } = require('./services/sessionRefresh'); const { withSessionRetry } = require('./services/sessionRefresh');
const { const {
getStoreStatus: getCachedStoreStatusEntry, getStoreStatus: getCachedStoreStatusEntry,
setStoreStatus: setCachedStoreStatusEntry, setStoreStatus: setCachedStoreStatusEntry,
persistStoreStatusCache persistStoreStatusCache
} = require('./services/storeStatusCache'); } = require('./services/storeStatusCache');
const maintenanceMode = require('./services/maintenanceMode');
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000; const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
const adminEmail = (process.env.ADMIN_EMAIL || '').toLowerCase(); const adminEmail = (process.env.ADMIN_EMAIL || '').toLowerCase();
@@ -52,6 +55,36 @@ let storeLocationIndexUpdatedAt = 0;
const STORE_LOCATION_INDEX_TTL_MS = 12 * 60 * 60 * 1000; const STORE_LOCATION_INDEX_TTL_MS = 12 * 60 * 60 * 1000;
const BACKGROUND_STORE_REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000; const BACKGROUND_STORE_REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000;
function maintenanceSummary() {
const snapshot = maintenanceMode.getSnapshot();
return {
active: snapshot.active,
activatedAt: snapshot.activatedAt,
reason: snapshot.reason
};
}
function normalizePickupEntry(pickup) {
if (!pickup || typeof pickup !== 'object') {
return null;
}
const date = pickup.date || pickup.pickupDate || null;
const parsedDate = date ? new Date(date) : null;
if (!parsedDate || Number.isNaN(parsedDate.getTime())) {
return null;
}
const store = pickup.store && typeof pickup.store === 'object' ? pickup.store : {};
const storeId = store.id != null ? String(store.id) : pickup.storeId != null ? String(pickup.storeId) : '';
return {
date: parsedDate.toISOString(),
storeId,
storeName: store.name || pickup.storeName || (storeId ? `Store ${storeId}` : 'Unbekannter Betrieb'),
description: pickup.description || '',
slots: Number.isFinite(Number(pickup.slots)) ? Number(pickup.slots) : null,
isConfirmed: pickup.isConfirmed === true
};
}
function toRadians(value) { function toRadians(value) {
return (value * Math.PI) / 180; return (value * Math.PI) / 180;
} }
@@ -194,6 +227,7 @@ function rescheduleAllSessions() {
function mergeStoresIntoConfig(config = [], stores = []) { function mergeStoresIntoConfig(config = [], stores = []) {
const entries = Array.isArray(config) ? config : []; const entries = Array.isArray(config) ? config : [];
const storeList = Array.isArray(stores) ? stores : [];
const map = new Map(); const map = new Map();
entries.forEach((entry) => { entries.forEach((entry) => {
if (!entry || !entry.id) { if (!entry || !entry.id) {
@@ -203,7 +237,7 @@ function mergeStoresIntoConfig(config = [], stores = []) {
}); });
let changed = false; let changed = false;
stores.forEach((store) => { storeList.forEach((store) => {
if (!store?.id) { if (!store?.id) {
return; return;
} }
@@ -229,6 +263,20 @@ function mergeStoresIntoConfig(config = [], stores = []) {
} }
}); });
const activeStoreIds = new Set(
storeList
.filter((store) => store?.id)
.map((store) => String(store.id))
);
Array.from(map.keys()).forEach((id) => {
if (activeStoreIds.has(id)) {
return;
}
map.delete(id);
changed = true;
});
return { merged: Array.from(map.values()), changed }; return { merged: Array.from(map.values()), changed };
} }
@@ -264,6 +312,16 @@ function getCachedStoreStatus(storeId) {
return getCachedStoreStatusEntry(storeId); return getCachedStoreStatusEntry(storeId);
} }
function resolveStoreTeamStatus(details) {
const status = Number(details?.teamStatus ?? details?.teamSearchStatus);
return Number.isFinite(status) ? status : null;
}
function isStoreStatusAccessError(error) {
const status = error?.response?.status;
return status === 403 || status === 404;
}
function normalizeJournalReminder(reminder = {}) { function normalizeJournalReminder(reminder = {}) {
const unit = ['days', 'weeks', 'months'].includes(reminder.beforeUnit) ? reminder.beforeUnit : 'days'; const unit = ['days', 'weeks', 'months'].includes(reminder.beforeUnit) ? reminder.beforeUnit : 'days';
const parsedBeforeValue = Number(reminder.beforeValue); const parsedBeforeValue = Number(reminder.beforeValue);
@@ -274,14 +332,18 @@ function normalizeJournalReminder(reminder = {}) {
? Math.max(0, parsedDaysBefore) ? Math.max(0, parsedDaysBefore)
: 6; : 6;
const daysBefore = unit === 'weeks' ? beforeValue * 7 : unit === 'months' ? beforeValue * 30 : beforeValue; const daysBefore = unit === 'weeks' ? beforeValue * 7 : unit === 'months' ? beforeValue * 30 : beforeValue;
const reference = normalizeReminderReference(reminder.reference);
return { return {
enabled: !!reminder.enabled, enabled: !!reminder.enabled,
interval: ['monthly', 'quarterly', 'yearly'].includes(reminder.interval) interval: reference.mode === 'event'
? 'yearly'
: ['monthly', 'quarterly', 'yearly'].includes(reminder.interval)
? reminder.interval ? reminder.interval
: 'yearly', : 'yearly',
beforeUnit: unit, beforeUnit: unit,
beforeValue, beforeValue,
daysBefore daysBefore,
reference
}; };
} }
@@ -469,13 +531,12 @@ async function refreshStoreStatus(
continue; continue;
} }
try { try {
const details = await withSessionRetry( const marker = await withSessionRetry(
session, session,
() => foodsharingClient.fetchStoreDetails(storeId, session.cookieHeader, session), () => foodsharingClient.fetchStoreMarker(storeId, session.cookieHeader, session),
{ label: 'fetchStoreDetails' } { label: 'fetchStoreMarker' }
); );
const status = Number(details?.teamSearchStatus); const normalized = resolveStoreTeamStatus(marker);
const normalized = Number.isFinite(status) ? status : null;
const previous = entry ? entry.teamSearchStatus : null; const previous = entry ? entry.teamSearchStatus : null;
setCachedStoreStatusEntry(storeId, { setCachedStoreStatusEntry(storeId, {
teamSearchStatus: normalized, teamSearchStatus: normalized,
@@ -494,6 +555,14 @@ async function refreshStoreStatus(
} }
refreshed += 1; refreshed += 1;
} catch (error) { } catch (error) {
if (isStoreStatusAccessError(error)) {
setCachedStoreStatusEntry(storeId, {
teamSearchStatus: null,
fetchedAt: now
});
refreshed += 1;
continue;
}
console.error(`[STORE-STATUS] Status für Store ${storeId} konnte nicht aktualisiert werden:`, error.message); console.error(`[STORE-STATUS] Status für Store ${storeId} konnte nicht aktualisiert werden:`, error.message);
} }
} }
@@ -603,6 +672,9 @@ function triggerStoreRefresh(session, { force = false, reason } = {}) {
if (!session?.id) { if (!session?.id) {
return { started: false }; return { started: false };
} }
if (maintenanceMode.isActive()) {
return { started: false, maintenance: true, maintenanceStatus: maintenanceSummary() };
}
const existing = getStoreRefreshJob(session.id); const existing = getStoreRefreshJob(session.id);
if (existing && existing.status === 'running') { if (existing && existing.status === 'running') {
return { started: false, job: existing }; return { started: false, job: existing };
@@ -636,6 +708,7 @@ function triggerStoreRefresh(session, { force = false, reason } = {}) {
} }
async function runStoreRefreshJob(session, job) { async function runStoreRefreshJob(session, job) {
maintenanceMode.ensureInactive('Store-Refresh ist während des Wartungsmodus deaktiviert.');
job.status = 'running'; job.status = 'running';
job.startedAt = Date.now(); job.startedAt = Date.now();
const settings = adminConfig.readSettings(); const settings = adminConfig.readSettings();
@@ -657,6 +730,9 @@ async function runStoreRefreshJob(session, job) {
), ),
{ label: 'fetchStores' } { label: 'fetchStores' }
); );
if (stores.length === 0) {
throw new Error('Store-Refresh lieferte keine Betriebe; bestehende Daten bleiben unverändert.');
}
job.processed = stores.length; job.processed = stores.length;
job.total = stores.length; job.total = stores.length;
job.currentStore = null; job.currentStore = null;
@@ -664,6 +740,7 @@ async function runStoreRefreshJob(session, job) {
sessionStore.update(session.id, { sessionStore.update(session.id, {
storesCache: { data: stores, fetchedAt: Date.now() } storesCache: { data: stores, fetchedAt: Date.now() }
}); });
writeStoresCache(session.profile.id, sessionStore.get(session.id)?.storesCache);
let config = readConfig(session.profile.id); let config = readConfig(session.profile.id);
const { merged, changed } = mergeStoresIntoConfig(config, stores); const { merged, changed } = mergeStoresIntoConfig(config, stores);
@@ -761,13 +838,14 @@ async function restoreSessionsFromDisk() {
credentials, credentials,
isAdmin: isAdminUser isAdmin: isAdminUser
}, credentials.token, ONE_YEAR_MS); }, credentials.token, ONE_YEAR_MS);
const persistedStoresCache = readStoresCache(profile.id);
credentialStore.save(profile.id, { credentialStore.save(profile.id, {
email: credentials.email, email: credentials.email,
password: credentials.password, password: credentials.password,
token: session.id token: session.id
}); });
sessionStore.update(session.id, { sessionStore.update(session.id, {
storesCache: sessionStore.get(session.id)?.storesCache || null storesCache: persistedStoresCache
}); });
scheduleConfig(session.id, config, schedulerSettings); scheduleConfig(session.id, config, schedulerSettings);
triggerStoreRefresh(sessionStore.get(session.id), { force: true, reason: 'restore' }); triggerStoreRefresh(sessionStore.get(session.id), { force: true, reason: 'restore' });
@@ -850,6 +928,11 @@ app.post('/api/auth/login', async (req, res) => {
} else if (cachedStoreSnapshots.has(profile.id)) { } else if (cachedStoreSnapshots.has(profile.id)) {
sessionStore.update(session.id, { storesCache: cachedStoreSnapshots.get(profile.id) }); sessionStore.update(session.id, { storesCache: cachedStoreSnapshots.get(profile.id) });
cachedStoreSnapshots.delete(profile.id); cachedStoreSnapshots.delete(profile.id);
} else {
const persistedStoresCache = readStoresCache(profile.id);
if (persistedStoresCache) {
sessionStore.update(session.id, { storesCache: persistedStoresCache });
}
} }
const currentSession = sessionStore.get(session.id); const currentSession = sessionStore.get(session.id);
const needsRefresh = !isStoreCacheFresh(currentSession); const needsRefresh = !isStoreCacheFresh(currentSession);
@@ -1028,6 +1111,7 @@ app.post('/api/store-watch/subscriptions', requireAuth, (req, res) => {
app.post('/api/store-watch/check', requireAuth, async (req, res) => { app.post('/api/store-watch/check', requireAuth, async (req, res) => {
try { try {
maintenanceMode.ensureInactive('Ad-hoc-Store-Watch ist während des Wartungsmodus deaktiviert.');
const settings = adminConfig.readSettings(); const settings = adminConfig.readSettings();
const summary = await runStoreWatchCheck(req.session.id, settings, { const summary = await runStoreWatchCheck(req.session.id, settings, {
sendSummary: true, sendSummary: true,
@@ -1035,6 +1119,12 @@ app.post('/api/store-watch/check', requireAuth, async (req, res) => {
}); });
res.json({ success: true, stores: Array.isArray(summary) ? summary : [] }); res.json({ success: true, stores: Array.isArray(summary) ? summary : [] });
} catch (error) { } catch (error) {
if (error?.code === 'MAINTENANCE_MODE_ACTIVE') {
return res.status(error.statusCode || 503).json({
error: error.message,
maintenance: maintenanceSummary()
});
}
console.error('[STORE-WATCH] Ad-hoc-Prüfung fehlgeschlagen:', error.message); console.error('[STORE-WATCH] Ad-hoc-Prüfung fehlgeschlagen:', error.message);
res.status(500).json({ error: 'Ad-hoc-Prüfung fehlgeschlagen' }); res.status(500).json({ error: 'Ad-hoc-Prüfung fehlgeschlagen' });
} }
@@ -1231,6 +1321,7 @@ app.put('/api/journal/:id', requireAuth, (req, res) => {
} }
const pickupDateChanged = existing.pickupDate !== pickupDate; const pickupDateChanged = existing.pickupDate !== pickupDate;
const reminderChanged = JSON.stringify(existing.reminder || {}) !== JSON.stringify(normalizedReminder);
const updated = { const updated = {
...existing, ...existing,
storeId: String(storeId), storeId: String(storeId),
@@ -1240,7 +1331,7 @@ app.put('/api/journal/:id', requireAuth, (req, res) => {
reminder: normalizedReminder, reminder: normalizedReminder,
images: collectedImages, images: collectedImages,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
lastReminderAt: pickupDateChanged ? null : existing.lastReminderAt lastReminderAt: pickupDateChanged || reminderChanged ? null : existing.lastReminderAt
}; };
entries[index] = updated; entries[index] = updated;
@@ -1296,12 +1387,24 @@ app.post('/api/config', requireAuth, (req, res) => {
if (!Array.isArray(req.body)) { if (!Array.isArray(req.body)) {
return res.status(400).json({ error: 'Konfiguration muss ein Array sein' }); return res.status(400).json({ error: 'Konfiguration muss ein Array sein' });
} }
const currentConfig = readConfig(req.session.profile.id);
if (req.body.length === 0 && currentConfig.length > 0) {
return res.status(400).json({
error: 'Leere Konfiguration wurde nicht gespeichert, weil bereits Einträge vorhanden sind.'
});
}
writeConfig(req.session.profile.id, req.body); writeConfig(req.session.profile.id, req.body);
scheduleWithCurrentSettings(req.session.id, req.body); scheduleWithCurrentSettings(req.session.id, req.body);
res.json({ success: true }); res.json({ success: true });
}); });
app.post('/api/config/check', requireAuth, (req, res) => { app.post('/api/config/check', requireAuth, (req, res) => {
if (maintenanceMode.isActive()) {
return res.status(503).json({
error: 'Sofortprüfung ist während des Wartungsmodus deaktiviert.',
maintenance: maintenanceSummary()
});
}
const config = readConfig(req.session.profile.id); const config = readConfig(req.session.profile.id);
const settings = adminConfig.readSettings(); const settings = adminConfig.readSettings();
runImmediatePickupCheck(req.session.id, config, settings).catch((error) => { runImmediatePickupCheck(req.session.id, config, settings).catch((error) => {
@@ -1310,6 +1413,22 @@ app.post('/api/config/check', requireAuth, (req, res) => {
res.json({ success: true }); res.json({ success: true });
}); });
app.get('/api/pickups/registered', requireAuth, async (req, res) => {
try {
const pickups = await withSessionRetry(
req.session,
() => foodsharingClient.fetchRegisteredPickups('current', req.session.cookieHeader, req.session),
{ label: 'fetchRegisteredPickups' }
);
res.json({
pickups: (Array.isArray(pickups) ? pickups : []).map(normalizePickupEntry).filter(Boolean)
});
} catch (error) {
console.error('[PICKUP] Eingetragene Pickups konnten nicht geladen werden:', error.message);
res.status(502).json({ error: 'Eingetragene Foodsharing-Slots konnten nicht geladen werden.' });
}
});
app.get('/api/notifications/settings', requireAuth, (req, res) => { app.get('/api/notifications/settings', requireAuth, (req, res) => {
const userSettings = readNotificationSettings(req.session.profile.id); const userSettings = readNotificationSettings(req.session.profile.id);
const adminSettings = adminConfig.readSettings(); const adminSettings = adminConfig.readSettings();
@@ -1381,13 +1500,21 @@ app.get('/api/stores', requireAuth, async (req, res) => {
}); });
app.post('/api/stores/refresh', requireAuth, (req, res) => { app.post('/api/stores/refresh', requireAuth, (req, res) => {
if (maintenanceMode.isActive()) {
return res.status(503).json({
started: false,
error: 'Store-Refresh ist während des Wartungsmodus deaktiviert.',
maintenance: maintenanceSummary()
});
}
const force = req.body?.force !== undefined ? !!req.body.force : true; const force = req.body?.force !== undefined ? !!req.body.force : true;
const reason = req.body?.reason || 'manual'; const reason = req.body?.reason || 'manual';
const result = triggerStoreRefresh(req.session, { force, reason }); const result = triggerStoreRefresh(req.session, { force, reason });
res.json({ res.json({
started: !!result.started, started: !!result.started,
storesFresh: isStoreCacheFresh(req.session), storesFresh: isStoreCacheFresh(req.session),
job: summarizeJob(result.job) job: summarizeJob(result.job),
maintenance: result.maintenanceStatus || null
}); });
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

89
services/jsonFileStore.js Normal file
View File

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

135
services/maintenanceMode.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,6 +40,7 @@ function App() {
const [focusedStoreId, setFocusedStoreId] = useState(null); const [focusedStoreId, setFocusedStoreId] = useState(null);
const [nearestStoreLabel, setNearestStoreLabel] = useState(null); const [nearestStoreLabel, setNearestStoreLabel] = useState(null);
const [regularPickupMap, setRegularPickupMap] = useState({}); const [regularPickupMap, setRegularPickupMap] = useState({});
const [registeredPickups, setRegisteredPickups] = useState([]);
const bootstrapOnceRef = useRef(false); const bootstrapOnceRef = useRef(false);
const minSelectableDate = useMemo(() => startOfDay(new Date()), []); const minSelectableDate = useMemo(() => startOfDay(new Date()), []);
@@ -150,6 +151,19 @@ function App() {
finishSyncProgress finishSyncProgress
}); });
useEffect(() => {
if (!session?.token || isDirty || initializing) {
return undefined;
}
fetchConfig(session.token, { silent: true });
const intervalId = setInterval(() => {
fetchConfig(session.token, { silent: true });
}, 60 * 1000);
return () => {
clearInterval(intervalId);
};
}, [fetchConfig, initializing, isDirty, session?.token]);
useEffect(() => { useEffect(() => {
let aborted = false; let aborted = false;
async function lookupNearestStore() { async function lookupNearestStore() {
@@ -244,6 +258,7 @@ function App() {
const resetSessionState = useCallback(() => { const resetSessionState = useCallback(() => {
setConfig([]); setConfig([]);
setStores([]); setStores([]);
setRegisteredPickups([]);
setStatus(''); setStatus('');
setError(''); setError('');
clearAdminSettings(); clearAdminSettings();
@@ -252,6 +267,7 @@ function App() {
}, [ }, [
setConfig, setConfig,
setStores, setStores,
setRegisteredPickups,
setStatus, setStatus,
setError, setError,
clearAdminSettings, clearAdminSettings,
@@ -282,6 +298,38 @@ function App() {
sessionToken: session?.token sessionToken: session?.token
}); });
const loadRegisteredPickups = useCallback(
async (tokenOverride) => {
const tokenToUse = tokenOverride || session?.token;
if (!tokenToUse) {
setRegisteredPickups([]);
return [];
}
try {
const response = await authorizedFetch('/api/pickups/registered', {}, tokenToUse);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const pickups = Array.isArray(data?.pickups) ? data.pickups : [];
setRegisteredPickups(pickups);
return pickups;
} catch (error) {
console.warn('Eingetragene Foodsharing-Slots konnten nicht geladen werden:', error.message);
setRegisteredPickups([]);
return [];
}
},
[authorizedFetch, session?.token]
);
useEffect(() => {
if (!session?.token || initializing) {
return;
}
loadRegisteredPickups(session.token);
}, [initializing, loadRegisteredPickups, session?.token]);
const handleLogin = async (event) => { const handleLogin = async (event) => {
event.preventDefault(); event.preventDefault();
setLoading(true); setLoading(true);
@@ -319,6 +367,7 @@ function App() {
tokenOverride: data.token tokenOverride: data.token
}); });
} }
await loadRegisteredPickups(data.token);
updateSyncProgress('Login abgeschlossen', 98); updateSyncProgress('Login abgeschlossen', 98);
setStatus('Anmeldung erfolgreich. Konfiguration geladen.'); setStatus('Anmeldung erfolgreich. Konfiguration geladen.');
setTimeout(() => setStatus(''), 3000); setTimeout(() => setStatus(''), 3000);
@@ -395,6 +444,7 @@ function App() {
tokenOverride: storedToken tokenOverride: storedToken
}); });
} }
await loadRegisteredPickups(storedToken);
} }
finishSyncProgress(); finishSyncProgress();
} catch (err) { } catch (err) {
@@ -415,6 +465,7 @@ function App() {
bootstrapSession, bootstrapSession,
finishSyncProgress, finishSyncProgress,
getStoredToken, getStoredToken,
loadRegisteredPickups,
nudgeSyncProgress, nudgeSyncProgress,
startSyncProgress, startSyncProgress,
syncStoresWithProgress, syncStoresWithProgress,
@@ -672,6 +723,14 @@ function App() {
return { ...preferences.location }; return { ...preferences.location };
}, [preferences?.location, nearestStoreLabel]); }, [preferences?.location, nearestStoreLabel]);
const handleRefreshStoresAndPickups = useCallback(
async (options) => {
await refreshStoresAndConfig(options);
await loadRegisteredPickups();
},
[loadRegisteredPickups, refreshStoresAndConfig]
);
const sharedNotificationProps = { const sharedNotificationProps = {
error: notificationError, error: notificationError,
message: notificationMessage, message: notificationMessage,
@@ -736,7 +795,7 @@ function App() {
const dashboardContent = ( const dashboardContent = (
<DashboardView <DashboardView
session={session} session={session}
onRefresh={refreshStoresAndConfig} onRefresh={handleRefreshStoresAndPickups}
onLogout={handleLogout} onLogout={handleLogout}
notificationPanelOpen={notificationPanelOpen} notificationPanelOpen={notificationPanelOpen}
onToggleNotificationPanel={() => setNotificationPanelOpen((prev) => !prev)} onToggleNotificationPanel={() => setNotificationPanelOpen((prev) => !prev)}
@@ -752,6 +811,7 @@ function App() {
status={status} status={status}
visibleConfig={visibleConfig} visibleConfig={visibleConfig}
config={config} config={config}
registeredPickups={registeredPickups}
onToggleActive={handleToggleActive} onToggleActive={handleToggleActive}
onToggleProfileCheck={handleToggleProfileCheck} onToggleProfileCheck={handleToggleProfileCheck}
onToggleOnlyNotify={handleToggleOnlyNotify} onToggleOnlyNotify={handleToggleOnlyNotify}
@@ -852,6 +912,8 @@ function App() {
<RangePickerModal <RangePickerModal
entry={activeRangeEntry} entry={activeRangeEntry}
minDate={minSelectableDate} minDate={minSelectableDate}
config={config}
registeredPickups={registeredPickups}
onSelectRange={(startDate, endDate) => onSelectRange={(startDate, endDate) =>
activeRangeEntry ? handleDateRangeSelection(activeRangeEntry.id, startDate, endDate) : null activeRangeEntry ? handleDateRangeSelection(activeRangeEntry.id, startDate, endDate) : null
} }

View File

@@ -207,7 +207,7 @@ const AdminSettingsPanel = ({
<SettingSection <SettingSection
title="Store-Watch Monitoring" title="Store-Watch Monitoring"
subtitle="Steuert die Team-Status-Abfragen für geschlossene Betriebe (teamSearchStatus)." subtitle="Steuert die Team-Status-Abfragen für geschlossene Betriebe (Foodsharing-Feld `teamStatus`)."
> >
<SettingField <SettingField
label="Store-Watch Cron" label="Store-Watch Cron"
@@ -281,6 +281,152 @@ const AdminSettingsPanel = ({
</SettingField> </SettingField>
</SettingSection> </SettingSection>
<SettingSection
title="Nachrichten-Watcher"
subtitle="Reagiert auf Betriebs-Teamnachrichten, um danach gezielt nur den betroffenen Pickup-Store zu prüfen."
>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 text-sm text-blue-900">
<p className="font-semibold">Hybrid-Modus</p>
<p>
Socket.IO ist der Primärweg mit sehr wenig Requests. Der Fallback pollt Konversationen nur,
wenn der Socket nicht verbunden ist.
</p>
</div>
<SettingField
label="Socket.IO aktiv"
description="Hält eine dauerhafte Verbindung zu Foodsharing offen und reagiert auf Push-Events. Das reduziert REST-Polling."
>
<label className="inline-flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
checked={adminSettings.messageWatcherSocketEnabled || false}
onChange={(event) => onSettingChange('messageWatcherSocketEnabled', event.target.checked)}
className="h-4 w-4 rounded text-purple-600 focus:ring-purple-500"
/>
<span>Socket-Verbindung verwenden</span>
</label>
</SettingField>
<SettingField
label="Fallback-Poll aktiv"
description="Prüft Konversationen nur zum Cron-Zeitpunkt und nur, wenn der Socket getrennt ist. Sollte für die Anfangsphase aktiv bleiben."
>
<label className="inline-flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
checked={adminSettings.messageWatcherFallbackEnabled || false}
onChange={(event) => onSettingChange('messageWatcherFallbackEnabled', event.target.checked)}
className="h-4 w-4 rounded text-purple-600 focus:ring-purple-500"
/>
<span>Fallback-Polling verwenden</span>
</label>
</SettingField>
<SettingField
label="Fallback-Poll Cron"
description="Nur relevant, wenn der Socket nicht verbunden ist. Standard: stündlich tagsüber von 6 bis 23 Uhr."
>
<input
type="text"
value={adminSettings.messageWatcherFallbackCron || ''}
onChange={(event) => onSettingChange('messageWatcherFallbackCron', event.target.value)}
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="z. B. 0 6-23 * * *"
/>
</SettingField>
<SettingField
label="Debug-Logging aktiv"
description="Schreibt in der Anfangsphase strukturierte Watcher-Logs in die Debug-Request-Logs: Start, Socket-Status, Events, Fallback-Polls und Trigger."
>
<label className="inline-flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
checked={adminSettings.messageWatcherDebugLogging || false}
onChange={(event) => onSettingChange('messageWatcherDebugLogging', event.target.checked)}
className="h-4 w-4 rounded text-purple-600 focus:ring-purple-500"
/>
<span>Ausführlich loggen</span>
</label>
</SettingField>
</SettingSection>
<SettingSection
title="Wartungsmodus"
subtitle="Pausiert automatische Foodsharing-Abläufe, wenn gehäuft systemische API-Fehler auftreten."
>
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<p className="font-semibold">
Status: {adminSettings.maintenanceModeActive ? 'Aktiv' : 'Inaktiv'}
</p>
{adminSettings.maintenanceModeActivatedAt && (
<p>Aktiv seit: {new Date(adminSettings.maintenanceModeActivatedAt).toLocaleString('de-DE')}</p>
)}
{adminSettings.maintenanceModeReason && <p>Grund: {adminSettings.maintenanceModeReason}</p>}
</div>
<SettingField
label="Wartungsmodus aktiv"
description="Kann manuell deaktiviert werden. Bei weiter anhaltenden API-Fehlern wird er erneut automatisch aktiviert."
>
<label className="inline-flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
checked={adminSettings.maintenanceModeActive || false}
onChange={(event) => onSettingChange('maintenanceModeActive', event.target.checked)}
className="h-4 w-4 rounded text-purple-600 focus:ring-purple-500"
/>
<span>Aktiv</span>
</label>
</SettingField>
<SettingField
label="Automatische Aktivierung"
description="Aktiviert den Wartungsmodus automatisch bei gehäuften relevanten Outbound-Fehlern wie 429, Timeout oder 5xx."
>
<label className="inline-flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
checked={adminSettings.maintenanceModeAutoEnabled || false}
onChange={(event) => onSettingChange('maintenanceModeAutoEnabled', event.target.checked)}
className="h-4 w-4 rounded text-purple-600 focus:ring-purple-500"
/>
<span>Automatisch aktivieren</span>
</label>
</SettingField>
<SettingField
label="Fehlerschwelle"
description="Ab wie vielen relevanten Outbound-Fehlern innerhalb des Fensters der Wartungsmodus automatisch aktiviert wird."
>
<input
type="number"
min="1"
value={adminSettings.maintenanceModeErrorThreshold}
onChange={(event) => onSettingChange('maintenanceModeErrorThreshold', event.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="z. B. 8"
/>
</SettingField>
<SettingField
label="Fehlerfenster (Minuten)"
description="Zeitfenster für die Auswertung relevanter Outbound-Fehler."
>
<input
type="number"
min="1"
value={adminSettings.maintenanceModeErrorWindowMinutes}
onChange={(event) =>
onSettingChange('maintenanceModeErrorWindowMinutes', event.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="z. B. 10"
/>
</SettingField>
</SettingSection>
<SettingSection <SettingSection
title="Ignorierte Slots" title="Ignorierte Slots"
subtitle="Diese Einträge überspringt der Scheduler dauerhaft (z. B. TVS-Zeiten oder Test-Slots)." subtitle="Diese Einträge überspringt der Scheduler dauerhaft (z. B. TVS-Zeiten oder Test-Slots)."

View File

@@ -13,6 +13,58 @@ import NotificationPanel from './NotificationPanel';
const CONFIG_TABLE_STATE_KEY = 'dashboardConfigTableState'; const CONFIG_TABLE_STATE_KEY = 'dashboardConfigTableState';
const columnHelper = createColumnHelper(); const columnHelper = createColumnHelper();
const formatDateKey = (value) => {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const addDays = (date, days) => {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
};
const formatPickupListTime = (value) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return 'Unbekannt';
}
const time = date.toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit'
});
const today = new Date();
const dateKey = formatDateKey(date);
if (dateKey === formatDateKey(today)) {
return `Heute ${time}`;
}
if (dateKey === formatDateKey(addDays(today, 1))) {
return `Morgen ${time}`;
}
const diffDays = Math.round((new Date(`${dateKey}T00:00:00`).getTime() - new Date(`${formatDateKey(today)}T00:00:00`).getTime()) / (24 * 60 * 60 * 1000));
if (diffDays > 1 && diffDays < 7) {
const weekday = date.toLocaleDateString('de-DE', { weekday: 'short' });
return `${weekday} ${time}`;
}
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
const compareByDate = (a, b) => {
const aTime = new Date(a.date).getTime();
const bTime = new Date(b.date).getTime();
return (Number.isNaN(aTime) ? 0 : aTime) - (Number.isNaN(bTime) ? 0 : bTime);
};
const ColumnTextFilter = ({ column, placeholder }) => { const ColumnTextFilter = ({ column, placeholder }) => {
if (!column.getCanFilter()) { if (!column.getCanFilter()) {
@@ -148,6 +200,7 @@ const DashboardView = ({
status, status,
visibleConfig, visibleConfig,
config, config,
registeredPickups,
onToggleActive, onToggleActive,
onToggleProfileCheck, onToggleProfileCheck,
onToggleOnlyNotify, onToggleOnlyNotify,
@@ -206,6 +259,38 @@ const DashboardView = ({
[visibleConfig] [visibleConfig]
); );
const upcomingRegisteredPickups = useMemo(
() =>
(Array.isArray(registeredPickups) ? registeredPickups : [])
.filter((pickup) => pickup?.date)
.slice()
.sort(compareByDate),
[registeredPickups]
);
const plannedPickupEntries = useMemo(
() =>
(Array.isArray(visibleConfig) ? visibleConfig : [])
.filter((entry) => entry?.active)
.map((entry) => {
const range = entry.desiredDateRange || (entry.desiredDate ? { start: entry.desiredDate, end: entry.desiredDate } : null);
let schedule = 'Aktiv ohne Zeitfenster';
if (range?.start || range?.end) {
schedule = formatRangeLabel(range.start || range.end || '', range.end || range.start || '');
} else if (entry.desiredWeekday) {
schedule = entry.desiredWeekday;
}
return {
id: entry.id,
label: entry.label || `Store ${entry.id}`,
schedule,
onlyNotify: !!entry.onlyNotify
};
})
.sort((a, b) => a.label.localeCompare(b.label, 'de-DE')),
[formatRangeLabel, visibleConfig]
);
const weekdaysOptions = useMemo( const weekdaysOptions = useMemo(
() => () =>
weekdays.map((day) => ({ weekdays.map((day) => ({
@@ -785,6 +870,61 @@ const DashboardView = ({
</div> </div>
)} )}
<div className="mb-4 grid gap-3 lg:grid-cols-2">
<section className="border border-gray-200 rounded-lg bg-gray-50 p-3">
<div className="flex items-center justify-between gap-3 mb-2">
<h2 className="text-sm font-semibold text-gray-800">Foodsharing-Slots</h2>
<span className="text-xs text-gray-500">{upcomingRegisteredPickups.length}</span>
</div>
{upcomingRegisteredPickups.length === 0 ? (
<p className="text-sm text-gray-500">Keine eingetragenen künftigen Slots gefunden.</p>
) : (
<ul className="space-y-1.5 max-h-40 overflow-y-auto">
{upcomingRegisteredPickups.slice(0, 8).map((pickup) => (
<li
key={`${pickup.storeId || 'store'}-${pickup.date}-${pickup.description || ''}`}
className="flex items-start justify-between gap-3 rounded bg-white px-2 py-1.5 text-sm"
>
<div className="min-w-0">
<p className="truncate font-medium text-gray-800">{pickup.storeName}</p>
<p className="truncate text-xs text-gray-500">{pickup.description || 'Abholslot'}</p>
</div>
<div className="shrink-0 text-right">
<p className="text-xs font-medium text-blue-700">{formatPickupListTime(pickup.date)}</p>
<p className="text-xs text-gray-500">{pickup.isConfirmed ? 'bestätigt' : 'offen'}</p>
</div>
</li>
))}
</ul>
)}
</section>
<section className="border border-gray-200 rounded-lg bg-gray-50 p-3">
<div className="flex items-center justify-between gap-3 mb-2">
<h2 className="text-sm font-semibold text-gray-800">Pickup-Manager geplant</h2>
<span className="text-xs text-gray-500">{plannedPickupEntries.length}</span>
</div>
{plannedPickupEntries.length === 0 ? (
<p className="text-sm text-gray-500">Keine aktiven Planungen im Pickup Manager.</p>
) : (
<ul className="space-y-1.5 max-h-40 overflow-y-auto">
{plannedPickupEntries.slice(0, 8).map((entry) => (
<li
key={entry.id}
className="flex items-center justify-between gap-3 rounded bg-white px-2 py-1.5 text-sm"
>
<p className="min-w-0 truncate font-medium text-gray-800">{entry.label}</p>
<div className="shrink-0 text-right">
<p className="text-xs font-medium text-emerald-700">{entry.schedule}</p>
{entry.onlyNotify && <p className="text-xs text-gray-500">nur Hinweis</p>}
</div>
</li>
))}
</ul>
)}
</section>
</div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full bg-white border border-gray-200"> <table className="min-w-full bg-white border border-gray-200">
<thead className="bg-gray-100"> <thead className="bg-gray-100">

View File

@@ -320,7 +320,7 @@ const DebugPage = ({ authorizedFetch }) => {
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div> <div>
<h1 className="text-2xl font-bold text-gray-800">Request-Debug</h1> <h1 className="text-2xl font-bold text-gray-800">Request-Debug</h1>
<p className="text-gray-500 text-sm">Alle Requests der letzten 14 Tage</p> <p className="text-gray-500 text-sm">Alle Requests der letzten 4 Wochen</p>
</div> </div>
<button <button
onClick={fetchLogs} onClick={fetchLogs}

View File

@@ -1,6 +1,14 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import ConfirmationDialog from './ConfirmationDialog'; import ConfirmationDialog from './ConfirmationDialog';
import './JournalPage.css'; import './JournalPage.css';
import {
formatReferenceLabel,
formatReminderOffset,
getNextReminderSchedule,
getReminderOffset,
getReminderReference,
referenceEventOptions
} from '../utils/journalReminder';
const intervalLabels = { const intervalLabels = {
monthly: 'Monatlich', monthly: 'Monatlich',
@@ -12,11 +20,6 @@ const reminderUnitLabels = {
weeks: 'Wochen', weeks: 'Wochen',
months: 'Monate' months: 'Monate'
}; };
const reminderUnitSingular = {
days: 'Tag',
weeks: 'Woche',
months: 'Monat'
};
const JournalPage = ({ authorizedFetch, stores }) => { const JournalPage = ({ authorizedFetch, stores }) => {
const [entries, setEntries] = useState([]); const [entries, setEntries] = useState([]);
@@ -53,7 +56,11 @@ const JournalPage = ({ authorizedFetch, stores }) => {
reminderEnabled: true, reminderEnabled: true,
reminderInterval: 'yearly', reminderInterval: 'yearly',
reminderBeforeValue: 6, reminderBeforeValue: 6,
reminderBeforeUnit: 'weeks' reminderBeforeUnit: 'weeks',
reminderReferenceMode: 'pickupDate',
reminderReferenceEventKey: 'easter_sunday',
reminderReferenceOffsetValue: 0,
reminderReferenceOffsetUnit: 'weeks'
}); });
const [images, setImages] = useState([]); const [images, setImages] = useState([]);
const [existingImages, setExistingImages] = useState([]); const [existingImages, setExistingImages] = useState([]);
@@ -89,12 +96,6 @@ const JournalPage = ({ authorizedFetch, stores }) => {
return map; return map;
}, [sortedStores, entries]); }, [sortedStores, entries]);
const storeFilterOptions = useMemo(() => {
const list = Array.from(storeLabelMap.entries()).map(([id, name]) => ({ id, name }));
list.sort((a, b) => a.name.localeCompare(b.name));
return list;
}, [storeLabelMap]);
const parseDateValue = useCallback((value) => { const parseDateValue = useCallback((value) => {
if (!value) { if (!value) {
return null; return null;
@@ -106,94 +107,66 @@ const JournalPage = ({ authorizedFetch, stores }) => {
return parsed.getTime(); return parsed.getTime();
}, []); }, []);
const getIntervalMonths = useCallback((interval) => { const formatDateLabel = useCallback((date) => {
if (interval === 'monthly') { if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
return 1; return 'unbekannt';
} }
if (interval === 'quarterly') { return date.toLocaleDateString('de-DE', {
return 3; weekday: 'long',
} day: '2-digit',
return 12; month: '2-digit',
}, []); year: 'numeric'
});
const getReminderOffset = useCallback((reminder) => {
const unit = ['days', 'weeks', 'months'].includes(reminder?.beforeUnit) ? reminder.beforeUnit : 'days';
const rawValue = Number(reminder?.beforeValue);
const fallbackValue = Number(reminder?.daysBefore);
const value = Number.isFinite(rawValue)
? Math.max(0, rawValue)
: Number.isFinite(fallbackValue)
? Math.max(0, fallbackValue)
: 6;
return { unit, value };
}, []);
const subtractReminderOffset = useCallback(
(date, reminder) => {
const { unit, value } = getReminderOffset(reminder);
const copy = new Date(date.getTime());
if (unit === 'weeks') {
copy.setDate(copy.getDate() - value * 7);
return copy;
}
if (unit === 'months') {
copy.setMonth(copy.getMonth() - value);
return copy;
}
copy.setDate(copy.getDate() - value);
return copy;
},
[getReminderOffset]
);
const formatReminderOffset = useCallback(
(reminder) => {
const { unit, value } = getReminderOffset(reminder);
const label = value === 1 ? reminderUnitSingular[unit] : reminderUnitLabels[unit];
return `${value} ${label}`;
},
[getReminderOffset]
);
const addMonths = useCallback((date, months) => {
const copy = new Date(date.getTime());
copy.setMonth(copy.getMonth() + months);
return copy;
}, []);
const startOfDay = useCallback((date) => {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}, []); }, []);
const getNextReminderTimestamp = useCallback( const getNextReminderTimestamp = useCallback(
(entry) => { (entry) => {
if (!entry?.reminder?.enabled || !entry.pickupDate) { const schedule = getNextReminderSchedule(entry);
return null; return schedule?.reminderDate?.getTime() ?? null;
}
const baseDate = new Date(`${entry.pickupDate}T00:00:00`);
if (Number.isNaN(baseDate.getTime())) {
return null;
}
const intervalMonths = getIntervalMonths(entry.reminder.interval);
const todayStart = startOfDay(new Date());
let occurrence = startOfDay(baseDate);
const guardYear = todayStart.getFullYear() + 200;
while (occurrence < todayStart && occurrence.getFullYear() < guardYear) {
occurrence = startOfDay(addMonths(occurrence, intervalMonths));
}
if (occurrence.getFullYear() >= guardYear) {
return null;
}
let reminderDate = startOfDay(subtractReminderOffset(occurrence, entry.reminder));
if (reminderDate < todayStart) {
occurrence = startOfDay(addMonths(occurrence, intervalMonths));
reminderDate = startOfDay(subtractReminderOffset(occurrence, entry.reminder));
}
return reminderDate.getTime();
}, },
[addMonths, getIntervalMonths, startOfDay, subtractReminderOffset] []
); );
const reminderPreview = useMemo(() => {
if (!form.reminderEnabled) {
return null;
}
const previewEntry = {
pickupDate: form.pickupDate,
reminder: {
enabled: true,
interval: form.reminderReferenceMode === 'event' ? 'yearly' : form.reminderInterval,
beforeUnit: form.reminderBeforeUnit,
beforeValue: Number(form.reminderBeforeValue),
reference: {
mode: form.reminderReferenceMode,
eventKey: form.reminderReferenceEventKey,
occurrenceOffsetValue: Number(form.reminderReferenceOffsetValue),
occurrenceOffsetUnit: form.reminderReferenceOffsetUnit
}
}
};
const schedule = getNextReminderSchedule(previewEntry);
if (!schedule?.occurrence || !schedule?.reminderDate) {
return null;
}
return {
occurrenceLabel: formatDateLabel(schedule.occurrence),
reminderLabel: formatDateLabel(schedule.reminderDate)
};
}, [
form.pickupDate,
form.reminderBeforeUnit,
form.reminderBeforeValue,
form.reminderEnabled,
form.reminderInterval,
form.reminderReferenceEventKey,
form.reminderReferenceMode,
form.reminderReferenceOffsetUnit,
form.reminderReferenceOffsetValue,
formatDateLabel
]);
const filteredEntries = useMemo(() => { const filteredEntries = useMemo(() => {
const query = filters.query.trim().toLowerCase(); const query = filters.query.trim().toLowerCase();
const fromValue = parseDateValue(filters.dateFrom); const fromValue = parseDateValue(filters.dateFrom);
@@ -335,15 +308,19 @@ const JournalPage = ({ authorizedFetch, stores }) => {
setFormOpen(false); setFormOpen(false);
setClosingForm(false); setClosingForm(false);
setEditingStoreLabel(''); setEditingStoreLabel('');
setForm({ setForm({
storeId: '', storeId: '',
pickupDate: '', pickupDate: '',
note: '', note: '',
reminderEnabled: true, reminderEnabled: true,
reminderInterval: 'yearly', reminderInterval: 'yearly',
reminderBeforeValue: 6, reminderBeforeValue: 6,
reminderBeforeUnit: 'weeks' reminderBeforeUnit: 'weeks',
}); reminderReferenceMode: 'pickupDate',
reminderReferenceEventKey: 'easter_sunday',
reminderReferenceOffsetValue: 0,
reminderReferenceOffsetUnit: 'weeks'
});
}, [images]); }, [images]);
const resetForm = useCallback(() => { const resetForm = useCallback(() => {
@@ -434,9 +411,15 @@ const JournalPage = ({ authorizedFetch, stores }) => {
note: form.note.trim(), note: form.note.trim(),
reminder: { reminder: {
enabled: form.reminderEnabled, enabled: form.reminderEnabled,
interval: form.reminderInterval, interval: form.reminderReferenceMode === 'event' ? 'yearly' : form.reminderInterval,
beforeUnit: form.reminderBeforeUnit, beforeUnit: form.reminderBeforeUnit,
beforeValue: Number(form.reminderBeforeValue) beforeValue: Number(form.reminderBeforeValue),
reference: {
mode: form.reminderReferenceMode,
eventKey: form.reminderReferenceEventKey,
occurrenceOffsetValue: Number(form.reminderReferenceOffsetValue),
occurrenceOffsetUnit: form.reminderReferenceOffsetUnit
}
}, },
images: imagePayload, images: imagePayload,
keepImageIds: existingImages.map((image) => image.id) keepImageIds: existingImages.map((image) => image.id)
@@ -515,7 +498,11 @@ const JournalPage = ({ authorizedFetch, stores }) => {
reminderEnabled: entry.reminder?.enabled ?? true, reminderEnabled: entry.reminder?.enabled ?? true,
reminderInterval: entry.reminder?.interval || 'yearly', reminderInterval: entry.reminder?.interval || 'yearly',
reminderBeforeValue: getReminderOffset(entry.reminder).value, reminderBeforeValue: getReminderOffset(entry.reminder).value,
reminderBeforeUnit: getReminderOffset(entry.reminder).unit reminderBeforeUnit: getReminderOffset(entry.reminder).unit,
reminderReferenceMode: getReminderReference(entry.reminder).mode,
reminderReferenceEventKey: getReminderReference(entry.reminder).eventKey,
reminderReferenceOffsetValue: getReminderReference(entry.reminder).occurrenceOffsetValue,
reminderReferenceOffsetUnit: getReminderReference(entry.reminder).occurrenceOffsetUnit
}); });
setFormOpen(true); setFormOpen(true);
}; };
@@ -679,7 +666,7 @@ const JournalPage = ({ authorizedFetch, stores }) => {
{filteredEntries.map((entry) => { {filteredEntries.map((entry) => {
const reminder = entry.reminder || {}; const reminder = entry.reminder || {};
const reminderLabel = reminder.enabled const reminderLabel = reminder.enabled
? `${intervalLabels[reminder.interval] || 'Jährlich'}, ${formatReminderOffset(reminder)} vorher` ? `${intervalLabels[reminder.interval] || 'Jährlich'}, ${formatReminderOffset(reminder)} vorher, ${formatReferenceLabel(reminder)}`
: 'Keine Erinnerung'; : 'Keine Erinnerung';
const storeLabel = const storeLabel =
entry.storeName || entry.storeName ||
@@ -979,8 +966,9 @@ const JournalPage = ({ authorizedFetch, stores }) => {
<div className="space-y-1"> <div className="space-y-1">
<label className="block text-sm text-gray-600">Intervall</label> <label className="block text-sm text-gray-600">Intervall</label>
<select <select
value={form.reminderInterval} value={form.reminderReferenceMode === 'event' ? 'yearly' : form.reminderInterval}
onChange={(event) => handleFormChange({ reminderInterval: event.target.value })} onChange={(event) => handleFormChange({ reminderInterval: event.target.value })}
disabled={form.reminderReferenceMode === 'event'}
className="w-full" className="w-full"
> >
{Object.entries(intervalLabels).map(([value, label]) => ( {Object.entries(intervalLabels).map(([value, label]) => (
@@ -1018,9 +1006,84 @@ const JournalPage = ({ authorizedFetch, stores }) => {
</div> </div>
</div> </div>
</div> </div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1">
<label className="block text-sm text-gray-600">Erinnerung basiert auf</label>
<select
value={form.reminderReferenceMode}
onChange={(event) =>
handleFormChange({
reminderReferenceMode: event.target.value,
reminderInterval: event.target.value === 'event' ? 'yearly' : form.reminderInterval
})
}
className="w-full"
>
<option value="pickupDate">Gespeichertem Abholdatum</option>
<option value="event">Referenz-Event</option>
</select>
</div>
{form.reminderReferenceMode === 'event' ? (
<div className="space-y-1">
<label className="block text-sm text-gray-600">Referenz-Event</label>
<select
value={form.reminderReferenceEventKey}
onChange={(event) => handleFormChange({ reminderReferenceEventKey: event.target.value })}
className="w-full"
>
{referenceEventOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
) : null}
</div>
{form.reminderReferenceMode === 'event' ? (
<div className="space-y-1">
<label className="block text-sm text-gray-600">Abholung relativ zum Event</label>
<div className="flex gap-2">
<input
type="number"
value={form.reminderReferenceOffsetValue}
onChange={(event) =>
handleFormChange({ reminderReferenceOffsetValue: event.target.value })
}
className="w-24 border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
/>
<select
value={form.reminderReferenceOffsetUnit}
onChange={(event) =>
handleFormChange({ reminderReferenceOffsetUnit: event.target.value })
}
className="flex-1"
>
{Object.entries(reminderUnitLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
<p className="text-xs text-gray-500">
Positive Werte liegen nach dem Event, negative davor. Beispiel: 2 Wochen nach Ostern oder -4 Wochen vor Ostern.
</p>
</div>
) : null}
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
Erinnerung wird standardmäßig jährlich eingeplant. Bei Referenz-Events wird die Erinnerung immer jährlich berechnet.
</p> </p>
{reminderPreview ? (
<div className="rounded-md border border-blue-200 bg-blue-50 px-3 py-3 text-sm text-blue-900">
<p>
Geplante Abholung: <strong>{reminderPreview.occurrenceLabel}</strong>
</p>
<p>
Erinnerung kommt am: <strong>{reminderPreview.reminderLabel}</strong>
</p>
</div>
) : null}
</> </>
) : null} ) : null}
</div> </div>

View File

@@ -1,12 +1,125 @@
import { useMemo } from 'react';
import { DateRange } from 'react-date-range'; import { DateRange } from 'react-date-range';
import { de } from 'date-fns/locale'; import { de } from 'date-fns/locale';
import { buildSelectionRange } from '../utils/dateUtils'; import { buildSelectionRange } from '../utils/dateUtils';
const RangePickerModal = ({ entry, minDate, onSelectRange, onResetRange, onClose }) => { const WEEKDAY_INDEX = {
Sonntag: 0,
Montag: 1,
Dienstag: 2,
Mittwoch: 3,
Donnerstag: 4,
Freitag: 5,
Samstag: 6
};
const formatDateKey = (value) => {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const formatTime = (value) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return date.toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit'
});
};
const formatRangeText = (entry) => {
const range = entry?.desiredDateRange || (entry?.desiredDate ? { start: entry.desiredDate, end: entry.desiredDate } : null);
if (range?.start || range?.end) {
const start = range.start || range.end;
const end = range.end || range.start;
return start === end ? start : `${start} bis ${end}`;
}
return entry?.desiredWeekday || 'aktiv ohne Zeitfenster';
};
const entryHasPlannedDay = (entry, date, dayKey) => {
if (!entry?.active || entry.hidden) {
return false;
}
const range = entry.desiredDateRange || (entry.desiredDate ? { start: entry.desiredDate, end: entry.desiredDate } : null);
if (range?.start || range?.end) {
const start = range.start || range.end;
const end = range.end || range.start;
return dayKey >= start && dayKey <= end;
}
return entry.desiredWeekday ? WEEKDAY_INDEX[entry.desiredWeekday] === date.getDay() : false;
};
const RangePickerModal = ({
entry,
minDate,
config,
registeredPickups,
onSelectRange,
onResetRange,
onClose
}) => {
const registeredPickupsByDate = useMemo(() => {
const byDate = new Map();
(Array.isArray(registeredPickups) ? registeredPickups : []).forEach((pickup) => {
const key = formatDateKey(pickup?.date);
if (key) {
const items = byDate.get(key) || [];
items.push(pickup);
byDate.set(key, items);
}
});
return byDate;
}, [registeredPickups]);
const plannedEntries = useMemo(
() => (Array.isArray(config) ? config : []).filter((item) => item?.active && !item.hidden),
[config]
);
if (!entry) { if (!entry) {
return null; return null;
} }
const renderDayContent = (date) => {
const dayKey = formatDateKey(date);
const foodsharingPickups = registeredPickupsByDate.get(dayKey) || [];
const managerPlans = plannedEntries.filter((item) => entryHasPlannedDay(item, date, dayKey));
const hasFoodsharingPickup = foodsharingPickups.length > 0;
const hasManagerPlan = managerPlans.length > 0;
const titleParts = [
...foodsharingPickups.map((pickup) => {
const time = formatTime(pickup.date);
const label = pickup.storeName || `Store ${pickup.storeId || ''}`.trim();
const description = pickup.description ? `, ${pickup.description}` : '';
return `Foodsharing: ${label}${time ? ` ${time}` : ''}${description}`;
}),
...managerPlans.map((plan) => `Pickup Manager: ${plan.label || `Store ${plan.id}`} (${formatRangeText(plan)})`)
];
return (
<span
className="relative inline-flex h-7 w-7 items-center justify-center"
title={titleParts.length > 0 ? titleParts.join('\n') : undefined}
>
<span>{date.getDate()}</span>
{(hasFoodsharingPickup || hasManagerPlan) && (
<span className="absolute bottom-0 flex gap-0.5">
{hasFoodsharingPickup && <span className="h-1.5 w-1.5 rounded-full bg-blue-600" />}
{hasManagerPlan && <span className="h-1.5 w-1.5 rounded-full bg-emerald-600" />}
</span>
)}
</span>
);
};
return ( return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black bg-opacity-40 px-4" onClick={onClose}> <div className="fixed inset-0 z-40 flex items-center justify-center bg-black bg-opacity-40 px-4" onClick={onClose}>
<div <div
@@ -37,7 +150,18 @@ const RangePickerModal = ({ entry, minDate, onSelectRange, onResetRange, onClose
showDateDisplay={false} showDateDisplay={false}
locale={de} locale={de}
minDate={minDate} minDate={minDate}
dayContentRenderer={renderDayContent}
/> />
<div className="mt-3 flex flex-wrap items-center gap-3 px-3 text-xs text-gray-600">
<span className="inline-flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-blue-600" />
Foodsharing eingetragen
</span>
<span className="inline-flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-emerald-600" />
Pickup Manager geplant
</span>
</div>
</div> </div>
<div className="flex items-center justify-between px-5 py-3 border-t bg-gray-50 rounded-b-2xl"> <div className="flex items-center justify-between px-5 py-3 border-t bg-gray-50 rounded-b-2xl">
<button type="button" className="text-sm text-gray-600 hover:text-gray-900" onClick={onResetRange}> <button type="button" className="text-sm text-gray-600 hover:text-gray-900" onClick={onResetRange}>

View File

@@ -441,9 +441,10 @@ const StoreWatchPage = ({
const membership = membershipMap.has(String(store.id)); const membership = membershipMap.has(String(store.id));
const lat = Number(store.location?.lat); const lat = Number(store.location?.lat);
const lon = Number(store.location?.lon); const lon = Number(store.location?.lon);
const statusValue = store.teamSearchStatus === null || store.teamSearchStatus === undefined const rawTeamStatus = store.teamStatus ?? store.teamSearchStatus;
const statusValue = rawTeamStatus === null || rawTeamStatus === undefined
? null ? null
: Number(store.teamSearchStatus); : Number(rawTeamStatus);
const distance = const distance =
userLocation && Number.isFinite(lat) && Number.isFinite(lon) userLocation && Number.isFinite(lat) && Number.isFinite(lon)
? haversineDistanceKm(userLocation.lat, userLocation.lon, lat, lon) ? haversineDistanceKm(userLocation.lat, userLocation.lon, lat, lon)

View File

@@ -25,8 +25,8 @@ const useStoreSync = ({
} }
if (!silent) { if (!silent) {
setStatus(''); setStatus('');
setLoading(true);
} }
setLoading(true);
setError(''); setError('');
try { try {
const response = await authorizedFetch('/api/config', {}, tokenToUse); const response = await authorizedFetch('/api/config', {}, tokenToUse);
@@ -42,7 +42,9 @@ const useStoreSync = ({
} catch (err) { } catch (err) {
setError(`Fehler beim Laden der Konfiguration: ${err.message}`); setError(`Fehler beim Laden der Konfiguration: ${err.message}`);
} finally { } finally {
setLoading(false); if (!silent) {
setLoading(false);
}
} }
}, },
[sessionToken, authorizedFetch, setStatus, setLoading, setError, setConfig, normalizeConfigEntries] [sessionToken, authorizedFetch, setStatus, setLoading, setError, setConfig, normalizeConfigEntries]

View File

@@ -20,6 +20,20 @@ export const normalizeAdminSettings = (raw) => {
storeWatchInitialDelayMaxSeconds: raw.storeWatchInitialDelayMaxSeconds ?? '', storeWatchInitialDelayMaxSeconds: raw.storeWatchInitialDelayMaxSeconds ?? '',
storeWatchRequestDelayMs: raw.storeWatchRequestDelayMs ?? '', storeWatchRequestDelayMs: raw.storeWatchRequestDelayMs ?? '',
storeWatchStatusCacheMaxAgeMinutes: raw.storeWatchStatusCacheMaxAgeMinutes ?? '', storeWatchStatusCacheMaxAgeMinutes: raw.storeWatchStatusCacheMaxAgeMinutes ?? '',
messageWatcherSocketEnabled:
raw.messageWatcherSocketEnabled === undefined ? true : !!raw.messageWatcherSocketEnabled,
messageWatcherFallbackEnabled:
raw.messageWatcherFallbackEnabled === undefined ? true : !!raw.messageWatcherFallbackEnabled,
messageWatcherFallbackCron: raw.messageWatcherFallbackCron || '0 6-23 * * *',
messageWatcherDebugLogging:
raw.messageWatcherDebugLogging === undefined ? true : !!raw.messageWatcherDebugLogging,
maintenanceModeActive: !!raw.maintenanceModeActive,
maintenanceModeAutoEnabled:
raw.maintenanceModeAutoEnabled === undefined ? true : !!raw.maintenanceModeAutoEnabled,
maintenanceModeActivatedAt: raw.maintenanceModeActivatedAt || '',
maintenanceModeReason: raw.maintenanceModeReason || '',
maintenanceModeErrorThreshold: raw.maintenanceModeErrorThreshold ?? '',
maintenanceModeErrorWindowMinutes: raw.maintenanceModeErrorWindowMinutes ?? '',
ignoredSlots: Array.isArray(raw.ignoredSlots) ignoredSlots: Array.isArray(raw.ignoredSlots)
? raw.ignoredSlots.map((slot) => ({ ? raw.ignoredSlots.map((slot) => ({
storeId: slot?.storeId ? String(slot.storeId) : '', storeId: slot?.storeId ? String(slot.storeId) : '',
@@ -88,6 +102,16 @@ export const serializeAdminSettings = (adminSettings) => {
storeWatchInitialDelayMaxSeconds: toNumberOrUndefined(adminSettings.storeWatchInitialDelayMaxSeconds), storeWatchInitialDelayMaxSeconds: toNumberOrUndefined(adminSettings.storeWatchInitialDelayMaxSeconds),
storeWatchRequestDelayMs: toNumberOrUndefined(adminSettings.storeWatchRequestDelayMs), storeWatchRequestDelayMs: toNumberOrUndefined(adminSettings.storeWatchRequestDelayMs),
storeWatchStatusCacheMaxAgeMinutes: toNumberOrUndefined(adminSettings.storeWatchStatusCacheMaxAgeMinutes), storeWatchStatusCacheMaxAgeMinutes: toNumberOrUndefined(adminSettings.storeWatchStatusCacheMaxAgeMinutes),
messageWatcherSocketEnabled: !!adminSettings.messageWatcherSocketEnabled,
messageWatcherFallbackEnabled: !!adminSettings.messageWatcherFallbackEnabled,
messageWatcherFallbackCron: adminSettings.messageWatcherFallbackCron || '',
messageWatcherDebugLogging: !!adminSettings.messageWatcherDebugLogging,
maintenanceModeActive: !!adminSettings.maintenanceModeActive,
maintenanceModeAutoEnabled: !!adminSettings.maintenanceModeAutoEnabled,
maintenanceModeActivatedAt: adminSettings.maintenanceModeActivatedAt || null,
maintenanceModeReason: adminSettings.maintenanceModeReason || '',
maintenanceModeErrorThreshold: toNumberOrUndefined(adminSettings.maintenanceModeErrorThreshold),
maintenanceModeErrorWindowMinutes: toNumberOrUndefined(adminSettings.maintenanceModeErrorWindowMinutes),
ignoredSlots: (adminSettings.ignoredSlots || []).map((slot) => ({ ignoredSlots: (adminSettings.ignoredSlots || []).map((slot) => ({
storeId: slot.storeId || '', storeId: slot.storeId || '',
slotName: slot.slotName || '', slotName: slot.slotName || '',

View File

@@ -0,0 +1,260 @@
export const referenceEventOptions = [
{ value: 'new_years_day', label: 'Neujahr' },
{ value: 'epiphany', label: 'Heilige Drei Könige' },
{ value: 'valentines_day', label: 'Valentinstag' },
{ value: 'womens_day', label: 'Internationaler Frauentag' },
{ value: 'carnival_monday', label: 'Rosenmontag' },
{ value: 'ash_wednesday', label: 'Aschermittwoch' },
{ value: 'palm_sunday', label: 'Palmsonntag' },
{ value: 'maundy_thursday', label: 'Gründonnerstag' },
{ value: 'good_friday', label: 'Karfreitag' },
{ value: 'easter_sunday', label: 'Ostersonntag' },
{ value: 'easter_monday', label: 'Ostermontag' },
{ value: 'may_day', label: 'Tag der Arbeit' },
{ value: 'ascension_day', label: 'Christi Himmelfahrt' },
{ value: 'pentecost_sunday', label: 'Pfingstsonntag' },
{ value: 'pentecost_monday', label: 'Pfingstmontag' },
{ value: 'corpus_christi', label: 'Fronleichnam' },
{ value: 'german_unity_day', label: 'Tag der Deutschen Einheit' },
{ value: 'halloween', label: 'Halloween' },
{ value: 'all_saints_day', label: 'Allerheiligen' },
{ value: 'st_nicholas_day', label: 'Nikolaus' },
{ value: 'christmas_eve', label: 'Heiligabend' },
{ value: 'christmas_day', label: '1. Weihnachtstag' },
{ value: 'boxing_day', label: '2. Weihnachtstag' },
{ value: 'new_years_eve', label: 'Silvester' }
];
const referenceEventLabelMap = referenceEventOptions.reduce((acc, entry) => {
acc[entry.value] = entry.label;
return acc;
}, {});
export const reminderUnitLabels = {
days: 'Tage',
weeks: 'Wochen',
months: 'Monate'
};
export const reminderUnitSingular = {
days: 'Tag',
weeks: 'Woche',
months: 'Monat'
};
function startOfDay(date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
function addMonths(date, months) {
const copy = new Date(date.getTime());
copy.setMonth(copy.getMonth() + months);
return copy;
}
function applyOffset(date, unit, value = 0) {
const copy = new Date(date.getTime());
const numericValue = Number(value) || 0;
if (unit === 'weeks') {
copy.setDate(copy.getDate() + numericValue * 7);
return copy;
}
if (unit === 'months') {
copy.setMonth(copy.getMonth() + numericValue);
return copy;
}
copy.setDate(copy.getDate() + numericValue);
return copy;
}
function subtractOffset(date, unit, value = 0) {
return applyOffset(date, unit, -(Number(value) || 0));
}
function getEasterSunday(year) {
const a = year % 19;
const b = Math.floor(year / 100);
const c = year % 100;
const d = Math.floor(b / 4);
const e = b % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const month = Math.floor((h + l - 7 * m + 114) / 31);
const day = ((h + l - 7 * m + 114) % 31) + 1;
return new Date(year, month - 1, day);
}
function getReferenceEventDate(eventKey, year) {
if (eventKey === 'new_years_day') return new Date(year, 0, 1);
if (eventKey === 'epiphany') return new Date(year, 0, 6);
if (eventKey === 'valentines_day') return new Date(year, 1, 14);
if (eventKey === 'womens_day') return new Date(year, 2, 8);
if (eventKey === 'may_day') return new Date(year, 4, 1);
if (eventKey === 'german_unity_day') return new Date(year, 9, 3);
if (eventKey === 'halloween') return new Date(year, 9, 31);
if (eventKey === 'all_saints_day') return new Date(year, 10, 1);
if (eventKey === 'st_nicholas_day') return new Date(year, 11, 6);
if (eventKey === 'christmas_eve') return new Date(year, 11, 24);
if (eventKey === 'christmas_day') return new Date(year, 11, 25);
if (eventKey === 'boxing_day') return new Date(year, 11, 26);
if (eventKey === 'new_years_eve') return new Date(year, 11, 31);
const easterSunday = getEasterSunday(year);
const offsets = {
carnival_monday: -48,
ash_wednesday: -46,
palm_sunday: -7,
maundy_thursday: -3,
good_friday: -2,
easter_sunday: 0,
easter_monday: 1,
ascension_day: 39,
pentecost_sunday: 49,
pentecost_monday: 50,
corpus_christi: 60
};
if (!(eventKey in offsets)) {
return null;
}
return applyOffset(easterSunday, 'days', offsets[eventKey]);
}
export function getIntervalMonths(interval) {
if (interval === 'monthly') {
return 1;
}
if (interval === 'quarterly') {
return 3;
}
return 12;
}
export function getReminderOffset(reminder = {}) {
const unit = ['days', 'weeks', 'months'].includes(reminder?.beforeUnit) ? reminder.beforeUnit : 'days';
const rawValue = Number(reminder?.beforeValue);
const fallbackValue = Number(reminder?.daysBefore);
const value = Number.isFinite(rawValue)
? Math.max(0, rawValue)
: Number.isFinite(fallbackValue)
? Math.max(0, fallbackValue)
: 6;
return { unit, value };
}
export function getReminderReference(reminder = {}) {
const reference = reminder?.reference;
if (!reference || reference.mode !== 'event' || !referenceEventLabelMap[reference.eventKey]) {
return {
mode: 'pickupDate',
eventKey: 'easter_sunday',
occurrenceOffsetUnit: 'weeks',
occurrenceOffsetValue: 0
};
}
const unit = ['days', 'weeks', 'months'].includes(reference.occurrenceOffsetUnit)
? reference.occurrenceOffsetUnit
: 'days';
const value = Number.isFinite(Number(reference.occurrenceOffsetValue))
? Number(reference.occurrenceOffsetValue)
: 0;
return {
mode: 'event',
eventKey: reference.eventKey,
occurrenceOffsetUnit: unit,
occurrenceOffsetValue: value
};
}
export function formatUnitLabel(unit, value) {
const labels = value === 1 ? reminderUnitSingular : reminderUnitLabels;
return `${value} ${labels[unit] || labels.days}`;
}
export function formatRelativeEventOffset(reference = {}) {
const eventLabel = referenceEventLabelMap[reference.eventKey] || 'dem Referenz-Event';
const unit = ['days', 'weeks', 'months'].includes(reference.occurrenceOffsetUnit)
? reference.occurrenceOffsetUnit
: 'days';
const value = Number(reference.occurrenceOffsetValue) || 0;
if (value === 0) {
return `am ${eventLabel}`;
}
const absValue = Math.abs(value);
const offsetLabel = formatUnitLabel(unit, absValue);
return value > 0 ? `${offsetLabel} nach ${eventLabel}` : `${offsetLabel} vor ${eventLabel}`;
}
export function formatReminderReference(reminder = {}) {
const reference = getReminderReference(reminder);
if (reference.mode !== 'event') {
return 'gespeichertes Abholdatum';
}
return formatRelativeEventOffset(reference);
}
export function formatReminderOffset(reminder = {}) {
const { unit, value } = getReminderOffset(reminder);
return formatUnitLabel(unit, value);
}
export function formatReferenceLabel(reminder = {}) {
return formatReminderReference(reminder);
}
export function subtractReminderOffset(date, reminder = {}) {
const { unit, value } = getReminderOffset(reminder);
return subtractOffset(date, unit, value);
}
export function getNextReminderSchedule(entry, today = new Date()) {
if (!entry?.reminder?.enabled) {
return null;
}
const todayStart = startOfDay(today);
const reference = getReminderReference(entry.reminder);
if (reference.mode === 'event') {
for (let year = todayStart.getFullYear(); year <= todayStart.getFullYear() + 2; year += 1) {
const eventDate = getReferenceEventDate(reference.eventKey, year);
if (!eventDate) {
return null;
}
const occurrence = startOfDay(
applyOffset(eventDate, reference.occurrenceOffsetUnit, reference.occurrenceOffsetValue)
);
const reminderDate = startOfDay(subtractReminderOffset(occurrence, entry.reminder));
if (reminderDate >= todayStart) {
return { occurrence, reminderDate };
}
}
return null;
}
if (!entry.pickupDate) {
return null;
}
const baseDate = new Date(`${entry.pickupDate}T00:00:00`);
if (Number.isNaN(baseDate.getTime())) {
return null;
}
const intervalMonths = getIntervalMonths(entry.reminder.interval);
let occurrence = startOfDay(baseDate);
const guardYear = todayStart.getFullYear() + 200;
while (occurrence < todayStart && occurrence.getFullYear() < guardYear) {
occurrence = startOfDay(addMonths(occurrence, intervalMonths));
}
if (occurrence.getFullYear() >= guardYear) {
return null;
}
let reminderDate = startOfDay(subtractReminderOffset(occurrence, entry.reminder));
if (reminderDate < todayStart) {
occurrence = startOfDay(addMonths(occurrence, intervalMonths));
reminderDate = startOfDay(subtractReminderOffset(occurrence, entry.reminder));
}
return { occurrence, reminderDate };
}

View File

@@ -0,0 +1,52 @@
import { getNextReminderSchedule } from './journalReminder';
function toLocalDateString(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
describe('journalReminder', () => {
it('calculates Easter-relative reminders correctly', () => {
const entry = {
pickupDate: '2025-04-20',
reminder: {
enabled: true,
interval: 'yearly',
beforeUnit: 'weeks',
beforeValue: 6,
reference: {
mode: 'event',
eventKey: 'easter_sunday',
occurrenceOffsetUnit: 'weeks',
occurrenceOffsetValue: 2
}
}
};
const schedule = getNextReminderSchedule(entry, new Date('2026-01-10T00:00:00'));
expect(schedule).not.toBeNull();
expect(toLocalDateString(schedule.occurrence)).toBe('2026-04-19');
expect(toLocalDateString(schedule.reminderDate)).toBe('2026-03-08');
});
it('keeps fixed pickup-date reminders working', () => {
const entry = {
pickupDate: '2024-12-24',
reminder: {
enabled: true,
interval: 'yearly',
beforeUnit: 'weeks',
beforeValue: 2
}
};
const schedule = getNextReminderSchedule(entry, new Date('2026-01-10T00:00:00'));
expect(schedule).not.toBeNull();
expect(toLocalDateString(schedule.occurrence)).toBe('2026-12-24');
expect(toLocalDateString(schedule.reminderDate)).toBe('2026-12-10');
});
});

View File

@@ -0,0 +1,81 @@
jest.mock('../../services/foodsharingClient', () => ({
fetchConversations: jest.fn()
}));
jest.mock('socket.io-client', () => ({
io: jest.fn()
}));
jest.mock('../../services/requestLogStore', () => ({
add: jest.fn()
}));
jest.mock('../../services/sessionStore', () => ({
get: jest.fn()
}));
const {
collectStoreIds,
extractStoreIdFromHref,
getMatchingActiveEntries,
getMessageWatcherSettings,
isPotentialMessageSocketEvent
} = require('../../services/storeMessageWatcher');
describe('storeMessageWatcher', () => {
test('extracts store ids from modern and legacy foodsharing links', () => {
expect(extractStoreIdFromHref('/store/33875')).toBe('33875');
expect(extractStoreIdFromHref('/?page=fsbetrieb&id=44975')).toBe('44975');
expect(extractStoreIdFromHref('/fairteiler/2974')).toBeNull();
});
test('detects relevant socket event names', () => {
expect(isPotentialMessageSocketEvent('conversation:message')).toBe(true);
expect(isPotentialMessageSocketEvent('notification')).toBe(true);
expect(isPotentialMessageSocketEvent('connect')).toBe(false);
});
test('matches only active entries for the affected store', () => {
const config = [
{ id: '33875', active: true },
{ id: 33875, active: false },
{ id: '44975', active: true }
];
expect(getMatchingActiveEntries(config, '33875')).toEqual([{ id: '33875', active: true }]);
});
test('collects store ids from socket-like payloads', () => {
const ids = Array.from(
collectStoreIds([
{ conversation: { storeId: 44975 } },
{ href: '/store/33875' },
{ text: '/?page=fsbetrieb&id=42264' }
])
).sort();
expect(ids).toEqual(['33875', '42264', '44975']);
});
test('normalizes message watcher settings', () => {
expect(getMessageWatcherSettings({ messageWatcherFallbackCron: '*/15 * * * *' })).toMatchObject({
socketEnabled: true,
fallbackEnabled: true,
fallbackCron: '*/15 * * * *',
debugLogging: true
});
expect(
getMessageWatcherSettings({
messageWatcherSocketEnabled: false,
messageWatcherFallbackEnabled: false,
messageWatcherFallbackCron: 'invalid',
messageWatcherDebugLogging: false
})
).toMatchObject({
socketEnabled: false,
fallbackEnabled: false,
fallbackCron: '0 6-23 * * *',
debugLogging: false
});
});
});