feat: T4 wave 10 – bottling quality, phenology and honey chain UI, Telegram link/receive and ops
- Bottling quality: water content above the legal limit (strict >) answers 409 CONFIRMATION_REQUIRED bound to the measured value; prefill for jar size, best-before (shelfLifeMonths, month-end clamping) and water content; impossible dates give 400; water-content checks in src/common/quality. - Frontend phenology: "Entwicklung melden" sheet, IndexedDB v4 queue, season header on the apiary page, confirmed revoke for all apiaries. - Frontend honey chain: "Vorschlag aus …" hints with explicit take-over, prefill status note, movement ledger with withdraw/correct dialogs. - Telegram: link tokens (hashed, single use), private-chat receiver with lease and offset CAS, bounded poison-update skip, Retry-After, pending deliveries skipped on disconnect; system API for deliveries and retry; worker-only secret wiring and manifest policy tests. - ARCHITECTURE.md §7, §9, §10, §11.11–11.13, Annahmen 51–55. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
16cc557462
commit
fab7f7e12f
91 files changed
+8463
-104
No files matched your search
+39
-10
@@ -160,12 +160,17 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
||||
42. **Wetterquelle Open-Meteo (Entscheidung des Inhabers)**: Die Pflanzenentwicklung nutzt die freie Open-Meteo-API (Forecast-Endpunkt für die letzten 90 Tage, Archiv für ältere Lücken). Die nicht-kommerziellen Bedingungen und das damit verbundene Lizenzrisiko hat der Inhaber bewusst übernommen. Es wird kein API-Schlüssel verwendet (`OPEN_METEO_API_KEY` bleibt leer). Die Quelle liegt hinter der Schnittstelle `WeatherHistorySource` (`src/weather/history`); ein Wechsel geht über `WEATHER_HISTORY_SOURCE` und einen neuen Adapter. Koordinaten werden auf 0,01° gerundet. URL und Schlüssel erscheinen nie in Logs oder Fehlermeldungen. Details: Abschnitt 11.11.
|
||||
43. **Pflanzenentwicklung: Tabellen und Fremdschlüssel**: Abweichend vom Entwurf (Spec 4.2) verweisen `ApiaryPhenology.apiaryId` und `ApiaryWeatherDay.apiaryId` mit `ON DELETE CASCADE` auf `Apiary` (abgeleitete Daten und Cache, keine Waisen, ein Restore von Ständen wird nie blockiert). `PhenologyReport.apiaryId` verweist mit `ON DELETE RESTRICT` auf `Apiary`: Der Prisma-Standard für eine optionale Relation, `SetNull`, machte aus einer Meldung still eine Meldung „für alle Stände“. Die Migration `20261015100000_t4_phenology` vergibt `phenology:read`, `phenology:create` und `phenology:update` (Gruppe „Pflanzenentwicklung“) an alle Systemrollen; `read` geht an Rollen, die `colonies:read` halten, `create` und `update` an Rollen mit `inspections:create`, jeweils nur Nicht-Systemrollen. Die Migration legt die Rechte selbst an, weil der Katalog-Abgleich beim Start neue Rechte nur für Zeilen vergibt, die er selbst anlegt (`PermissionCatalogSyncService`).
|
||||
44. **Bewegungsbuch der Honigcharge**: `HoneyBatch.amount` bleibt der laufende Rest in kg, `HoneyBatch.initialAmount` (kg) ist die Anfangsmenge. `BatchMovement.quantity` ist bei Honig eine vorzeichenbehaftete Menge in Gramm mit `honeyBatchId`. Invariante: `amount = initialAmount + Summe(quantity) / 1000`, auf das Gramm gerundet, nie unter 0, Toleranz 0,5 g. Eine Charge ohne `amount` und `initialAmount` wird nicht gebucht. Jede `BatchMovement` gehört zu genau einer Quelle aus Abfüllung, Herstellungscharge, Rohstoff-Charge und Honigcharge (CHECK `BatchMovement_exactly_one_batch`, `num_nonnulls(…) = 1`). Rückrechnung für Altbestand (Migration `20261015100100_t4_honey_ledger`, Import alter Bundles): `initialAmount = amount + Summe(Gramm der Abfüllungen) / 1000`, je Abfüllung eine Buchung `BOTTLING` mit `sourceBottlingBatchId`, Datum und Benutzer der Abfüllung (Grammwerte halb aufgerundet, `floor(x·1000 + 0,5)`). Frühere Handkorrekturen von `amount` stecken damit in `initialAmount`. Die Migration endet mit einer Prüfung (`RAISE NOTICE` mit Zählern, `RAISE EXCEPTION` bei verletzter Invariante, dann Rollback). Laufzeit: `HoneyBatchLedgerService` (`src/honey-batches/honey-batch-ledger.service.ts`) ist der einzige Schreiber von `HoneyBatch.amount` (Wächter `honey-batch-amount-writes.spec.ts`). Buchungen laufen in der Transaktion des Aufrufers unter `lockRowForUpdate('HoneyBatch')`: `bookIn` (vorzeichenbehaftete Gramm; `amount` wird aus `initialAmount + Summe(quantity) / 1000` auf das Gramm neu berechnet, nie unter 0), `setInitialIn` (erste Menge: `amount` und `initialAmount` zugleich, keine Buchung) und `clearIn` (Menge entfernen, nur ohne Buchungen, sonst 400 „Die Menge wird ueber das Bewegungsbuch gefuehrt. Bitte eine Korrektur buchen.“). Eine Abbuchung braucht genug Rest (Toleranz 0,5 g), sonst 400 (Abfüllung: „Abfuellmenge uebersteigt die verfuegbare Menge der Honigcharge“, sonst „Die Menge kann nicht unter 0 sinken.“). Gerundet wird überall gleich, `floor(x·1000 + 0,5)` Gramm, auch bei Mengen, die Nutzer eingeben (12,3456 kg werden 12,346 kg). Eine Abfüllung anlegen bucht `BOTTLING` negativ mit `sourceBottlingBatchId`, Ändern die Differenz (bei Chargenwechsel zurück auf die alte, von der neuen), Entfernen `BOTTLING` positiv mit der Notiz „Abfuellung entfernt“ (ohne Quelle: die Abfüllung wird danach gelöscht). `PATCH amount` bucht `CORRECTION` (neu minus aktuell; `initialAmount` bleibt, Startmenge und Summe der Buchungen weichen danach ab); der erste Wert einer Charge ohne Menge (`null` → Wert) setzt `initialAmount`. Eine Charge ohne `initialAmount` bucht nichts, die Abfüllung setzt trotzdem den Status `BOTTLED`. Eine Abfüllung, die dem Buch nie etwas entnommen hat (Heuristik `drewFromLedger`: keine `BOTTLING`-Buchung mit `honeyBatchId` und ihrer `sourceBottlingBatchId`, etwa weil die Charge beim Abfüllen noch keine Menge hatte), bucht bei Änderung und Entfernung nichts zurück; wird sie in eine andere Charge verschoben, entnimmt sie erst dort. Das folgt aus den Buchungen und ist kein gespeichertes Merkmal. Sperrreihenfolge je Pfad: Abfüllung → Honigcharge (zwei Chargen in einer Anweisung, aufsteigend; die Abfüllung sperrt die Charge schon vor dem Anlegen), Schleuderung → Honigcharge. API des Buchs: Abschnitt 11.12.
|
||||
45. **Honig-Kette: Vorschläge, Warnungen, nie sperren**: Vorbelegungen (Menge, Honigsorte, Wassergehalt) sind Vorschläge mit Übernehmen-Knopf, nie stillschweigend eingetragen. Plausibilitätswarnungen (`EXTRACTION_EXCEEDS_HARVEST`, `BATCHES_EXCEED_EXTRACTION`, Hinweis `HARVEST_NET_MISSING`) und die Wartezeitwarnung sperren nie (kein 4xx). Die Wartezeitwarnung `WITHDRAWAL_ACTIVE` bekommt jeder, der eine Schleuderung erfasst oder vorbelegt, auch ohne `treatments:read` (Lebensmittelsicherheit vor der Lese-Trennung). Die Vorbelegungs-Endpunkte hängen an den Anlege-Rechten (`extractions:create`, `honey-batches:create`): Sie liefern dieselben Daten wie der Anlege-Ablauf. Die Honigsorte kommt aus den Ernten, weil `Extraction` kein Sortenfeld hat. Abschnitt 11.12.
|
||||
46. **Telegram: Token nur im Worker, Zustellung über Outbox**: Der Bot-Token (`TELEGRAM_BOT_TOKEN`) liegt nur in der Umgebung des Workers (optionales Secret `apicore-telegram`); der API-Pod kennt nur `TELEGRAM_BOT_USERNAME` für den Deep Link. Der Token erscheint nie in Logs oder Fehlern. Ein fehlerhaft geformter Token (oder in Produktion ein Platzhalter) lässt den Worker nicht abstürzen: Fehler-Log ohne Wert, Telegram bleibt aus. Gesendet wird nur aus Worker-Schleifen über `NotificationDelivery`, nie aus Anfragen, Handlern oder Jobs; die Zustellung läuft in der Schleife `telegram-send` (Abschnitt 11.13). Der Sender nimmt genau ein Worker-Replikat an (`replicas: 1`): Chat-Abstand, Senderate und die Pausen nach 401 und 429 liegen im Speicher des Pods, mit mehreren Pods hätte jeder seine eigenen. Die SQL-Regel „eine Zustellung je Nutzer und Batch“ und Telegrams 429-Antworten schützen den Bot auch dann. Ohne `FRONTEND_ORIGIN` geht die Nachricht außerhalb der Entwicklung (`NODE_ENV` leer oder `development`) ohne Link hinaus (ein Warn-Log je Pod), nie mit einem localhost-Link. Abschnitt 11.13.
|
||||
45. **Honig-Kette: Vorschläge, Warnungen, nie sperren**: Vorbelegungen (Menge, Honigsorte, Wassergehalt, Glasgröße, MHD) sind Vorschläge mit Übernehmen-Knopf, nie stillschweigend eingetragen. Plausibilitätswarnungen (`EXTRACTION_EXCEEDS_HARVEST`, `BATCHES_EXCEED_EXTRACTION`, Hinweis `HARVEST_NET_MISSING`) und die Wartezeitwarnung sperren nie (kein 4xx). Die Wartezeitwarnung `WITHDRAWAL_ACTIVE` bekommt jeder, der eine Schleuderung erfasst oder vorbelegt, auch ohne `treatments:read` (Lebensmittelsicherheit vor der Lese-Trennung). Die Vorbelegungs-Endpunkte hängen an den Anlege-Rechten (`extractions:create`, `honey-batches:create`): Sie liefern dieselben Daten wie der Anlege-Ablauf. Die Honigsorte kommt aus den Ernten, weil `Extraction` kein Sortenfeld hat. Abschnitt 11.12.
|
||||
46. **Telegram: Token nur im Worker, Zustellung über Outbox**: Der Bot-Token (`TELEGRAM_BOT_TOKEN`) liegt nur in der Umgebung des Workers (optionales Secret `apicore-telegram`); der API-Pod kennt nur `TELEGRAM_BOT_USERNAME` für den Deep Link. Der Token erscheint nie in Logs oder Fehlern. Ein fehlerhaft geformter Token (oder in Produktion ein Platzhalter) lässt den Worker nicht abstürzen: Fehler-Log ohne Wert, Telegram bleibt aus. Gesendet wird nur aus Worker-Schleifen über `NotificationDelivery`, nie aus Anfragen, Handlern oder Jobs; die Zustellung läuft in der Schleife `telegram-send`, der Empfang in `telegram-updates` (Abschnitt 11.13). Der Sender nimmt genau ein Worker-Replikat an (`replicas: 1`): Chat-Abstand, Senderate und die Pausen nach 401 und 429 liegen im Speicher des Pods, mit mehreren Pods hätte jeder seine eigenen. Die SQL-Regel „eine Zustellung je Nutzer und Batch“ und Telegrams 429-Antworten schützen den Bot auch dann. Ohne `FRONTEND_ORIGIN` geht die Nachricht außerhalb der Entwicklung (`NODE_ENV` leer oder `development`) ohne Link hinaus (ein Warn-Log je Pod), nie mit einem localhost-Link. Abschnitt 11.13.
|
||||
47. **Pflanzen-Meldungen, Neuberechnung und Saisonwechsel**: Rechte: `phenology:read` (Liste), `phenology:create` (Melden), `phenology:update` (Bearbeiten und Zurückziehen); `GET /phenology/state` braucht `apiaries:read` und enthält nie Koordinaten (Abschnitt 11.11). Das Datum muss ein echter Kalendertag sein und darf nicht nach dem Berliner Tag von heute liegen (400). Ein unbekannter Stand ergibt 404 (in der Transaktion geprüft, Stand vor Meldung in der Sperrreihenfolge); `apiaryId` leer (`""`) ergibt 400, `null` heißt „alle Stände“. Wer `phenology:update` hält, darf auch fremde Meldungen bearbeiten und zurückziehen (kleines Team, bewusst); eine zurückgezogene Meldung lässt sich nicht mehr bearbeiten (409), erneutes Zurückziehen ebenso (409). Eine Änderung ohne tatsächliche Abweichung schreibt weder Datensatz noch Audit-Eintrag noch Ereignis. `PhenologyReported` und `PhenologyCorrected` (auch beim Zurückziehen) entstehen in der Schreib-Transaktion. `PhenologyCorrected` trägt `previousApiaryId` (beim Zurückziehen gleich `apiaryId`): Eine Verschiebung von Stand A nach B berechnet nur A und B neu; ist eine Seite `null` (Meldung erweitert oder eingeengt), gilt „alle Stände“. Der Handler `phenology.recompute` rechnet für den späteren von Berliner Tag des Ereignisses (`occurredAt`) und heute, damit eine verzögerte Zustellung keinen älteren Tag (GTS-Stand, Abschnittsbeginn) zurückschreibt; bei einem oder zwei Ständen direkt, in Id-Reihenfolge. Gilt „alle Stände“, schreibt er je aktivem Stand ein internes Ereignis `PhenologyRecomputeRequested { apiaryId, allowBackward, day }` (kein Schlüssel), und `phenology.recompute-apiary` rechnet einen Stand je Zustellung (übersprungen, wenn er nicht mehr aktiv ist). So läuft eine Meldung für alle Stände nicht in einer einzigen Zustell-Transaktion. **Bewusste Abweichung von der Spec:** Saisonwechsel erscheinen nur in der Morgenübersicht (Abschnitt 11.9), nie als Einzelmeldung: Meldungen für alle Stände ergäben sonst Stände × Nutzer Meldungen. `SeasonPhaseChanged` bleibt für den Autopiloten.
|
||||
48. **Autopilot A4–A10: Annahmen**: A4 (Sommerbehandlung planen) und A8 (Umweiseln planen) sind `PLAN`-Vorschläge ohne gespeicherten Plan: Übernehmen legt genau eine Aufgabe an, heute fällig (Spec 8.2). Der gespeicherte Saisonzustand kennt nur den Beginn des aktuellen Abschnitts. Im Abschnitt `HERBST` ist der Beginn von `SPAETSOMMER` daher genähert: der Kalendertag von `TRACHTENDE` (`PHENOLOGY.calendarFallback`), höchstens der Beginn von `HERBST` (`lateSummerStart`). Wurde die Phase viel früher oder später gemeldet als der Kalender sagt, zählt eine Fütterung zwischen beiden Tagen für A5 falsch (nur A5 betrifft das). Der genaue Weg ist eine Spalte `lateSummerSince` an `ApiaryPhenology` (Migration, späteres Schema-Fenster; im Code als TODO vermerkt). A6 ohne Durchsicht zählt Fütterungen seit dem späteren von Anlagetag des Volkes und heute minus 60 Tage; A7 nutzt nur eine Durchsicht ab `springSince` des laufenden Jahres (sonst ab 1. Januar). „Gemeldet am TT.MM.“ im Grund von A4 steht nur, wenn `reportedOn` vorliegt (bei gespeicherten Zeilen nie), sonst „Tracht vorbei (gemeldet)“, bei Schätzung oder Kalender „(geschätzt)“ bzw. „(Kalender)“. `syncApiaryIn` gleicht alle Völker eines Stands in einer Transaktion ab (etwa 10 Abfragen je Volk); für sehr große Stände wäre eine Aufteilung nötig. Ein Wächter (`src/suggestions/suggesting-triggers.spec.ts`) verlangt für jeden vorschlagenden Trigger eine Textvorlage und für jeden im Quelltext gefundenen `reasonCode` einen Registereintrag; ein neuer Trigger fällt durch, bis er eine Vorlage hat oder bewusst ausgenommen wird.
|
||||
49. **Telegram-Zustellung: Zustellzeit, Zeitumstellung, Prüfung beim Senden**: Zeiten rechnen auf der Berliner Wanduhr (Europe/Berlin, `src/notifications/delivery-time.ts`). Verfügbar ab dem späteren von jetzt und der Kategorie-Zeit (`TASK_DUE`: eine Minute nach der Morgenzeit, `DIGEST`: die Morgenzeit, jeweils heute); liegt das Ergebnis in der Ruhezeit (Standard 21:00 bis 06:30, über Mitternacht, Ende ausgeschlossen; fehlende oder gleiche Grenzen = keine Ruhezeit), rückt es an deren Ende. Eine Uhrzeit, die im Herbst doppelt vorkommt (Doppelstunde 02:xx), gilt als das erste Vorkommen (Sommerzeit); eine Uhrzeit in der Frühjahrslücke (02:30 existiert nicht) als der Zeitpunkt eine Stunde später (03:30 Sommerzeit). Antworten des Bots (`templateCode`) umgehen Ruhezeit und Prüfungen. Beim Senden gilt der Stand dieses Moments, nicht der der Planung: Nutzer und Rolle aktiv, Telegram an, Verbindung, Kategorie, jedes Recht aus `requiredPermissions` (frisch gelesen, nicht aus dem 30-s-Cache) und Frische. `TASK_DUE` und `DIGEST` sind nur am selben Berliner Tag frisch, alle anderen höchstens 24 Stunden; eine `TASK_DUE`-Zustellung braucht die Aufgabe noch offen. Sonst `SKIPPED` mit Grund. Der Batch ist auf 120 s begrenzt; der Rest geht ohne Fehlversuch zurück. Abschnitt 11.13.
|
||||
50. **Sperrreihenfolge: Stand vor Volk**: Die Karte `LOCKABLE_TABLES` (`LOCK_ORDER`, `src/prisma/row-lock.ts`) gilt für jede Transaktion, die Zeilen mehrerer Tabellen sperrt (Tabellen in dieser Reihenfolge, innerhalb einer Tabelle Id aufsteigend), mit einer Ausnahme (Entscheidung zu Task 27): `Apiary` wird vor `Colony` gesperrt. Der Abgleich je Stand (`AutopilotService.syncApiaryIn`) sperrt die Standzeile, liest danach die Völker, sperrt alle in einer Anweisung (`lockRowsForUpdate`, Id aufsteigend) und erst dann Vorschläge und Regeln; der Handler `autopilot.colony-sync` sperrt die Völker eines Ereignisses ebenso in einer Anweisung. Kein Pfad sperrt ein Volk und danach einen Stand (die Neuberechnung der Pflanzenentwicklung sperrt nur den Stand); ein künftiger Pfad Volk → Stand würde mit dem Abgleich je Stand verklemmen. Die Karte im Code führt `Apiary` weiterhin weiter hinten (Stand dieses Dokuments): Die Ausnahme steht als Regel hier, nicht in der Karte.
|
||||
51. **Telegram: fehlerhaftes Update wird übersprungen (Annahme der Umsetzung)**: Ein Update, dessen Verarbeitung scheitert, wird bis zu 5 Mal oder höchstens 10 Minuten wiederholt (Zähler und erste Fehlzeit je `update_id` im Speicher des Pods; `MAX_UPDATE_ATTEMPTS`, `MAX_UPDATE_RETRY_MS`). Danach überspringt es eine eigene Transaktion: Offset weiter, `lastError` = „Telegram-Nachricht übersprungen“, eine Warnung ohne IDs und Text im Log, der Rest des Batches läuft weiter. Ohne diese Grenze würde ein dauerhaft scheiternder Befehl alle späteren Befehle (etwa jedes `/start`) sperren. Kosten bei falscher Annahme: ein verlorenes `/start`, der Nutzer erzeugt den Link neu. Eine verlorene Lease zählt nie als Fehlversuch. Abschnitt 11.13.
|
||||
52. **Wassergehalt: Bestätigung gilt für den gemessenen Wert**: Die Bestätigung über der gesetzlichen Grenze (`acknowledgedWarnings: ['WATER_CONTENT_ABOVE_LEGAL']`) trägt zusätzlich `acknowledgedWaterContent` mit dem Wert aus der 409-Antwort. Sie gilt nur, wenn der unter der Sperre geprüfte Wert genau gleich ist; sonst folgt eine frische 409. Wer bestätigt hat, hat also diesen Wert gesehen, und eine zwischenzeitlich neue Messung entwertet die Bestätigung (Lebensmittelsicherheit; Kosten bei falscher Annahme: ein zusätzliches Feld). Streng größer als die Grenze löst die Bestätigung aus, genau 20,0 % warnt nur. Abschnitt 11.12.
|
||||
53. **Vorbelegungs-Hinweise: Speichern bleibt erlaubt**: Laden die Hinweise einer Vorbelegung (Warnungen, etwa die Wartezeit) nicht, sperrt die Oberfläche das Speichern nicht; eine dauerhafte Zeile weist darauf hin, das Backend prüft beim Speichern. Kosten bei falscher Annahme: Ein Nutzer speichert, ohne die Wartezeitwarnung gesehen zu haben; die Antwort des Speicherns trägt die Warnung trotzdem. Abschnitt 11.12.
|
||||
54. **Telegram: `LINKED` ohne Antwortgrenze**: Die Grenze von 3 Antworten je Chat in 10 Minuten gilt für `HELP`, `LINK_INVALID` und `STOPPED`, nicht für `LINKED`. `LINKED` braucht ein gültiges Einmal-Token des angemeldeten Nutzers, ein Fremder kann es nicht auslösen. Abschnitt 11.13.
|
||||
55. **Pflanzenmeldung: doppelte Meldung beim erneuten Öffnen des Blatts**: Jede Meldung des Blatts „Entwicklung melden“ bekommt einen eigenen Schlüssel. Öffnet jemand das Blatt erneut und meldet dieselbe Phase noch einmal, entsteht eine zweite Meldung (auch aus der Offline-Warteschlange). Das ist bewusst hingenommen: Die Auswertung nimmt den frühesten Meldetag der Phase, die Zeile lässt sich zurückziehen (Kosten bei falscher Annahme: eine Zeile mehr). Abschnitt 11.11.
|
||||
|
||||
## 7. Kubernetes
|
||||
|
||||
@@ -180,6 +185,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
||||
- `api-hpa`: HPA `apicore-backend` (`autoscaling/v2`, 1–2 Pods bei 70 % CPU); entfernt `replicas` aus dem Deployment.
|
||||
- `overlays/encry/`: Namespace `apicore`, ConfigMaps, Ingresses (Traefik, Middleware `apicore-cloudflare-only@kubernetescrd`, cert-manager `letsencrypt-prod` mit HTTP-01), Image-Digests. Hosts, ConfigMap-Werte und Digests sind bis zum Rollout Platzhalter (`example.com`, `sha256:000…`).
|
||||
- `tests/`: `manifest-policy.test.mjs` prüft das gerenderte Overlay (NetworkPolicies nach Kubernetes-Semantik, Pod-Härtung, Ressourcen, CronJobs, Migration, Digests, Live-Selektoren) und jeden Rollout-Schritt (DNS, Ingress, api → Postgres und alle DB-Clients bleiben erreichbar). `render-stages.mjs` rendert die Schritte 1, 2, 3 und 5 aus dem Overlay. Die CI (`.gitea/workflows/manifests.yml`, nur `push` und `workflow_dispatch`, kein `pull_request`, weil der Runner im Cluster läuft) rendert mit `kubectl kustomize`, validiert mit `kubeconform -strict` und führt den Test aus; kubectl, kubeconform und yq mit festen Versionen und SHA-256.
|
||||
- **Telegram-Secret.** `apicore-telegram` (Schlüssel `TELEGRAM_BOT_TOKEN`) bindet nur `apicore-worker` ein: als einzelne `secretKeyRef`-Variable mit `optional: true` am Haupt-Container, ohne `envFrom`, ohne Volume, nicht im initContainer. Die api bekommt das Secret nie. Fehlt es, startet der Worker normal, nur die Telegram-Schleifen laufen nicht. `TELEGRAM_BOT_USERNAME` steht in der ConfigMap `apicore-backend-config` (api und Worker lesen sie per `envFrom`; leerer Platzhalter, bis der Bot angelegt ist); das Token steht nie in einer ConfigMap. `manifest-policy.test.mjs` prüft für den Zielstand und jede Rollout-Stufe mit Worker: genau ein Worker-Pod, genau eine Token-Variable ohne Klartextwert, kein anderer Pod mit `TELEGRAM_BOT_TOKEN`. Einrichtung bei BotFather: je Umgebung ein eigener Bot, Gruppen aus (`/setjoingroups` → Disable), kein Webhook (der Worker ruft `getUpdates`). Der Worker läuft mit `replicas: 1`, weil der Versand das annimmt (Annahme 46); Claims und Empfangs-Lease bleiben auch mit mehr Pods korrekt.
|
||||
|
||||
**Rollen-Deployments** (ein Image, Rolle per `APICORE_ROLE`):
|
||||
|
||||
@@ -256,7 +262,7 @@ Grundlage: OWASP API Security Top 10 (2023) und OWASP ASVS Level 2. Umsetzung un
|
||||
| Authentifizierung | JWT mit festem Algorithmus, Issuer und Audience je Zweck; MFA (TOTP) mit Replay-Schutz; Kontosperre 15 min nach 5 Fehlversuchen; gleiche Antwort für unbekannte/gesperrte Konten; bcrypt auf jedem Pfad |
|
||||
| Passwörter | mind. 12 Zeichen, max. 72 Byte, Abgleich mit Have I Been Pwned (k-Anonymität, abschaltbar per `PASSWORD_BREACH_CHECK=false`) |
|
||||
| Sitzungen | Refresh-Rotation atomar, Diebstahlerkennung, max. 7 Tage; Einmal-Tokens für SSO-Abschluss und MFA-Schritt |
|
||||
| Step-up | Backup-Import, MFA ein/aus verlangen aktuelles Passwort (+ TOTP) |
|
||||
| Step-up | Backup-Import, MFA ein/aus und Telegram verbinden verlangen aktuelles Passwort (+ TOTP); das Verbinden ist auf 10 pro Minute gedrosselt |
|
||||
| Autorisierung | fail-closed `PermissionsGuard`; keine Rechteausweitung über Benutzer-/Rollenpflege; Suche nur über lesbare Objektarten |
|
||||
| Maschinen-Zugriff | API-Schlüssel nur als Hash (Suche über den Hash, kein Vergleich von Geheimnissen), Scope-Allowlist (`shop-sync:read`, `shop-sync:write`, `erp-inventory:read`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben. Je neuem Schlüssel genau ein Recht: `ApiKeysService.create` lehnt mehrere Rechte mit 400 ab (Lesen und Buchen damit immer getrennt); bereits ausgestellte Schlüssel mit mehreren Rechten bleiben gültig und werden in der Oberfläche mit Hinweis markiert; `erp-inventory:read` zählt als Lesen. Nach 20 ungültigen Schlüsseln in 15 min je IP (IPv6: je /64) `429` für weitere ungültige – gültige Schlüssel nie, falsch aufgebaute Tokens zählen nicht; Zähler in `ThrottlerHit`, über alle Pods geteilt (Annahme 30); Warnung beim Start, wenn `TRUST_PROXY` in Produktion fehlt; Logs nur mit Schlüssel-Präfix |
|
||||
| Ausgehende Aufrufe | Webhooks: nie aus der Anfrage heraus gesendet, sondern über die Zustelltabelle `WebhookDelivery` (Warteschlange und Zustellprotokoll), die der Worker abarbeitet; Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, keine Weiterleitungen, 5 s Leerlauf-Timeout und 10 s Frist je Versuch (danach wird die Verbindung abgebrochen); nur eine `2xx`-Antwort gilt als zugestellt, sonst Wiederholung nach der Outbox-Regel (30 s bis 6 h, nach 8 Versuchen `FAILED`); stabile Delivery-ID (`X-ApiCore-Delivery` = `WebhookDelivery.id`, mit gleichem Body über alle Wiederholungen); Signatur über Zeitstempel+ID+Body; Antwortkörper wird nie gelesen, `lastError` nur über `loggableError()` |
|
||||
@@ -276,7 +282,7 @@ Grundlage: OWASP API Security Top 10 (2023) und OWASP ASVS Level 2. Umsetzung un
|
||||
| `TELEGRAM_BOT_USERNAME` | ConfigMap, API und Worker | Benutzername des Bots (ohne `@`, 5–32 Zeichen, `^[A-Za-z][A-Za-z0-9_]{4,31}$`), erzeugt den Deep Link; ungültig = nicht gesetzt, mit Warnung in jeder Rolle |
|
||||
| `TELEGRAM_BOT_TOKEN` | Secret `apicore-telegram`, nur Worker | Bot-Token von BotFather. Der API-Pod darf das Secret nicht mounten. Fehlt er, ist Telegram aus; ein Benutzername ohne Token oder umgekehrt warnt im Log |
|
||||
|
||||
Die Manifeste verdrahten das Telegram-Secret noch nicht; ohne Token läuft die Worker-Schleife `telegram-send` nicht (Abschnitt 10).
|
||||
Die Manifeste binden das Secret nur am Worker ein (Abschnitt 7). Ohne Token laufen die Worker-Schleifen `telegram-send` und `telegram-updates` nicht (Abschnitt 10).
|
||||
|
||||
**Bekannte Restpunkte (bewusst nicht umgesetzt):**
|
||||
|
||||
@@ -367,7 +373,9 @@ Daraus folgen die Webhooks `batch.updated` und `stock.changed` (`webhooks.forwar
|
||||
|
||||
**Zustellplanung.** Kein Handler: `NotificationsService.write` plant nach dem Einfügen der Meldungen (dieselbe Transaktion; ohne Aufrufer-Transaktion öffnet es eine eigene, damit Meldungen und Zustellungen zusammen bestehen) über den `NotificationDeliveryPlanner` je neu eingefügter Meldung höchstens eine `NotificationDelivery` für Telegram und weckt den Worker per `pg_notify('notification_delivery', '')`, nur wenn Zeilen entstanden sind. Eine wiederholte Meldung (gleicher `dedupeKey`) plant nichts. Regeln: Annahme 49, Abschnitt 11.13.
|
||||
|
||||
**Worker-Schleife `telegram-send`.** Takt 5 000 ms und auf `notification_delivery`; sie ist nur registriert, wenn `TELEGRAM_BOT_TOKEN` gültig gesetzt ist (`TelegramClient.isConfigured()`). Ein Takt ruft `TelegramSender.releaseStaleClaims()` und dann `drain(stopping.signal)` auf. Mit `telegram-updates` (Task 36, noch offen) ist sie der einzige Netzzugriff zur Bot-API. Rechte kommen aus dem globalen `JobPermissionsModule` des Workers, `TelegramModule` importiert kein `PermissionsModule`.
|
||||
**Worker-Schleife `telegram-send`.** Takt 5 000 ms und auf `notification_delivery`; sie ist nur registriert, wenn `TELEGRAM_BOT_TOKEN` gültig gesetzt ist (`TelegramClient.isConfigured()`). Ein Takt ruft `TelegramSender.releaseStaleClaims()` und dann `drain(stopping.signal)` auf. Mit `telegram-updates` ist sie der einzige Netzzugriff zur Bot-API. Rechte kommen aus dem globalen `JobPermissionsModule` des Workers, `TelegramModule` importiert kein `PermissionsModule`.
|
||||
|
||||
**Worker-Schleife `telegram-updates`.** Takt 30 000 ms (die Wartezeit ohne Lease, nach 409 und nach Fehlern), kein Weckkanal; wie `telegram-send` nur mit gültigem `TELEGRAM_BOT_TOKEN` registriert. Ein Takt ruft `TelegramUpdatesService.pollOnce(stopping.signal)`; nach `'polled'` wird die Schleife sofort wieder geweckt, das Stop-Signal bricht einen laufenden Abruf ab. Störungen stehen nur in `TelegramPollState.lastError` (Verwaltung > System) und im Log; sie machen den Worker nicht ungesund. Ablauf und Regeln: Abschnitt 11.13.
|
||||
|
||||
### Webhook-Zustellung
|
||||
|
||||
@@ -395,7 +403,7 @@ Daraus folgen die Webhooks `batch.updated` und `stock.changed` (`webhooks.forwar
|
||||
- `ThrottlerHit`-Zeilen, deren Fenster abgelaufen ist und die keine aktive Sperre tragen (`"expiresAt" < now()` und `"blockedUntil"` leer oder `< now()`); eine noch laufende Sperre bleibt so erhalten.
|
||||
- `DONE`-Ereignisse und `DELIVERED`-Zustellungen nach 30 Tagen (`occurredAt` bzw. `createdAt`), `FAILED`-Einträge beider Tabellen nach 90 Tagen; die Deliveries eines Ereignisses gehen per `ON DELETE CASCADE` mit. 30 Tage liegen bewusst über dem Nachholfenster der täglichen Auswertung, sonst würde ein gelöschter `dedupeKey` ein Vorkommen erneut erzeugen.
|
||||
- `JobRun`-Zeilen nach 90 Tagen.
|
||||
- `NotificationDelivery` (nach `createdAt`, auch Antworten des Bots ohne Nutzer): `DELIVERED` und `SKIPPED` nach 30 Tagen, `FAILED` nach 90 Tagen, `PENDING` nach 7 Tagen (`PENDING_DELIVERY_MAX_AGE_DAYS`; eine so alte Zustellung ist hängen geblieben, sie wird mit Warn-Log samt Zahl gelöscht). `TelegramLinkToken` 30 Tage nach Ablauf.
|
||||
- `NotificationDelivery` (nach `createdAt`, auch Antworten des Bots ohne Nutzer): `DELIVERED` und `SKIPPED` nach 30 Tagen, `FAILED` nach 90 Tagen, `PENDING` nach 7 Tagen (`PENDING_DELIVERY_MAX_AGE_DAYS`; eine so alte Zustellung ist hängen geblieben, sie wird mit Warn-Log samt Zahl gelöscht). `TelegramLinkToken`, die seit mehr als 24 h abgelaufen oder verbraucht sind.
|
||||
|
||||
Das Ergebnis zählt jede Kategorie (`idempotencyRecords`, `consumedTokens`, `throttlerHits`, `domainEvents.done`, `domainEvents.failed`, `webhookDeliveries.delivered`, `webhookDeliveries.failed`, `jobRuns.aborted`, `jobRuns.deleted`, `notificationDeliveries.done`, `notificationDeliveries.failed`, `notificationDeliveries.pending`, `telegramLinkTokens`).
|
||||
|
||||
@@ -425,6 +433,8 @@ Das Ergebnis zählt jede Kategorie (`idempotencyRecords`, `consumedTokens`, `thr
|
||||
|
||||
Die Seite „Verwaltung > System“ (Rechte `system:read` zum Ansehen, `system:manage` zum Wiederholen; nur Systemrollen) zeigt je Job den letzten Lauf (`JobRun`), die Warteschlange der Domänenereignisse (offen, in Arbeit, fehlgeschlagen, Alter des ältesten offenen Eintrags nach DB-Uhr) und die offenen und fehlgeschlagenen Webhook-Zustellungen. Routen: `GET /system/overview`, `GET /system/job-runs?job=&limit=1..100` (Standard 50), `GET /system/domain-events/failed`, `POST /system/domain-events/:id/retry` (204), `GET /system/webhook-deliveries/failed`, `POST /system/webhook-deliveries/:id/retry` (204). Wiederholen ist nur für `FAILED` möglich (409 sonst, 404 unbekannt): Versuche und `availableAt` werden in SQL (`now()`) zurückgesetzt, fehlgeschlagene Handler-Zustellungen eines Ereignisses wieder `PENDING`, ein Audit-Eintrag (UPDATE) geschrieben und der Worker per `pg_notify` geweckt. Die Ansichten enthalten nie Payload, Webhook-Body, Secrets oder vollständige URLs (nur den Host); `lastError` steht wie gespeichert (`loggableError()`). API-Schlüssel erreichen `/system` nie (Scope-Allowlist).
|
||||
|
||||
**Telegram-Karte** (nur mit `system:manage`; Betrieb des Bots: Abschnitt 11.13). `GET /system/notification-deliveries?status=` liefert `telegram` und `deliveries`. `telegram.botNameSet` heißt nur, dass der Bot-Name gesetzt ist: Ob der Versand läuft, weiß die api nicht, weil sie das Token nie hat; er läuft nur im Worker, wenn das Secret vorhanden ist. `receiver`: `leaseOwner` (nur bei laufender Lease), `lastPollAgeSeconds` (DB-Uhr), `lastError`. `sender`: `pending`, `oldestPendingAgeSeconds` (längste Überfälligkeit; wegen der Ruhezeit Wartende zählen nicht), `failed24h` (in den letzten 24 h angelegt und fehlgeschlagen), `lastErrors` (letzte 5, mit `createdAt` = angelegt am). `deliveries`: die neuesten 100 (Status, Kategorie, Vorlage, Versuche, `availableAt`, Statuscode, Fehler, angelegt/zugestellt), nie Chat-ID, Nutzer oder Text. `POST /system/notification-deliveries/:id/retry` (204): nur `FAILED` (409 sonst, 404 unbekannt); setzt `PENDING`, Versuche 0, `availableAt = now()`, schreibt ein Audit und weckt den Worker. `POST /system/notification-deliveries/retry-failed` (200 `{ retried }`): dasselbe für die neuesten `FAILED`, höchstens 100 je Aufruf (bei mehr erneut aufrufen), unter Zeilensperre, ein Audit je Zeile.
|
||||
|
||||
## 11. Logik-Ketten und Saison-Autopilot (T4a bis T4c)
|
||||
|
||||
Regeln, Ketten und der Autopilot schreiben Vorschläge statt Aufgaben; ein Mensch übernimmt oder verwirft. Alles hängt am Ereignis-Rückgrat (Abschnitt 10, Handler-Übersicht) und am Job `daily-evaluation`. Entwurf: Spec `docs/superpowers/specs/2026-10-13-t4-logik-ketten-design.md`. T4b und T4c ergänzen Pflanzenentwicklung und Wetter (11.11), Honig-Kette und Bewegungsbuch (11.12) sowie Telegram (11.13). Noch offen sind die Bestätigung beim Wassergehalt (Regel `HONEY_WATER_CONTENT`) und das Verknüpfen und Abfragen von Telegram (Task 36).
|
||||
@@ -569,7 +579,7 @@ Ein einziger Fenster-Helfer (`src/treatments/withdrawal-window.ts`, Annahme 10)
|
||||
|
||||
### 11.11 Pflanzenentwicklung und Wetter
|
||||
|
||||
Stand: Schema, Wetterquelle, Berechnung, Job-Schritt und die Meldungs-API (Melden, Bearbeiten, Zurückziehen mit Neuberechnung) sind fertig.
|
||||
Stand: Schema, Wetterquelle, Berechnung, Job-Schritt, die Meldungs-API (Melden, Bearbeiten, Zurückziehen mit Neuberechnung) und die Oberfläche zum Melden (Offline-Warteschlange eingeschlossen) sind fertig.
|
||||
|
||||
**Tabellen** (Migration `20261015100000_t4_phenology`, Annahme 43). `PhenologyReport` hält eine gemeldete Phase (Stand oder alle Stände, Datum, Rücknahme per `revokedAt`; Index `(apiaryId, phase, date)`). `ApiaryPhenology (apiaryId, year)` ist der abgeleitete Saisonzustand. `ApiaryWeatherDay` ist der Wetter-Cache (Tagesmittel, `source` `forecast` oder `archive`, `fetchedAt`). Rechte `phenology:read`, `phenology:create`, `phenology:update` (Gruppe „Pflanzenentwicklung“). Backup: Abschnitt 8.
|
||||
|
||||
@@ -589,9 +599,11 @@ Stand: Schema, Wetterquelle, Berechnung, Job-Schritt und die Meldungs-API (Melde
|
||||
|
||||
**Meldungen** (`PhenologyModule`, Annahme 47). Routen: `GET /phenology/state` (`apiaries:read`; je aktivem Stand Zustand ohne Koordinaten, `reportedOn` nur bei Quelle `REPORT` der früheste Meldetag der geltenden Phase), `GET /phenology/reports?year=` (`phenology:read`, Standard laufendes Jahr, auch zurückgezogene mit `revokedAt`, Stand- und Nutzername), `POST /phenology/reports` (`phenology:create`, `@Idempotent()`; Phase, Datum `JJJJ-MM-TT`, `apiaryId` oder `null` für alle, Notiz höchstens 500 Zeichen), `PATCH /phenology/reports/:id` (`phenology:update`; `null` auf Phase oder Datum: 400, auf Stand oder Notiz: leert), `POST /phenology/reports/:id/revoke` (`phenology:update`, `@Idempotent()`, schon zurückgezogen: 409). Datum in der Zukunft: 400 „Das Datum liegt in der Zukunft.“, ungültiger Tag: 400 „Das Datum ist ungültig.“, unbekannter Stand: 404 „Stand nicht gefunden.“, Bearbeiten einer zurückgezogenen Meldung: 409 „Die Meldung wurde schon zurückgezogen.“. Jede Änderung ist eine Transaktion aus Sperre auf die Meldung (`lockRowForUpdate`, danach „vorher“ neu lesen), Schreiben, Audit-Eintrag mit Vorher-Wert und Ereignis; Handler `phenology.recompute` und `phenology.recompute-apiary` (Abschnitt 10) berechnen danach die Stände neu. Saisonwechsel erscheinen in der Morgenübersicht (Abschnitt 11.9).
|
||||
|
||||
**Oberfläche** (Seite eines Standes). Der Kopf `ApiarySeason` zeigt Saisonabschnitt (`SeasonChip`), Phase und Quelle („gemeldet am TT.MM.“, „geschätzt“, „Kalender“) und bei schwacher Wetterbasis einen Hinweis. Das Intervall im Chip („alle 7 Tage“) stammt aus `GET /autopilot/today` (`inspectionDue[].intervalDays`) und fehlt für Stände ohne fällige Durchsicht oder ohne Recht; ein Feld `intervalDays` an `GET /phenology/state` würde es exakt machen (offen, Nachfolgearbeit). Der Knopf „Pflanze blüht“ (`phenology:create`) öffnet das Blatt „Entwicklung melden“ (`PhenologyReportSheet`): acht Zeilen (Icon und Wort, 56 px), ein Tipp meldet die Phase für heute (Berliner Tag), vorgewählt „Für alle Stände“, wahlweise „Nur <Stand>“. Jede Phase und jeder Bereich ist eine eigene Meldung mit eigenem `Idempotency-Key`. Ein Serverfehler bleibt im Blatt sichtbar; während einer Meldung oder Rücknahme lässt es sich nicht schließen. Darunter stehen die Meldungen des Jahres (`phenology:read`; eigener Stand und „alle Stände“, zurückgezogene durchgestrichen) mit „Zurückziehen“ (`phenology:update`, nur online, sonst „Nur mit Verbindung“); das Zurückziehen einer Meldung für alle Stände fragt über den `ConfirmDialog` nach. Offline landet die Meldung im IndexedDB-Speicher `pending-phenology-reports` (`apicore-offline`, Version 4; gleiche Form wie die übrigen Einzel-Warteschlangen mit Schlüssel und Besitzer; Anzeige „Pflanzenmeldung“). Der Sync sendet ihn mit den anderen Warteschlangen (`POST /phenology/reports`, originaler Schlüssel; abgelehnte Einträge bleiben mit der Antwort des Servers stehen). Danach lösen Offline-Band und Warteschlangen-Sheet ein globales `invalidateQueries()` aus, sodass Saisonzustand und Meldungsliste neu laden. Eine Meldung für einen früheren Tag bietet die Oberfläche nicht an (nur die API per PATCH). Dass ein erneutes Öffnen des Blatts dieselbe Phase doppelt melden kann, ist Annahme 55.
|
||||
|
||||
### 11.12 Honig-Kette und Bewegungsbuch
|
||||
|
||||
Stand: Ereignisse, Vorbelegung, Warnungen, Wartezeit bei der Schleuderung, der Webhook und die Laufzeit-Buchungen des Bewegungsbuchs sind fertig. Offen ist die Bestätigung beim Wassergehalt der Abfüllung (409, `acknowledgedWarnings`).
|
||||
Stand: Ereignisse, Vorbelegung, Warnungen, Wartezeit bei der Schleuderung, der Webhook, die Laufzeit-Buchungen des Bewegungsbuchs und die Qualitätsprüfung der Abfüllung (API) sind fertig, ebenso die Oberfläche von Schleuderung, Honigcharge und Bewegungsbuch. Offen ist die Abfüllungsmaske mit der Wassergehalt-Bestätigung (409), sie folgt als Task 33.
|
||||
|
||||
**Ereignisse.** `ExtractionRecorded`/`ExtractionCorrected` und `HoneyBatchRecorded`/`HoneyBatchCorrected` entstehen in der Schreib-Transaktion mit dem Audit-Eintrag (Abschnitt 10). Der Webhook `batch.created` kommt über `webhooks.forward` aus `HoneyBatchRecorded`; `HoneyBatchCorrected` sendet nichts, wie vorher beim Ändern.
|
||||
|
||||
@@ -612,11 +624,20 @@ Stand: Ereignisse, Vorbelegung, Warnungen, Wartezeit bei der Schleuderung, der W
|
||||
|
||||
**API des Buchs.** `GET /honey-batches/:id/movements` (`honey-batches:read`; 404 für eine unbekannte Charge) antwortet `{ items, hasMore }` mit höchstens 200 Buchungen, neueste zuerst (`id`, `type`, `quantityGrams`, `reason`, `notes`, `sourceBottlingBatchId`, `createdAt`, `userId`). `POST /honey-batches/:id/movements` (`honey-batches:update`, `@Idempotent()`) nimmt `{ type: 'WITHDRAWAL' | 'CORRECTION', deltaKg, reason?, notes? }` (`deltaKg` endlich, ungleich 0, Betrag höchstens 100 000; Notiz höchstens 500 Zeichen) und antwortet mit der Buchung. Eine `WITHDRAWAL` braucht einen Grund („Bitte einen Grund angeben.“) und eine negative Menge („Eine Entnahme braucht eine negative Menge.“); bei einer `CORRECTION` ist ein Grund erlaubt und wird gespeichert. Eine Charge ohne erfasste Menge ergibt 400 „Die Menge dieser Honigcharge ist nicht erfasst. Bitte zuerst die Menge eintragen.“; ein widersprüchlicher Bestand (`initialAmount` gesetzt, `amount` leer) 409. Die Buchung schreibt Audit `HoneyBatch UPDATE` und `HoneyBatchCorrected`. Die Oberfläche zeigt die Startmenge getrennt von der Liste der Buchungen und liest `.items`.
|
||||
|
||||
**Antwort-Form.** Antworten von `POST/PATCH /extractions` und `/honey-batches` tragen `warnings`. Eine Bestätigung (`acknowledgedWarnings`, 409) gibt es hier nicht; sie gehört zur Abfüllung.
|
||||
**Antwort-Form.** Antworten von `POST/PATCH /extractions` und `/honey-batches` tragen `warnings`. Eine Bestätigung (`acknowledgedWarnings`, 409) gibt es hier nicht; sie gehört zur Abfüllung (nächster Absatz).
|
||||
|
||||
**Qualität vor der Abfüllung** (Annahmen 52 und 53; `src/common/quality/water-content.ts`, `src/bottling/bottling-defaults.ts`). Der Wassergehalt der Honigcharge wird gegen die Regel `HONEY_WATER_CONTENT` geprüft (Standard: Warnschwelle 18 % nach D.I.B. „Echter Deutscher Honig“, Bestätigungsgrenze 20 % nach HonigV Anlage 2; je Honigsorte überschreibbar, etwa `{ "Heidehonig": { confirmAbovePercent: 23 } }`, Sorte ohne Beachtung der Groß-/Kleinschreibung, sonst exakt; ein Eintrag darf nur eine der beiden Grenzen setzen). Es zählt die neueste `HoneyMeasurement` (nach `measuredAt`), sonst `HoneyBatch.waterContent`.
|
||||
- `WATER_CONTENT_HIGH` (WARNUNG): über der Warnschwelle. `WATER_CONTENT_MISSING` (HINWEIS): kein Wert. `WATER_CONTENT_ABOVE_LEGAL` (BESTÄTIGEN): streng über der Bestätigungsgrenze; genau 20,0 % warnt nur mit `WATER_CONTENT_HIGH`. `JAR_SIZE_DIFFERS` (HINWEIS): die gesendete Glasgröße weicht von `Product.packagingSize` ab.
|
||||
- `POST /bottling-batches` antwortet bei `WATER_CONTENT_ABOVE_LEGAL` mit 409 `{ statusCode, code: 'CONFIRMATION_REQUIRED', message, waterContent, warnings }`, solange die Bestätigung fehlt. `warnings` enthält die Qualitätsbefunde der Anfrage (Wassergehalt und Glasgröße), nicht die Wartezeitwarnungen. Die Prüfung läuft in der Transaktion nach der Sperre der Honigcharge und vor dem Schreiben: Bei 409 entstehen weder Abfüllung noch Buchung, Ereignis oder Audit-Eintrag; eine zwischenzeitlich erfasste Messung zählt.
|
||||
- Die bestätigte Wiederholung sendet denselben Body plus `acknowledgedWarnings: ['WATER_CONTENT_ABOVE_LEGAL']` (jeder andere Inhalt: 400) und `acknowledgedWaterContent` (Zahl 0–100) mit dem `waterContent` der 409-Antwort. Die Bestätigung gilt nur, wenn der unter der Sperre geprüfte Wert genau diesem Wert entspricht; sonst (neue Messung, Code ohne Wert, anderer Wert) folgt eine frische 409 mit dem neuen Wert. Der Idempotenz-Schlüssel wird bei Fehlern freigegeben, die Wiederholung darf also denselben `Idempotency-Key` nutzen; eine weitere Anfrage mit diesem Schlüssel nach dem 201 gibt die gespeicherte Antwort wieder.
|
||||
- Die 201-Antwort trägt zuerst die Qualitätsbefunde (auch den bestätigten `WATER_CONTENT_ABOVE_LEGAL`), dann die Wartezeitwarnungen. `PATCH /bottling-batches/:id` und die Messungs-Routen der Honigcharge (`POST` und `PATCH …/measurements`, Antwort mit `warnings`) liefern nie 409. Das `PATCH` meldet Qualitätsbefunde nur, wenn sich Honigcharge oder Glasgröße ändern.
|
||||
- Vorbelegung `GET /bottling-batches/prefill?honeyBatchId=&productId=&date=` (`bottling:create`, vor `:id` deklariert): `{ jarSize, jarSizeSource: 'PRODUCT' | null, bestBeforeDate, shelfLifeMonths, waterContent, warnings }`. Die Glasgröße kommt vom Artikel (`packagingSize`), das MHD aus `Product.shelfLifeMonths` (1–60, beim Anlegen und Ändern des Artikels setzbar, `null` löscht): Abfülltag plus Monate, gekürzt auf das Monatsende (31.01. + 1 Monat = 28.02.); ein Zeitstempel zählt als sein Berliner Tag, `date` ist standardmäßig heute. `honeyBatchId` fehlt: 400; unbekannte Charge oder Artikel: 404; Artikel nicht `BOTTLED_HONEY`: 400. Beim Anlegen gewinnt ein gesendeter Wert, sonst der Artikelwert; fehlt beides, antwortet die API mit 400 („Glasgröße und MHD fehlen. …“, „Ohne Haltbarkeit am Artikel bitte ein MHD eintragen.“, „Ohne Packungsgröße am Artikel bitte eine Glasgröße eintragen.“). Unmögliche Tage (30.02.) in `date`, `bestBeforeDate` und der Vorbelegung ergeben 400 „Das Datum ist ungültig. …“.
|
||||
|
||||
**Oberfläche der Honig-Kette.** Vorbelegungen heißen „Vorschlag aus …“ („aus 2 Ernten“, „aus den Ernten: Raps“, „aus der Schleuderung (Rest)“) und werden nur durch einen Knopf übernommen. Laden die Hinweise nicht, zeigt `PrefillStatus` (feste `aria-live`-Zeile) „Hinweise (z. B. Wartezeit) konnten nicht geladen werden.“ mit „Erneut versuchen“; Speichern bleibt erlaubt, das Backend prüft (Annahme 53). `lib/honey-batches/errors.ts` (`describeSaveError`, `saveErrorText`) ist der einzige Ort, der einen fehlgeschlagenen Speichervorgang liest (400-`issues` je Feld, Nest-`message[]` durch deutschen Text ersetzt, 409-Meldung durchgereicht, Netzfehler). Das Anlegen einer Honigcharge bietet „Wassergehalt (%)“ mit dem Vorschlag aus der Schleuderung. Die Detailseite hat die Karte „Bewegungen“ (Buchungen mit Link zur Abfüllung, Grund und Korrektur mit Vorzeichen; bei `hasMore` der Hinweis „Ältere Buchungen werden nicht angezeigt“). „Entnahme“ (nur `WITHDRAWAL`, Grund Pflicht und nicht vorgewählt, Menge mindestens 1 g und höchstens der Rest) und „Menge korrigieren“ (Ausgangswert beim Öffnen gesperrt, Differenz sichtbar, bei 0 „Keine Abweichung“, Grund optional, Buchung als „Korrektur“ beschriftet) gibt es mit `honey-batches:update` und erfasster Menge. Beide Auslöser sind offline gesperrt („Nur mit Verbindung“) und buchen mit eigenem Schlüssel je Buchung. Alle Abfragen der Detailseite liegen unter dem Schlüsselpräfix `["honey-batches"]`, das beide Dialoge ungültig machen. Die Bestätigung beim Wassergehalt (409) gehört zur Abfüllungsmaske (Task 33), nicht zu diesen Dialogen.
|
||||
|
||||
### 11.13 Telegram
|
||||
|
||||
Stand: Schema, Client, Prüfung beim Start, Planung und Senden sind fertig (`TelegramModule` importieren `WorkerRuntimeModule` und `WorkerModule`). Das Verknüpfen eines Chats und das Abfragen von Nachrichten (Task 36) fehlen noch: Es gibt noch keinen Weg, eine `TelegramLink` anzulegen. `NotificationPreference.telegramEnabled` setzt bisher nur der Sender, beim Lösen der Verbindung auf aus (Abschnitt 11.9).
|
||||
Stand: Schema, Client, Prüfung beim Start, Planung, Senden, Verbinden, Empfangen und die Betriebsansicht sind fertig (`TelegramModule` importiert `WorkerRuntimeModule` und `WorkerModule`). `NotificationPreference.telegramEnabled` schaltet das Verbinden ein, das Trennen (und der Sender bei 403) aus (Abschnitt 11.9).
|
||||
|
||||
**Tabellen** (Migration `20261015110000_t4_telegram`). Enums `NotificationChannel` und `NotificationDeliveryStatus`. `TelegramLink` (Chat-Bindung eines Nutzers; eindeutig je Chat), `TelegramLinkToken` (Einmal-Token zum Verknüpfen), `NotificationDelivery` (Zustell-Warteschlange), `TelegramPollState` (genau eine Zeile `singleton`, per CHECK `id = 'singleton'`). Alle Fremdschlüssel auf `User` laufen mit `ON DELETE CASCADE`, ebenso `Notification.deliveries`. Keine dieser Tabellen ist Teil des Backups (Abschnitt 8).
|
||||
|
||||
@@ -647,4 +668,12 @@ Stand: Schema, Client, Prüfung beim Start, Planung und Senden sind fertig (`Tel
|
||||
- 401 (Token ungültig): `deferClaim` um 15 min, der Sender pausiert 15 min (monotone Uhr, kein Claim), und `system:manage` bekommt einmal je Berliner Tag „Telegram: Bot-Token ungültig.“ (Schlüsselpräfix `telegram-token-invalid:<Tag>`). Scheitert die Meldung, wird das nur geloggt.
|
||||
- Alles andere (5xx, Zeitüberschreitung, Netzfehler, sonstige 4xx, unerwartete Ausnahmen): `settleFailure` mit `loggableError(...).message`, Wiederholung nach der Outbox-Regel (30 s bis 6 h, nach 8 Versuchen `FAILED`).
|
||||
|
||||
**Verbinden.** `POST /profile/telegram/link-token` (jeder angemeldete Nutzer; Step-up mit aktuellem Passwort und bei MFA TOTP; 10 pro Minute; 201; nicht `@Idempotent`, weil die Antwort ein Geheimnis enthält; `Cache-Control: no-store`) antwortet `{ deepLink, expiresAt }` mit `https://t.me/<Bot>?start=<Token>`. Ohne `TELEGRAM_BOT_USERNAME` ergibt es 409 „Telegram ist nicht eingerichtet.“; das Bot-Token kennt die api nie. Das Token besteht aus 32 Zufallsbytes (Base64url, 43 Zeichen), gespeichert wird nur der SHA-256. Es gilt 10 Minuten (Ablauf nach DB-Uhr) und einmal; ein neues entwertet ältere offene Token desselben Nutzers. Der Job `cleanup` löscht Token, die seit mehr als 24 h abgelaufen oder verbraucht sind. `POST /profile/telegram/unlink` (204) trennt.
|
||||
|
||||
**Empfangen** (`TelegramUpdatesService.pollOnce`, Schleife `telegram-updates`, Abschnitt 10). Long Polling (`getUpdates`, `timeout=25`, `allowed_updates ['message']`), kein öffentlicher Eingang, nur private Chats. Genau ein Empfänger: eine Lease in `TelegramPollState` (60 s, nur DB-Uhr, jede Runde verlängert); wer sie nicht hat, wartet 30 s, ebenso nach 409 und nach Fehlern. Das setzt ein einzelnes Worker-Replikat voraus (Abschnitt 7). Jedes Update läuft in einer eigenen Transaktion, in der zuerst der Offset (`update_id + 1`) per Compare-and-Set auf Lease-Inhaber und alten Stand weitergeschoben wird; geht die Lease verloren, bricht die Transaktion samt Wirkung ab, jedes Update wirkt genau einmal. Nicht auswertbare Updates (etwa ohne `message.chat`) schiebt der Offset ohne Wirkung weiter, auch am Ende eines Batches (`getUpdates` liefert `{ updates, highestUpdateId }`). Ein Update, dessen Wirkung immer scheitert, wird nach 5 Versuchen oder 10 Minuten übersprungen (Annahme 51). Auf 429 wartet der Empfang bis `retry_after` (ohne Angabe 30 s, höchstens 1 Stunde), ohne DB und Telegram anzufassen. Beim Beenden bricht das Stop-Signal den Abruf ab, und der Worker gibt die Lease sofort frei. `lastPollAt` steht nach dem Abruf; `lastError` (fester Text plus Klassenname, nie IDs oder Nachrichtentext) wird erst nach einem Batch ohne Fehler und ohne Überspringen gelöscht.
|
||||
|
||||
**Befehle.** `/start <Token>` (auch `/start@Bot <Token>`) verbraucht das Token per Compare-and-Set (nur offen und nicht abgelaufen), legt die Präferenz an, sperrt `NotificationPreference` aller betroffenen Nutzer in einer Anweisung (aufsteigend) und liest Verbindung und Präferenz erst danach (dieselbe Sperre wie der 403-Pfad des Senders). Es ersetzt eine frühere Verbindung desselben Nutzers (`linkedAt` beginnt neu), löst die Verbindung eines anderen Nutzers mit demselben Chat (Meldung „Deine Telegram-Verbindung wurde getrennt, weil der Chat mit einem anderen Konto verbunden wurde.“), schaltet Telegram ein und schreibt die Meldung „Telegram verbunden mit „{Vorname}“ am TT.MM.JJJJ. Warst du das nicht? In den Einstellungen trennen.“ (deckt einen mitgelesenen Link auf; Vorname bereinigt, höchstens 64 Zeichen). Ist Nutzer oder Rolle inaktiv, oder das Token ungültig, abgelaufen oder benutzt, antwortet der Bot `LINK_INVALID` ohne Hinweis auf Konten. `/stop` und „Trennen“ in der App löschen die Verbindung, schalten Telegram aus und senden `STOPPED`. `/hilfe`, `/help`, `/start` ohne Token und anderer Text ergeben `HELP`. Antworten des Bots werden als `NotificationDelivery` mit `templateCode` und `chatId` eingereiht (kein Netzaufruf in der Transaktion) und vom Sender verschickt. Höchstens 3 Antworten je Chat in 10 Minuten (Zähler im Speicher, gezählt erst nach dem Commit); `LINKED` ist ausgenommen (Annahme 54). Jede Trennung (App, `/stop`, Chat zieht zu einem anderen Konto um) markiert die wartenden Telegram-Zustellungen des Nutzers (`PENDING`, `PROCESSING`) in derselben Transaktion als `SKIPPED` („unlinked“). Datenschutz: Vorname, Chat-ID und Token stehen nie in Logs; die Chat-ID verlässt den Server nicht (auch die Einstellungs-Antwort enthält sie nicht). Je Umgebung ein eigener Bot, weil ein Bot nur einen Empfänger haben darf.
|
||||
|
||||
**Betrieb.** Die Karte „Telegram“ unter Verwaltung > System (Abschnitt 10) zeigt Empfänger (Lease, Alter des letzten Abrufs, `lastError`) und Sender (wartend, überfällig, fehlgeschlagen in 24 h, letzte Fehler) und erlaubt das Wiederholen fehlgeschlagener Zustellungen. Verdrahtung des Secrets und Einrichtung des Bots: Abschnitt 7.
|
||||
|
||||
Logs nennen nur Zustellungs-IDs, nie Token, Chat-ID oder Text.
|
||||
Reference in new issue
Block a user