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:
3
.commitmessage
Normal file
3
.commitmessage
Normal 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
3
.gitignore
vendored
@@ -13,10 +13,13 @@
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.codex
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
/config/*.json
|
||||
/demo requests/*.har
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
||||
@@ -13,9 +13,18 @@
|
||||
- `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 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).
|
||||
- `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
|
||||
- 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.
|
||||
|
||||
35
README.md
35
README.md
@@ -85,6 +85,7 @@ Weitere Laufzeitdaten:
|
||||
| `npm start` | CRA-Devserver inkl. Live-Reload. |
|
||||
| `npm run build` | Produktionsbundle (CRA). |
|
||||
| `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). |
|
||||
|
||||
Docker:
|
||||
@@ -137,6 +138,40 @@ CI=true npm test
|
||||
docker-compose up --build
|
||||
```
|
||||
- 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.
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
177
package-lock.json
generated
@@ -24,6 +24,7 @@
|
||||
"react-dom": "18.2.0",
|
||||
"react-router-dom": "^7.9.5",
|
||||
"react-scripts": "5.0.1",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"uuid": "^11.0.3",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
@@ -77,7 +78,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@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",
|
||||
"integrity": "sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-annotate-as-pure": "^7.27.1",
|
||||
"@babel/helper-module-imports": "^7.27.1",
|
||||
@@ -3318,6 +3316,12 @@
|
||||
"@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": {
|
||||
"version": "2.2.3",
|
||||
"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",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
@@ -4572,7 +4575,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -4694,7 +4696,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -5586,7 +5587,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.19",
|
||||
"caniuse-lite": "^1.0.30001751",
|
||||
@@ -6728,7 +6728,6 @@
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
|
||||
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.21.0"
|
||||
},
|
||||
@@ -7163,6 +7162,72 @@
|
||||
"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": {
|
||||
"version": "5.18.3",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
|
||||
@@ -7448,7 +7513,6 @@
|
||||
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
|
||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
@@ -10314,7 +10378,6 @@
|
||||
"resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz",
|
||||
"integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jest/core": "^27.5.1",
|
||||
"import-local": "^3.0.2",
|
||||
@@ -11200,7 +11263,6 @@
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -12621,7 +12683,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -13756,7 +13817,6 @@
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
|
||||
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
@@ -14089,7 +14149,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
|
||||
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -14240,7 +14299,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
|
||||
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.0"
|
||||
@@ -14275,7 +14333,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
|
||||
"integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -14769,7 +14826,6 @@
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz",
|
||||
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
},
|
||||
@@ -15015,7 +15071,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -15392,6 +15447,80 @@
|
||||
"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": {
|
||||
"version": "0.3.24",
|
||||
"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",
|
||||
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -17178,7 +17306,6 @@
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz",
|
||||
"integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/eslint-scope": "^3.7.7",
|
||||
"@types/estree": "^1.0.8",
|
||||
@@ -17250,7 +17377,6 @@
|
||||
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz",
|
||||
"integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/bonjour": "^3.5.9",
|
||||
"@types/connect-history-api-fallback": "^1.3.5",
|
||||
@@ -17660,7 +17786,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -18012,6 +18137,14 @@
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"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": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"react-dom": "18.2.0",
|
||||
"react-router-dom": "^7.9.5",
|
||||
"react-scripts": "5.0.1",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"uuid": "^11.0.3",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
@@ -29,6 +30,7 @@
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"check:foodsharing-api": "node scripts/check-foodsharing-endpoints.js",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
#/bin/bash
|
||||
# Bauen Sie die React-App neu
|
||||
npm run build
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Erzwingen Sie einen Neubau des Docker-Images
|
||||
docker compose down
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
# Nach relevanten Backend-, API-, Dockerfile-, Compose- oder Frontend-Änderungen
|
||||
# den Container mit dem aktuellen Stand neu bauen und starten.
|
||||
docker compose up -d --build
|
||||
|
||||
172
scripts/check-foodsharing-endpoints.js
Normal file
172
scripts/check-foodsharing-endpoints.js
Normal 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
149
server.js
@@ -20,6 +20,7 @@ const { readNotificationSettings, writeNotificationSettings } = require('./servi
|
||||
const notificationService = require('./services/notificationService');
|
||||
const { readStoreWatch, writeStoreWatch, listWatcherProfiles } = require('./services/storeWatchStore');
|
||||
const { readPreferences, writePreferences, sanitizeLocation } = require('./services/userPreferencesStore');
|
||||
const { readStoresCache, writeStoresCache } = require('./services/storeCacheStore');
|
||||
const requestLogStore = require('./services/requestLogStore');
|
||||
const {
|
||||
readJournal,
|
||||
@@ -28,12 +29,14 @@ const {
|
||||
deleteJournalImage,
|
||||
getProfileImageDir
|
||||
} = require('./services/journalStore');
|
||||
const { normalizeReminderReference } = require('./services/journalReminderUtils');
|
||||
const { withSessionRetry } = require('./services/sessionRefresh');
|
||||
const {
|
||||
getStoreStatus: getCachedStoreStatusEntry,
|
||||
setStoreStatus: setCachedStoreStatusEntry,
|
||||
persistStoreStatusCache
|
||||
} = require('./services/storeStatusCache');
|
||||
const maintenanceMode = require('./services/maintenanceMode');
|
||||
|
||||
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
|
||||
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 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) {
|
||||
return (value * Math.PI) / 180;
|
||||
}
|
||||
@@ -194,6 +227,7 @@ function rescheduleAllSessions() {
|
||||
|
||||
function mergeStoresIntoConfig(config = [], stores = []) {
|
||||
const entries = Array.isArray(config) ? config : [];
|
||||
const storeList = Array.isArray(stores) ? stores : [];
|
||||
const map = new Map();
|
||||
entries.forEach((entry) => {
|
||||
if (!entry || !entry.id) {
|
||||
@@ -203,7 +237,7 @@ function mergeStoresIntoConfig(config = [], stores = []) {
|
||||
});
|
||||
|
||||
let changed = false;
|
||||
stores.forEach((store) => {
|
||||
storeList.forEach((store) => {
|
||||
if (!store?.id) {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -264,6 +312,16 @@ function getCachedStoreStatus(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 = {}) {
|
||||
const unit = ['days', 'weeks', 'months'].includes(reminder.beforeUnit) ? reminder.beforeUnit : 'days';
|
||||
const parsedBeforeValue = Number(reminder.beforeValue);
|
||||
@@ -274,14 +332,18 @@ function normalizeJournalReminder(reminder = {}) {
|
||||
? Math.max(0, parsedDaysBefore)
|
||||
: 6;
|
||||
const daysBefore = unit === 'weeks' ? beforeValue * 7 : unit === 'months' ? beforeValue * 30 : beforeValue;
|
||||
const reference = normalizeReminderReference(reminder.reference);
|
||||
return {
|
||||
enabled: !!reminder.enabled,
|
||||
interval: ['monthly', 'quarterly', 'yearly'].includes(reminder.interval)
|
||||
interval: reference.mode === 'event'
|
||||
? 'yearly'
|
||||
: ['monthly', 'quarterly', 'yearly'].includes(reminder.interval)
|
||||
? reminder.interval
|
||||
: 'yearly',
|
||||
beforeUnit: unit,
|
||||
beforeValue,
|
||||
daysBefore
|
||||
daysBefore,
|
||||
reference
|
||||
};
|
||||
}
|
||||
|
||||
@@ -469,13 +531,12 @@ async function refreshStoreStatus(
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const details = await withSessionRetry(
|
||||
const marker = await withSessionRetry(
|
||||
session,
|
||||
() => foodsharingClient.fetchStoreDetails(storeId, session.cookieHeader, session),
|
||||
{ label: 'fetchStoreDetails' }
|
||||
() => foodsharingClient.fetchStoreMarker(storeId, session.cookieHeader, session),
|
||||
{ label: 'fetchStoreMarker' }
|
||||
);
|
||||
const status = Number(details?.teamSearchStatus);
|
||||
const normalized = Number.isFinite(status) ? status : null;
|
||||
const normalized = resolveStoreTeamStatus(marker);
|
||||
const previous = entry ? entry.teamSearchStatus : null;
|
||||
setCachedStoreStatusEntry(storeId, {
|
||||
teamSearchStatus: normalized,
|
||||
@@ -494,6 +555,14 @@ async function refreshStoreStatus(
|
||||
}
|
||||
refreshed += 1;
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
@@ -603,6 +672,9 @@ function triggerStoreRefresh(session, { force = false, reason } = {}) {
|
||||
if (!session?.id) {
|
||||
return { started: false };
|
||||
}
|
||||
if (maintenanceMode.isActive()) {
|
||||
return { started: false, maintenance: true, maintenanceStatus: maintenanceSummary() };
|
||||
}
|
||||
const existing = getStoreRefreshJob(session.id);
|
||||
if (existing && existing.status === 'running') {
|
||||
return { started: false, job: existing };
|
||||
@@ -636,6 +708,7 @@ function triggerStoreRefresh(session, { force = false, reason } = {}) {
|
||||
}
|
||||
|
||||
async function runStoreRefreshJob(session, job) {
|
||||
maintenanceMode.ensureInactive('Store-Refresh ist während des Wartungsmodus deaktiviert.');
|
||||
job.status = 'running';
|
||||
job.startedAt = Date.now();
|
||||
const settings = adminConfig.readSettings();
|
||||
@@ -657,6 +730,9 @@ async function runStoreRefreshJob(session, job) {
|
||||
),
|
||||
{ label: 'fetchStores' }
|
||||
);
|
||||
if (stores.length === 0) {
|
||||
throw new Error('Store-Refresh lieferte keine Betriebe; bestehende Daten bleiben unverändert.');
|
||||
}
|
||||
job.processed = stores.length;
|
||||
job.total = stores.length;
|
||||
job.currentStore = null;
|
||||
@@ -664,6 +740,7 @@ async function runStoreRefreshJob(session, job) {
|
||||
sessionStore.update(session.id, {
|
||||
storesCache: { data: stores, fetchedAt: Date.now() }
|
||||
});
|
||||
writeStoresCache(session.profile.id, sessionStore.get(session.id)?.storesCache);
|
||||
|
||||
let config = readConfig(session.profile.id);
|
||||
const { merged, changed } = mergeStoresIntoConfig(config, stores);
|
||||
@@ -761,13 +838,14 @@ async function restoreSessionsFromDisk() {
|
||||
credentials,
|
||||
isAdmin: isAdminUser
|
||||
}, credentials.token, ONE_YEAR_MS);
|
||||
const persistedStoresCache = readStoresCache(profile.id);
|
||||
credentialStore.save(profile.id, {
|
||||
email: credentials.email,
|
||||
password: credentials.password,
|
||||
token: session.id
|
||||
});
|
||||
sessionStore.update(session.id, {
|
||||
storesCache: sessionStore.get(session.id)?.storesCache || null
|
||||
storesCache: persistedStoresCache
|
||||
});
|
||||
scheduleConfig(session.id, config, schedulerSettings);
|
||||
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)) {
|
||||
sessionStore.update(session.id, { storesCache: cachedStoreSnapshots.get(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 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) => {
|
||||
try {
|
||||
maintenanceMode.ensureInactive('Ad-hoc-Store-Watch ist während des Wartungsmodus deaktiviert.');
|
||||
const settings = adminConfig.readSettings();
|
||||
const summary = await runStoreWatchCheck(req.session.id, settings, {
|
||||
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 : [] });
|
||||
} 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);
|
||||
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 reminderChanged = JSON.stringify(existing.reminder || {}) !== JSON.stringify(normalizedReminder);
|
||||
const updated = {
|
||||
...existing,
|
||||
storeId: String(storeId),
|
||||
@@ -1240,7 +1331,7 @@ app.put('/api/journal/:id', requireAuth, (req, res) => {
|
||||
reminder: normalizedReminder,
|
||||
images: collectedImages,
|
||||
updatedAt: new Date().toISOString(),
|
||||
lastReminderAt: pickupDateChanged ? null : existing.lastReminderAt
|
||||
lastReminderAt: pickupDateChanged || reminderChanged ? null : existing.lastReminderAt
|
||||
};
|
||||
|
||||
entries[index] = updated;
|
||||
@@ -1296,12 +1387,24 @@ app.post('/api/config', requireAuth, (req, res) => {
|
||||
if (!Array.isArray(req.body)) {
|
||||
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);
|
||||
scheduleWithCurrentSettings(req.session.id, req.body);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
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 settings = adminConfig.readSettings();
|
||||
runImmediatePickupCheck(req.session.id, config, settings).catch((error) => {
|
||||
@@ -1310,6 +1413,22 @@ app.post('/api/config/check', requireAuth, (req, res) => {
|
||||
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) => {
|
||||
const userSettings = readNotificationSettings(req.session.profile.id);
|
||||
const adminSettings = adminConfig.readSettings();
|
||||
@@ -1381,13 +1500,21 @@ app.get('/api/stores', requireAuth, async (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 reason = req.body?.reason || 'manual';
|
||||
const result = triggerStoreRefresh(req.session, { force, reason });
|
||||
res.json({
|
||||
started: !!result.started,
|
||||
storesFresh: isStoreCacheFresh(req.session),
|
||||
job: summarizeJob(result.job)
|
||||
job: summarizeJob(result.job),
|
||||
maintenance: result.maintenanceStatus || null
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { readJsonFile, writeJsonFile } = require('./jsonFileStore');
|
||||
|
||||
const CONFIG_DIR = path.join(__dirname, '..', 'config');
|
||||
const SETTINGS_FILE = path.join(CONFIG_DIR, 'admin-settings.json');
|
||||
@@ -20,6 +21,16 @@ const DEFAULT_SETTINGS = {
|
||||
storeWatchRequestDelayMs: 1000,
|
||||
storeWatchStatusCacheMaxAgeMinutes: 120,
|
||||
storePickupCheckDelayMs: 400,
|
||||
messageWatcherSocketEnabled: true,
|
||||
messageWatcherFallbackEnabled: true,
|
||||
messageWatcherFallbackCron: '0 6-23 * * *',
|
||||
messageWatcherDebugLogging: true,
|
||||
maintenanceModeActive: false,
|
||||
maintenanceModeAutoEnabled: true,
|
||||
maintenanceModeActivatedAt: null,
|
||||
maintenanceModeReason: '',
|
||||
maintenanceModeErrorThreshold: 8,
|
||||
maintenanceModeErrorWindowMinutes: 10,
|
||||
ignoredSlots: [
|
||||
{
|
||||
storeId: '51450',
|
||||
@@ -100,6 +111,11 @@ function sanitizeString(value) {
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function sanitizeNullableString(value, fallback = null) {
|
||||
const sanitized = sanitizeString(value);
|
||||
return sanitized || fallback;
|
||||
}
|
||||
|
||||
function sanitizeNotifications(input = {}) {
|
||||
const defaults = DEFAULT_SETTINGS.notifications;
|
||||
return {
|
||||
@@ -121,13 +137,16 @@ function sanitizeNotifications(input = {}) {
|
||||
function readSettings() {
|
||||
ensureDir();
|
||||
if (!fs.existsSync(SETTINGS_FILE)) {
|
||||
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(DEFAULT_SETTINGS, null, 2));
|
||||
writeJsonFile(SETTINGS_FILE, DEFAULT_SETTINGS, { backup: false });
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(SETTINGS_FILE, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
const parsed = readJsonFile(
|
||||
SETTINGS_FILE,
|
||||
DEFAULT_SETTINGS,
|
||||
(value) => value && typeof value === 'object' && !Array.isArray(value)
|
||||
);
|
||||
return {
|
||||
scheduleCron: parsed.scheduleCron || DEFAULT_SETTINGS.scheduleCron,
|
||||
pickupFallbackCron: parsed.pickupFallbackCron || DEFAULT_SETTINGS.pickupFallbackCron,
|
||||
@@ -162,6 +181,35 @@ function readSettings() {
|
||||
parsed.storePickupCheckDelayMs,
|
||||
DEFAULT_SETTINGS.storePickupCheckDelayMs
|
||||
),
|
||||
messageWatcherSocketEnabled:
|
||||
parsed.messageWatcherSocketEnabled === undefined
|
||||
? DEFAULT_SETTINGS.messageWatcherSocketEnabled
|
||||
: !!parsed.messageWatcherSocketEnabled,
|
||||
messageWatcherFallbackEnabled:
|
||||
parsed.messageWatcherFallbackEnabled === undefined
|
||||
? DEFAULT_SETTINGS.messageWatcherFallbackEnabled
|
||||
: !!parsed.messageWatcherFallbackEnabled,
|
||||
messageWatcherFallbackCron:
|
||||
parsed.messageWatcherFallbackCron || DEFAULT_SETTINGS.messageWatcherFallbackCron,
|
||||
messageWatcherDebugLogging:
|
||||
parsed.messageWatcherDebugLogging === undefined
|
||||
? DEFAULT_SETTINGS.messageWatcherDebugLogging
|
||||
: !!parsed.messageWatcherDebugLogging,
|
||||
maintenanceModeActive: !!parsed.maintenanceModeActive,
|
||||
maintenanceModeAutoEnabled:
|
||||
parsed.maintenanceModeAutoEnabled === undefined
|
||||
? DEFAULT_SETTINGS.maintenanceModeAutoEnabled
|
||||
: !!parsed.maintenanceModeAutoEnabled,
|
||||
maintenanceModeActivatedAt: sanitizeNullableString(parsed.maintenanceModeActivatedAt, null),
|
||||
maintenanceModeReason: sanitizeString(parsed.maintenanceModeReason),
|
||||
maintenanceModeErrorThreshold: sanitizeNumber(
|
||||
parsed.maintenanceModeErrorThreshold,
|
||||
DEFAULT_SETTINGS.maintenanceModeErrorThreshold
|
||||
),
|
||||
maintenanceModeErrorWindowMinutes: sanitizeNumber(
|
||||
parsed.maintenanceModeErrorWindowMinutes,
|
||||
DEFAULT_SETTINGS.maintenanceModeErrorWindowMinutes
|
||||
),
|
||||
ignoredSlots: sanitizeIgnoredSlots(parsed.ignoredSlots),
|
||||
notifications: sanitizeNotifications(parsed.notifications)
|
||||
};
|
||||
@@ -207,6 +255,43 @@ function writeSettings(patch = {}) {
|
||||
patch.storePickupCheckDelayMs,
|
||||
current.storePickupCheckDelayMs
|
||||
),
|
||||
messageWatcherSocketEnabled:
|
||||
patch.messageWatcherSocketEnabled === undefined
|
||||
? current.messageWatcherSocketEnabled
|
||||
: !!patch.messageWatcherSocketEnabled,
|
||||
messageWatcherFallbackEnabled:
|
||||
patch.messageWatcherFallbackEnabled === undefined
|
||||
? current.messageWatcherFallbackEnabled
|
||||
: !!patch.messageWatcherFallbackEnabled,
|
||||
messageWatcherFallbackCron: patch.messageWatcherFallbackCron || current.messageWatcherFallbackCron,
|
||||
messageWatcherDebugLogging:
|
||||
patch.messageWatcherDebugLogging === undefined
|
||||
? current.messageWatcherDebugLogging
|
||||
: !!patch.messageWatcherDebugLogging,
|
||||
maintenanceModeActive:
|
||||
patch.maintenanceModeActive === undefined
|
||||
? current.maintenanceModeActive
|
||||
: !!patch.maintenanceModeActive,
|
||||
maintenanceModeAutoEnabled:
|
||||
patch.maintenanceModeAutoEnabled === undefined
|
||||
? current.maintenanceModeAutoEnabled
|
||||
: !!patch.maintenanceModeAutoEnabled,
|
||||
maintenanceModeActivatedAt:
|
||||
patch.maintenanceModeActivatedAt === undefined
|
||||
? current.maintenanceModeActivatedAt
|
||||
: sanitizeNullableString(patch.maintenanceModeActivatedAt, null),
|
||||
maintenanceModeReason:
|
||||
patch.maintenanceModeReason === undefined
|
||||
? current.maintenanceModeReason
|
||||
: sanitizeString(patch.maintenanceModeReason),
|
||||
maintenanceModeErrorThreshold: sanitizeNumber(
|
||||
patch.maintenanceModeErrorThreshold,
|
||||
current.maintenanceModeErrorThreshold
|
||||
),
|
||||
maintenanceModeErrorWindowMinutes: sanitizeNumber(
|
||||
patch.maintenanceModeErrorWindowMinutes,
|
||||
current.maintenanceModeErrorWindowMinutes
|
||||
),
|
||||
ignoredSlots:
|
||||
patch.ignoredSlots !== undefined
|
||||
? sanitizeIgnoredSlots(patch.ignoredSlots)
|
||||
@@ -217,8 +302,13 @@ function writeSettings(patch = {}) {
|
||||
: current.notifications
|
||||
};
|
||||
|
||||
if (!next.maintenanceModeActive) {
|
||||
next.maintenanceModeActivatedAt = null;
|
||||
next.maintenanceModeReason = '';
|
||||
}
|
||||
|
||||
ensureDir();
|
||||
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(next, null, 2));
|
||||
writeJsonFile(SETTINGS_FILE, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const defaultConfig = require('../data/defaultConfig');
|
||||
const { readJsonFile, writeJsonFile } = require('./jsonFileStore');
|
||||
|
||||
const CONFIG_DIR = path.join(__dirname, '..', 'config');
|
||||
|
||||
@@ -18,7 +19,7 @@ function hydrateConfigFile(profileId) {
|
||||
ensureDir();
|
||||
const filePath = getConfigPath(profileId);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fs.writeFileSync(filePath, JSON.stringify(defaultConfig, null, 2));
|
||||
writeJsonFile(filePath, defaultConfig, { backup: false });
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
@@ -26,8 +27,7 @@ function hydrateConfigFile(profileId) {
|
||||
function readConfig(profileId) {
|
||||
const filePath = hydrateConfigFile(profileId);
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
const parsed = readJsonFile(filePath, defaultConfig, Array.isArray);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (err) {
|
||||
console.error(`Failed to read config for ${profileId}:`, err);
|
||||
@@ -37,7 +37,7 @@ function readConfig(profileId) {
|
||||
|
||||
function writeConfig(profileId, payload) {
|
||||
const filePath = hydrateConfigFile(profileId);
|
||||
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2));
|
||||
writeJsonFile(filePath, payload);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { readJsonFile, writeJsonFile } = require('./jsonFileStore');
|
||||
|
||||
const CONFIG_DIR = path.join(__dirname, '..', 'config');
|
||||
const CREDENTIAL_FILE = path.join(CONFIG_DIR, 'credentials.json');
|
||||
@@ -13,12 +14,15 @@ function ensureDir() {
|
||||
function readStore() {
|
||||
ensureDir();
|
||||
if (!fs.existsSync(CREDENTIAL_FILE)) {
|
||||
fs.writeFileSync(CREDENTIAL_FILE, JSON.stringify({}, null, 2));
|
||||
writeJsonFile(CREDENTIAL_FILE, {}, { backup: false });
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(CREDENTIAL_FILE, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
const parsed = readJsonFile(
|
||||
CREDENTIAL_FILE,
|
||||
{},
|
||||
(value) => value && typeof value === 'object' && !Array.isArray(value)
|
||||
);
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch (error) {
|
||||
console.error('Konnte Credential-Store nicht lesen:', error.message);
|
||||
@@ -28,7 +32,7 @@ function readStore() {
|
||||
|
||||
function writeStore(store) {
|
||||
ensureDir();
|
||||
fs.writeFileSync(CREDENTIAL_FILE, JSON.stringify(store, null, 2));
|
||||
writeJsonFile(CREDENTIAL_FILE, store);
|
||||
}
|
||||
|
||||
function save(profileId, credentials) {
|
||||
|
||||
@@ -3,6 +3,7 @@ const http = require('http');
|
||||
const https = require('https');
|
||||
const requestLogStore = require('./requestLogStore');
|
||||
const sessionStore = require('./sessionStore');
|
||||
const maintenanceMode = require('./maintenanceMode');
|
||||
|
||||
const BASE_URL = 'https://foodsharing.de';
|
||||
|
||||
@@ -30,6 +31,7 @@ client.interceptors.response.use(
|
||||
(response) => {
|
||||
const startedAt = response?.config?.metadata?.startedAt || Date.now();
|
||||
const metadata = response?.config?.metadata || {};
|
||||
if (!metadata.skipRequestLog) {
|
||||
try {
|
||||
requestLogStore.add({
|
||||
direction: 'outgoing',
|
||||
@@ -46,14 +48,17 @@ client.interceptors.response.use(
|
||||
} catch (error) {
|
||||
console.warn('[REQUEST-LOG] Outgoing-Log fehlgeschlagen:', error.message);
|
||||
}
|
||||
}
|
||||
updateSessionCookiesFromResponse(response);
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
const startedAt = error?.config?.metadata?.startedAt || Date.now();
|
||||
const metadata = error?.config?.metadata || {};
|
||||
let logEntry = null;
|
||||
if (!metadata.skipRequestLog) {
|
||||
try {
|
||||
requestLogStore.add({
|
||||
logEntry = requestLogStore.add({
|
||||
direction: 'outgoing',
|
||||
target: 'foodsharing.de',
|
||||
method: (error.config?.method || 'GET').toUpperCase(),
|
||||
@@ -69,6 +74,18 @@ client.interceptors.response.use(
|
||||
} catch (logError) {
|
||||
console.warn('[REQUEST-LOG] Outgoing-Error-Log fehlgeschlagen:', logError.message);
|
||||
}
|
||||
try {
|
||||
maintenanceMode.recordOutboundFailure(logEntry || {
|
||||
direction: 'outgoing',
|
||||
target: 'foodsharing.de',
|
||||
status: error?.response?.status || null,
|
||||
error: error?.message || 'Unbekannter Fehler',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
} catch (maintenanceError) {
|
||||
console.warn('[MAINTENANCE] Fehlerauswertung fehlgeschlagen:', maintenanceError.message);
|
||||
}
|
||||
}
|
||||
if (error?.response) {
|
||||
updateSessionCookiesFromResponse(error.response);
|
||||
}
|
||||
@@ -235,7 +252,7 @@ function updateSessionCookiesFromResponse(response) {
|
||||
|
||||
async function getCurrentUserDetails(cookieHeader, context, options = {}) {
|
||||
const response = await client.get(
|
||||
'/api/user/current/details',
|
||||
'/api/users/current/details',
|
||||
buildRequestConfig({ cookieHeader, context })
|
||||
);
|
||||
return options.raw ? response : response.data;
|
||||
@@ -245,22 +262,23 @@ async function login(email, password) {
|
||||
const payload = {
|
||||
email,
|
||||
password,
|
||||
remember_me: true
|
||||
code: '',
|
||||
rememberMe: true
|
||||
};
|
||||
|
||||
const headers = {
|
||||
'sec-ch-ua': '"Chromium";v="128", "Not;A=Brand";v="24", "Google Chrome";v="128"',
|
||||
Origin: BASE_URL,
|
||||
Referer: BASE_URL,
|
||||
DNT: '1',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"Linux"',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
Pragma: 'no-cache',
|
||||
Expires: '0',
|
||||
'Content-Type': 'application/json; charset=utf-8'
|
||||
};
|
||||
|
||||
const response = await client.post('/api/user/login', payload, { headers });
|
||||
const response = await client.post('/api/login', payload, { headers });
|
||||
const cookies = response.headers['set-cookie'] || [];
|
||||
const csrfToken = extractCsrfToken(cookies);
|
||||
let cookieHeader = serializeCookies(cookies);
|
||||
let cookieHeader = mergeCookieHeaders('', cookies);
|
||||
const detailsResponse = await getCurrentUserDetails(cookieHeader, null, { raw: true });
|
||||
const detailsCookies = detailsResponse?.headers?.['set-cookie'] || [];
|
||||
if (detailsCookies.length > 0) {
|
||||
@@ -307,6 +325,20 @@ function wait(ms = 0) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function hasRegularStoreAccess(store) {
|
||||
if (!store) {
|
||||
return false;
|
||||
}
|
||||
if (store.isManaging) {
|
||||
return true;
|
||||
}
|
||||
const membershipStatus = Number(store.membershipStatus);
|
||||
if (!Number.isFinite(membershipStatus)) {
|
||||
return true;
|
||||
}
|
||||
return membershipStatus === 1;
|
||||
}
|
||||
|
||||
async function fetchStores(cookieHeader, profileId, options = {}, context) {
|
||||
if (!profileId) {
|
||||
return [];
|
||||
@@ -320,8 +352,8 @@ async function fetchStores(cookieHeader, profileId, options = {}, context) {
|
||||
: null;
|
||||
try {
|
||||
const response = await client.get(
|
||||
`/api/user/${profileId}/stores`,
|
||||
buildRequestConfig({ cookieHeader, params: { activeStores: 1 }, context })
|
||||
`/api/users/${profileId}/stores`,
|
||||
buildRequestConfig({ cookieHeader, params: { excludeInactive: true }, context })
|
||||
);
|
||||
const stores = Array.isArray(response.data) ? response.data : [];
|
||||
const normalized = stores.map((store) => ({
|
||||
@@ -332,7 +364,7 @@ async function fetchStores(cookieHeader, profileId, options = {}, context) {
|
||||
isManaging: !!store.isManaging,
|
||||
city: store.city || '',
|
||||
street: store.street || '',
|
||||
zip: store.zip || ''
|
||||
zip: store.zipCode || store.zip || ''
|
||||
}));
|
||||
|
||||
return annotateStoresWithPickupSlots(
|
||||
@@ -375,6 +407,14 @@ async function annotateStoresWithPickupSlots(
|
||||
await wait(delayMs);
|
||||
}
|
||||
let hasPickupSlots = null;
|
||||
if (!hasRegularStoreAccess(store)) {
|
||||
hasPickupSlots = false;
|
||||
console.info(
|
||||
`Pickups für Store ${store.id} werden wegen Mitgliedschaftsstatus ${store.membershipStatus} nicht abgefragt.`
|
||||
);
|
||||
annotated.push({ ...store, hasPickupSlots });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const pickups = await fetchPickups(store.id, cookieHeader, context);
|
||||
hasPickupSlots = Array.isArray(pickups) && pickups.length > 0;
|
||||
@@ -398,6 +438,9 @@ async function fetchPickups(storeId, cookieHeader, context) {
|
||||
`/api/stores/${storeId}/pickups`,
|
||||
buildRequestConfig({ cookieHeader, context })
|
||||
);
|
||||
if (Array.isArray(response.data)) {
|
||||
return response.data;
|
||||
}
|
||||
return response.data?.pickups || [];
|
||||
}
|
||||
|
||||
@@ -406,21 +449,22 @@ async function fetchRegionStores(regionId, cookieHeader, context) {
|
||||
return { total: 0, stores: [] };
|
||||
}
|
||||
const response = await client.get(
|
||||
`/api/region/${regionId}/stores`,
|
||||
`/api/regions/${regionId}/stores`,
|
||||
buildRequestConfig({ cookieHeader, context })
|
||||
);
|
||||
const stores = Array.isArray(response.data) ? response.data : Array.isArray(response.data?.stores) ? response.data.stores : [];
|
||||
return {
|
||||
total: Number(response.data?.total) || 0,
|
||||
stores: Array.isArray(response.data?.stores) ? response.data.stores : []
|
||||
total: Number(response.data?.total) || stores.length,
|
||||
stores
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchStoreDetails(storeId, cookieHeader, context) {
|
||||
async function fetchStoreMarker(storeId, cookieHeader, context) {
|
||||
if (!storeId) {
|
||||
return null;
|
||||
}
|
||||
const response = await client.get(
|
||||
`/api/map/stores/${storeId}`,
|
||||
`/api/map/markers/stores/${storeId}`,
|
||||
buildRequestConfig({ cookieHeader, context })
|
||||
);
|
||||
return response.data || null;
|
||||
@@ -428,14 +472,14 @@ async function fetchStoreDetails(storeId, cookieHeader, context) {
|
||||
|
||||
async function pickupRuleCheck(storeId, utcDate, profileId, session) {
|
||||
const response = await client.get(
|
||||
`/api/stores/${storeId}/pickupRuleCheck/${utcDate}/${profileId}`,
|
||||
`/api/stores/${storeId}/pickups/${utcDate}/eligibility`,
|
||||
buildRequestConfig({
|
||||
cookieHeader: session.cookieHeader,
|
||||
csrfToken: session.csrfToken,
|
||||
context: session
|
||||
})
|
||||
);
|
||||
return response.data?.result === true;
|
||||
return response.data?.isEligible === true || response.data?.result === true;
|
||||
}
|
||||
|
||||
async function fetchStoreMembers(storeId, cookieHeader, context) {
|
||||
@@ -443,7 +487,7 @@ async function fetchStoreMembers(storeId, cookieHeader, context) {
|
||||
return [];
|
||||
}
|
||||
const response = await client.get(
|
||||
`/api/stores/${storeId}/member`,
|
||||
`/api/stores/${storeId}/members`,
|
||||
buildRequestConfig({ cookieHeader, context })
|
||||
);
|
||||
return Array.isArray(response.data) ? response.data : [];
|
||||
@@ -454,15 +498,41 @@ async function fetchRegularPickup(storeId, cookieHeader, context) {
|
||||
return [];
|
||||
}
|
||||
const response = await client.get(
|
||||
`/api/stores/${storeId}/regularPickup`,
|
||||
`/api/stores/${storeId}/regular-pickups`,
|
||||
buildRequestConfig({ cookieHeader, context })
|
||||
);
|
||||
return Array.isArray(response.data) ? response.data : [];
|
||||
}
|
||||
|
||||
async function fetchRegisteredPickups(userId, cookieHeader, context) {
|
||||
const normalizedUserId = userId ? String(userId) : 'current';
|
||||
const response = await client.get(
|
||||
`/api/users/${normalizedUserId}/pickups/registered`,
|
||||
buildRequestConfig({ cookieHeader, context })
|
||||
);
|
||||
return Array.isArray(response.data) ? response.data : [];
|
||||
}
|
||||
|
||||
async function fetchConversations(cookieHeader, options = {}, context) {
|
||||
const limit = Math.max(1, Math.min(Number(options.limit) || 20, 100));
|
||||
const offset = Math.max(0, Number(options.offset) || 0);
|
||||
const config = buildRequestConfig({ cookieHeader, params: { limit, offset }, context });
|
||||
config.metadata = { ...(config.metadata || {}), skipRequestLog: true };
|
||||
const response = await client.get('/api/conversations', config);
|
||||
const conversations = Array.isArray(response.data?.conversations)
|
||||
? response.data.conversations
|
||||
: Array.isArray(response.data)
|
||||
? response.data
|
||||
: [];
|
||||
return {
|
||||
conversations,
|
||||
profiles: Array.isArray(response.data?.profiles) ? response.data.profiles : []
|
||||
};
|
||||
}
|
||||
|
||||
async function bookSlot(storeId, utcDate, profileId, session) {
|
||||
await client.post(
|
||||
`/api/stores/${storeId}/pickups/${utcDate}/${profileId}`,
|
||||
`/api/stores/${storeId}/pickups/${utcDate}/users/current`,
|
||||
{},
|
||||
{
|
||||
...buildRequestConfig({
|
||||
@@ -480,9 +550,11 @@ module.exports = {
|
||||
fetchStores,
|
||||
fetchPickups,
|
||||
fetchRegionStores,
|
||||
fetchStoreDetails,
|
||||
fetchStoreMarker,
|
||||
fetchStoreMembers,
|
||||
fetchRegularPickup,
|
||||
fetchRegisteredPickups,
|
||||
fetchConversations,
|
||||
pickupRuleCheck,
|
||||
bookSlot
|
||||
};
|
||||
|
||||
215
services/journalReminderUtils.js
Normal file
215
services/journalReminderUtils.js
Normal 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
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { readJsonFile, writeJsonFile } = require('./jsonFileStore');
|
||||
|
||||
const CONFIG_DIR = path.join(__dirname, '..', 'config');
|
||||
const IMAGE_ROOT = path.join(CONFIG_DIR, 'journal-images');
|
||||
@@ -27,7 +28,7 @@ function hydrateJournalFile(profileId) {
|
||||
ensureBaseDirs();
|
||||
const filePath = getJournalPath(profileId);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fs.writeFileSync(filePath, JSON.stringify([], null, 2));
|
||||
writeJsonFile(filePath, [], { backup: false });
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
@@ -35,8 +36,7 @@ function hydrateJournalFile(profileId) {
|
||||
function readJournal(profileId) {
|
||||
const filePath = hydrateJournalFile(profileId);
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
const parsed = readJsonFile(filePath, [], Array.isArray);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (error) {
|
||||
console.error(`[JOURNAL] Konnte Journal für ${profileId} nicht lesen:`, error.message);
|
||||
@@ -46,7 +46,7 @@ function readJournal(profileId) {
|
||||
|
||||
function writeJournal(profileId, entries = []) {
|
||||
const filePath = hydrateJournalFile(profileId);
|
||||
fs.writeFileSync(filePath, JSON.stringify(entries, null, 2));
|
||||
writeJsonFile(filePath, entries);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
|
||||
89
services/jsonFileStore.js
Normal file
89
services/jsonFileStore.js
Normal 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
135
services/maintenanceMode.js
Normal 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
|
||||
};
|
||||
@@ -351,6 +351,22 @@ async function sendAdminSessionErrorNotification({
|
||||
});
|
||||
}
|
||||
|
||||
async function sendAdminMaintenanceModeNotification({ reason, activatedAt, errorThreshold, errorWindowMinutes }) {
|
||||
const messageLines = [
|
||||
'Der Wartungsmodus wurde automatisch aktiviert.',
|
||||
activatedAt ? `Aktiv seit: ${formatDateLabel(activatedAt)}.` : null,
|
||||
Number.isFinite(errorThreshold) && Number.isFinite(errorWindowMinutes)
|
||||
? `Schwelle: ${errorThreshold} relevante Fehler in ${errorWindowMinutes} Minuten.`
|
||||
: null,
|
||||
reason ? `Grund: ${reason}.` : null
|
||||
].filter(Boolean);
|
||||
await sendAdminTelegramNotification({
|
||||
title: 'Wartungsmodus aktiviert',
|
||||
message: messageLines.join('\n'),
|
||||
priority: 'high'
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendSlotNotification,
|
||||
sendStoreWatchNotification,
|
||||
@@ -360,5 +376,6 @@ module.exports = {
|
||||
sendDormantPickupWarning,
|
||||
sendJournalReminderNotification,
|
||||
sendAdminBookingErrorNotification,
|
||||
sendAdminSessionErrorNotification
|
||||
sendAdminSessionErrorNotification,
|
||||
sendAdminMaintenanceModeNotification
|
||||
};
|
||||
|
||||
@@ -11,6 +11,9 @@ const { readJournal, writeJournal } = require('./journalStore');
|
||||
const { getStoreStatus, setStoreStatus, persistStoreStatusCache } = require('./storeStatusCache');
|
||||
const { sendDormantPickupWarning, sendJournalReminderNotification } = require('./notificationService');
|
||||
const { ensureSession, withSessionRetry } = require('./sessionRefresh');
|
||||
const maintenanceMode = require('./maintenanceMode');
|
||||
const { computeReminderSchedule } = require('./journalReminderUtils');
|
||||
const { startStoreMessageWatcher } = require('./storeMessageWatcher');
|
||||
|
||||
function wait(ms) {
|
||||
if (!ms || ms <= 0) {
|
||||
@@ -19,6 +22,14 @@ function wait(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function shouldPauseForMaintenance(scope) {
|
||||
if (!maintenanceMode.isActive()) {
|
||||
return false;
|
||||
}
|
||||
console.warn(`[MAINTENANCE] ${scope} übersprungen, weil Wartungsmodus aktiv ist.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const DEFAULT_STORE_WATCH_STATUS_MAX_AGE_MINUTES = 120;
|
||||
const storeWatchInFlight = new Map();
|
||||
const pickupCheckInFlight = new Map();
|
||||
@@ -118,13 +129,37 @@ function loadRegularPickupCacheFromDisk() {
|
||||
const PICKUP_FALLBACK_RETRY_MS = 60 * 60 * 1000;
|
||||
const TIME_ZONE = 'Europe/Berlin';
|
||||
|
||||
function hasDormantMemberAccess(store) {
|
||||
if (!store) {
|
||||
return false;
|
||||
}
|
||||
if (store.isManaging) {
|
||||
return true;
|
||||
}
|
||||
const membershipStatus = Number(store.membershipStatus);
|
||||
if (!Number.isFinite(membershipStatus)) {
|
||||
return true;
|
||||
}
|
||||
return membershipStatus === 1;
|
||||
}
|
||||
|
||||
function resolveStoreTeamStatus(details) {
|
||||
const status = Number(details?.teamStatus ?? details?.teamSearchStatus);
|
||||
return Number.isFinite(status) ? status : null;
|
||||
}
|
||||
|
||||
function isStoreStatusAccessError(error) {
|
||||
const status = error?.response?.status;
|
||||
return status === 403 || status === 404;
|
||||
}
|
||||
|
||||
async function fetchSharedStoreStatus(session, storeId, { forceRefresh = false, maxAgeMs } = {}) {
|
||||
if (!storeId) {
|
||||
return { status: null, fetchedAt: null, fromCache: false };
|
||||
}
|
||||
const cacheEntry = getStoreStatus(storeId);
|
||||
const cachedStatus = cacheEntry?.teamSearchStatus;
|
||||
const hasCachedStatus = cachedStatus === 0 || cachedStatus === 1;
|
||||
const hasCachedStatus = cacheEntry != null;
|
||||
const cachedAt = Number(cacheEntry?.fetchedAt) || 0;
|
||||
const effectiveMaxAge =
|
||||
Number.isFinite(maxAgeMs) && maxAgeMs >= 0
|
||||
@@ -141,15 +176,23 @@ async function fetchSharedStoreStatus(session, storeId, { forceRefresh = false,
|
||||
}
|
||||
|
||||
const fetchPromise = (async () => {
|
||||
const details = await withSessionRetry(
|
||||
session,
|
||||
() => foodsharingClient.fetchStoreDetails(storeId, session.cookieHeader, session),
|
||||
{ label: 'fetchStoreDetails' }
|
||||
);
|
||||
const status = details?.teamSearchStatus === 1 ? 1 : 0;
|
||||
const fetchedAt = Date.now();
|
||||
try {
|
||||
const marker = await withSessionRetry(
|
||||
session,
|
||||
() => foodsharingClient.fetchStoreMarker(storeId, session.cookieHeader, session),
|
||||
{ label: 'fetchStoreMarker' }
|
||||
);
|
||||
const status = resolveStoreTeamStatus(marker);
|
||||
setStoreStatus(storeId, { teamSearchStatus: status, fetchedAt });
|
||||
return { status, fetchedAt, fromCache: false };
|
||||
} catch (error) {
|
||||
if (!isStoreStatusAccessError(error)) {
|
||||
throw error;
|
||||
}
|
||||
setStoreStatus(storeId, { teamSearchStatus: null, fetchedAt });
|
||||
return { status: null, fetchedAt, fromCache: false };
|
||||
}
|
||||
})();
|
||||
|
||||
storeWatchInFlight.set(key, fetchPromise);
|
||||
@@ -361,6 +404,20 @@ function resolveSettings(settings) {
|
||||
storeWatchStatusCacheMaxAgeMinutes: Number.isFinite(settings.storeWatchStatusCacheMaxAgeMinutes)
|
||||
? settings.storeWatchStatusCacheMaxAgeMinutes
|
||||
: DEFAULT_SETTINGS.storeWatchStatusCacheMaxAgeMinutes,
|
||||
messageWatcherSocketEnabled:
|
||||
settings.messageWatcherSocketEnabled === undefined
|
||||
? DEFAULT_SETTINGS.messageWatcherSocketEnabled
|
||||
: !!settings.messageWatcherSocketEnabled,
|
||||
messageWatcherFallbackEnabled:
|
||||
settings.messageWatcherFallbackEnabled === undefined
|
||||
? DEFAULT_SETTINGS.messageWatcherFallbackEnabled
|
||||
: !!settings.messageWatcherFallbackEnabled,
|
||||
messageWatcherFallbackCron:
|
||||
settings.messageWatcherFallbackCron || DEFAULT_SETTINGS.messageWatcherFallbackCron,
|
||||
messageWatcherDebugLogging:
|
||||
settings.messageWatcherDebugLogging === undefined
|
||||
? DEFAULT_SETTINGS.messageWatcherDebugLogging
|
||||
: !!settings.messageWatcherDebugLogging,
|
||||
ignoredSlots: Array.isArray(settings.ignoredSlots) ? settings.ignoredSlots : DEFAULT_SETTINGS.ignoredSlots,
|
||||
notifications: {
|
||||
ntfy: {
|
||||
@@ -460,6 +517,9 @@ async function getRegularPickupSchedule(session, storeId) {
|
||||
if (!session?.profile?.id || !storeId) {
|
||||
return { rules: [], error: 'missing-session-or-store', fromCache: false };
|
||||
}
|
||||
if (shouldPauseForMaintenance(`Regular-Pickup-Refresh für Store ${storeId}`)) {
|
||||
return { rules: [], error: 'maintenance-mode', fromCache: false };
|
||||
}
|
||||
const key = String(storeId);
|
||||
const cached = getRegularPickupCacheEntry(key);
|
||||
if (cached) {
|
||||
@@ -570,7 +630,7 @@ function deactivateEntryInMemory(entry) {
|
||||
|
||||
function persistEntryDeactivation(profileId, entryId, options = {}) {
|
||||
if (!profileId || !entryId) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const config = readConfig(profileId);
|
||||
@@ -603,11 +663,28 @@ function persistEntryDeactivation(profileId, entryId, options = {}) {
|
||||
if (changed) {
|
||||
writeConfig(profileId, updated);
|
||||
}
|
||||
return changed;
|
||||
} catch (error) {
|
||||
console.error(`[CONFIG] Konnte Eintrag ${entryId} für Profil ${profileId} nicht deaktivieren:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function deactivateEntry(session, entry, options = {}) {
|
||||
if (!session?.profile?.id || !entry?.id) {
|
||||
deactivateEntryInMemory(entry);
|
||||
return false;
|
||||
}
|
||||
deactivateEntryInMemory(entry);
|
||||
const changed = persistEntryDeactivation(session.profile.id, entry.id, options);
|
||||
if (changed) {
|
||||
const config = readConfig(session.profile.id);
|
||||
const settings = resolveSettings();
|
||||
scheduleConfig(session.id, config, settings);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function isEntryActiveInConfig(profileId, entryId) {
|
||||
if (!profileId || !entryId) {
|
||||
return false;
|
||||
@@ -692,9 +769,8 @@ async function handleExpiredDesiredWindow(session, entry) {
|
||||
console.log(
|
||||
`[INFO] Wunschzeitraum abgelaufen für ${storeName}${desiredLabel ? ` (${desiredLabel})` : ''}. Eintrag wird deaktiviert.`
|
||||
);
|
||||
deactivateEntryInMemory(entry);
|
||||
resetDesiredWindow(entry);
|
||||
persistEntryDeactivation(profileId, entry.id, { resetDesiredWindow: true });
|
||||
deactivateEntry(session, entry, { resetDesiredWindow: true });
|
||||
if (profileId) {
|
||||
try {
|
||||
await notificationService.sendDesiredWindowMissedNotification({
|
||||
@@ -777,8 +853,7 @@ async function processBooking(session, entry, pickup) {
|
||||
booked: false,
|
||||
storeId: entry.id
|
||||
});
|
||||
deactivateEntryInMemory(entry);
|
||||
persistEntryDeactivation(session.profile.id, entry.id);
|
||||
deactivateEntry(session, entry);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -808,8 +883,7 @@ async function processBooking(session, entry, pickup) {
|
||||
booked: true,
|
||||
storeId: entry.id
|
||||
});
|
||||
deactivateEntryInMemory(entry);
|
||||
persistEntryDeactivation(session.profile.id, entry.id);
|
||||
deactivateEntry(session, entry);
|
||||
} catch (error) {
|
||||
console.error(`[ERROR] Buchung für ${storeName} am ${readableDate} fehlgeschlagen:`, error.message);
|
||||
try {
|
||||
@@ -831,6 +905,9 @@ async function processBooking(session, entry, pickup) {
|
||||
}
|
||||
|
||||
async function checkEntry(sessionId, entry, settings) {
|
||||
if (shouldPauseForMaintenance(`Pickup-Check für Store ${entry?.id || 'unbekannt'}`)) {
|
||||
return;
|
||||
}
|
||||
if (entry?.active === false) {
|
||||
return;
|
||||
}
|
||||
@@ -927,6 +1004,9 @@ async function checkEntry(sessionId, entry, settings) {
|
||||
}
|
||||
|
||||
async function checkWatchedStores(sessionId, settings = DEFAULT_SETTINGS, options = {}) {
|
||||
if (shouldPauseForMaintenance(`Store-Watch für Session ${sessionId}`)) {
|
||||
return [];
|
||||
}
|
||||
const session = sessionStore.get(sessionId);
|
||||
if (!session?.profile?.id) {
|
||||
return [];
|
||||
@@ -1066,6 +1146,9 @@ function scheduleRegularPickupRefresh(settings) {
|
||||
regularPickupRefreshJob = cron.schedule(
|
||||
cronExpression,
|
||||
async () => {
|
||||
if (shouldPauseForMaintenance('Regular-Pickup-Cronlauf')) {
|
||||
return;
|
||||
}
|
||||
const sessions = sessionStore.list();
|
||||
const storeSessionMap = new Map();
|
||||
for (const session of sessions) {
|
||||
@@ -1170,12 +1253,15 @@ function scheduleEntry(sessionId, entry, settings) {
|
||||
|
||||
function scheduleConfig(sessionId, config, settings) {
|
||||
const resolvedSettings = resolveSettings(settings);
|
||||
const entries = Array.isArray(config) ? config : [];
|
||||
sessionStore.clearJobs(sessionId);
|
||||
scheduleJournalReminders(sessionId);
|
||||
if (shouldPauseForMaintenance(`Scheduler für Session ${sessionId}`)) {
|
||||
return;
|
||||
}
|
||||
scheduleDormantMembershipCheck(sessionId, resolvedSettings);
|
||||
const watchScheduled = scheduleStoreWatchers(sessionId, resolvedSettings);
|
||||
scheduleFallbackPickupChecks(sessionId, resolvedSettings);
|
||||
scheduleJournalReminders(sessionId);
|
||||
const entries = Array.isArray(config) ? config : [];
|
||||
const activeEntries = entries.filter((entry) => entry.active);
|
||||
if (activeEntries.length === 0) {
|
||||
if (watchScheduled) {
|
||||
@@ -1187,6 +1273,15 @@ function scheduleConfig(sessionId, config, settings) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
sessionStore.attachJob(
|
||||
sessionId,
|
||||
startStoreMessageWatcher({
|
||||
sessionId,
|
||||
config: entries,
|
||||
settings: resolvedSettings,
|
||||
runPickupCheck: runImmediatePickupCheck
|
||||
})
|
||||
);
|
||||
activeEntries.forEach((entry) => scheduleEntry(sessionId, entry, resolvedSettings));
|
||||
console.log(
|
||||
`[INFO] Scheduler für Session ${sessionId} mit ${activeEntries.length} Jobs aktiv.`
|
||||
@@ -1194,11 +1289,13 @@ function scheduleConfig(sessionId, config, settings) {
|
||||
}
|
||||
|
||||
async function runStoreWatchCheck(sessionId, settings, options = {}) {
|
||||
maintenanceMode.ensureInactive('Store-Watch ist während des Wartungsmodus deaktiviert.');
|
||||
const resolvedSettings = resolveSettings(settings);
|
||||
return checkWatchedStores(sessionId, resolvedSettings, options);
|
||||
}
|
||||
|
||||
async function runImmediatePickupCheck(sessionId, config, settings) {
|
||||
maintenanceMode.ensureInactive('Pickup-Prüfungen sind während des Wartungsmodus deaktiviert.');
|
||||
const resolvedSettings = resolveSettings(settings);
|
||||
const entries = Array.isArray(config) ? config : [];
|
||||
const activeEntries = entries.filter((entry) => entry?.active);
|
||||
@@ -1227,6 +1324,9 @@ function getMissingLastPickupStoreIds(config = []) {
|
||||
}
|
||||
|
||||
async function checkDormantMembers(sessionId, options = {}) {
|
||||
if (shouldPauseForMaintenance(`Dormant-Check für Session ${sessionId}`)) {
|
||||
return;
|
||||
}
|
||||
const session = sessionStore.get(sessionId);
|
||||
if (!session?.profile?.id) {
|
||||
return;
|
||||
@@ -1272,10 +1372,19 @@ async function checkDormantMembers(sessionId, options = {}) {
|
||||
const stores = Array.isArray(session.storesCache?.data) ? session.storesCache.data : [];
|
||||
if (stores.length === 0) {
|
||||
console.warn(`[DORMANT] Keine Stores für Session ${sessionId} im Cache gefunden.`);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeStoreIds = new Set();
|
||||
const activeStoresById = new Map();
|
||||
stores.forEach((store) => {
|
||||
const storeId = store?.id ? String(store.id) : null;
|
||||
if (!storeId || !storeTargets.has(storeId)) {
|
||||
if (!storeId) {
|
||||
return;
|
||||
}
|
||||
activeStoreIds.add(storeId);
|
||||
activeStoresById.set(storeId, store);
|
||||
if (!storeTargets.has(storeId)) {
|
||||
return;
|
||||
}
|
||||
const target = storeTargets.get(storeId);
|
||||
@@ -1284,7 +1393,27 @@ async function checkDormantMembers(sessionId, options = {}) {
|
||||
storeName: store.name || target.storeName
|
||||
});
|
||||
});
|
||||
|
||||
Array.from(storeTargets.keys()).forEach((storeId) => {
|
||||
if (activeStoreIds.has(storeId)) {
|
||||
return;
|
||||
}
|
||||
console.info(
|
||||
`[DORMANT] Store ${storeId} wird für Profil ${profileId} übersprungen, weil er nicht mehr in den aktuellen Store-Mitgliedschaften enthalten ist.`
|
||||
);
|
||||
storeTargets.delete(storeId);
|
||||
});
|
||||
|
||||
Array.from(storeTargets.keys()).forEach((storeId) => {
|
||||
const store = activeStoresById.get(storeId);
|
||||
if (hasDormantMemberAccess(store)) {
|
||||
return;
|
||||
}
|
||||
console.info(
|
||||
`[DORMANT] Store ${storeId} wird für Profil ${profileId} übersprungen, weil die aktuelle Mitgliedschaft (${store?.membershipStatus ?? 'unbekannt'}) keinen Zugriff auf die Mitgliederliste erlaubt.`
|
||||
);
|
||||
storeTargets.delete(storeId);
|
||||
});
|
||||
|
||||
if (storeTargets.size === 0) {
|
||||
return;
|
||||
@@ -1302,6 +1431,13 @@ async function checkDormantMembers(sessionId, options = {}) {
|
||||
{ label: 'fetchStoreMembers' }
|
||||
);
|
||||
} catch (error) {
|
||||
const status = error?.response?.status;
|
||||
if (status === 403 || status === 404) {
|
||||
console.warn(
|
||||
`[DORMANT] Mitglieder von Store ${storeId} konnten für Profil ${profileId} nicht geladen werden (${status}). Der Store wird für diese Session übersprungen.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
console.warn(`[DORMANT] Mitglieder von Store ${storeId} konnten nicht geladen werden:`, error.message);
|
||||
continue;
|
||||
}
|
||||
@@ -1310,7 +1446,17 @@ async function checkDormantMembers(sessionId, options = {}) {
|
||||
continue;
|
||||
}
|
||||
const reasons = [];
|
||||
const lastFetchMs = memberEntry.last_fetch ? Number(memberEntry.last_fetch) * 1000 : null;
|
||||
const lastFetchRaw = memberEntry.lastFetch ?? memberEntry.last_fetch ?? null;
|
||||
let lastFetchMs = null;
|
||||
if (typeof lastFetchRaw === 'string') {
|
||||
const parsedLastFetch = new Date(lastFetchRaw);
|
||||
if (!Number.isNaN(parsedLastFetch.getTime())) {
|
||||
lastFetchMs = parsedLastFetch.getTime();
|
||||
}
|
||||
} else if (Number.isFinite(Number(lastFetchRaw))) {
|
||||
const numericLastFetch = Number(lastFetchRaw);
|
||||
lastFetchMs = numericLastFetch > 1e12 ? numericLastFetch : numericLastFetch * 1000;
|
||||
}
|
||||
if (Number.isFinite(lastFetchMs)) {
|
||||
const configEntry = configEntryMap.get(storeId)?.entry;
|
||||
const lastPickupAt = new Date(lastFetchMs).toISOString();
|
||||
@@ -1323,8 +1469,10 @@ async function checkDormantMembers(sessionId, options = {}) {
|
||||
const lastFetchLabel = lastFetchMs ? new Date(lastFetchMs).toLocaleDateString('de-DE') : 'unbekannt';
|
||||
reasons.push(`Letzte Abholung: ${lastFetchLabel} (älter als 4 Monate)`);
|
||||
}
|
||||
if (memberEntry.hygiene_certificate_until) {
|
||||
const expiry = new Date(memberEntry.hygiene_certificate_until.replace(' ', 'T'));
|
||||
const hygieneCertificateUntil =
|
||||
memberEntry.hygieneCertificateUntil ?? memberEntry.hygiene_certificate_until ?? null;
|
||||
if (hygieneCertificateUntil) {
|
||||
const expiry = new Date(String(hygieneCertificateUntil).replace(' ', 'T'));
|
||||
if (!Number.isNaN(expiry.getTime()) && expiry.getTime() < hygieneCutoff) {
|
||||
reasons.push(
|
||||
`Hygiene-Nachweis läuft bald ab: ${expiry.toLocaleDateString('de-DE')} (unter 6 Wochen)`
|
||||
@@ -1395,6 +1543,7 @@ function scheduleDormantMembershipCheck(sessionId, settings) {
|
||||
}
|
||||
|
||||
async function runDormantMembershipCheck(sessionId, options = {}) {
|
||||
maintenanceMode.ensureInactive('Dormant-Prüfungen sind während des Wartungsmodus deaktiviert.');
|
||||
await checkDormantMembers(sessionId, options);
|
||||
}
|
||||
|
||||
@@ -1417,17 +1566,11 @@ async function checkJournalReminders(sessionId) {
|
||||
if (!entry?.reminder?.enabled || !entry.pickupDate) {
|
||||
continue;
|
||||
}
|
||||
const intervalMonths = getIntervalMonths(entry.reminder.interval);
|
||||
const baseDate = new Date(`${entry.pickupDate}T00:00:00`);
|
||||
const occurrence = getNextOccurrence(baseDate, intervalMonths, todayStart);
|
||||
if (!occurrence) {
|
||||
const schedule = computeReminderSchedule(entry, todayStart);
|
||||
if (!schedule?.occurrence || !schedule?.reminderDate) {
|
||||
continue;
|
||||
}
|
||||
const daysBefore = Number.isFinite(entry.reminder.daysBefore)
|
||||
? Math.max(0, entry.reminder.daysBefore)
|
||||
: 42;
|
||||
const reminderDate = new Date(occurrence.getTime());
|
||||
reminderDate.setDate(reminderDate.getDate() - daysBefore);
|
||||
const { occurrence, reminderDate } = schedule;
|
||||
|
||||
if (!isSameDay(reminderDate, todayStart)) {
|
||||
continue;
|
||||
|
||||
@@ -4,7 +4,7 @@ const { v4: uuid } = require('uuid');
|
||||
|
||||
const CONFIG_DIR = path.join(__dirname, '..', 'config');
|
||||
const LOG_FILE = path.join(CONFIG_DIR, 'request-logs.json');
|
||||
const TTL_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
const TTL_MS = 28 * 24 * 60 * 60 * 1000;
|
||||
const MAX_BODY_CHARS = 10000;
|
||||
|
||||
function ensureDir() {
|
||||
@@ -79,10 +79,13 @@ function add(entry = {}) {
|
||||
return record;
|
||||
}
|
||||
|
||||
function list(limit = 500) {
|
||||
const sanitizedLimit = Math.max(1, Math.min(Number(limit) || 500, 2000));
|
||||
function list(limit) {
|
||||
const logs = prune(readLogs());
|
||||
persistLogs(logs);
|
||||
if (limit == null) {
|
||||
return logs.slice().reverse();
|
||||
}
|
||||
const sanitizedLimit = Math.max(1, Math.min(Number(limit) || 500, 10000));
|
||||
return logs.slice(-sanitizedLimit).reverse();
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,20 @@ function shouldNotifyAdminSessionError(key) {
|
||||
|
||||
function isUnauthorizedError(error) {
|
||||
const status = error?.response?.status;
|
||||
return status === 401 || status === 403;
|
||||
if (status === 401) {
|
||||
return true;
|
||||
}
|
||||
if (status !== 403) {
|
||||
return false;
|
||||
}
|
||||
const data = error?.response?.data;
|
||||
const message =
|
||||
typeof data === 'string'
|
||||
? data
|
||||
: typeof data?.message === 'string'
|
||||
? data.message
|
||||
: '';
|
||||
return message.toLowerCase().includes('not logged in');
|
||||
}
|
||||
|
||||
function isCsrfError(error) {
|
||||
|
||||
72
services/storeCacheStore.js
Normal file
72
services/storeCacheStore.js
Normal 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
|
||||
};
|
||||
421
services/storeMessageWatcher.js
Normal file
421
services/storeMessageWatcher.js
Normal 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
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { readJsonFile, writeJsonFile } = require('./jsonFileStore');
|
||||
|
||||
const STORE_STATUS_FILE = path.join(__dirname, '..', 'config', 'store-watch-status.json');
|
||||
|
||||
@@ -13,12 +14,15 @@ function ensureDir() {
|
||||
function readStoreStatus() {
|
||||
ensureDir();
|
||||
if (!fs.existsSync(STORE_STATUS_FILE)) {
|
||||
fs.writeFileSync(STORE_STATUS_FILE, JSON.stringify({}, null, 2));
|
||||
writeJsonFile(STORE_STATUS_FILE, {}, { backup: false });
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const raw = fs.readFileSync(STORE_STATUS_FILE, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
const parsed = readJsonFile(
|
||||
STORE_STATUS_FILE,
|
||||
{},
|
||||
(value) => value && typeof value === 'object' && !Array.isArray(value)
|
||||
);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return parsed;
|
||||
}
|
||||
@@ -31,7 +35,7 @@ function readStoreStatus() {
|
||||
|
||||
function writeStoreStatus(cache = {}) {
|
||||
ensureDir();
|
||||
fs.writeFileSync(STORE_STATUS_FILE, JSON.stringify(cache, null, 2));
|
||||
writeJsonFile(STORE_STATUS_FILE, cache);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { readJsonFile, writeJsonFile } = require('./jsonFileStore');
|
||||
|
||||
const STORE_WATCH_DIR = path.join(__dirname, '..', 'config');
|
||||
|
||||
@@ -41,12 +42,11 @@ function readStoreWatch(profileId) {
|
||||
ensureDir();
|
||||
const filePath = getStoreWatchPath(profileId);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fs.writeFileSync(filePath, JSON.stringify([], null, 2));
|
||||
writeJsonFile(filePath, [], { backup: false });
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
const parsed = readJsonFile(filePath, [], Array.isArray);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
@@ -76,7 +76,7 @@ function writeStoreWatch(profileId, entries = []) {
|
||||
});
|
||||
ensureDir();
|
||||
const filePath = getStoreWatchPath(profileId);
|
||||
fs.writeFileSync(filePath, JSON.stringify(sanitized, null, 2));
|
||||
writeJsonFile(filePath, sanitized);
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { readJsonFile, writeJsonFile } = require('./jsonFileStore');
|
||||
|
||||
const PREF_DIR = path.join(__dirname, '..', 'config');
|
||||
|
||||
@@ -40,12 +41,15 @@ function readPreferences(profileId) {
|
||||
ensureDir();
|
||||
const filePath = getPreferencesPath(profileId);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fs.writeFileSync(filePath, JSON.stringify(DEFAULT_PREFERENCES, null, 2));
|
||||
writeJsonFile(filePath, DEFAULT_PREFERENCES, { backup: false });
|
||||
return { ...DEFAULT_PREFERENCES };
|
||||
}
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
const parsed = readJsonFile(
|
||||
filePath,
|
||||
DEFAULT_PREFERENCES,
|
||||
(value) => value && typeof value === 'object' && !Array.isArray(value)
|
||||
);
|
||||
return {
|
||||
location: sanitizeLocation(parsed.location) || null
|
||||
};
|
||||
@@ -65,7 +69,7 @@ function writePreferences(profileId, patch = {}) {
|
||||
};
|
||||
ensureDir();
|
||||
const filePath = getPreferencesPath(profileId);
|
||||
fs.writeFileSync(filePath, JSON.stringify(next, null, 2));
|
||||
writeJsonFile(filePath, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { readJsonFile, writeJsonFile } = require('./jsonFileStore');
|
||||
|
||||
const SETTINGS_DIR = path.join(__dirname, '..', 'config');
|
||||
|
||||
@@ -45,7 +46,7 @@ function hydrateSettingsFile(profileId) {
|
||||
ensureDir();
|
||||
const filePath = getSettingsPath(profileId);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fs.writeFileSync(filePath, JSON.stringify(DEFAULT_USER_SETTINGS, null, 2));
|
||||
writeJsonFile(filePath, DEFAULT_USER_SETTINGS, { backup: false });
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
@@ -53,8 +54,11 @@ function hydrateSettingsFile(profileId) {
|
||||
function readNotificationSettings(profileId) {
|
||||
const filePath = hydrateSettingsFile(profileId);
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
const parsed = readJsonFile(
|
||||
filePath,
|
||||
DEFAULT_USER_SETTINGS,
|
||||
(value) => value && typeof value === 'object' && !Array.isArray(value)
|
||||
);
|
||||
return {
|
||||
notifications: {
|
||||
ntfy: {
|
||||
@@ -97,7 +101,7 @@ function writeNotificationSettings(profileId, patch = {}) {
|
||||
};
|
||||
|
||||
const filePath = hydrateSettingsFile(profileId);
|
||||
fs.writeFileSync(filePath, JSON.stringify(next, null, 2));
|
||||
writeJsonFile(filePath, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
64
src/App.js
64
src/App.js
@@ -40,6 +40,7 @@ function App() {
|
||||
const [focusedStoreId, setFocusedStoreId] = useState(null);
|
||||
const [nearestStoreLabel, setNearestStoreLabel] = useState(null);
|
||||
const [regularPickupMap, setRegularPickupMap] = useState({});
|
||||
const [registeredPickups, setRegisteredPickups] = useState([]);
|
||||
const bootstrapOnceRef = useRef(false);
|
||||
const minSelectableDate = useMemo(() => startOfDay(new Date()), []);
|
||||
|
||||
@@ -150,6 +151,19 @@ function App() {
|
||||
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(() => {
|
||||
let aborted = false;
|
||||
async function lookupNearestStore() {
|
||||
@@ -244,6 +258,7 @@ function App() {
|
||||
const resetSessionState = useCallback(() => {
|
||||
setConfig([]);
|
||||
setStores([]);
|
||||
setRegisteredPickups([]);
|
||||
setStatus('');
|
||||
setError('');
|
||||
clearAdminSettings();
|
||||
@@ -252,6 +267,7 @@ function App() {
|
||||
}, [
|
||||
setConfig,
|
||||
setStores,
|
||||
setRegisteredPickups,
|
||||
setStatus,
|
||||
setError,
|
||||
clearAdminSettings,
|
||||
@@ -282,6 +298,38 @@ function App() {
|
||||
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) => {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
@@ -319,6 +367,7 @@ function App() {
|
||||
tokenOverride: data.token
|
||||
});
|
||||
}
|
||||
await loadRegisteredPickups(data.token);
|
||||
updateSyncProgress('Login abgeschlossen', 98);
|
||||
setStatus('Anmeldung erfolgreich. Konfiguration geladen.');
|
||||
setTimeout(() => setStatus(''), 3000);
|
||||
@@ -395,6 +444,7 @@ function App() {
|
||||
tokenOverride: storedToken
|
||||
});
|
||||
}
|
||||
await loadRegisteredPickups(storedToken);
|
||||
}
|
||||
finishSyncProgress();
|
||||
} catch (err) {
|
||||
@@ -415,6 +465,7 @@ function App() {
|
||||
bootstrapSession,
|
||||
finishSyncProgress,
|
||||
getStoredToken,
|
||||
loadRegisteredPickups,
|
||||
nudgeSyncProgress,
|
||||
startSyncProgress,
|
||||
syncStoresWithProgress,
|
||||
@@ -672,6 +723,14 @@ function App() {
|
||||
return { ...preferences.location };
|
||||
}, [preferences?.location, nearestStoreLabel]);
|
||||
|
||||
const handleRefreshStoresAndPickups = useCallback(
|
||||
async (options) => {
|
||||
await refreshStoresAndConfig(options);
|
||||
await loadRegisteredPickups();
|
||||
},
|
||||
[loadRegisteredPickups, refreshStoresAndConfig]
|
||||
);
|
||||
|
||||
const sharedNotificationProps = {
|
||||
error: notificationError,
|
||||
message: notificationMessage,
|
||||
@@ -736,7 +795,7 @@ function App() {
|
||||
const dashboardContent = (
|
||||
<DashboardView
|
||||
session={session}
|
||||
onRefresh={refreshStoresAndConfig}
|
||||
onRefresh={handleRefreshStoresAndPickups}
|
||||
onLogout={handleLogout}
|
||||
notificationPanelOpen={notificationPanelOpen}
|
||||
onToggleNotificationPanel={() => setNotificationPanelOpen((prev) => !prev)}
|
||||
@@ -752,6 +811,7 @@ function App() {
|
||||
status={status}
|
||||
visibleConfig={visibleConfig}
|
||||
config={config}
|
||||
registeredPickups={registeredPickups}
|
||||
onToggleActive={handleToggleActive}
|
||||
onToggleProfileCheck={handleToggleProfileCheck}
|
||||
onToggleOnlyNotify={handleToggleOnlyNotify}
|
||||
@@ -852,6 +912,8 @@ function App() {
|
||||
<RangePickerModal
|
||||
entry={activeRangeEntry}
|
||||
minDate={minSelectableDate}
|
||||
config={config}
|
||||
registeredPickups={registeredPickups}
|
||||
onSelectRange={(startDate, endDate) =>
|
||||
activeRangeEntry ? handleDateRangeSelection(activeRangeEntry.id, startDate, endDate) : null
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ const AdminSettingsPanel = ({
|
||||
|
||||
<SettingSection
|
||||
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
|
||||
label="Store-Watch Cron"
|
||||
@@ -281,6 +281,152 @@ const AdminSettingsPanel = ({
|
||||
</SettingField>
|
||||
</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
|
||||
title="Ignorierte Slots"
|
||||
subtitle="Diese Einträge überspringt der Scheduler dauerhaft (z. B. TVS-Zeiten oder Test-Slots)."
|
||||
|
||||
@@ -13,6 +13,58 @@ import NotificationPanel from './NotificationPanel';
|
||||
|
||||
const CONFIG_TABLE_STATE_KEY = 'dashboardConfigTableState';
|
||||
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 }) => {
|
||||
if (!column.getCanFilter()) {
|
||||
@@ -148,6 +200,7 @@ const DashboardView = ({
|
||||
status,
|
||||
visibleConfig,
|
||||
config,
|
||||
registeredPickups,
|
||||
onToggleActive,
|
||||
onToggleProfileCheck,
|
||||
onToggleOnlyNotify,
|
||||
@@ -206,6 +259,38 @@ const DashboardView = ({
|
||||
[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(
|
||||
() =>
|
||||
weekdays.map((day) => ({
|
||||
@@ -785,6 +870,61 @@ const DashboardView = ({
|
||||
</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">
|
||||
<table className="min-w-full bg-white border border-gray-200">
|
||||
<thead className="bg-gray-100">
|
||||
|
||||
@@ -320,7 +320,7 @@ const DebugPage = ({ authorizedFetch }) => {
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<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>
|
||||
<button
|
||||
onClick={fetchLogs}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import ConfirmationDialog from './ConfirmationDialog';
|
||||
import './JournalPage.css';
|
||||
import {
|
||||
formatReferenceLabel,
|
||||
formatReminderOffset,
|
||||
getNextReminderSchedule,
|
||||
getReminderOffset,
|
||||
getReminderReference,
|
||||
referenceEventOptions
|
||||
} from '../utils/journalReminder';
|
||||
|
||||
const intervalLabels = {
|
||||
monthly: 'Monatlich',
|
||||
@@ -12,11 +20,6 @@ const reminderUnitLabels = {
|
||||
weeks: 'Wochen',
|
||||
months: 'Monate'
|
||||
};
|
||||
const reminderUnitSingular = {
|
||||
days: 'Tag',
|
||||
weeks: 'Woche',
|
||||
months: 'Monat'
|
||||
};
|
||||
|
||||
const JournalPage = ({ authorizedFetch, stores }) => {
|
||||
const [entries, setEntries] = useState([]);
|
||||
@@ -53,7 +56,11 @@ const JournalPage = ({ authorizedFetch, stores }) => {
|
||||
reminderEnabled: true,
|
||||
reminderInterval: 'yearly',
|
||||
reminderBeforeValue: 6,
|
||||
reminderBeforeUnit: 'weeks'
|
||||
reminderBeforeUnit: 'weeks',
|
||||
reminderReferenceMode: 'pickupDate',
|
||||
reminderReferenceEventKey: 'easter_sunday',
|
||||
reminderReferenceOffsetValue: 0,
|
||||
reminderReferenceOffsetUnit: 'weeks'
|
||||
});
|
||||
const [images, setImages] = useState([]);
|
||||
const [existingImages, setExistingImages] = useState([]);
|
||||
@@ -89,12 +96,6 @@ const JournalPage = ({ authorizedFetch, stores }) => {
|
||||
return map;
|
||||
}, [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) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
@@ -106,94 +107,66 @@ const JournalPage = ({ authorizedFetch, stores }) => {
|
||||
return parsed.getTime();
|
||||
}, []);
|
||||
|
||||
const getIntervalMonths = useCallback((interval) => {
|
||||
if (interval === 'monthly') {
|
||||
return 1;
|
||||
const formatDateLabel = useCallback((date) => {
|
||||
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
||||
return 'unbekannt';
|
||||
}
|
||||
if (interval === 'quarterly') {
|
||||
return 3;
|
||||
}
|
||||
return 12;
|
||||
}, []);
|
||||
|
||||
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());
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
weekday: 'long',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getNextReminderTimestamp = useCallback(
|
||||
(entry) => {
|
||||
if (!entry?.reminder?.enabled || !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);
|
||||
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();
|
||||
const schedule = getNextReminderSchedule(entry);
|
||||
return schedule?.reminderDate?.getTime() ?? null;
|
||||
},
|
||||
[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 query = filters.query.trim().toLowerCase();
|
||||
const fromValue = parseDateValue(filters.dateFrom);
|
||||
@@ -342,7 +315,11 @@ const JournalPage = ({ authorizedFetch, stores }) => {
|
||||
reminderEnabled: true,
|
||||
reminderInterval: 'yearly',
|
||||
reminderBeforeValue: 6,
|
||||
reminderBeforeUnit: 'weeks'
|
||||
reminderBeforeUnit: 'weeks',
|
||||
reminderReferenceMode: 'pickupDate',
|
||||
reminderReferenceEventKey: 'easter_sunday',
|
||||
reminderReferenceOffsetValue: 0,
|
||||
reminderReferenceOffsetUnit: 'weeks'
|
||||
});
|
||||
}, [images]);
|
||||
|
||||
@@ -434,9 +411,15 @@ const JournalPage = ({ authorizedFetch, stores }) => {
|
||||
note: form.note.trim(),
|
||||
reminder: {
|
||||
enabled: form.reminderEnabled,
|
||||
interval: form.reminderInterval,
|
||||
interval: form.reminderReferenceMode === 'event' ? 'yearly' : form.reminderInterval,
|
||||
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,
|
||||
keepImageIds: existingImages.map((image) => image.id)
|
||||
@@ -515,7 +498,11 @@ const JournalPage = ({ authorizedFetch, stores }) => {
|
||||
reminderEnabled: entry.reminder?.enabled ?? true,
|
||||
reminderInterval: entry.reminder?.interval || 'yearly',
|
||||
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);
|
||||
};
|
||||
@@ -679,7 +666,7 @@ const JournalPage = ({ authorizedFetch, stores }) => {
|
||||
{filteredEntries.map((entry) => {
|
||||
const reminder = entry.reminder || {};
|
||||
const reminderLabel = reminder.enabled
|
||||
? `${intervalLabels[reminder.interval] || 'Jährlich'}, ${formatReminderOffset(reminder)} vorher`
|
||||
? `${intervalLabels[reminder.interval] || 'Jährlich'}, ${formatReminderOffset(reminder)} vorher, ${formatReferenceLabel(reminder)}`
|
||||
: 'Keine Erinnerung';
|
||||
const storeLabel =
|
||||
entry.storeName ||
|
||||
@@ -979,8 +966,9 @@ const JournalPage = ({ authorizedFetch, stores }) => {
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm text-gray-600">Intervall</label>
|
||||
<select
|
||||
value={form.reminderInterval}
|
||||
value={form.reminderReferenceMode === 'event' ? 'yearly' : form.reminderInterval}
|
||||
onChange={(event) => handleFormChange({ reminderInterval: event.target.value })}
|
||||
disabled={form.reminderReferenceMode === 'event'}
|
||||
className="w-full"
|
||||
>
|
||||
{Object.entries(intervalLabels).map(([value, label]) => (
|
||||
@@ -1018,9 +1006,84 @@ const JournalPage = ({ authorizedFetch, stores }) => {
|
||||
</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">
|
||||
Erinnerung wird standardmäßig jährlich eingeplant.
|
||||
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">
|
||||
Bei Referenz-Events wird die Erinnerung immer jährlich berechnet.
|
||||
</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}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,125 @@
|
||||
import { useMemo } from 'react';
|
||||
import { DateRange } from 'react-date-range';
|
||||
import { de } from 'date-fns/locale';
|
||||
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) {
|
||||
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 (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black bg-opacity-40 px-4" onClick={onClose}>
|
||||
<div
|
||||
@@ -37,7 +150,18 @@ const RangePickerModal = ({ entry, minDate, onSelectRange, onResetRange, onClose
|
||||
showDateDisplay={false}
|
||||
locale={de}
|
||||
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 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}>
|
||||
|
||||
@@ -441,9 +441,10 @@ const StoreWatchPage = ({
|
||||
const membership = membershipMap.has(String(store.id));
|
||||
const lat = Number(store.location?.lat);
|
||||
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
|
||||
: Number(store.teamSearchStatus);
|
||||
: Number(rawTeamStatus);
|
||||
const distance =
|
||||
userLocation && Number.isFinite(lat) && Number.isFinite(lon)
|
||||
? haversineDistanceKm(userLocation.lat, userLocation.lon, lat, lon)
|
||||
|
||||
@@ -25,8 +25,8 @@ const useStoreSync = ({
|
||||
}
|
||||
if (!silent) {
|
||||
setStatus('');
|
||||
}
|
||||
setLoading(true);
|
||||
}
|
||||
setError('');
|
||||
try {
|
||||
const response = await authorizedFetch('/api/config', {}, tokenToUse);
|
||||
@@ -42,8 +42,10 @@ const useStoreSync = ({
|
||||
} catch (err) {
|
||||
setError(`Fehler beim Laden der Konfiguration: ${err.message}`);
|
||||
} finally {
|
||||
if (!silent) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[sessionToken, authorizedFetch, setStatus, setLoading, setError, setConfig, normalizeConfigEntries]
|
||||
);
|
||||
|
||||
@@ -20,6 +20,20 @@ export const normalizeAdminSettings = (raw) => {
|
||||
storeWatchInitialDelayMaxSeconds: raw.storeWatchInitialDelayMaxSeconds ?? '',
|
||||
storeWatchRequestDelayMs: raw.storeWatchRequestDelayMs ?? '',
|
||||
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)
|
||||
? raw.ignoredSlots.map((slot) => ({
|
||||
storeId: slot?.storeId ? String(slot.storeId) : '',
|
||||
@@ -88,6 +102,16 @@ export const serializeAdminSettings = (adminSettings) => {
|
||||
storeWatchInitialDelayMaxSeconds: toNumberOrUndefined(adminSettings.storeWatchInitialDelayMaxSeconds),
|
||||
storeWatchRequestDelayMs: toNumberOrUndefined(adminSettings.storeWatchRequestDelayMs),
|
||||
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) => ({
|
||||
storeId: slot.storeId || '',
|
||||
slotName: slot.slotName || '',
|
||||
|
||||
260
src/utils/journalReminder.js
Normal file
260
src/utils/journalReminder.js
Normal 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 };
|
||||
}
|
||||
52
src/utils/journalReminder.test.js
Normal file
52
src/utils/journalReminder.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
81
src/utils/storeMessageWatcher.test.js
Normal file
81
src/utils/storeMessageWatcher.test.js
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user