Compare commits

...
2 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5.5 9760ce154f feat: T4 wave 11 – bottling form with article values and confirmation, Telegram in the app
Backend Image / build-and-push (push) Successful in 4m27s
Frontend Image / build-and-push (push) Successful in 9m34s
Manifests / validate (push) Successful in 19s
- Bottling form: jar size and best-before from the product with source,
  differs-hints before saving, prefill status, water-content confirmation
  naming the measured value, idempotency key renewed on body change;
  product form gets shelf life (months) and whole-gram jar weight.
- Telegram frontend: link card with step-up dialog, t.me-only deep link,
  status polling, unlink; system Telegram card with receiver/sender state
  and confirmed retry; StepUpDialog shared with the backup import.
- Reauth calls never refresh-and-retry on 401 (no double lockout count);
  German texts for expired session and 429; service worker never caches
  Telegram, notification settings and system responses.
- Telegram backend round 2: shared link removal on every disconnect path,
  lease released on shutdown, connection errors not counted as poison.
- ARCHITECTURE.md §2, §6 (Annahmen 56–58), §10, §11.12, §11.13; TASKS.md T4.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 01:21:08 +02:00
Nick SzaunigandClaude Opus 5.5 fab7f7e12f 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]>
2026-10-14 22:47:17 +02:00
148 changed files with 11995 additions and 272 deletions

No files matched your search

+57 -11
View File
@@ -73,6 +73,8 @@ Zentrale Schnellaktion "+ Neu" (Abschnitt 22): permanent sichtbarer Floating-But
**Offline-Seiten:** Ohne Netz zeigen `/offline` (Start: geladene Stände), `/offline/apiary?id=…` und `/offline/colony?id=…` die Daten aus den Offline-Paketen (IndexedDB). QR- und NFC-Deep-Links (`/apiaries/<id>`, `/colonies/<id>`) weicht der Service Worker ohne Netz auf diese Seiten aus; der Weg zurück („Mit Netz öffnen“) übernimmt `from` nur bei gleicher Herkunft (same-origin). Formulare lesen Völker und Königinnen aus dem Paket, wenn die API nicht antwortet (`withPackFallback`, `networkMode: "always"`). **Offline-Seiten:** Ohne Netz zeigen `/offline` (Start: geladene Stände), `/offline/apiary?id=…` und `/offline/colony?id=…` die Daten aus den Offline-Paketen (IndexedDB). QR- und NFC-Deep-Links (`/apiaries/<id>`, `/colonies/<id>`) weicht der Service Worker ohne Netz auf diese Seiten aus; der Weg zurück („Mit Netz öffnen“) übernimmt `from` nur bei gleicher Herkunft (same-origin). Formulare lesen Völker und Königinnen aus dem Paket, wenn die API nicht antwortet (`withPackFallback`, `networkMode: "always"`).
**API-Client und Service Worker.** `apiFetch` und `apiFetchFormData` nehmen die Option `refreshOn401: false`: Ein 401 wird dann weder erneuert noch wiederholt, die deutsche Meldung des Servers kommt unverändert an. Alle Nachweis-Aufrufe (Step-up, Abschnitt 11.13) setzen sie (Annahme 56). Der Service Worker legt `/api/v1/profile/telegram`, `/api/v1/profile/notification-settings` und `/api/v1/system` nie im Cache ab (`NEVER_CACHED_API_PATHS` in `public/sw-routes.js`; eine Änderung hebt `SW_ROUTES_VERSION` in `public/sw.js`).
**Stand-Seite und Standrunde:** `apiaries/[id]` zeigt die lebenden Völker mit Ampel und „Runde starten“; nach dem Speichern führt „Weiter mit …“ zum nächsten Volk. Der Rundenzustand liegt je Benutzer lokal und wird beim Abmelden gelöscht. **Stand-Seite und Standrunde:** `apiaries/[id]` zeigt die lebenden Völker mit Ampel und „Runde starten“; nach dem Speichern führt „Weiter mit …“ zum nächsten Volk. Der Rundenzustand liegt je Benutzer lokal und wird beim Abmelden gelöscht.
**Als Nächstes und Heute:** Die Volksseite zeigt oben „Als Nächstes“ (höchstens 3 Schritte, `GET /colonies/:id/next-steps`), die Startseite „Heute“ den dringlichsten Schritt und die fälligen Durchsichten je Stand (`GET /autopilot/today`; bei Fehler, offline oder ohne Recht gilt die bisherige Aufgabenlogik). Die Stand-Seite nennt je Volk „Durchsicht fällig in n Tagen“ und „Als Nächstes: …“. Offline zeigt die Volksseite die Schritte aus dem Paket nur lesend. Einzelheiten: Abschnitt 11.5. **Als Nächstes und Heute:** Die Volksseite zeigt oben „Als Nächstes“ (höchstens 3 Schritte, `GET /colonies/:id/next-steps`), die Startseite „Heute“ den dringlichsten Schritt und die fälligen Durchsichten je Stand (`GET /autopilot/today`; bei Fehler, offline oder ohne Recht gilt die bisherige Aufgabenlogik). Die Stand-Seite nennt je Volk „Durchsicht fällig in n Tagen“ und „Als Nächstes: …“. Offline zeigt die Volksseite die Schritte aus dem Paket nur lesend. Einzelheiten: Abschnitt 11.5.
@@ -160,12 +162,20 @@ 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. 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`). 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. 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. 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` (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. 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. 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. 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. 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. 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, Verbindungsfehler ebenso wenig; die Zähler werden nach jedem Batch bereinigt. 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.
56. **Step-up: ein 401 wird nie mit Refresh wiederholt**: Bei Aufrufen mit Nachweis (Telegram verbinden, Backup-Import, Passwort ändern, Konto deaktivieren, MFA bestätigen und abschalten) bedeutet ein 401 „Nachweis falsch“. Der Client erneuert dort das Token nicht und wiederholt nicht (`refreshOn401: false`), weil ein zweiter Versuch den Sperrzähler verdoppeln würde. Ist die Sitzung wirklich abgelaufen, erscheint statt Stille der deutsche Hinweis „Sitzung abgelaufen. Bitte die Seite neu laden und erneut anmelden.“ (der Client kennt den Ablauf des Tokens nicht und erneuert nicht vorab). Kosten bei falscher Annahme: Eine abgelaufene Sitzung zeigt bei einem Step-up einmal einen Fehler statt einer stillen Erneuerung. Abschnitt 11.13.
57. **Abfüllung: Produktwechsel setzt Artikelwerte zurück**: Wechselt das Produkt, verwirft die Maske von Hand geänderte Glasgröße und MHD und nimmt die Werte des neuen Artikels; ein Datumswechsel lässt eigene Eingaben stehen. Sonst bliebe die Glasgröße eines anderen Artikels unbemerkt im Formular. Kosten bei falscher Annahme: Wer erst tippt und dann das Produkt wählt, muss neu eintragen. Abschnitt 11.12.
58. **„Alle fehlgeschlagenen erneut versuchen“: die Zahl kann zu niedrig sein**: Die API liefert keine Gesamtzahl der `FAILED`-Zeilen, die Rückfrage nennt daher nur „Mindestens N“ (größerer Wert aus `failed24h` und den `FAILED`-Zeilen der Liste) und immer die Grenze „höchstens 100, neueste zuerst“. Kosten bei falscher Annahme: Es gehen mehr (bis 100) Nachrichten erneut hinaus als angezeigt. Abschnitt 10, System-Seite.
## 7. Kubernetes ## 7. Kubernetes
@@ -180,6 +190,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. - `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…`). - `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. - `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`): **Rollen-Deployments** (ein Image, Rolle per `APICORE_ROLE`):
@@ -256,7 +267,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 | | 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`) | | 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 | | 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 | | 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 | | 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()` | | 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 +287,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_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 | | `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):** **Bekannte Restpunkte (bewusst nicht umgesetzt):**
@@ -367,7 +378,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. **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 ### Webhook-Zustellung
@@ -395,7 +408,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. - `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. - `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. - `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`). 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 +438,10 @@ 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). 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.
**Telegram-Karte (Oberfläche).** Die Karte erscheint nur mit `system:manage`, weil `GET /system/notification-deliveries` dieses Recht verlangt (die Seite selbst braucht `system:read`). Sie zeigt Bot (`botNameSet`, Name, Hinweis, dass der Versand nur im Worker läuft), Empfang (Lease oder „Kein Empfänger aktiv“, Alter des letzten Abrufs, `lastError` im Wortlaut des Servers), Versand (wartend, ältestes Wartendes mit Warnung ab 600 Sekunden, `FAILED` der letzten 24 Stunden, letzte fünf Fehler mit HTTP-Code) und die neuesten 100 Zustellungen (Status immer mit Wort). Liegt der letzte Abruf länger als 120 Sekunden zurück, erscheint auch mit Lease die feste Warnung „Kein Abruf seit über 2 Minuten“ (`role="status"`), das Alter steht getrennt daneben. „Erneut versuchen“ gibt es nur an `FAILED`-Zeilen; „Alle fehlgeschlagenen erneut versuchen“ fragt per `ConfirmDialog` nach („Mindestens N sind fehlgeschlagen.“, immer „Es werden höchstens 100 gesendet, neueste zuerst.“; Annahme 58). Offline sind die Knöpfe mit sichtbarem Grund „Nur mit Verbindung“ gesperrt. Die Ereignisnamen der Warteschlange stehen in `DOMAIN_EVENT_LABELS`.
## 11. Logik-Ketten und Saison-Autopilot (T4a bis T4c) ## 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). 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 +586,7 @@ Ein einziger Fenster-Helfer (`src/treatments/withdrawal-window.ts`, Annahme 10)
### 11.11 Pflanzenentwicklung und Wetter ### 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. **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 +606,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). **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 ### 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 sowie die Abfüllungsmaske mit Artikelwerten und der Wassergehalt-Bestätigung (409).
**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. **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 +631,26 @@ 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`. **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 (nächster Absatz), nicht zu diesen Dialogen.
**Abfüllungsmaske** (`/bottling/new`, Annahme 57). Sie fragt `GET /bottling-batches/prefill` je Honigcharge, Produkt und Datum ab; die letzte Antwort bleibt beim Nachladen stehen, zählt für Beschriftung, Differenz-Hinweise und Wassergehalt aber nur für genau diese Charge, dieses Produkt und diesen Tag. Glasgröße und MHD stammen als Stammdaten vom Artikel (keine Beobachtung) und nennen ihre Herkunft („aus Artikel: 24 Monate“, „aus Artikel: 250 g“, „aus Entwurf“, bei fehlgeschlagenem Abruf „nicht aus Artikel“). Eine eigene Eingabe gewinnt, bis sich das Produkt ändert (dann werden die Eingaben zurückgesetzt); ein Datumswechsel lässt sie stehen. Ein wiederhergestellter Entwurf behält die Kennzeichnung getippter Werte. Weichen Glasgröße oder MHD vom Artikel ab, erscheint der Hinweis schon vor dem Speichern („Glasgröße weicht vom Artikel ab (Artikel: 250 g).“). Wassergehalt der Charge und Server-Hinweise stehen in einem dauerhaften Statusbereich (Symbol plus Wort); `PrefillStatus` meldet Laden und Fehler mit „Erneut versuchen“, und „Abfüllung anlegen“ ist gesperrt, solange die Artikelwerte laden („Artikelwerte werden geladen …“). Die Abfüllung wird nicht offline eingereiht: Offline ist der Knopf mit sichtbarem Grund „Nur mit Verbindung“ gesperrt.
Antwortet das Anlegen mit 409 `CONFIRMATION_REQUIRED`, öffnet ein Dialog mit Fokusfalle den gemessenen Wert („Wassergehalt 20,5 % über der gesetzlichen Grenze. Trotzdem abfüllen?“). „Trotzdem abfüllen“ (genau eine Anfrage, auch bei Doppelklick) sendet denselben Body mit `acknowledgedWarnings` und `acknowledgedWaterContent` unter demselben `Idempotency-Key`; eine frische 409 mit neuem Wert fragt erneut. „Abbrechen“ schreibt nichts, behält das Formular, gibt den Fokus zurück und erneuert den Schlüssel. Der Schlüssel wird außerdem erneuert, sobald der Body vom zuletzt gesendeten abweicht (auch durch eine geänderte Vorbelegung), und nach einem Erfolg. Fehler beim Speichern liest allein `describeSaveError`.
**Haltbarkeit und Gewicht am Artikel.** Das Artikelformular (neu und bearbeiten) hat „Haltbarkeit (Monate)“ (ganze Monate 1–60, leerbar) und „Gewicht pro Glas (g)“ (`packagingSize`, ganze Gramm 1–10000). Im Bearbeiten-Dialog wird das Gewicht nur geprüft, wenn es geändert wurde; ein alter Dezimalwert wird nie umgerechnet. Die Prüfung von `packagingSize` im Backend (`@IsInt`, 1–10000) ist ein Folgepunkt, zuvor wird die Produktionsdatenlage geprüft (Rollout-Schritt mit Freigabe).
### 11.13 Telegram ### 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, die Betriebsansicht und die Oberfläche (Profil-Karte, Step-up, Systemkarte) 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). **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).
@@ -643,8 +677,20 @@ Stand: Schema, Client, Prüfung beim Start, Planung und Senden sind fertig (`Tel
**Antworten von Telegram.** **Antworten von Telegram.**
- 200: `DELIVERED`. - 200: `DELIVERED`.
- 429: `deferClaim` um `retry_after` (ohne Angabe 5 s), kein Fehlversuch; der Sender pausiert für den ganzen Pod bis dahin (`pausedUntil`, der Rest des Batches geht zurück). - 429: `deferClaim` um `retry_after` (ohne Angabe 5 s), kein Fehlversuch; der Sender pausiert für den ganzen Pod bis dahin (`pausedUntil`, der Rest des Batches geht zurück).
- 403 und 400 „chat not found“ (Bot blockiert, Chat gelöscht): Bei einer Nutzer-Zustellung sperrt eine Transaktion `NotificationPreference` (`lockRowForUpdate`), löscht die `TelegramLink` mit `userId` und `chatId` und schaltet nur dann `telegramEnabled` aus und schreibt die Meldung „Telegram-Zustellung beendet. Der Bot wurde blockiert oder der Chat gelöscht.“ (`SYSTEM`, HINWEIS), wenn die Verbindung noch auf diesen Chat zeigt; die Zustellung wird `SKIPPED` („chat unavailable“). Antworten des Bots werden nur `SKIPPED`. - 403 und 400 „chat not found“ (Bot blockiert, Chat gelöscht): Bei einer Nutzer-Zustellung sperrt eine Transaktion `NotificationPreference` (`lockRowForUpdate`), entfernt über den gemeinsamen Helfer `removeLinkIn` (siehe „Befehle“) die `TelegramLink` mit `userId` und `chatId`, schaltet nur dann `telegramEnabled` aus und schreibt die Meldung „Telegram-Zustellung beendet. Der Bot wurde blockiert oder der Chat gelöscht.“ (`SYSTEM`, HINWEIS), wenn die Verbindung noch auf diesen Chat zeigt; die Zustellung wird `SKIPPED` („chat unavailable“). Antworten des Bots werden nur `SKIPPED`.
- 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. - 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`). - 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). Verbindungsfehler (Datenbank oder Netz nicht erreichbar, auch `TelegramApiError` ohne Status) werden gespeichert, zählen aber nie; die Zähler werden nach jedem Batch bereinigt (Einträge hinter dem Offset und ältere als das Fenster). Grenzen und Uhr kommen über das optionale Token `TELEGRAM_UPDATES_OPTIONS` (`{ attempts, windowMs, now }`) in den Dienst. Eine Antwort mit Einträgen, aber ohne `update_id`, gilt als Fehler (`lastError` „getUpdates answer without update ids“, Offset bleibt, 30 s Wartezeit). 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; `WorkerRuntime.onModuleDestroy` ruft nach dem Stopp der Schleifen `releaseLease()` auf (ohne gehaltene Lease ohne Wirkung, auch bei einem Stopp während der 30-s-Wartezeit), der nächste Empfänger kommt sofort zum Zug. `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 entfernen die Verbindung 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 Beendigung einer Verbindung (App, `/stop`, Chat zieht zu einem anderen Konto um, Bot blockiert oder Chat gelöscht im Sender) läuft über denselben Helfer `removeLinkIn` (`src/telegram/telegram-link-removal.ts`): Verbindung löschen (bei Bedarf nur, wenn sie noch auf diesen Chat zeigt), `telegramEnabled = false` und die wartenden Telegram-Zustellungen des Nutzers (`PENDING`, `PROCESSING`) in derselben Transaktion auf `SKIPPED` mit `claimedAt` leer und Grund `unlinked`, `chat unavailable` oder `relinked`. Wird die Verbindung eines Nutzers auf einen anderen Chat umgestellt, verfallen die wartenden Zustellungen ebenfalls (`relinked`); derselbe Chat noch einmal ändert nichts. Alte Nachrichten erreichen so nie einen später verbundenen Chat. 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. Die Einstellungsansicht (`GET /profile/notification-settings`) nennt das Feld `telegram.botNameSet` (Bot-Name gesetzt; das Token kennt die api nicht), derselbe Begriff wie am System-Endpunkt. Verdrahtung des Secrets und Einrichtung des Bots: Abschnitt 7.
**Oberfläche: Profil > Telegram** (`TelegramLinkCard`). Der Zustand steht in einer Statuszeile (`role="status"`): „Telegram ist nicht eingerichtet.“ (ohne `botNameSet`, dann ohne Knopf), „Nicht verbunden“ oder „Verbunden mit „{Vorname}“ seit {TT.MM.JJJJ}“. „Telegram verbinden“ öffnet den `StepUpDialog`; danach erscheint der große Knopf „In Telegram öffnen“ (`target="_blank"`, `rel="noopener noreferrer"`) mit „Gültig bis {HH:MM}“ (Berliner Zeit), und der Fokus springt auf diesen Knopf; nach „Trennen“ (`ConfirmDialog`) springt er auf „Telegram verbinden“. Der Deep Link ist ein Geheimnis: Er wird nur gezeigt, wenn er mit `https://t.me/` beginnt (sonst „Der Telegram-Link ist ungültig. …“), liegt nur im Zustand der Komponente (nie im React-Query-Cache, in `localStorage`/IndexedDB oder im Log) und verschwindet bei Ablauf oder sobald der Status „verbunden“ meldet. Solange ein Link gültig und noch nicht verbunden ist, lädt die Karte den Status alle 5 Sekunden neu. Verbundene Nutzer wählen die sechs Kategorien (`ToggleChip`, während des Speicherns und offline aus) und den Schalter „Mit Details“ mit dem Hinweis „Nachrichten laufen über Server von Telegram und sind dort nicht Ende-zu-Ende verschlüsselt.“; jede Änderung geht einzeln per `PATCH`. Verbinden, Trennen und Speichern sind offline gesperrt („Nur mit Verbindung“).
**Step-up** (`src/components/auth/step-up-dialog.tsx`, geteilt mit dem Backup-Import; `pendingLabel`, beim Import „Wird eingespielt…“). Der Dialog fragt das aktuelle Passwort und bei MFA den 6-stelligen Code; `onConfirm(proof)` darf werfen, dann bleibt er offen, leert die Felder, fokussiert das Passwortfeld und zeigt den deutschen Grund in einer dauerhaften Live-Region. Während der Ausführung sind die Eingaben `readOnly`, Esc, Hintergrund und Schließen-Knopf wirkungslos. Die Felder leben nur, solange der Dialog offen ist. Ein fehlgeschlagener Import erscheint im Dialog statt als Toast. Alle Step-up-Aufrufe senden mit `refreshOn401: false` (Annahme 56): `createTelegramLinkToken`, `importBackup`, `changePassword`, `deactivateAccount`, `confirmMfaEnrollment` und `disableMfa`. `stepUpErrorText` (`src/lib/auth/step-up-error.ts`, auch im Passwortformular des Profils) liefert: 429 „Zu viele Versuche. Bitte in einer Minute noch einmal.“, ein 401 ohne eigene Meldung „Sitzung abgelaufen. Bitte die Seite neu laden und erneut anmelden.“, deutsche Server-Meldungen unverändert. Der Client kennt den Ablauf des Tokens nicht und erneuert vor Step-up-Aufrufen nicht vorab.
Logs nennen nur Zustellungs-IDs, nie Token, Chat-ID oder Text. Logs nennen nur Zustellungs-IDs, nie Token, Chat-ID oder Text.
+3
View File
@@ -131,5 +131,8 @@ ENABLE_SWAGGER=false
# einbinden. Ein fehlerhaftes Token stoppt nichts: Telegram bleibt aus, der # einbinden. Ein fehlerhaftes Token stoppt nichts: Telegram bleibt aus, der
# Worker loggt einen Fehler (nie den Wert). Bei BotFather Gruppen ausschalten # Worker loggt einen Fehler (nie den Wert). Bei BotFather Gruppen ausschalten
# (/setjoingroups) und keinen Webhook setzen. # (/setjoingroups) und keinen Webhook setzen.
# Je Umgebung ein eigener Bot. Im Cluster steht der Name in der ConfigMap
# (TELEGRAM_BOT_USERNAME, leer = Telegram in der App nicht eingerichtet); den
# Zustand zeigt Verwaltung > System (Telegram-Karte, nur system:manage).
# TELEGRAM_BOT_USERNAME= # TELEGRAM_BOT_USERNAME=
# TELEGRAM_BOT_TOKEN= # TELEGRAM_BOT_TOKEN=
@@ -0,0 +1,87 @@
import { BadRequestException } from '@nestjs/common';
import {
bestBeforeFromShelfLife,
dayOfDate,
jarSizeWarnings,
resolveJarAndBestBefore,
} from './bottling-defaults';
const article = { id: 'p1', packagingSize: 500, shelfLifeMonths: 24 };
describe('bottling defaults', () => {
it('a timestamp counts as the Berlin day, a plain day stays', () => {
expect(dayOfDate('2026-07-31')).toBe('2026-07-31');
expect(dayOfDate('2026-07-31T22:30:00.000Z')).toBe('2026-08-01');
});
it('best before is the day plus the shelf life, cut at the month end', () => {
expect(bestBeforeFromShelfLife('2027-01-31', 1)).toBe('2027-02-28');
expect(bestBeforeFromShelfLife('2026-05-10', 24)).toBe('2028-05-10');
expect(bestBeforeFromShelfLife('2026-05-10', null)).toBeNull();
});
it('takes both values from the article', () => {
expect(resolveJarAndBestBefore({ date: '2026-05-10' }, article)).toEqual({
jarSize: 500,
bestBeforeDate: '2028-05-10',
warnings: [],
});
});
it('sent values win', () => {
const result = resolveJarAndBestBefore(
{ date: '2026-05-10', jarSize: 250, bestBeforeDate: '2027-01-01' },
article,
);
expect(result.jarSize).toBe(250);
expect(result.bestBeforeDate).toBe('2027-01-01');
expect(result.warnings.map((w) => w.code)).toEqual(['JAR_SIZE_DIFFERS']);
});
it('the same jar size gives no hint, an article without a size neither', () => {
expect(jarSizeWarnings(500, article)).toEqual([]);
expect(jarSizeWarnings(250, { ...article, packagingSize: null })).toEqual(
[],
);
expect(jarSizeWarnings(250, null)).toEqual([]);
});
it('names what is missing', () => {
expect(() => resolveJarAndBestBefore({ date: '2026-05-10' }, null)).toThrow(
new BadRequestException(
'Glasgröße und MHD fehlen. Bitte einen Artikel wählen oder beides eintragen.',
),
);
expect(() =>
resolveJarAndBestBefore(
{ date: '2026-05-10' },
{ ...article, shelfLifeMonths: null },
),
).toThrow('Ohne Haltbarkeit am Artikel bitte ein MHD eintragen.');
expect(() =>
resolveJarAndBestBefore(
{ date: '2026-05-10' },
{ ...article, packagingSize: null },
),
).toThrow('Ohne Packungsgröße am Artikel');
});
});
describe('impossible days', () => {
it('are a 400, never a 500', () => {
expect(() => dayOfDate('2026-02-30')).toThrow(BadRequestException);
expect(() => dayOfDate('not a date')).toThrow(BadRequestException);
expect(() =>
resolveJarAndBestBefore(
{ date: '2026-02-30', jarSize: 500, bestBeforeDate: '2027-01-01' },
null,
),
).toThrow(BadRequestException);
expect(() =>
resolveJarAndBestBefore(
{ date: '2026-02-01', jarSize: 500, bestBeforeDate: '2027-02-30' },
null,
),
).toThrow(BadRequestException);
});
});
@@ -0,0 +1,109 @@
import { BadRequestException } from '@nestjs/common';
import { addMonthsClamped, berlinDayOf, dayToDate } from '../common/dates';
import {
WARNING_SEVERITY,
type PlausibilityWarning,
} from '../common/warnings/plausibility-warning';
import { formatNumberDe } from '../suggestions/texts/format-number';
/** What a bottling takes over from its article (spec 7.8). */
export interface ArticleDefaults {
id: string;
packagingSize: number | null;
shelfLifeMonths: number | null;
}
const DAY = /^\d{4}-\d{2}-\d{2}$/;
const INVALID_DATE =
'Das Datum ist ungültig. Bitte ein Datum wie 2026-07-31 angeben.';
/** The calendar day of a date or timestamp; a timestamp counts in Berlin. */
export function dayOfDate(date: string): string {
try {
const day = DAY.test(date) ? date : berlinDayOf(new Date(date));
dayToDate(day);
return day;
} catch {
throw new BadRequestException(INVALID_DATE);
}
}
/** Bottling day plus the article's shelf life; null without a shelf life. */
export function bestBeforeFromShelfLife(
date: string,
shelfLifeMonths: number | null,
): string | null {
return shelfLifeMonths === null
? null
: addMonthsClamped(dayOfDate(date), shelfLifeMonths);
}
export interface ResolvedJarAndBestBefore {
jarSize: number;
/** YYYY-MM-DD, or the text the client sent. */
bestBeforeDate: string;
warnings: PlausibilityWarning[];
}
/**
* Jar size and best-before date of a bottling: what was sent wins, else the
* article's packaging size and date + shelf life. There is no default shelf
* life: without one the beekeeper enters the date (spec 7.8).
*/
export function resolveJarAndBestBefore(
sent: { jarSize?: number; bestBeforeDate?: string; date: string },
article: ArticleDefaults | null,
): ResolvedJarAndBestBefore {
// An impossible day is the client's error (400), never a 500.
dayOfDate(sent.date);
if (sent.bestBeforeDate !== undefined) {
dayOfDate(sent.bestBeforeDate);
}
const jarSize = sent.jarSize ?? article?.packagingSize ?? null;
const bestBeforeDate =
sent.bestBeforeDate ??
bestBeforeFromShelfLife(sent.date, article?.shelfLifeMonths ?? null);
if (jarSize === null || bestBeforeDate === null) {
throw new BadRequestException(
missingMessage(jarSize, bestBeforeDate, article),
);
}
const warnings =
sent.jarSize === undefined ? [] : jarSizeWarnings(sent.jarSize, article);
return { jarSize, bestBeforeDate, warnings };
}
function missingMessage(
jarSize: number | null,
bestBeforeDate: string | null,
article: ArticleDefaults | null,
): string {
if (article === null || (jarSize === null && bestBeforeDate === null)) {
return 'Glasgröße und MHD fehlen. Bitte einen Artikel wählen oder beides eintragen.';
}
return jarSize === null
? 'Ohne Packungsgröße am Artikel bitte eine Glasgröße eintragen.'
: 'Ohne Haltbarkeit am Artikel bitte ein MHD eintragen.';
}
/** The hint for a jar size that differs from the article's packaging size. */
export function jarSizeWarnings(
jarSize: number,
article: ArticleDefaults | null,
): PlausibilityWarning[] {
if (
article === null ||
article.packagingSize === null ||
article.packagingSize === jarSize
) {
return [];
}
return [
{
code: 'JAR_SIZE_DIFFERS',
severity: WARNING_SEVERITY.JAR_SIZE_DIFFERS,
message: `Die Glasgröße (${formatNumberDe(jarSize, 0)} g) weicht vom Artikel ab (${formatNumberDe(article.packagingSize, 0)} g).`,
refs: [{ type: 'Product', id: article.id }],
},
];
}
@@ -0,0 +1,105 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import type { PrismaService } from '../prisma/prisma.service';
import { BottlingPrefillService } from './bottling-prefill.service';
describe('BottlingPrefillService', () => {
let service: BottlingPrefillService;
let prisma: {
honeyBatch: { findUnique: jest.Mock };
honeyMeasurement: { findMany: jest.Mock };
automationRule: { findUnique: jest.Mock };
product: { findUnique: jest.Mock };
};
beforeEach(() => {
prisma = {
honeyBatch: {
findUnique: jest.fn().mockResolvedValue({
id: 'h1',
honeyType: null,
waterContent: 19,
}),
},
honeyMeasurement: { findMany: jest.fn().mockResolvedValue([]) },
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
product: { findUnique: jest.fn() },
};
service = new BottlingPrefillService(prisma as unknown as PrismaService);
});
it('offers jar size, best before and water content with the warning', async () => {
prisma.product.findUnique.mockResolvedValue({
kind: 'BOTTLED_HONEY',
packagingSize: 500,
shelfLifeMonths: 24,
});
const result = await service.prefill({
honeyBatchId: 'h1',
productId: 'p1',
date: '2026-05-10',
});
expect(result).toMatchObject({
jarSize: 500,
jarSizeSource: 'PRODUCT',
bestBeforeDate: '2028-05-10',
shelfLifeMonths: 24,
waterContent: 19,
});
expect(result.warnings.map((w) => w.code)).toEqual(['WATER_CONTENT_HIGH']);
});
it('without an article only the water content is offered', async () => {
const result = await service.prefill({ honeyBatchId: 'h1' });
expect(result).toMatchObject({
jarSize: null,
jarSizeSource: null,
bestBeforeDate: null,
shelfLifeMonths: null,
waterContent: 19,
});
});
it('an article without a shelf life gives no best before', async () => {
prisma.product.findUnique.mockResolvedValue({
kind: 'BOTTLED_HONEY',
packagingSize: 250,
shelfLifeMonths: null,
});
const result = await service.prefill({
honeyBatchId: 'h1',
productId: 'p1',
});
expect(result.jarSize).toBe(250);
expect(result.bestBeforeDate).toBeNull();
});
it('reports an unknown honey batch or article', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue(null);
await expect(
service.prefill({ honeyBatchId: 'missing' }),
).rejects.toBeInstanceOf(NotFoundException);
prisma.honeyBatch.findUnique.mockResolvedValue({ id: 'h1' });
prisma.product.findUnique.mockResolvedValue(null);
await expect(
service.prefill({ honeyBatchId: 'h1', productId: 'missing' }),
).rejects.toBeInstanceOf(NotFoundException);
});
it('refuses an article that is not bottled honey', async () => {
prisma.product.findUnique.mockResolvedValue({
kind: 'MANUFACTURED',
packagingSize: null,
shelfLifeMonths: null,
});
await expect(
service.prefill({ honeyBatchId: 'h1', productId: 'p2' }),
).rejects.toBeInstanceOf(BadRequestException);
});
});
@@ -0,0 +1,99 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';
import { berlinToday } from '../common/dates';
import type { PlausibilityWarning } from '../common/warnings/plausibility-warning';
import { PrismaService } from '../prisma/prisma.service';
import { bestBeforeFromShelfLife } from './bottling-defaults';
import type { BottlingPrefillQuery } from './dto/bottling-prefill.query';
import { waterContentWarningsOfBatch } from '../common/quality/water-content';
const SOURCE_NOTE =
'Stammdaten des Artikels, keine Beobachtung. Das Formular nennt die Herkunft.';
/** What the bottling form may fill in for a honey batch and an article. */
export class BottlingPrefill {
@ApiProperty({ type: Number, nullable: true, description: SOURCE_NOTE })
jarSize!: number | null;
@ApiProperty({ enum: ['PRODUCT'], nullable: true })
jarSizeSource!: 'PRODUCT' | null;
@ApiProperty({
type: String,
nullable: true,
description: 'Abfülldatum plus Haltbarkeit des Artikels (YYYY-MM-DD)',
})
bestBeforeDate!: string | null;
@ApiProperty({ type: Number, nullable: true })
shelfLifeMonths!: number | null;
@ApiProperty({
type: Number,
nullable: true,
description: 'Jüngste Messung der Charge, sonst ihr eigener Wert',
})
waterContent!: number | null;
@ApiProperty({ type: 'array', items: { type: 'object' } })
warnings!: PlausibilityWarning[];
}
export type BottlingPrefillInput = Pick<
BottlingPrefillQuery,
'honeyBatchId' | 'productId' | 'date'
>;
/** Read only: nothing is written, nothing is locked. */
@Injectable()
export class BottlingPrefillService {
constructor(private readonly prisma: PrismaService) {}
async prefill(query: BottlingPrefillInput): Promise<BottlingPrefill> {
const water = await waterContentWarningsOfBatch(
this.prisma,
query.honeyBatchId,
);
if (water === null) {
throw new NotFoundException('Honey batch not found');
}
const product =
query.productId === undefined
? null
: await this.prisma.product.findUnique({
where: { id: query.productId },
select: {
kind: true,
packagingSize: true,
shelfLifeMonths: true,
},
});
if (query.productId !== undefined) {
if (!product) {
throw new NotFoundException('Product not found');
}
if (product.kind !== 'BOTTLED_HONEY') {
throw new BadRequestException(
'Abfüllungen nur für Honig-Artikel - hergestellte Waren bekommen Herstellungschargen',
);
}
}
const shelfLifeMonths = product?.shelfLifeMonths ?? null;
const jarSize = product?.packagingSize ?? null;
return {
jarSize,
jarSizeSource: jarSize === null ? null : 'PRODUCT',
bestBeforeDate: bestBeforeFromShelfLife(
query.date ?? berlinToday(),
shelfLifeMonths,
),
shelfLifeMonths,
waterContent: water.waterContent,
warnings: water.warnings,
};
}
}
@@ -15,6 +15,8 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto'; import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto'; import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto';
import { BottlingService } from './bottling.service'; import { BottlingService } from './bottling.service';
import { BottlingPrefillService } from './bottling-prefill.service';
import { BottlingPrefillQuery } from './dto/bottling-prefill.query';
import { Idempotent } from '../common/idempotency/idempotent.decorator'; import { Idempotent } from '../common/idempotency/idempotent.decorator';
import { StockService } from '../stock/stock.service'; import { StockService } from '../stock/stock.service';
import { WithdrawStockDto } from '../stock/dto/withdraw-stock.dto'; import { WithdrawStockDto } from '../stock/dto/withdraw-stock.dto';
@@ -26,6 +28,7 @@ import { CorrectStockDto } from '../stock/dto/correct-stock.dto';
export class BottlingController { export class BottlingController {
constructor( constructor(
private readonly bottlingService: BottlingService, private readonly bottlingService: BottlingService,
private readonly prefillService: BottlingPrefillService,
private readonly stock: StockService, private readonly stock: StockService,
) {} ) {}
@@ -35,6 +38,15 @@ export class BottlingController {
return this.bottlingService.findAll({ honeyBatchId }); return this.bottlingService.findAll({ honeyBatchId });
} }
// Before ':id', so 'prefill' is not read as an id. Needs the create
// permission, like the other prefill routes: it offers what the create
// flow needs.
@RequirePermission('bottling:create')
@Get('prefill')
prefill(@Query() query: BottlingPrefillQuery) {
return this.prefillService.prefill(query);
}
@RequirePermission('bottling:read') @RequirePermission('bottling:read')
@Get(':id') @Get(':id')
findOne(@Param('id') id: string) { findOne(@Param('id') id: string) {
@@ -5,6 +5,7 @@ import { StockModule } from '../stock/stock.module';
import { WithdrawalModule } from '../withdrawal/withdrawal.module'; import { WithdrawalModule } from '../withdrawal/withdrawal.module';
import { BottlingController } from './bottling.controller'; import { BottlingController } from './bottling.controller';
import { BottlingService } from './bottling.service'; import { BottlingService } from './bottling.service';
import { BottlingPrefillService } from './bottling-prefill.service';
@Module({ @Module({
imports: [ imports: [
@@ -14,6 +15,6 @@ import { BottlingService } from './bottling.service';
WithdrawalModule, WithdrawalModule,
], ],
controllers: [BottlingController], controllers: [BottlingController],
providers: [BottlingService], providers: [BottlingService, BottlingPrefillService],
}) })
export class BottlingModule {} export class BottlingModule {}
@@ -70,6 +70,8 @@ describe('BottlingService', () => {
update: jest.Mock; update: jest.Mock;
delete: jest.Mock; delete: jest.Mock;
}; };
honeyMeasurement: { findMany: jest.Mock };
automationRule: { findUnique: jest.Mock };
honeyBatch: { findUnique: jest.Mock; update: jest.Mock }; honeyBatch: { findUnique: jest.Mock; update: jest.Mock };
batchMovement: { count: jest.Mock }; batchMovement: { count: jest.Mock };
}; };
@@ -94,6 +96,9 @@ describe('BottlingService', () => {
return Promise.resolve([{ id: 'row' }]); return Promise.resolve([{ id: 'row' }]);
}), }),
product: { findUnique: jest.fn() }, product: { findUnique: jest.fn() },
// No measurements and no rule row: the built-in limits apply.
honeyMeasurement: { findMany: jest.fn().mockResolvedValue([]) },
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
// By default a bottling has drawn from the ledger (own BOTTLING booking). // By default a bottling has drawn from the ledger (own BOTTLING booking).
batchMovement: { count: jest.fn().mockResolvedValue(1) }, batchMovement: { count: jest.fn().mockResolvedValue(1) },
bottlingBatch: { bottlingBatch: {
@@ -1006,4 +1011,311 @@ describe('BottlingService', () => {
); );
}); });
}); });
describe('quality before bottling (spec 7.8)', () => {
const base = {
honeyBatchId: 'h1',
date: '2026-01-31',
jarCount: 10,
lotNumber: 'L-1',
};
/** A honey batch in the transaction with the given water content. */
function batchWith(waterContent: number | null, honeyType: string | null) {
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h1' });
honeyBatches.set('h1', { amount: 25 });
tx.honeyBatch.findUnique.mockImplementation(
({ where }: { where: { id: string } }) =>
Promise.resolve({
id: where.id,
amount: 25,
waterContent,
honeyType,
}),
);
tx.bottlingBatch.create.mockResolvedValue({
id: 'b1',
honeyBatchId: 'h1',
});
tx.bottlingBatch.findUnique.mockResolvedValue({ id: 'b1' });
}
function article(extra: Record<string, unknown> = {}) {
const product = {
id: 'p1',
kind: 'BOTTLED_HONEY',
packagingSize: 500,
shelfLifeMonths: 24,
...extra,
};
prisma.product.findUnique.mockResolvedValue(product);
return product;
}
it('19 % is written and answers WATER_CONTENT_HIGH', async () => {
batchWith(19, null);
const result = await service.create(
{ ...base, jarSize: 500, bestBeforeDate: '2027-01-01' },
'user-1',
);
expect(result.warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_HIGH',
]);
expect(tx.bottlingBatch.create).toHaveBeenCalledTimes(1);
});
it('above the legal limit it refuses with CONFIRMATION_REQUIRED and writes nothing', async () => {
batchWith(20.5, null);
const error = await service
.create(
{ ...base, jarSize: 500, bestBeforeDate: '2027-01-01' },
'user-1',
)
.catch((e: unknown) => e);
expect(error).toBeInstanceOf(ConflictException);
const body = (error as ConflictException).getResponse() as {
code: string;
message: string;
warnings: PlausibilityWarning[];
};
expect(body.code).toBe('CONFIRMATION_REQUIRED');
expect(body.warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_ABOVE_LEGAL',
]);
expect(typeof body.message).toBe('string');
expect(tx.bottlingBatch.create).not.toHaveBeenCalled();
expect(ledger.bookIn).not.toHaveBeenCalled();
});
it('the confirmed repeat is written and still reports the finding', async () => {
batchWith(20.5, null);
const result = await service.create(
{
...base,
jarSize: 500,
bestBeforeDate: '2027-01-01',
acknowledgedWarnings: ['WATER_CONTENT_ABOVE_LEGAL'],
acknowledgedWaterContent: 20.5,
},
'user-1',
);
expect(result.warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_ABOVE_LEGAL',
]);
expect(tx.bottlingBatch.create).toHaveBeenCalledTimes(1);
});
it('a newer measurement decides, read under the batch lock', async () => {
batchWith(21, null);
tx.honeyMeasurement.findMany.mockResolvedValue([
{ waterContent: 17, measuredAt: new Date('2026-01-20') },
]);
const result = await service.create(
{ ...base, jarSize: 500, bestBeforeDate: '2027-01-01' },
'user-1',
);
expect(result.warnings).toEqual([]);
expect(steps[0]).toBe('lock');
});
it('a missing water content is a hint, not a stop', async () => {
batchWith(null, null);
const result = await service.create(
{ ...base, jarSize: 500, bestBeforeDate: '2027-01-01' },
'user-1',
);
expect(result.warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_MISSING',
]);
});
it('takes jar size and best before from the article, month end cut', async () => {
batchWith(17, null);
article({ shelfLifeMonths: 1 });
await service.create({ ...base, productId: 'p1' }, 'user-1');
expect(tx.bottlingBatch.create).toHaveBeenCalledWith({
data: expect.objectContaining({
jarSize: 500,
totalAmount: 5,
bestBeforeDate: new Date('2026-02-28'),
}) as unknown,
});
});
it('a sent value wins, and another jar size gives JAR_SIZE_DIFFERS', async () => {
batchWith(17, null);
article();
const result = await service.create(
{
...base,
productId: 'p1',
jarSize: 250,
bestBeforeDate: '2027-05-05',
},
'user-1',
);
expect(result.warnings.map((w) => w.code)).toEqual(['JAR_SIZE_DIFFERS']);
expect(tx.bottlingBatch.create).toHaveBeenCalledWith({
data: expect.objectContaining({
jarSize: 250,
bestBeforeDate: new Date('2027-05-05'),
}) as unknown,
});
});
it('refuses with a clear text when neither jar size nor best before is known', async () => {
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h1' });
await expect(service.create(base, 'user-1')).rejects.toThrow(
new BadRequestException(
'Glasgröße und MHD fehlen. Bitte einen Artikel wählen oder beides eintragen.',
),
);
});
it('refuses without a shelf life on the article when no best before is sent', async () => {
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h1' });
article({ shelfLifeMonths: null });
await expect(
service.create({ ...base, productId: 'p1' }, 'user-1'),
).rejects.toThrow(
new BadRequestException(
'Ohne Haltbarkeit am Artikel bitte ein MHD eintragen.',
),
);
expect(tx.bottlingBatch.create).not.toHaveBeenCalled();
});
it('a correction only reports: never a 409', async () => {
const before = {
id: 'b1',
lotNumber: 'L-1',
honeyBatchId: 'h1',
productId: null,
honeyBatch: { id: 'h1', amount: 10 },
jarSize: 500,
jarCount: 10,
totalAmount: 5,
};
lockedRows(before, before);
tx.honeyBatch.findUnique.mockResolvedValue({
id: 'h1',
amount: 10,
waterContent: 22,
honeyType: null,
});
honeyBatches.set('h1', { amount: 10 });
const result = await service.update('b1', { jarSize: 250 }, 'user-1');
expect(result.warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_ABOVE_LEGAL',
]);
});
it('a correction that touches neither honey batch nor jar size reports no quality finding', async () => {
const before = {
id: 'b1',
lotNumber: 'L-1',
honeyBatchId: 'h1',
productId: null,
honeyBatch: { id: 'h1', amount: 10 },
jarSize: 500,
jarCount: 10,
totalAmount: 5,
};
lockedRows(before, before);
tx.honeyBatch.findUnique.mockResolvedValue({
id: 'h1',
amount: 10,
waterContent: 22,
honeyType: null,
});
const result = await service.update('b1', { lotNumber: 'L-2' }, 'user-1');
expect(result.warnings).toEqual([]);
});
async function refusal(
extra: Record<string, unknown>,
): Promise<{ waterContent: number; code: string }> {
batchWith(20.5, null);
const error = await service
.create(
{
...base,
jarSize: 500,
bestBeforeDate: '2027-01-01',
acknowledgedWarnings: ['WATER_CONTENT_ABOVE_LEGAL'],
...extra,
},
'user-1',
)
.catch((e: unknown) => e);
expect(error).toBeInstanceOf(ConflictException);
return (error as ConflictException).getResponse() as {
waterContent: number;
code: string;
};
}
it('the 409 names the checked value', async () => {
batchWith(20.5, null);
const error = await service
.create(
{ ...base, jarSize: 500, bestBeforeDate: '2027-01-01' },
'user-1',
)
.catch((e: unknown) => e);
expect(
((error as ConflictException).getResponse() as { waterContent: number })
.waterContent,
).toBe(20.5);
});
it('a confirmation for another value gets a fresh 409 with the new value', async () => {
const body = await refusal({ acknowledgedWaterContent: 20.2 });
expect(body).toMatchObject({
code: 'CONFIRMATION_REQUIRED',
waterContent: 20.5,
});
expect(tx.bottlingBatch.create).not.toHaveBeenCalled();
});
it('the code without the value is not a confirmation', async () => {
const body = await refusal({});
expect(body.code).toBe('CONFIRMATION_REQUIRED');
});
it('an impossible day is a 400', async () => {
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h1' });
await expect(
service.create(
{
...base,
date: '2026-02-30',
jarSize: 500,
bestBeforeDate: '2027-01-01',
},
'user-1',
),
).rejects.toBeInstanceOf(BadRequestException);
});
});
}); });
+114 -20
View File
@@ -12,10 +12,15 @@ import { DomainEventsService } from '../domain-events/domain-events.service';
import type { AnyNewDomainEvent } from '../domain-events/domain-event-types'; import type { AnyNewDomainEvent } from '../domain-events/domain-event-types';
import { AutomationService } from '../automation/automation.service'; import { AutomationService } from '../automation/automation.service';
import { StockService } from '../stock/stock.service'; import { StockService } from '../stock/stock.service';
import { withWarnings } from '../common/warnings/plausibility-warning'; import {
withWarnings,
type PlausibilityWarning,
} from '../common/warnings/plausibility-warning';
import { WithdrawalCheckService } from '../withdrawal/withdrawal-check.service'; import { WithdrawalCheckService } from '../withdrawal/withdrawal-check.service';
import { Prisma } from '../../generated/prisma'; import { Prisma } from '../../generated/prisma';
import { HoneyBatchLedgerService } from '../honey-batches/honey-batch-ledger.service'; import { HoneyBatchLedgerService } from '../honey-batches/honey-batch-ledger.service';
import { jarSizeWarnings, resolveJarAndBestBefore } from './bottling-defaults';
import { waterContentWarningsOfBatch } from '../common/quality/water-content';
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto'; import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto'; import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto';
@@ -114,15 +119,36 @@ export class BottlingService {
db: DbClient, db: DbClient,
honeyBatchId: string, honeyBatchId: string,
bottling: T, bottling: T,
qualityWarnings: readonly PlausibilityWarning[] = [],
) { ) {
const conflicts = await this.withdrawalCheck.conflictsForHoneyBatch( const conflicts = await this.withdrawalCheck.conflictsForHoneyBatch(
db, db,
honeyBatchId, honeyBatchId,
); );
return withWarnings( return withWarnings(bottling, [
bottling, ...qualityWarnings,
await this.withdrawalCheck.warningsFor(db, conflicts), ...(await this.withdrawalCheck.warningsFor(db, conflicts)),
); ]);
}
/**
* The quality findings for the honey batch as it stands in the caller's
* transaction: its newest water content against the rule (spec 7.8) and
* the hint for a jar size that differs from the article.
*/
private async qualityWarnings(
tx: Tx,
honeyBatchId: string,
jarWarnings: readonly PlausibilityWarning[],
): Promise<{
warnings: PlausibilityWarning[];
waterContent: number | null;
}> {
const water = await waterContentWarningsOfBatch(tx, honeyBatchId);
return {
warnings: [...(water?.warnings ?? []), ...jarWarnings],
waterContent: water?.waterContent ?? null,
};
} }
async findAll(filter: BottlingBatchFilter = {}) { async findAll(filter: BottlingBatchFilter = {}) {
@@ -158,10 +184,7 @@ export class BottlingService {
} }
/** Honey is bottled; other articles keep their stock in production batches. */ /** Honey is bottled; other articles keep their stock in production batches. */
private async assertBottledProduct( private async assertBottledProduct(productId: string, tx?: Tx) {
productId: string,
tx?: Tx,
): Promise<void> {
const db: DbClient = tx ?? this.prisma; const db: DbClient = tx ?? this.prisma;
const product = await db.product.findUnique({ const product = await db.product.findUnique({
where: { id: productId }, where: { id: productId },
@@ -174,6 +197,7 @@ export class BottlingService {
'Abfüllungen nur für Honig-Artikel - hergestellte Waren bekommen Herstellungschargen', 'Abfüllungen nur für Honig-Artikel - hergestellte Waren bekommen Herstellungschargen',
); );
} }
return product;
} }
async create(dto: CreateBottlingBatchDto, actingUserId: string) { async create(dto: CreateBottlingBatchDto, actingUserId: string) {
@@ -184,11 +208,11 @@ export class BottlingService {
if (!honeyBatch) { if (!honeyBatch) {
throw new NotFoundException('Honey batch not found'); throw new NotFoundException('Honey batch not found');
} }
if (dto.productId) { const article = dto.productId
await this.assertBottledProduct(dto.productId); ? await this.assertBottledProduct(dto.productId)
} : null;
const resolved = resolveJarAndBestBefore(dto, article);
const totalAmount = roundKg((dto.jarSize * dto.jarCount) / 1000); const totalAmount = roundKg((resolved.jarSize * dto.jarCount) / 1000);
const lotNumber = const lotNumber =
dto.lotNumber ?? dto.lotNumber ??
@@ -205,15 +229,22 @@ export class BottlingService {
if (!(await lockRowForUpdate(tx, 'HoneyBatch', dto.honeyBatchId))) { if (!(await lockRowForUpdate(tx, 'HoneyBatch', dto.honeyBatchId))) {
throw new NotFoundException('Honey batch not found'); throw new NotFoundException('Honey batch not found');
} }
// Under the batch lock: a measurement entered meanwhile counts.
const quality = await this.qualityWarnings(
tx,
dto.honeyBatchId,
resolved.warnings,
);
this.assertConfirmed(quality, dto);
const created = await tx.bottlingBatch.create({ const created = await tx.bottlingBatch.create({
data: { data: {
honeyBatchId: dto.honeyBatchId, honeyBatchId: dto.honeyBatchId,
productId: dto.productId, productId: dto.productId,
date: new Date(dto.date), date: new Date(dto.date),
jarSize: dto.jarSize, jarSize: resolved.jarSize,
jarCount: dto.jarCount, jarCount: dto.jarCount,
totalAmount, totalAmount,
bestBeforeDate: new Date(dto.bestBeforeDate), bestBeforeDate: new Date(resolved.bestBeforeDate),
lotNumber, lotNumber,
userId: actingUserId, userId: actingUserId,
}, },
@@ -247,10 +278,52 @@ export class BottlingService {
tx, tx,
created.honeyBatchId, created.honeyBatchId,
await this.findById(created.id, tx), await this.findById(created.id, tx),
quality.warnings,
); );
}); });
} }
/**
* A finding that needs a confirmation (BESTAETIGEN) stops the write with
* 409 CONFIRMATION_REQUIRED, unless the request names its code in
* `acknowledgedWarnings` AND the checked value in `acknowledgedWaterContent`:
* a confirmation belongs to the value the beekeeper saw. If the value under
* the lock differs (a new measurement meanwhile), a fresh 409 with the new
* value follows. The body carries every finding of the request, so the form
* can show them in one dialog. The idempotency interceptor frees the key on
* this error: the confirmed repeat may use the same key.
*/
private assertConfirmed(
quality: {
warnings: readonly PlausibilityWarning[];
waterContent: number | null;
},
sent: Pick<
CreateBottlingBatchDto,
'acknowledgedWarnings' | 'acknowledgedWaterContent'
>,
): void {
const acknowledged: readonly string[] = sent.acknowledgedWarnings ?? [];
const open = quality.warnings.filter(
(warning) =>
warning.severity === 'BESTAETIGEN' &&
!(
acknowledged.includes(warning.code) &&
sent.acknowledgedWaterContent !== undefined &&
sent.acknowledgedWaterContent === quality.waterContent
),
);
if (open.length > 0) {
throw new ConflictException({
statusCode: 409,
code: 'CONFIRMATION_REQUIRED',
message: open.map((warning) => warning.message).join(' '),
waterContent: quality.waterContent,
warnings: quality.warnings,
});
}
}
/** /**
* Corrects any field of an existing bottling batch. Changing the honey * Corrects any field of an existing bottling batch. Changing the honey
* batch, the jar size or the jar count also rebalances the remaining amount * batch, the jar size or the jar count also rebalances the remaining amount
@@ -269,9 +342,11 @@ export class BottlingService {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
await lockRowForUpdate(tx, 'BottlingBatch', id); await lockRowForUpdate(tx, 'BottlingBatch', id);
const before = await this.findById(id, tx); const before = await this.findById(id, tx);
if (dto.productId) { const article = dto.productId
await this.assertBottledProduct(dto.productId, tx); ? await this.assertBottledProduct(dto.productId, tx)
} : dto.productId === undefined && before.productId
? await tx.product.findUnique({ where: { id: before.productId } })
: null;
const honeyBatchId = dto.honeyBatchId ?? before.honeyBatchId; const honeyBatchId = dto.honeyBatchId ?? before.honeyBatchId;
const movedToOtherHoneyBatch = honeyBatchId !== before.honeyBatchId; const movedToOtherHoneyBatch = honeyBatchId !== before.honeyBatchId;
@@ -342,6 +417,20 @@ export class BottlingService {
}); });
const bottlingBatch = await this.findById(id, tx); const bottlingBatch = await this.findById(id, tx);
// A correction only reports (spec 7.8): the confirmation belongs to
// the creation.
// Only when the correction touches what the findings are about.
const qualityChecked =
movedToOtherHoneyBatch || jarSize !== before.jarSize;
const quality = qualityChecked
? (
await this.qualityWarnings(
tx,
honeyBatchId,
jarSizeWarnings(jarSize, article),
)
).warnings
: [];
await this.auditLog.record( await this.auditLog.record(
{ {
entityType: 'BottlingBatch', entityType: 'BottlingBatch',
@@ -362,7 +451,12 @@ export class BottlingService {
payload: { honeyBatchId, removed: false }, payload: { honeyBatchId, removed: false },
actorUserId: actingUserId, actorUserId: actingUserId,
}); });
return this.withWithdrawalWarnings(tx, honeyBatchId, bottlingBatch); return this.withWithdrawalWarnings(
tx,
honeyBatchId,
bottlingBatch,
quality,
);
}); });
} }
@@ -0,0 +1,33 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsISO8601,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
const MAX_ID_LENGTH = 64;
const ID_PATTERN = /^[A-Za-z0-9_-]+$/;
export class BottlingPrefillQuery {
@ApiProperty({ description: 'Die Honigcharge, die abgefüllt wird' })
@IsString()
@MaxLength(MAX_ID_LENGTH)
@Matches(ID_PATTERN)
honeyBatchId!: string;
@ApiPropertyOptional({ description: 'Der Artikel (Honig-Artikel)' })
@IsOptional()
@IsString()
@MaxLength(MAX_ID_LENGTH)
@Matches(ID_PATTERN)
productId?: string;
@ApiPropertyOptional({
description: 'Abfülldatum (Standard: heute), Grundlage für das MHD',
})
@IsOptional()
@IsISO8601({ strict: true })
date?: string;
}
@@ -1,6 +1,12 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { import {
Max,
Min,
ArrayMaxSize,
ArrayUnique,
IsArray,
IsDateString, IsDateString,
IsIn,
IsInt, IsInt,
IsNumber, IsNumber,
IsOptional, IsOptional,
@@ -8,6 +14,12 @@ import {
IsString, IsString,
} from 'class-validator'; } from 'class-validator';
export const BOTTLING_ACKNOWLEDGEABLE_WARNINGS = [
'WATER_CONTENT_ABOVE_LEGAL',
] as const;
export type AcknowledgeableWarning =
(typeof BOTTLING_ACKNOWLEDGEABLE_WARNINGS)[number];
export class CreateBottlingBatchDto { export class CreateBottlingBatchDto {
@ApiProperty() @ApiProperty()
@IsString() @IsString()
@@ -19,22 +31,30 @@ export class CreateBottlingBatchDto {
productId?: string; productId?: string;
@ApiProperty() @ApiProperty()
@IsDateString() @IsDateString({ strict: true })
date!: string; date!: string;
@ApiProperty({ description: 'Glasgröße in Gramm' }) @ApiPropertyOptional({
description:
'Glasgröße in Gramm. Fehlt sie, gilt die Packungsgröße des Artikels.',
})
@IsOptional()
@IsNumber() @IsNumber()
@IsPositive() @IsPositive()
jarSize!: number; jarSize?: number;
@ApiProperty() @ApiProperty()
@IsInt() @IsInt()
@IsPositive() @IsPositive()
jarCount!: number; jarCount!: number;
@ApiProperty() @ApiPropertyOptional({
@IsDateString() description:
bestBeforeDate!: string; 'MHD. Fehlt es, gilt Abfülldatum plus Haltbarkeit des Artikels (Monatsende abgeschnitten).',
})
@IsOptional()
@IsDateString({ strict: true })
bestBeforeDate?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description:
@@ -43,4 +63,27 @@ export class CreateBottlingBatchDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
lotNumber?: string; lotNumber?: string;
@ApiPropertyOptional({
enum: BOTTLING_ACKNOWLEDGEABLE_WARNINGS,
isArray: true,
description:
'Bestätigte Warnungen. Über der gesetzlichen Wassergehaltsgrenze nötig, sonst 409 CONFIRMATION_REQUIRED.',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(1)
@ArrayUnique()
@IsIn(BOTTLING_ACKNOWLEDGEABLE_WARNINGS, { each: true })
acknowledgedWarnings?: AcknowledgeableWarning[];
@ApiPropertyOptional({
description:
'Der Wassergehalt (%), den die Bestätigung meint: der Wert aus der 409-Antwort. Weicht der geprüfte Wert ab, folgt eine neue 409.',
})
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
acknowledgedWaterContent?: number;
} }
+16
View File
@@ -1,5 +1,6 @@
import { import {
addDays, addDays,
addMonthsClamped,
berlinDateTime, berlinDateTime,
berlinDayOf, berlinDayOf,
berlinToday, berlinToday,
@@ -97,4 +98,19 @@ describe('dates', () => {
it('daysBetween counts whole days across a DST change', () => { it('daysBetween counts whole days across a DST change', () => {
expect(daysBetween('2026-03-28', '2026-03-30')).toBe(2); expect(daysBetween('2026-03-28', '2026-03-30')).toBe(2);
}); });
it('best before 2027-01-31 plus 1 month is 2027-02-28', () => {
expect(addMonthsClamped('2027-01-31', 1)).toBe('2027-02-28');
});
it('addMonthsClamped keeps the leap day and crosses year ends', () => {
expect(addMonthsClamped('2028-01-31', 1)).toBe('2028-02-29');
expect(addMonthsClamped('2026-11-15', 3)).toBe('2027-02-15');
expect(addMonthsClamped('2026-03-31', 24)).toBe('2028-03-31');
expect(addMonthsClamped('2026-08-31', 1)).toBe('2026-09-30');
});
it('addMonthsClamped rejects an impossible day', () => {
expect(() => addMonthsClamped('2026-02-30', 1)).toThrow();
});
}); });
+21
View File
@@ -55,6 +55,27 @@ export function addDays(day: string, days: number): string {
return result.toISOString().slice(0, 10); return result.toISOString().slice(0, 10);
} }
/**
* Calendar months on a YYYY-MM-DD string. A day the target month does not
* have is cut to its last day: 2027-01-31 + 1 month is 2027-02-28.
*/
export function addMonthsClamped(day: string, months: number): string {
const start = dayToDate(day);
const monthIndex = start.getUTCMonth() + months;
const lastDay = new Date(
Date.UTC(start.getUTCFullYear(), monthIndex + 1, 0),
).getUTCDate();
return new Date(
Date.UTC(
start.getUTCFullYear(),
monthIndex,
Math.min(start.getUTCDate(), lastDay),
),
)
.toISOString()
.slice(0, 10);
}
/** Whole calendar days from `from` to `to` (negative when `to` is earlier). */ /** Whole calendar days from `from` to `to` (negative when `to` is earlier). */
export function daysBetween(from: string, to: string): number { export function daysBetween(from: string, to: string): number {
return dayNumber(to) - dayNumber(from); return dayNumber(to) - dayNumber(from);
@@ -0,0 +1,114 @@
import type { WaterContentConfig } from '../../automation/rule-config/rule-config.types';
import { latestWaterContent, waterContentWarnings } from './water-content';
const config: WaterContentConfig = {
warnAbovePercent: 18,
confirmAbovePercent: 20,
byHoneyType: {},
};
const refs = [{ type: 'HoneyBatch', id: 'hb1' }];
describe('latestWaterContent', () => {
it('the newest measurement wins over the batch value', () => {
expect(
latestWaterContent({ waterContent: 17 }, [
{ waterContent: 19, measuredAt: new Date('2026-08-01') },
{ waterContent: 21, measuredAt: new Date('2026-09-01') },
{ waterContent: 18, measuredAt: new Date('2026-07-01') },
]),
).toBe(21);
});
it('falls back to the batch value without measurements', () => {
expect(latestWaterContent({ waterContent: 17.5 }, [])).toBe(17.5);
});
it('is null without any value', () => {
expect(latestWaterContent({ waterContent: null }, [])).toBeNull();
});
});
describe('waterContentWarnings', () => {
it('stays silent at the warning limit and below', () => {
expect(waterContentWarnings(18, 'Blütenhonig', config, refs)).toEqual([]);
expect(waterContentWarnings(12, null, config, refs)).toEqual([]);
});
it('warns above 18 %', () => {
const [warning, ...rest] = waterContentWarnings(19, null, config, refs);
expect(rest).toEqual([]);
expect(warning).toMatchObject({
code: 'WATER_CONTENT_HIGH',
severity: 'WARNUNG',
refs,
});
});
it('asks for a confirmation above 20 %', () => {
const [warning, ...rest] = waterContentWarnings(20.5, null, config, refs);
expect(rest).toEqual([]);
expect(warning).toMatchObject({
code: 'WATER_CONTENT_ABOVE_LEGAL',
severity: 'BESTAETIGEN',
});
});
it('20.0 % itself only warns', () => {
expect(
waterContentWarnings(20, null, config, refs).map((w) => w.code),
).toEqual(['WATER_CONTENT_HIGH']);
});
it('hints at a missing value', () => {
expect(
waterContentWarnings(null, null, config, refs).map((w) => [
w.code,
w.severity,
]),
).toEqual([['WATER_CONTENT_MISSING', 'HINWEIS']]);
});
it('a configured heather honey confirms only above 23 %', () => {
const heather: WaterContentConfig = {
...config,
byHoneyType: { Heidehonig: { confirmAbovePercent: 23 } },
};
expect(
waterContentWarnings(22, 'heidehonig', heather, refs).map((w) => w.code),
).toEqual(['WATER_CONTENT_HIGH']);
expect(
waterContentWarnings(23.5, 'HEIDEHONIG', heather, refs).map(
(w) => w.code,
),
).toEqual(['WATER_CONTENT_ABOVE_LEGAL']);
// Another type keeps the general limits; a near name is not a match.
expect(
waterContentWarnings(22, 'Heidehonig-Mix', heather, refs).map(
(w) => w.code,
),
).toEqual(['WATER_CONTENT_ABOVE_LEGAL']);
});
it('a type entry with a warning limit only keeps the general confirm limit', () => {
const custom: WaterContentConfig = {
...config,
byHoneyType: { Raps: { warnAbovePercent: 17 } },
};
expect(
waterContentWarnings(17.5, 'Raps', custom, refs).map((w) => w.code),
).toEqual(['WATER_CONTENT_HIGH']);
});
});
describe('water content messages', () => {
it('shows two decimals when one decimal would hide the difference to the limit', () => {
const [warning] = waterContentWarnings(20.04, null, config, refs);
expect(warning.message).toContain('(20,04 %)');
expect(warning.message).toContain('Grenze von 20 %');
});
it('keeps one decimal otherwise', () => {
const [warning] = waterContentWarnings(20.5, null, config, refs);
expect(warning.message).toContain('(20,5 %)');
});
});
@@ -0,0 +1,152 @@
import type { HoneyBatch, HoneyMeasurement } from '../../../generated/prisma';
import type { WaterContentConfig } from '../../automation/rule-config/rule-config.types';
import { readRule } from '../../automation/rule-config/rule-config.reader';
import { formatNumberDe } from '../../suggestions/texts/format-number';
import type { DbClient } from '../../prisma/db-client';
import {
WARNING_SEVERITY,
type PlausibilityWarning,
} from '../warnings/plausibility-warning';
type Ref = { type: string; id: string };
/**
* The water content that counts (spec 7.8): the newest measurement by
* `measuredAt`, else the value on the batch itself.
*/
export function latestWaterContent(
batch: Pick<HoneyBatch, 'waterContent'>,
measurements: readonly Pick<
HoneyMeasurement,
'waterContent' | 'measuredAt'
>[],
): number | null {
let newest: Pick<HoneyMeasurement, 'waterContent' | 'measuredAt'> | null =
null;
for (const measurement of measurements) {
if (newest === null || measurement.measuredAt > newest.measuredAt) {
newest = measurement;
}
}
return newest === null ? batch.waterContent : newest.waterContent;
}
/** The limits for a honey type: its own entry (any case, exact name) first. */
function limitsFor(
honeyType: string | null,
config: WaterContentConfig,
): { warn: number; confirm: number } {
const wanted = honeyType?.trim().toLowerCase();
const own =
wanted === undefined || wanted === ''
? undefined
: Object.entries(config.byHoneyType).find(
([name]) => name.toLowerCase() === wanted,
)?.[1];
return {
warn: own?.warnAbovePercent ?? config.warnAbovePercent,
confirm: own?.confirmAbovePercent ?? config.confirmAbovePercent,
};
}
/**
* A percentage with one decimal; with more when one decimal would round the
* value onto the limit and hide that it is above it (20.04 % is "20,04 %").
*/
export function percentText(value: number, limit?: number): string {
const maxDigits = 3;
let digits = 1;
while (
limit !== undefined &&
value !== limit &&
Number(value.toFixed(digits)) === limit &&
digits < maxDigits
) {
digits += 1;
}
return `${formatNumberDe(value, digits)} %`;
}
/**
* Water content against the limits of the rule HONEY_WATER_CONTENT. Above
* the confirm limit only WATER_CONTENT_ABOVE_LEGAL is reported (the stronger
* finding); a warning never blocks, the confirmation is asked for by
* POST /bottling-batches only.
*/
export function waterContentWarnings(
value: number | null,
honeyType: string | null,
config: WaterContentConfig,
refs: Ref[],
): PlausibilityWarning[] {
if (value === null) {
return [
{
code: 'WATER_CONTENT_MISSING',
severity: WARNING_SEVERITY.WATER_CONTENT_MISSING,
message:
'Für diese Honigcharge ist kein Wassergehalt erfasst. Bitte vor dem Abfüllen messen.',
refs,
},
];
}
const { warn, confirm } = limitsFor(honeyType, config);
if (value > confirm) {
return [
{
code: 'WATER_CONTENT_ABOVE_LEGAL',
severity: WARNING_SEVERITY.WATER_CONTENT_ABOVE_LEGAL,
message: `Der Wassergehalt (${percentText(value, confirm)}) liegt über der gesetzlichen Grenze von ${percentText(confirm)}. Abfüllen nur mit Bestätigung.`,
refs,
},
];
}
if (value > warn) {
return [
{
code: 'WATER_CONTENT_HIGH',
severity: WARNING_SEVERITY.WATER_CONTENT_HIGH,
message: `Der Wassergehalt (${percentText(value, warn)}) liegt über ${percentText(warn)}. Das Warenzeichen „Echter Deutscher Honig“ verlangt höchstens 18 %.`,
refs,
},
];
}
return [];
}
/**
* The warnings for a honey batch as it stands now: its newest water content
* against the rule in force. Read only; null when the batch does not exist.
*/
export async function waterContentWarningsOfBatch(
db: DbClient,
honeyBatchId: string,
): Promise<{
waterContent: number | null;
warnings: PlausibilityWarning[];
} | null> {
const batch = await db.honeyBatch.findUnique({
where: { id: honeyBatchId },
select: { id: true, honeyType: true, waterContent: true },
});
if (!batch) {
return null;
}
const [measurements, rule] = await Promise.all([
db.honeyMeasurement.findMany({
where: { honeyBatchId },
select: { waterContent: true, measuredAt: true },
}),
readRule(db, 'HONEY_WATER_CONTENT'),
]);
const waterContent = latestWaterContent(batch, measurements);
return {
waterContent,
warnings: waterContentWarnings(
waterContent,
batch.honeyType ?? null,
rule.config,
[{ type: 'HoneyBatch', id: batch.id }],
),
};
}
@@ -209,7 +209,14 @@ const contracts: Record<string, NullContract> = {
UpdateProductDto: { UpdateProductDto: {
dto: UpdateProductDto, dto: UpdateProductDto,
notNull: ['name', 'status', 'kind', 'shopCategory'], notNull: ['name', 'status', 'kind', 'shopCategory'],
clearable: ['description', 'sku', 'packagingSize', 'weight', 'salesUnit'], clearable: [
'description',
'sku',
'packagingSize',
'shelfLifeMonths',
'weight',
'salesUnit',
],
}, },
// apiaryId: null widens the report to all apiaries; notes: null clears them. // apiaryId: null widens the report to all apiaries; notes: null clears them.
UpdatePhenologyReportDto: { UpdatePhenologyReportDto: {
@@ -22,9 +22,11 @@ describe('HoneyBatchesService', () => {
}; };
honeyMeasurement: { honeyMeasurement: {
create: jest.Mock; create: jest.Mock;
findMany: jest.Mock;
findUnique: jest.Mock; findUnique: jest.Mock;
update: jest.Mock; update: jest.Mock;
}; };
automationRule: { findUnique: jest.Mock };
extraction: { findUnique: jest.Mock }; extraction: { findUnique: jest.Mock };
storageContainer: { findUnique: jest.Mock }; storageContainer: { findUnique: jest.Mock };
honeyBatchRelocation: { create: jest.Mock; findMany: jest.Mock }; honeyBatchRelocation: { create: jest.Mock; findMany: jest.Mock };
@@ -85,9 +87,12 @@ describe('HoneyBatchesService', () => {
}, },
honeyMeasurement: { honeyMeasurement: {
create: jest.fn(), create: jest.fn(),
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn(), findUnique: jest.fn(),
update: jest.fn(), update: jest.fn(),
}, },
// No rule row: the built-in water content limits apply.
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
extraction: { findUnique: jest.fn() }, extraction: { findUnique: jest.fn() },
storageContainer: { findUnique: jest.fn() }, storageContainer: { findUnique: jest.fn() },
honeyBatchRelocation: { create: jest.fn(), findMany: jest.fn() }, honeyBatchRelocation: { create: jest.fn(), findMany: jest.fn() },
@@ -949,4 +954,45 @@ describe('HoneyBatchesService', () => {
service.updateMeasurement('missing', { waterContent: 17.5 }, 'user-1'), service.updateMeasurement('missing', { waterContent: 17.5 }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException); ).rejects.toBeInstanceOf(NotFoundException);
}); });
describe('water content warnings on a measurement', () => {
function measured(newest: number) {
prisma.honeyBatch.findUnique.mockResolvedValue({
...honeyBatch,
honeyType: null,
waterContent: null,
});
prisma.honeyMeasurement.create.mockResolvedValue({ id: 'm1' });
prisma.honeyMeasurement.findMany.mockResolvedValue([
{ waterContent: newest, measuredAt: new Date('2026-08-01') },
]);
}
it('addMeasurement above the legal limit answers a warning and never refuses', async () => {
measured(21);
const result = await service.addMeasurement(
'batch-1',
{ waterContent: 21, measuredAt: '2026-08-01T10:00:00.000Z' },
'user-1',
);
expect(result.warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_ABOVE_LEGAL',
]);
expect(prisma.honeyMeasurement.create).toHaveBeenCalledTimes(1);
});
it('a good measurement answers no warning', async () => {
measured(16.5);
const result = await service.addMeasurement(
'batch-1',
{ waterContent: 16.5, measuredAt: '2026-08-01T10:00:00.000Z' },
'user-1',
);
expect(result.warnings).toEqual([]);
});
});
}); });
@@ -28,6 +28,7 @@ import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
import { UpdateHoneyMeasurementDto } from './dto/update-honey-measurement.dto'; import { UpdateHoneyMeasurementDto } from './dto/update-honey-measurement.dto';
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto'; import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
import { SetHoneyBatchContainerAllocationDto } from './dto/set-honey-batch-container-allocation.dto'; import { SetHoneyBatchContainerAllocationDto } from './dto/set-honey-batch-container-allocation.dto';
import { waterContentWarningsOfBatch } from '../common/quality/water-content';
import { honeyBatchInclude } from './honey-batch-include'; import { honeyBatchInclude } from './honey-batch-include';
import { import {
batchWarnings, batchWarnings,
@@ -403,6 +404,17 @@ export class HoneyBatchesService {
}; };
} }
/**
* The honey batch as the answer to a measurement, with the water content
* findings of its newest value (spec 7.8). Only a report: a measurement is
* a fact and is never refused or held back for a confirmation.
*/
private async withWaterWarnings(honeyBatchId: string) {
const batch = await this.findById(honeyBatchId);
const water = await waterContentWarningsOfBatch(this.prisma, honeyBatchId);
return withWarnings(batch, water?.warnings ?? []);
}
async updateMeasurement( async updateMeasurement(
measurementId: string, measurementId: string,
dto: UpdateHoneyMeasurementDto, dto: UpdateHoneyMeasurementDto,
@@ -429,7 +441,7 @@ export class HoneyBatchesService {
beforeData: before, beforeData: before,
afterData: measurement, afterData: measurement,
}); });
return this.findById(before.honeyBatchId); return this.withWaterWarnings(before.honeyBatchId);
} }
async addMeasurement( async addMeasurement(
@@ -453,7 +465,7 @@ export class HoneyBatchesService {
userId: actingUserId, userId: actingUserId,
afterData: measurement, afterData: measurement,
}); });
return this.findById(id); return this.withWaterWarnings(id);
} }
async relocate(id: string, dto: RelocateHoneyBatchDto, actingUserId: string) { async relocate(id: string, dto: RelocateHoneyBatchDto, actingUserId: string) {
+5 -2
View File
@@ -137,7 +137,7 @@ describe('CleanupJob', () => {
} }
}); });
it('cleanup deletes terminal NotificationDelivery rows after 30 days (FAILED after 90) and TelegramLinkToken rows expired for 30 days', async () => { it('cleanup deletes terminal NotificationDelivery rows after 30 days (FAILED after 90) and TelegramLinkToken rows used or expired for 24 hours', async () => {
await job.run(); await job.run();
const failed = statementOn('NotificationDelivery', 'FAILED'); const failed = statementOn('NotificationDelivery', 'FAILED');
@@ -153,7 +153,10 @@ describe('CleanupJob', () => {
expect(delivered?.values).toEqual([WEBHOOK_DELIVERY_RETENTION_DAYS]); expect(delivered?.values).toEqual([WEBHOOK_DELIVERY_RETENTION_DAYS]);
expect(delivered?.sql).toContain('"createdAt" < now()'); expect(delivered?.sql).toContain('"createdAt" < now()');
const tokens = statementOn('TelegramLinkToken'); const tokens = statementOn('TelegramLinkToken');
expect(tokens.sql).toContain('"expiresAt" < now() - interval \'30 days\''); expect(tokens.sql).toContain('"expiresAt" < now() - interval \'24 hours\'');
expect(tokens.sql).toContain(
'"consumedAt" < now() - interval \'24 hours\'',
);
expect(tokens.values).toEqual([]); expect(tokens.values).toEqual([]);
}); });
+4 -2
View File
@@ -84,10 +84,12 @@ export class CleanupJob implements Job {
`Removed ${notificationDeliveriesPending} Telegram deliveries that stayed pending for over ${PENDING_DELIVERY_MAX_AGE_DAYS} days`, `Removed ${notificationDeliveriesPending} Telegram deliveries that stayed pending for over ${PENDING_DELIVERY_MAX_AGE_DAYS} days`,
); );
} }
// A link token lives 10 minutes; used or not, it is of no use after that. // A link token lives 10 minutes and works once: a day after it was used
// or expired it is of no use (and the hash is not worth keeping).
const telegramLinkTokens = await this.prisma.$executeRaw` const telegramLinkTokens = await this.prisma.$executeRaw`
DELETE FROM "TelegramLinkToken" DELETE FROM "TelegramLinkToken"
WHERE "expiresAt" < now() - interval '30 days'`; WHERE "expiresAt" < now() - interval '24 hours'
OR "consumedAt" < now() - interval '24 hours'`;
// After abortStale: no run this old can still be RUNNING. // After abortStale: no run this old can still be RUNNING.
const jobRunsDeleted = await this.prisma.$executeRaw` const jobRunsDeleted = await this.prisma.$executeRaw`
DELETE FROM "JobRun" DELETE FROM "JobRun"
@@ -31,6 +31,7 @@ describe('NotificationPreferencesService', () => {
}; };
let prisma: { let prisma: {
notificationPreference: { upsert: jest.Mock }; notificationPreference: { upsert: jest.Mock };
telegramLink: { findUnique: jest.Mock };
$transaction: jest.Mock; $transaction: jest.Mock;
}; };
const order: string[] = []; const order: string[] = [];
@@ -62,6 +63,7 @@ describe('NotificationPreferencesService', () => {
notificationPreference: { notificationPreference: {
upsert: jest.fn().mockResolvedValue(stored), upsert: jest.fn().mockResolvedValue(stored),
}, },
telegramLink: { findUnique: jest.fn().mockResolvedValue(null) },
$transaction: jest $transaction: jest
.fn() .fn()
.mockImplementation((work: (t: unknown) => Promise<unknown>) => .mockImplementation((work: (t: unknown) => Promise<unknown>) =>
@@ -180,4 +182,75 @@ describe('NotificationPreferencesService', () => {
expect(arg.data).not.toHaveProperty('telegramEnabled'); expect(arg.data).not.toHaveProperty('telegramEnabled');
expect(arg.data.telegramCategories).toEqual(['SYSTEM']); expect(arg.data.telegramCategories).toEqual(['SYSTEM']);
}); });
describe('toView (Telegram status)', () => {
const originalUsername = process.env.TELEGRAM_BOT_USERNAME;
const originalToken = process.env.TELEGRAM_BOT_TOKEN;
afterEach(() => {
if (originalUsername === undefined) {
delete process.env.TELEGRAM_BOT_USERNAME;
} else {
process.env.TELEGRAM_BOT_USERNAME = originalUsername;
}
if (originalToken === undefined) {
delete process.env.TELEGRAM_BOT_TOKEN;
} else {
process.env.TELEGRAM_BOT_TOKEN = originalToken;
}
});
it('NotificationSettingsView.telegram.botNameSet is true with the username set and no token in the API process', async () => {
process.env.TELEGRAM_BOT_USERNAME = '@apicore_test_bot';
delete process.env.TELEGRAM_BOT_TOKEN;
const view = await service.toView(stored as never);
expect(view.telegram).toEqual({
botNameSet: true,
botUsername: 'apicore_test_bot',
linked: false,
firstName: null,
linkedAt: null,
enabled: false,
});
expect(JSON.stringify(view)).not.toMatch(/token/i);
});
it('is not configured without a bot username, even with a token', async () => {
process.env.TELEGRAM_BOT_USERNAME = '';
process.env.TELEGRAM_BOT_TOKEN =
'123456789:AAEhBOweik6ad9r_QXMENQjcrGbqCr4K-RM';
const view = await service.toView(stored as never);
expect(view.telegram.botNameSet).toBe(false);
expect(view.telegram.botUsername).toBeNull();
});
it('shows the link without the chat id', async () => {
process.env.TELEGRAM_BOT_USERNAME = 'apicore_test_bot';
prisma.telegramLink.findUnique.mockResolvedValue({
userId: 'u1',
chatId: 4_711_000_001n,
telegramFirstName: 'Anna',
linkedAt: new Date('2026-10-14T08:00:00.000Z'),
});
const view = await service.toView({
...stored,
telegramEnabled: true,
} as never);
expect(view.telegram).toMatchObject({
linked: true,
firstName: 'Anna',
linkedAt: '2026-10-14T08:00:00.000Z',
enabled: true,
});
expect(JSON.stringify(view)).not.toContain('4711000001');
expect(prisma.telegramLink.findUnique).toHaveBeenCalledWith({
where: { userId: 'u1' },
});
});
});
}); });
@@ -2,10 +2,12 @@ import { BadRequestException, Injectable } from '@nestjs/common';
import { import {
NotificationCategory, NotificationCategory,
NotificationPreference, NotificationPreference,
TelegramLink,
} from '../../generated/prisma'; } from '../../generated/prisma';
import { DbClient } from '../prisma/db-client'; import { DbClient } from '../prisma/db-client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate } from '../prisma/row-lock'; import { lockRowForUpdate } from '../prisma/row-lock';
import { telegramConfig } from '../telegram/telegram-config';
import { UpdateNotificationSettingsDto } from './dto/update-notification-settings.dto'; import { UpdateNotificationSettingsDto } from './dto/update-notification-settings.dto';
/** The digest time the owner may choose, Europe/Berlin (inclusive). */ /** The digest time the owner may choose, Europe/Berlin (inclusive). */
@@ -16,6 +18,19 @@ const DIGEST_TIME_MESSAGE =
const QUIET_PAIR_MESSAGE = const QUIET_PAIR_MESSAGE =
'Bitte Beginn und Ende der Ruhezeit zusammen leeren.'; 'Bitte Beginn und Ende der Ruhezeit zusammen leeren.';
/**
* Telegram as the API sees it. "botNameSet" means the bot name is known
* (the API pod never holds the bot token); the chat id never leaves the server.
*/
export interface TelegramStatusView {
botNameSet: boolean;
botUsername: string | null;
linked: boolean;
firstName: string | null;
linkedAt: string | null;
enabled: boolean;
}
export interface NotificationSettingsView { export interface NotificationSettingsView {
taskDueEnabled: boolean; taskDueEnabled: boolean;
digestEnabled: boolean; digestEnabled: boolean;
@@ -24,10 +39,27 @@ export interface NotificationSettingsView {
quietUntil: string | null; quietUntil: string | null;
telegramCategories: NotificationCategory[]; telegramCategories: NotificationCategory[];
telegramDetail: boolean; telegramDetail: boolean;
telegram: TelegramStatusView;
}
function toTelegramStatus(
preference: NotificationPreference,
link: Pick<TelegramLink, 'telegramFirstName' | 'linkedAt'> | null,
): TelegramStatusView {
const { botUsername } = telegramConfig();
return {
botNameSet: botUsername !== null,
botUsername,
linked: link !== null,
firstName: link?.telegramFirstName ?? null,
linkedAt: link?.linkedAt.toISOString() ?? null,
enabled: preference.telegramEnabled,
};
} }
export function toNotificationSettingsView( export function toNotificationSettingsView(
preference: NotificationPreference, preference: NotificationPreference,
link: Pick<TelegramLink, 'telegramFirstName' | 'linkedAt'> | null,
): NotificationSettingsView { ): NotificationSettingsView {
return { return {
taskDueEnabled: preference.taskDueEnabled, taskDueEnabled: preference.taskDueEnabled,
@@ -37,6 +69,7 @@ export function toNotificationSettingsView(
quietUntil: preference.quietUntil, quietUntil: preference.quietUntil,
telegramCategories: preference.telegramCategories, telegramCategories: preference.telegramCategories,
telegramDetail: preference.telegramDetail, telegramDetail: preference.telegramDetail,
telegram: toTelegramStatus(preference, link),
}; };
} }
@@ -63,6 +96,16 @@ export class NotificationPreferencesService {
}); });
} }
/** The settings as the API returns them, with the Telegram status. */
async toView(
preference: NotificationPreference,
): Promise<NotificationSettingsView> {
const link = await this.prisma.telegramLink.findUnique({
where: { userId: preference.userId },
});
return toNotificationSettingsView(preference, link);
}
async update( async update(
userId: string, userId: string,
dto: UpdateNotificationSettingsDto, dto: UpdateNotificationSettingsDto,
@@ -7,7 +7,6 @@ import { UpdateNotificationSettingsDto } from './dto/update-notification-setting
import { import {
NotificationPreferencesService, NotificationPreferencesService,
NotificationSettingsView, NotificationSettingsView,
toNotificationSettingsView,
} from './notification-preferences.service'; } from './notification-preferences.service';
/** /**
@@ -25,7 +24,7 @@ export class NotificationSettingsController {
async get( async get(
@CurrentUser() actor: AuthenticatedUser, @CurrentUser() actor: AuthenticatedUser,
): Promise<NotificationSettingsView> { ): Promise<NotificationSettingsView> {
return toNotificationSettingsView( return this.preferences.toView(
await this.preferences.getOrCreate(actor.id), await this.preferences.getOrCreate(actor.id),
); );
} }
@@ -35,7 +34,7 @@ export class NotificationSettingsController {
@Body() dto: UpdateNotificationSettingsDto, @Body() dto: UpdateNotificationSettingsDto,
@CurrentUser() actor: AuthenticatedUser, @CurrentUser() actor: AuthenticatedUser,
): Promise<NotificationSettingsView> { ): Promise<NotificationSettingsView> {
return toNotificationSettingsView( return this.preferences.toView(
await this.preferences.update(actor.id, dto), await this.preferences.update(actor.id, dto),
); );
} }
@@ -1,5 +1,13 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator'; import {
IsEnum,
IsInt,
IsNumber,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
import { ProductKind, ShopCategory } from '../../../generated/prisma'; import { ProductKind, ShopCategory } from '../../../generated/prisma';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null'; import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
@@ -23,6 +31,16 @@ export class CreateProductDto {
@IsNumber() @IsNumber()
packagingSize?: number; packagingSize?: number;
@ApiPropertyOptional({
description:
'Haltbarkeit in Monaten: Grundlage für das MHD beim Abfüllen (kein Standardwert)',
})
@IsOptional()
@IsInt()
@Min(1)
@Max(60)
shelfLifeMonths?: number;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@@ -0,0 +1,15 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, Matches, MaxLength } from 'class-validator';
/** Step-up proof for a new Telegram link token (Spec 9a.5). */
export class TelegramLinkTokenDto {
@ApiProperty({ description: 'Aktuelles Passwort' })
@IsString()
@MaxLength(200)
password!: string;
@ApiPropertyOptional({ description: 'Aktueller TOTP-Code, falls MFA aktiv' })
@IsOptional()
@Matches(/^\d{6}$/, { message: 'mfaCode must be a 6-digit number' })
mfaCode?: string;
}
@@ -1,13 +1,15 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MfaModule } from '../auth/mfa/mfa.module'; import { MfaModule } from '../auth/mfa/mfa.module';
import { ReauthModule } from '../auth/reauth/reauth.module'; import { ReauthModule } from '../auth/reauth/reauth.module';
import { TelegramModule } from '../telegram/telegram.module';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
import { ProfileController } from './profile.controller'; import { ProfileController } from './profile.controller';
import { ProfileService } from './profile.service'; import { ProfileService } from './profile.service';
import { TelegramController } from './telegram.controller';
@Module({ @Module({
imports: [MfaModule, ReauthModule, UsersModule], imports: [MfaModule, ReauthModule, TelegramModule, UsersModule],
controllers: [ProfileController], controllers: [ProfileController, TelegramController],
providers: [ProfileService], providers: [ProfileService],
}) })
export class ProfileModule {} export class ProfileModule {}
@@ -0,0 +1,46 @@
import { Body, Controller, Header, HttpCode, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { AnyAuthenticatedUser } from '../auth/decorators/any-authenticated-user.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { ReauthService } from '../auth/reauth/reauth.service';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { SENSITIVE_ACTION_THROTTLE } from '../auth/util/throttle';
import { TelegramLinkService } from '../telegram/telegram-link.service';
import { TelegramLinkTokenDto } from './dto/telegram-link-token.dto';
/**
* Connect and disconnect the caller's own Telegram chat. Self-scoped like
* the rest of /profile (no permission applies); @AnyAuthenticatedUser keeps
* API keys out. A new channel to the outside is a sensitive change, hence
* the step-up. The link-token answer holds a secret, so the route is not
* @Idempotent() (no stored replay).
*/
@ApiTags('profile')
@ApiBearerAuth()
@AnyAuthenticatedUser()
@Controller('profile/telegram')
export class TelegramController {
constructor(
private readonly links: TelegramLinkService,
private readonly reauth: ReauthService,
) {}
@Throttle(SENSITIVE_ACTION_THROTTLE)
// The answer holds a secret: no cache, no proxy may keep it.
@Header('Cache-Control', 'no-store')
@Post('link-token')
async createLinkToken(
@Body() proof: TelegramLinkTokenDto,
@CurrentUser() actor: AuthenticatedUser,
): Promise<{ deepLink: string; expiresAt: Date }> {
await this.reauth.verify(actor.id, proof);
return this.links.createLinkToken(actor.id);
}
@Post('unlink')
@HttpCode(204)
async unlink(@CurrentUser() actor: AuthenticatedUser): Promise<void> {
await this.links.unlink(actor.id);
}
}
@@ -0,0 +1,13 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional } from 'class-validator';
import { NotificationDeliveryStatus } from '../../../generated/prisma';
export class NotificationDeliveriesQueryDto {
@ApiPropertyOptional({
enum: NotificationDeliveryStatus,
description: 'Nur Zustellungen mit diesem Status',
})
@IsOptional()
@IsEnum(NotificationDeliveryStatus)
status?: NotificationDeliveryStatus;
}
@@ -0,0 +1,325 @@
import { ConflictException, NotFoundException } from '@nestjs/common';
import { AuditLogService } from '../audit/audit-log.service';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
import {
DELIVERY_RETRY_ALL_LIMIT,
NotificationDeliveriesService,
} from './notification-deliveries.service';
jest.mock('../prisma/row-lock', () => ({
lockRowForUpdate: jest.fn().mockResolvedValue(true),
lockRowsForUpdate: jest.fn().mockResolvedValue(undefined),
}));
type RawCall = [readonly string[], ...unknown[]];
const lockRow = jest.mocked(lockRowForUpdate);
const lockRows = jest.mocked(lockRowsForUpdate);
describe('NotificationDeliveriesService', () => {
let tx: {
$queryRaw: jest.Mock<Promise<unknown>, RawCall>;
$executeRaw: jest.Mock<Promise<number>, RawCall>;
notificationDelivery: { findUnique: jest.Mock; findMany: jest.Mock };
};
let prisma: {
$queryRaw: jest.Mock<Promise<unknown>, RawCall>;
$transaction: jest.Mock;
notificationDelivery: { findMany: jest.Mock };
};
let audit: { record: jest.Mock };
let service: NotificationDeliveriesService;
const originalUsername = process.env.TELEGRAM_BOT_USERNAME;
const originalToken = process.env.TELEGRAM_BOT_TOKEN;
const sqlText = (call: RawCall): string => call[0].join('?');
const pollRow = (overrides: Record<string, unknown> = {}) => ({
leaseOwner: null,
lastPollAgeSeconds: null,
lastError: null,
...overrides,
});
const senderRow = (overrides: Record<string, unknown> = {}) => ({
pending: 0,
oldestPendingAgeSeconds: null,
failed24h: 0,
...overrides,
});
/** overview(): poll state, sender counts, last errors (in this order). */
function mockOverview(
poll: Record<string, unknown> | null,
sender: Record<string, unknown>,
errors: unknown[] = [],
): void {
prisma.$queryRaw
.mockResolvedValueOnce(poll ? [poll] : [])
.mockResolvedValueOnce([sender])
.mockResolvedValueOnce(errors);
}
beforeEach(() => {
jest.clearAllMocks();
lockRow.mockResolvedValue(true);
delete process.env.TELEGRAM_BOT_USERNAME;
delete process.env.TELEGRAM_BOT_TOKEN;
tx = {
$queryRaw: jest.fn<Promise<unknown>, RawCall>().mockResolvedValue([]),
$executeRaw: jest.fn<Promise<number>, RawCall>().mockResolvedValue(1),
notificationDelivery: { findUnique: jest.fn(), findMany: jest.fn() },
};
prisma = {
$queryRaw: jest.fn<Promise<unknown>, RawCall>(),
$transaction: jest.fn((fn: (t: typeof tx) => Promise<unknown>) => fn(tx)),
notificationDelivery: { findMany: jest.fn().mockResolvedValue([]) },
};
audit = { record: jest.fn().mockResolvedValue(undefined) };
service = new NotificationDeliveriesService(
prisma as unknown as PrismaService,
audit as unknown as AuditLogService,
);
});
afterAll(() => {
if (originalUsername === undefined) {
delete process.env.TELEGRAM_BOT_USERNAME;
} else {
process.env.TELEGRAM_BOT_USERNAME = originalUsername;
}
if (originalToken === undefined) {
delete process.env.TELEGRAM_BOT_TOKEN;
} else {
process.env.TELEGRAM_BOT_TOKEN = originalToken;
}
});
it('shows the oldest pending age from the database clock and never a chat id or token', async () => {
mockOverview(
pollRow(),
senderRow({ pending: 3, oldestPendingAgeSeconds: 42.5, failed24h: 1 }),
[
{
lastStatusCode: 502,
lastError: 'HTTP 502',
createdAt: new Date('2026-10-14T08:00:00Z'),
},
],
);
prisma.notificationDelivery.findMany.mockResolvedValue([
{
id: 'd1',
status: 'FAILED',
templateCode: null,
attempts: 8,
availableAt: new Date('2026-10-14T08:00:00Z'),
lastStatusCode: 502,
lastError: 'HTTP 502',
createdAt: new Date('2026-10-14T07:00:00Z'),
deliveredAt: null,
notification: { category: 'TASK_DUE' },
},
]);
const { telegram, deliveries } = await service.overview();
expect(telegram.sender).toEqual({
pending: 3,
oldestPendingAgeSeconds: 42.5,
failed24h: 1,
lastErrors: [
{
statusCode: 502,
error: 'HTTP 502',
createdAt: new Date('2026-10-14T08:00:00Z'),
},
],
});
const senderSql = sqlText(prisma.$queryRaw.mock.calls[1]);
expect(senderSql).toContain(
'EXTRACT(EPOCH FROM now() - MIN("availableAt")',
);
expect(senderSql).toContain('"NotificationDelivery"');
expect(deliveries).toEqual([
expect.objectContaining({
id: 'd1',
category: 'TASK_DUE',
templateCode: null,
attempts: 8,
}),
]);
const raw = JSON.stringify({ telegram, deliveries });
expect(raw).not.toMatch(/chatId|token/i);
expect(deliveries[0]).not.toHaveProperty('notification');
const args = (
prisma.notificationDelivery.findMany.mock.calls as unknown[][]
)[0][0] as { select: Record<string, unknown>; where: unknown };
expect(args.select).not.toHaveProperty('chatId');
expect(args.select).not.toHaveProperty('userId');
});
it('configured follows the bot username, not the token, and receiver.lastPollAgeSeconds is null while the worker never polled', async () => {
process.env.TELEGRAM_BOT_USERNAME = 'apicore_test_bot';
mockOverview(pollRow(), senderRow());
const configured = await service.overview();
expect(configured.telegram).toMatchObject({
botNameSet: true,
botUsername: 'apicore_test_bot',
receiver: {
leaseOwner: null,
lastPollAgeSeconds: null,
lastError: null,
},
});
delete process.env.TELEGRAM_BOT_USERNAME;
process.env.TELEGRAM_BOT_TOKEN = '123456789:' + 'A'.repeat(35);
mockOverview(null, senderRow());
const tokenOnly = await service.overview();
expect(tokenOnly.telegram.botNameSet).toBe(false);
expect(tokenOnly.telegram.botUsername).toBeNull();
expect(tokenOnly.telegram.receiver.lastPollAgeSeconds).toBeNull();
expect(JSON.stringify(tokenOnly)).not.toContain('AAAAAAAAAA');
});
it('reports the receiver lease owner and poll age from the database clock', async () => {
mockOverview(
pollRow({ leaseOwner: 'pod-1', lastPollAgeSeconds: 7.25 }),
senderRow(),
);
const { telegram } = await service.overview();
expect(telegram.receiver.leaseOwner).toBe('pod-1');
expect(telegram.receiver.lastPollAgeSeconds).toBe(7.25);
const pollSql = sqlText(prisma.$queryRaw.mock.calls[0]);
expect(pollSql).toContain('"TelegramPollState"');
expect(pollSql).toContain('EXTRACT(EPOCH FROM now() - "lastPollAt")');
expect(pollSql).toContain('"leaseUntil" > now()');
});
it('filters the list by status and caps it', async () => {
mockOverview(pollRow(), senderRow());
await service.overview('FAILED');
const args = (
prisma.notificationDelivery.findMany.mock.calls as unknown[][]
)[0][0] as { where: unknown; take: number };
expect(args.where).toEqual({ status: 'FAILED' });
expect(args.take).toBe(100);
mockOverview(pollRow(), senderRow());
await service.overview();
const unfiltered = (
prisma.notificationDelivery.findMany.mock.calls as unknown[][]
)[1][0] as { where: unknown };
expect(unfiltered.where).toEqual({});
});
it('retry moves FAILED back to PENDING, writes an audit entry and wakes the worker', async () => {
tx.notificationDelivery.findUnique.mockResolvedValue({
status: 'FAILED',
attempts: 8,
});
await service.retry('d1', 'user-1');
expect(lockRow).toHaveBeenCalledWith(tx, 'NotificationDelivery', 'd1');
const update = sqlText(tx.$executeRaw.mock.calls[0]);
expect(update).toContain('"status" = \'PENDING\'');
expect(update).toContain('"attempts" = 0');
expect(update).toContain('"availableAt" = now()');
expect(update).toContain('"claimedAt" = NULL');
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'NotificationDelivery',
entityId: 'd1',
action: 'UPDATE',
userId: 'user-1',
beforeData: { status: 'FAILED', attempts: 8 },
afterData: { status: 'PENDING', attempts: 0 },
}),
tx,
);
expect(
tx.$executeRaw.mock.calls.some((c) =>
c.slice(1).includes('notification_delivery'),
),
).toBe(true);
});
it('retry of a non-FAILED delivery is 409 and of an unknown one 404', async () => {
tx.notificationDelivery.findUnique.mockResolvedValueOnce({
status: 'DELIVERED',
attempts: 1,
});
await expect(service.retry('d1', 'u')).rejects.toBeInstanceOf(
ConflictException,
);
tx.notificationDelivery.findUnique.mockResolvedValueOnce(null);
await expect(service.retry('nope', 'u')).rejects.toBeInstanceOf(
NotFoundException,
);
expect(audit.record).not.toHaveBeenCalled();
expect(tx.$executeRaw).not.toHaveBeenCalled();
});
it('retryFailed requeues every FAILED delivery under lock and reports the count', async () => {
tx.notificationDelivery.findMany.mockResolvedValue([
{ id: 'a' },
{ id: 'b' },
]);
tx.$queryRaw.mockResolvedValueOnce([
{ id: 'a', attempts: 8 },
{ id: 'b', attempts: 3 },
]);
await expect(service.retryFailed('user-1')).resolves.toEqual({
retried: 2,
});
expect(lockRows).toHaveBeenCalledWith(tx, 'NotificationDelivery', [
'a',
'b',
]);
const update = sqlText(tx.$queryRaw.mock.calls[0]);
expect(update).toContain('"status" = \'FAILED\'');
expect(update).toContain('"availableAt" = now()');
expect(audit.record).toHaveBeenCalledTimes(2);
expect(
tx.$executeRaw.mock.calls.some((c) =>
c.slice(1).includes('notification_delivery'),
),
).toBe(true);
});
it('retryFailed with nothing failed retries nothing and wakes nobody', async () => {
tx.notificationDelivery.findMany.mockResolvedValue([]);
await expect(service.retryFailed('user-1')).resolves.toEqual({
retried: 0,
});
expect(audit.record).not.toHaveBeenCalled();
expect(tx.$executeRaw).not.toHaveBeenCalled();
});
it('retryFailed takes the newest FAILED ones, at most the limit', async () => {
tx.notificationDelivery.findMany.mockResolvedValue([{ id: 'a' }]);
tx.$queryRaw.mockResolvedValueOnce([{ id: 'a', attempts: 8 }]);
await service.retryFailed('user-1');
const args = (
tx.notificationDelivery.findMany.mock.calls as unknown[][]
)[0][0] as { take: number; orderBy: unknown; where: unknown };
expect(args.take).toBe(DELIVERY_RETRY_ALL_LIMIT);
expect(args.where).toEqual({ status: 'FAILED' });
expect(args.orderBy).toEqual([{ createdAt: 'desc' }, { id: 'desc' }]);
});
it('retryFailed reports 0 when the re-check after the lock finds nothing FAILED', async () => {
tx.notificationDelivery.findMany.mockResolvedValue([{ id: 'a' }]);
tx.$queryRaw.mockResolvedValueOnce([]);
await expect(service.retryFailed('user-1')).resolves.toEqual({
retried: 0,
});
expect(lockRows).toHaveBeenCalled();
expect(audit.record).not.toHaveBeenCalled();
expect(tx.$executeRaw).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,254 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
NotificationCategory,
NotificationDeliveryStatus,
} from '../../generated/prisma';
import { AuditLogService } from '../audit/audit-log.service';
import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
import {
telegramBotAvailable,
telegramConfig,
} from '../telegram/telegram-config';
export interface TelegramStatusView {
/**
* The bot name is set. The API pod never has the token, so it cannot know
* whether sending works: that runs only in the worker, when the secret exists.
*/
botNameSet: boolean;
botUsername: string | null;
/** Whether updates are received shows only the poll state of the worker. */
receiver: {
leaseOwner: string | null;
lastPollAgeSeconds: number | null;
lastError: string | null;
};
sender: {
pending: number;
oldestPendingAgeSeconds: number | null;
failed24h: number;
lastErrors: { statusCode: number | null; error: string; createdAt: Date }[];
};
}
/** A delivery for the System page - never the chat id and never the text. */
export interface NotificationDeliveryView {
id: string;
status: NotificationDeliveryStatus;
category: NotificationCategory | null;
templateCode: string | null;
attempts: number;
availableAt: Date;
lastStatusCode: number | null;
lastError: string | null;
createdAt: Date;
deliveredAt: Date | null;
}
export interface NotificationDeliveriesOverview {
telegram: TelegramStatusView;
deliveries: NotificationDeliveryView[];
}
/** Most deliveries one list or one "retry all" call handles. */
export const DELIVERY_LIST_LIMIT = 100;
export const DELIVERY_RETRY_ALL_LIMIT = 100;
const LAST_ERRORS_LIMIT = 5;
interface PollRow {
leaseOwner: string | null;
lastPollAgeSeconds: number | null;
lastError: string | null;
}
interface SenderRow {
pending: number;
oldestPendingAgeSeconds: number | null;
failed24h: number;
}
interface RetriedRow {
id: string;
attempts: number;
}
/**
* Read model and retry actions of the Telegram card on the System page. All
* ages are computed by the database: the API pod's clock may differ.
*/
@Injectable()
export class NotificationDeliveriesService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditLogService,
) {}
async overview(
status?: NotificationDeliveryStatus,
): Promise<NotificationDeliveriesOverview> {
const [[poll], [sender], errors, rows] = await Promise.all([
// The lease owner counts only while its lease runs; no row = never polled.
this.prisma.$queryRaw<PollRow[]>`
SELECT
CASE WHEN "leaseUntil" > now() THEN "leaseOwner" END AS "leaseOwner",
EXTRACT(EPOCH FROM now() - "lastPollAt")::float8 AS "lastPollAgeSeconds",
"lastError"
FROM "TelegramPollState"
WHERE "id" = 'singleton'`,
// Oldest pending = the longest overdue one; a delivery held back until
// the end of the quiet hours is not late yet.
this.prisma.$queryRaw<SenderRow[]>`
SELECT
count(*) FILTER (WHERE "status" = 'PENDING')::int AS "pending",
EXTRACT(EPOCH FROM now() - MIN("availableAt") FILTER (WHERE "status" = 'PENDING' AND "availableAt" <= now()))::float8 AS "oldestPendingAgeSeconds",
count(*) FILTER (WHERE "status" = 'FAILED' AND "createdAt" > now() - interval '24 hours')::int AS "failed24h"
FROM "NotificationDelivery"`,
this.prisma.$queryRaw<
{ lastStatusCode: number | null; lastError: string; createdAt: Date }[]
>`
SELECT "lastStatusCode", "lastError", "createdAt"
FROM "NotificationDelivery"
WHERE "lastError" IS NOT NULL
ORDER BY "createdAt" DESC, "id" DESC
LIMIT ${LAST_ERRORS_LIMIT}`,
this.prisma.notificationDelivery.findMany({
where: status ? { status } : {},
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: DELIVERY_LIST_LIMIT,
// Never chatId, userId or a text: only what the System page shows.
select: {
id: true,
status: true,
templateCode: true,
attempts: true,
availableAt: true,
lastStatusCode: true,
lastError: true,
createdAt: true,
deliveredAt: true,
notification: { select: { category: true } },
},
}),
]);
return {
telegram: {
botNameSet: telegramBotAvailable(),
botUsername: telegramConfig().botUsername,
receiver: {
leaseOwner: poll?.leaseOwner ?? null,
lastPollAgeSeconds: poll?.lastPollAgeSeconds ?? null,
lastError: poll?.lastError ?? null,
},
sender: {
pending: sender?.pending ?? 0,
oldestPendingAgeSeconds: sender?.oldestPendingAgeSeconds ?? null,
failed24h: sender?.failed24h ?? 0,
lastErrors: errors.map((row) => ({
statusCode: row.lastStatusCode,
error: row.lastError,
createdAt: row.createdAt,
})),
},
},
deliveries: rows.map(({ notification, ...delivery }) => ({
...delivery,
category: notification?.category ?? null,
})),
};
}
/**
* Re-queues one FAILED delivery: lock, re-read, then PENDING with
* attempts 0 and availableAt from the database clock; audit in the same
* transaction, then wake the worker. 404 unknown, 409 not FAILED.
*/
async retry(id: string, actingUserId: string): Promise<void> {
await this.prisma.$transaction(async (tx) => {
await lockRowForUpdate(tx, 'NotificationDelivery', id);
const before = await tx.notificationDelivery.findUnique({
where: { id },
select: { status: true, attempts: true },
});
if (!before) {
throw new NotFoundException('Notification delivery not found');
}
if (before.status !== 'FAILED') {
throw new ConflictException('Notification delivery is not FAILED');
}
await tx.$executeRaw`
UPDATE "NotificationDelivery"
SET "status" = 'PENDING', "attempts" = 0, "availableAt" = now(),
"claimedAt" = NULL, "lastStatusCode" = NULL, "lastError" = NULL
WHERE "id" = ${id}`;
await this.audit.record(
{
entityType: 'NotificationDelivery',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: { status: 'FAILED', attempts: before.attempts },
afterData: { status: 'PENDING', attempts: 0 },
},
tx,
);
await notifyWorker(tx, WAKE_CHANNELS.notificationDeliveries);
});
}
/**
* Re-queues the newest FAILED deliveries (at most DELIVERY_RETRY_ALL_LIMIT
* per call; call again for more).
*/
async retryFailed(actingUserId: string): Promise<{ retried: number }> {
return this.prisma.$transaction(async (tx) => {
const candidates = await tx.notificationDelivery.findMany({
where: { status: 'FAILED' },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: DELIVERY_RETRY_ALL_LIMIT,
select: { id: true },
});
const ids = candidates.map((row) => row.id);
if (ids.length === 0) {
return { retried: 0 };
}
await lockRowsForUpdate(tx, 'NotificationDelivery', ids);
// The status is checked again after the lock: a row retried or sent
// meanwhile is left alone.
const retried = await tx.$queryRaw<RetriedRow[]>`
WITH old AS (
SELECT "id", "attempts" FROM "NotificationDelivery"
WHERE "id" = ANY(${ids}) AND "status" = 'FAILED'
)
UPDATE "NotificationDelivery" d
SET "status" = 'PENDING', "attempts" = 0, "availableAt" = now(),
"claimedAt" = NULL, "lastStatusCode" = NULL, "lastError" = NULL
FROM old
WHERE d."id" = old."id"
RETURNING d."id" AS "id", old."attempts" AS "attempts"`;
for (const row of retried) {
await this.audit.record(
{
entityType: 'NotificationDelivery',
entityId: row.id,
action: 'UPDATE',
userId: actingUserId,
beforeData: { status: 'FAILED', attempts: row.attempts },
afterData: { status: 'PENDING', attempts: 0 },
},
tx,
);
}
if (retried.length > 0) {
await notifyWorker(tx, WAKE_CHANNELS.notificationDeliveries);
}
return { retried: retried.length };
});
}
}
@@ -11,6 +11,11 @@ import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator'; import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { NotificationDeliveriesQueryDto } from './dto/notification-deliveries.query';
import {
NotificationDeliveriesOverview,
NotificationDeliveriesService,
} from './notification-deliveries.service';
import { import {
JobRunListQueryDto, JobRunListQueryDto,
SYSTEM_LIST_DEFAULT_LIMIT, SYSTEM_LIST_DEFAULT_LIMIT,
@@ -28,7 +33,10 @@ import {
@ApiBearerAuth() @ApiBearerAuth()
@Controller('system') @Controller('system')
export class SystemController { export class SystemController {
constructor(private readonly queue: SystemQueueService) {} constructor(
private readonly queue: SystemQueueService,
private readonly deliveries: NotificationDeliveriesService,
) {}
@RequirePermission('system:read') @RequirePermission('system:read')
@Get('overview') @Get('overview')
@@ -84,4 +92,32 @@ export class SystemController {
): Promise<void> { ): Promise<void> {
return this.queue.retryWebhookDelivery(id, actor.id); return this.queue.retryWebhookDelivery(id, actor.id);
} }
@RequirePermission('system:manage')
@Get('notification-deliveries')
notificationDeliveries(
@Query() query: NotificationDeliveriesQueryDto,
): Promise<NotificationDeliveriesOverview> {
return this.deliveries.overview(query.status);
}
// Declared before ':id/retry' so that "retry-failed" is never read as an id.
@RequirePermission('system:manage')
@Post('notification-deliveries/retry-failed')
@HttpCode(HttpStatus.OK)
retryFailedNotificationDeliveries(
@CurrentUser() actor: AuthenticatedUser,
): Promise<{ retried: number }> {
return this.deliveries.retryFailed(actor.id);
}
@RequirePermission('system:manage')
@Post('notification-deliveries/:id/retry')
@HttpCode(HttpStatus.NO_CONTENT)
retryNotificationDelivery(
@Param('id') id: string,
@CurrentUser() actor: AuthenticatedUser,
): Promise<void> {
return this.deliveries.retry(id, actor.id);
}
} }
+2 -1
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { JobRunService } from './job-run.service'; import { JobRunService } from './job-run.service';
import { NotificationDeliveriesService } from './notification-deliveries.service';
import { SystemController } from './system.controller'; import { SystemController } from './system.controller';
import { SystemQueueService } from './system-queue.service'; import { SystemQueueService } from './system-queue.service';
@@ -11,7 +12,7 @@ import { SystemQueueService } from './system-queue.service';
@Module({ @Module({
imports: [NotificationsModule], imports: [NotificationsModule],
controllers: [SystemController], controllers: [SystemController],
providers: [JobRunService, SystemQueueService], providers: [JobRunService, SystemQueueService, NotificationDeliveriesService],
exports: [JobRunService], exports: [JobRunService],
}) })
export class SystemModule {} export class SystemModule {}
@@ -286,7 +286,7 @@ describe('TelegramClient', () => {
}), }),
); );
const updates = await client.getUpdates(100n); const { updates, highestUpdateId } = await client.getUpdates(100n);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(`https://api.telegram.org/bot${TOKEN}/getUpdates`); expect(url).toBe(`https://api.telegram.org/bot${TOKEN}/getUpdates`);
@@ -295,6 +295,8 @@ describe('TelegramClient', () => {
timeout: 25, timeout: 25,
allowed_updates: ['message'], allowed_updates: ['message'],
}); });
// The dropped update 101 is not in the list, but 102 is the highest.
expect(highestUpdateId).toBe(102n);
expect(updates).toEqual([ expect(updates).toEqual([
{ {
updateId: 100n, updateId: 100n,
@@ -313,9 +315,31 @@ describe('TelegramClient', () => {
]); ]);
}); });
it('reports the highest update_id even when every update was dropped', async () => {
fetchMock.mockResolvedValue(
jsonResponse({
ok: true,
result: [
{ update_id: 7, edited_message: {} },
{ update_id: 9, callback_query: {} },
],
}),
);
await expect(client.getUpdates(7n)).resolves.toEqual({
updates: [],
highestUpdateId: 9n,
received: 2,
});
});
it('returns an empty list when nothing arrived', async () => { it('returns an empty list when nothing arrived', async () => {
fetchMock.mockResolvedValue(jsonResponse({ ok: true, result: [] })); fetchMock.mockResolvedValue(jsonResponse({ ok: true, result: [] }));
await expect(client.getUpdates(0n)).resolves.toEqual([]); await expect(client.getUpdates(0n)).resolves.toEqual({
updates: [],
highestUpdateId: null,
received: 0,
});
}); });
}); });
}); });
@@ -27,6 +27,19 @@ export interface TelegramUpdate {
firstName: string | null; firstName: string | null;
} }
/**
* What one getUpdates call returned: the updates this code understands and
* the highest raw update_id of the answer, also of updates that were dropped
* (no chat, no id of the right type). The receiver advances the offset past
* it, so a dropped update is never fetched again.
*/
export interface TelegramUpdateBatch {
updates: TelegramUpdate[];
highestUpdateId: bigint | null;
/** How many entries the answer held, parsed or not. */
received: number;
}
// eslint-disable-next-line no-control-regex // eslint-disable-next-line no-control-regex
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g; const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
// Zero-width characters, bidi marks, overrides and isolates: invisible, and // Zero-width characters, bidi marks, overrides and isolates: invisible, and
@@ -104,6 +117,17 @@ function retryAfterOf(parsed: JsonObject): number | null {
); );
} }
function highestRawUpdateId(raw: unknown[]): bigint | null {
let highest: bigint | null = null;
for (const entry of raw) {
const id = asInteger(asObject(entry)?.update_id);
if (id !== null && (highest === null || id > highest)) {
highest = id;
}
}
return highest;
}
function parseUpdate(raw: unknown): TelegramUpdate | null { function parseUpdate(raw: unknown): TelegramUpdate | null {
const update = asObject(raw); const update = asObject(raw);
const updateId = asInteger(update?.update_id); const updateId = asInteger(update?.update_id);
@@ -166,7 +190,7 @@ export class TelegramClient {
async getUpdates( async getUpdates(
offset: bigint, offset: bigint,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<TelegramUpdate[]> { ): Promise<TelegramUpdateBatch> {
const result = await this.call( const result = await this.call(
'getUpdates', 'getUpdates',
{ {
@@ -180,9 +204,13 @@ export class TelegramClient {
if (!Array.isArray(result)) { if (!Array.isArray(result)) {
throw new TelegramApiError('getUpdates', null, INVALID_RESPONSE, null); throw new TelegramApiError('getUpdates', null, INVALID_RESPONSE, null);
} }
return result return {
.map(parseUpdate) updates: result
.filter((update): update is TelegramUpdate => update !== null); .map(parseUpdate)
.filter((update): update is TelegramUpdate => update !== null),
highestUpdateId: highestRawUpdateId(result),
received: result.length,
};
} }
private jsonNumber(method: string, value: bigint): number { private jsonNumber(method: string, value: bigint): number {
@@ -0,0 +1,66 @@
import type { Prisma } from '../../generated/prisma';
import { removeLinkIn } from './telegram-link-removal';
describe('removeLinkIn', () => {
let tx: {
telegramLink: { deleteMany: jest.Mock };
notificationPreference: { updateMany: jest.Mock };
notificationDelivery: { updateMany: jest.Mock };
};
beforeEach(() => {
tx = {
telegramLink: { deleteMany: jest.fn().mockResolvedValue({ count: 1 }) },
notificationPreference: {
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
notificationDelivery: {
updateMany: jest.fn().mockResolvedValue({ count: 3 }),
},
};
});
const run = (reason: 'unlinked' | 'chat unavailable', chatId?: bigint) =>
removeLinkIn(
tx as unknown as Prisma.TransactionClient,
'u1',
reason,
chatId,
);
it('deletes the link, switches Telegram off and skips the waiting deliveries without a claim', async () => {
await expect(run('unlinked')).resolves.toBe(true);
expect(tx.telegramLink.deleteMany).toHaveBeenCalledWith({
where: { userId: 'u1' },
});
expect(tx.notificationPreference.updateMany).toHaveBeenCalledWith({
where: { userId: 'u1' },
data: { telegramEnabled: false },
});
expect(tx.notificationDelivery.updateMany).toHaveBeenCalledWith({
where: {
userId: 'u1',
channel: 'TELEGRAM',
status: { in: ['PENDING', 'PROCESSING'] },
},
data: { status: 'SKIPPED', claimedAt: null, lastError: 'unlinked' },
});
});
it('with a chat id only a link that still points to that chat goes', async () => {
await run('chat unavailable', 42n);
expect(tx.telegramLink.deleteMany).toHaveBeenCalledWith({
where: { userId: 'u1', chatId: 42n },
});
});
it('changes nothing else when there was no such link', async () => {
tx.telegramLink.deleteMany.mockResolvedValue({ count: 0 });
await expect(run('chat unavailable', 42n)).resolves.toBe(false);
expect(tx.notificationPreference.updateMany).not.toHaveBeenCalled();
expect(tx.notificationDelivery.updateMany).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,54 @@
import type { Prisma } from '../../generated/prisma';
/** Why waiting deliveries were dropped; lands in lastError, so no personal data. */
export type LinkRemovalReason = 'unlinked' | 'chat unavailable' | 'relinked';
/**
* Drops what is still waiting for the user's Telegram chat (PENDING and
* PROCESSING): after a disconnect or a move to another chat it must never
* reach a chat linked later. claimedAt is cleared like every settle does, so
* a sender that still holds a claim changes nothing afterwards.
*/
export async function skipPendingDeliveriesIn(
tx: Prisma.TransactionClient,
userId: string,
reason: LinkRemovalReason,
): Promise<void> {
await tx.notificationDelivery.updateMany({
where: {
userId,
channel: 'TELEGRAM',
status: { in: ['PENDING', 'PROCESSING'] },
},
data: { status: 'SKIPPED', claimedAt: null, lastError: reason },
});
}
/**
* The one way a Telegram connection ends (unlink, /stop, chat taken by
* another user, bot blocked or chat gone): the link goes, Telegram is off,
* the waiting deliveries are skipped. Call it with the preference row of the
* user locked. `onlyChatId` limits it to a link that still points to that
* chat (a newer link to another chat stays). Resolves to whether a link was
* removed; when none was, nothing else changes.
*/
export async function removeLinkIn(
tx: Prisma.TransactionClient,
userId: string,
reason: LinkRemovalReason,
onlyChatId?: bigint,
): Promise<boolean> {
const { count } = await tx.telegramLink.deleteMany({
where:
onlyChatId === undefined ? { userId } : { userId, chatId: onlyChatId },
});
if (count === 0) {
return false;
}
await tx.notificationPreference.updateMany({
where: { userId },
data: { telegramEnabled: false },
});
await skipPendingDeliveriesIn(tx, userId, reason);
return true;
}
@@ -0,0 +1,463 @@
import { createHash } from 'node:crypto';
import { ConflictException } from '@nestjs/common';
import type { NotificationPreferencesService } from '../notifications/notification-preferences.service';
import type { NotificationsService } from '../notifications/notifications.service';
import type { PrismaService } from '../prisma/prisma.service';
import * as rowLock from '../prisma/row-lock';
import {
LINK_TOKEN_BYTES,
LINK_TOKEN_TTL_MS,
TelegramLinkService,
} from './telegram-link.service';
jest.mock('../prisma/row-lock');
const lockRowsForUpdate = jest.mocked(rowLock.lockRowsForUpdate);
const CHAT = 4_711_000_001n;
const TOKEN = 'A'.repeat(43);
const TOKEN_HASH = createHash('sha256').update(TOKEN).digest('hex');
interface FakeTx {
$queryRaw: jest.Mock;
$executeRaw: jest.Mock;
telegramLinkToken: { create: jest.Mock; findUnique: jest.Mock };
telegramLink: {
findUnique: jest.Mock;
delete: jest.Mock;
deleteMany: jest.Mock;
create: jest.Mock;
};
notificationPreference: { updateMany: jest.Mock };
notificationDelivery: { create: jest.Mock; updateMany: jest.Mock };
user: { findFirst: jest.Mock };
}
/** Flattens a tagged-template call to its SQL text and bound values. */
function sqlOf(call: unknown[]): { text: string; values: unknown[] } {
const [first, ...values] = call as [unknown, ...unknown[]];
if (Array.isArray(first)) {
return { text: (first as string[]).join('?'), values };
}
const sql = first as { strings: string[]; values: unknown[] };
return { text: sql.strings.join('?'), values: sql.values };
}
/** The arguments of one call of a mock (negative: counted from the end). */
function callOf(mock: jest.Mock, index: number): unknown[] {
const calls = mock.mock.calls as unknown[][];
return calls.at(index) ?? [];
}
describe('TelegramLinkService', () => {
let tx: FakeTx;
let prisma: { $transaction: jest.Mock };
let preferences: { getOrCreate: jest.Mock };
let notifications: { record: jest.Mock };
let service: TelegramLinkService;
const originalUsername = process.env.TELEGRAM_BOT_USERNAME;
const originalToken = process.env.TELEGRAM_BOT_TOKEN;
beforeEach(() => {
jest.resetAllMocks();
process.env.TELEGRAM_BOT_USERNAME = 'apicore_test_bot';
delete process.env.TELEGRAM_BOT_TOKEN;
tx = {
$queryRaw: jest.fn().mockResolvedValue([]),
$executeRaw: jest.fn().mockResolvedValue(1),
telegramLinkToken: {
create: jest.fn().mockResolvedValue({ id: 'tok-1' }),
findUnique: jest.fn().mockResolvedValue(null),
},
telegramLink: {
findUnique: jest.fn().mockResolvedValue(null),
delete: jest.fn().mockResolvedValue({}),
deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
create: jest.fn().mockResolvedValue({}),
},
notificationPreference: {
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
notificationDelivery: {
create: jest.fn().mockResolvedValue({}),
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
},
user: { findFirst: jest.fn().mockResolvedValue({ id: 'u1' }) },
};
prisma = {
$transaction: jest
.fn()
.mockImplementation((work: (t: unknown) => unknown) =>
Promise.resolve(work(tx)),
),
};
preferences = { getOrCreate: jest.fn().mockResolvedValue({}) };
notifications = { record: jest.fn().mockResolvedValue({}) };
service = new TelegramLinkService(
prisma as unknown as PrismaService,
preferences as unknown as NotificationPreferencesService,
notifications as unknown as NotificationsService,
);
});
afterAll(() => {
if (originalUsername === undefined) {
delete process.env.TELEGRAM_BOT_USERNAME;
} else {
process.env.TELEGRAM_BOT_USERNAME = originalUsername;
}
if (originalToken !== undefined) {
process.env.TELEGRAM_BOT_TOKEN = originalToken;
}
});
describe('createLinkToken', () => {
beforeEach(() => {
tx.$queryRaw.mockResolvedValue([
{ expiresAt: new Date('2026-10-14T10:10:00.000Z') },
]);
});
it('creates a 43-character base64url token, stores only its SHA-256 and invalidates older open tokens', async () => {
const result = await service.createLinkToken('u1');
const prefix = 'https://t.me/apicore_test_bot?start=';
expect(result.deepLink.startsWith(prefix)).toBe(true);
const token = result.deepLink.slice(prefix.length);
expect(LINK_TOKEN_BYTES).toBe(32);
expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(result.expiresAt).toEqual(new Date('2026-10-14T10:10:00.000Z'));
const stored = callOf(tx.telegramLinkToken.create, 0)[0] as {
data: { userId: string; tokenHash: string };
};
expect(stored.data.tokenHash).toBe(
createHash('sha256').update(token).digest('hex'),
);
expect(JSON.stringify(stored)).not.toContain(token);
// Older open tokens end in the same transaction, before the new one.
const invalidate = sqlOf(callOf(tx.$executeRaw, 0));
expect(invalidate.text).toMatch(/"consumedAt" = now\(\)/);
expect(invalidate.text).toMatch(/"consumedAt" IS NULL/);
expect(invalidate.values).toEqual(['u1']);
expect(tx.$executeRaw.mock.invocationCallOrder[0]).toBeLessThan(
tx.telegramLinkToken.create.mock.invocationCallOrder[0],
);
});
it('sets the expiry with the database clock, ten minutes ahead', async () => {
await service.createLinkToken('u1');
expect(LINK_TOKEN_TTL_MS).toBe(600_000);
const expiry = sqlOf(callOf(tx.$queryRaw, 0));
expect(expiry.text).toMatch(/now\(\) \+ make_interval/);
expect(expiry.values.some((value) => value instanceof Date)).toBe(false);
});
it('locks the preference row of the user first', async () => {
await service.createLinkToken('u1');
expect(preferences.getOrCreate).toHaveBeenCalledWith('u1', tx);
expect(lockRowsForUpdate).toHaveBeenCalledWith(
tx,
'NotificationPreference',
['u1'],
);
expect(lockRowsForUpdate.mock.invocationCallOrder[0]).toBeLessThan(
tx.$executeRaw.mock.invocationCallOrder[0],
);
});
it('createLinkToken answers 409 only when the bot username is empty, not when the token is unset', async () => {
// Token unset (the API pod never has it) but username set: works.
await expect(service.createLinkToken('u1')).resolves.toBeDefined();
process.env.TELEGRAM_BOT_USERNAME = '';
await expect(service.createLinkToken('u1')).rejects.toThrow(
new ConflictException('Telegram ist nicht eingerichtet.'),
);
// Even with a valid token: no username, no link.
process.env.TELEGRAM_BOT_TOKEN =
'123456789:AAEhBOweik6ad9r_QXMENQjcrGbqCr4K-RM';
await expect(service.createLinkToken('u1')).rejects.toBeInstanceOf(
ConflictException,
);
});
});
describe('linkFromStartIn', () => {
/** A valid, unused token of user u1. */
function validToken(userId = 'u1'): void {
tx.telegramLinkToken.findUnique.mockResolvedValue({ userId });
tx.$queryRaw.mockResolvedValue([{ userId }]);
}
it('an expired or used token answers LINK_INVALID without naming an account', async () => {
tx.telegramLinkToken.findUnique.mockResolvedValue({ userId: 'u1' });
tx.$queryRaw.mockResolvedValue([]); // CAS found nothing: used or expired
const result = await service.linkFromStartIn(
tx as never,
TOKEN,
CHAT,
'Anna',
);
expect(result).toBe('LINK_INVALID');
expect(tx.telegramLink.create).not.toHaveBeenCalled();
expect(notifications.record).not.toHaveBeenCalled();
expect(tx.notificationPreference.updateMany).not.toHaveBeenCalled();
const cas = sqlOf(callOf(tx.$queryRaw, 0));
expect(cas.text).toMatch(/"consumedAt" IS NULL/);
expect(cas.text).toMatch(/"expiresAt" > now\(\)/);
expect(cas.text).toMatch(/RETURNING "userId"/);
expect(cas.values).toEqual([TOKEN_HASH]);
});
it('an unknown or malformed token answers LINK_INVALID without a lookup of its format', async () => {
await expect(
service.linkFromStartIn(tx as never, 'short', CHAT, null),
).resolves.toBe('LINK_INVALID');
tx.telegramLinkToken.findUnique.mockResolvedValue(null);
await expect(
service.linkFromStartIn(tx as never, TOKEN, CHAT, null),
).resolves.toBe('LINK_INVALID');
expect(tx.telegramLink.create).not.toHaveBeenCalled();
});
it('a new link shows an in-app notice with the Telegram first name', async () => {
validToken();
const result = await service.linkFromStartIn(
tx as never,
TOKEN,
CHAT,
' Anna\u0007 \u202e Beck ',
);
expect(result).toBe('LINKED');
expect(tx.telegramLink.create).toHaveBeenCalledWith({
data: { userId: 'u1', chatId: CHAT, telegramFirstName: 'Anna Beck' },
});
expect(tx.notificationPreference.updateMany).toHaveBeenCalledWith({
where: { userId: 'u1' },
data: { telegramEnabled: true },
});
const [notice, noticeTx] = notifications.record.mock.calls[0] as [
{ userId: string; category: string; message: string; link: string },
unknown,
];
expect(noticeTx).toBe(tx);
expect(notice.userId).toBe('u1');
expect(notice.category).toBe('SYSTEM');
expect(notice.link).toBe('/profile');
expect(notice.message).toMatch(
/^Telegram verbunden mit „Anna Beck“ am \d{2}\.\d{2}\.\d{4}\. Warst du das nicht\? In den Einstellungen trennen\.$/,
);
});
it('cuts a long first name to 64 characters', async () => {
validToken();
await service.linkFromStartIn(tx as never, TOKEN, CHAT, 'x'.repeat(200));
const data = (
callOf(tx.telegramLink.create, 0)[0] as {
data: { telegramFirstName: string };
}
).data;
expect(data.telegramFirstName).toHaveLength(64);
});
it('a chat that moves to another user unlinks the previous user with an in-app notice', async () => {
validToken('u2');
tx.telegramLink.findUnique.mockResolvedValue({
userId: 'u1',
chatId: CHAT,
});
const result = await service.linkFromStartIn(
tx as never,
TOKEN,
CHAT,
'Ben',
);
expect(result).toBe('LINKED');
// Both users are locked in one statement, after their rows exist.
expect(preferences.getOrCreate).toHaveBeenCalledWith('u1', tx);
expect(preferences.getOrCreate).toHaveBeenCalledWith('u2', tx);
expect(lockRowsForUpdate).toHaveBeenCalledTimes(1);
expect(lockRowsForUpdate).toHaveBeenCalledWith(
tx,
'NotificationPreference',
expect.arrayContaining(['u1', 'u2']),
);
expect(tx.telegramLink.deleteMany).toHaveBeenCalledWith({
where: { userId: 'u1' },
});
expect(tx.notificationPreference.updateMany).toHaveBeenCalledWith({
where: { userId: 'u1' },
data: { telegramEnabled: false },
});
const notices = notifications.record.mock.calls.map(
([input]) => input as { userId: string; message: string },
);
expect(notices.find((notice) => notice.userId === 'u1')?.message).toBe(
'Deine Telegram-Verbindung wurde getrennt, weil der Chat mit einem anderen Konto verbunden wurde.',
);
expect(notices.find((notice) => notice.userId === 'u2')?.message).toMatch(
/^Telegram verbunden mit „Ben“/,
);
// The old link goes before the new one: the chat id is unique.
expect(
tx.telegramLink.deleteMany.mock.invocationCallOrder[0],
).toBeLessThan(tx.telegramLink.create.mock.invocationCallOrder[0]);
});
it('replacing the old chat of the user by another chat skips the old backlog, the same chat again does not', async () => {
validToken();
const OLD_CHAT = CHAT + 1n;
const ownLink = (chat: bigint) =>
tx.telegramLink.findUnique.mockImplementation(
({ where }: { where: { chatId?: bigint; userId?: string } }) =>
Promise.resolve(
where.userId === 'u1' ? { userId: 'u1', chatId: chat } : null,
),
);
ownLink(OLD_CHAT);
await service.linkFromStartIn(tx as never, TOKEN, CHAT, 'Anna');
expect(tx.notificationDelivery.updateMany).toHaveBeenCalledWith({
where: {
userId: 'u1',
channel: 'TELEGRAM',
status: { in: ['PENDING', 'PROCESSING'] },
},
data: { status: 'SKIPPED', claimedAt: null, lastError: 'relinked' },
});
tx.notificationDelivery.updateMany.mockClear();
ownLink(CHAT);
await service.linkFromStartIn(tx as never, TOKEN, CHAT, 'Anna');
expect(tx.notificationDelivery.updateMany).not.toHaveBeenCalled();
});
it('reads the link of the chat before and after taking the locks and refuses a chat that changed owner', async () => {
validToken('u2');
tx.telegramLink.findUnique
.mockResolvedValueOnce(null) // peek: free
.mockResolvedValueOnce({ userId: 'u9', chatId: CHAT }); // after lock: taken
await expect(
service.linkFromStartIn(tx as never, TOKEN, CHAT, 'Ben'),
).rejects.toThrow('changed owner');
expect(tx.telegramLink.create).not.toHaveBeenCalled();
});
it('an inactive user answers LINK_INVALID', async () => {
validToken();
tx.user.findFirst.mockResolvedValue(null);
await expect(
service.linkFromStartIn(tx as never, TOKEN, CHAT, 'Anna'),
).resolves.toBe('LINK_INVALID');
expect(tx.telegramLink.create).not.toHaveBeenCalled();
});
it('locks first, reads the link afterwards', async () => {
validToken();
await service.linkFromStartIn(tx as never, TOKEN, CHAT, 'Anna');
const lockOrder = lockRowsForUpdate.mock.invocationCallOrder[0];
const casOrder = tx.$queryRaw.mock.invocationCallOrder[0];
expect(lockOrder).toBeLessThan(casOrder);
});
});
describe('stopIn / unlink', () => {
it('stopIn deletes the link of the chat and switches Telegram off under the preference lock', async () => {
tx.telegramLink.findUnique.mockResolvedValue({
userId: 'u1',
chatId: CHAT,
});
await service.stopIn(tx as never, CHAT);
expect(lockRowsForUpdate).toHaveBeenCalledWith(
tx,
'NotificationPreference',
['u1'],
);
expect(tx.telegramLink.deleteMany).toHaveBeenCalledWith({
where: { userId: 'u1' },
});
expect(tx.notificationPreference.updateMany).toHaveBeenCalledWith({
where: { userId: 'u1' },
data: { telegramEnabled: false },
});
});
it('stopIn for an unknown chat changes nothing', async () => {
await service.stopIn(tx as never, CHAT);
expect(tx.telegramLink.deleteMany).not.toHaveBeenCalled();
expect(tx.notificationPreference.updateMany).not.toHaveBeenCalled();
expect(lockRowsForUpdate).not.toHaveBeenCalled();
});
it('unlink deletes the link, switches Telegram off and queues STOPPED for the chat', async () => {
tx.telegramLink.findUnique.mockResolvedValue({
userId: 'u1',
chatId: CHAT,
});
await service.unlink('u1');
expect(preferences.getOrCreate).toHaveBeenCalledWith('u1', tx);
expect(lockRowsForUpdate).toHaveBeenCalledWith(
tx,
'NotificationPreference',
['u1'],
);
expect(tx.telegramLink.deleteMany).toHaveBeenCalledWith({
where: { userId: 'u1' },
});
expect(tx.notificationPreference.updateMany).toHaveBeenCalledWith({
where: { userId: 'u1' },
data: { telegramEnabled: false },
});
expect(tx.notificationDelivery.create).toHaveBeenCalledWith({
data: { channel: 'TELEGRAM', chatId: CHAT, templateCode: 'STOPPED' },
});
});
it('every disconnect marks the pending Telegram deliveries of the user SKIPPED', async () => {
tx.telegramLink.findUnique.mockResolvedValue({
userId: 'u1',
chatId: CHAT,
});
await service.unlink('u1');
await service.stopIn(tx as never, CHAT);
expect(tx.notificationDelivery.updateMany).toHaveBeenCalledTimes(2);
expect(tx.notificationDelivery.updateMany).toHaveBeenCalledWith({
where: {
userId: 'u1',
channel: 'TELEGRAM',
status: { in: ['PENDING', 'PROCESSING'] },
},
data: { status: 'SKIPPED', claimedAt: null, lastError: 'unlinked' },
});
});
it('unlink without a link is a no-op', async () => {
await service.unlink('u1');
expect(tx.telegramLink.deleteMany).not.toHaveBeenCalled();
expect(tx.notificationDelivery.create).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,266 @@
import { createHash, randomBytes } from 'node:crypto';
import { ConflictException, Injectable } from '@nestjs/common';
import { Prisma } from '../../generated/prisma';
import { berlinToday } from '../common/dates';
import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup';
import { assertTransactionClient } from '../prisma/db-client';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowsForUpdate } from '../prisma/row-lock';
import { removeLinkIn, skipPendingDeliveriesIn } from './telegram-link-removal';
import { NotificationPreferencesService } from '../notifications/notification-preferences.service';
import { NotificationsService } from '../notifications/notifications.service';
import { telegramConfig } from './telegram-config';
import type { TelegramTemplateCode } from './telegram-texts';
export const LINK_TOKEN_TTL_MS = 600_000;
export const LINK_TOKEN_BYTES = 32;
/** 32 random bytes as base64url. */
const LINK_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
const MAX_FIRST_NAME_CHARS = 64;
const NOT_CONFIGURED_MESSAGE = 'Telegram ist nicht eingerichtet.';
const UNLINKED_NOTICE =
'Deine Telegram-Verbindung wurde getrennt, weil der Chat mit einem anderen Konto verbunden wurde.';
const PROFILE_LINK = '/profile';
const UNKNOWN_FIRST_NAME = 'Telegram';
export type LinkResult = 'LINKED' | 'LINK_INVALID';
// eslint-disable-next-line no-control-regex
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/g;
const INVISIBLE_CHARACTERS = /[\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g;
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
/**
* The first name Telegram reports: trimmed, no control or invisible
* characters, whitespace collapsed, at most 64 characters (by code point).
* It ends up in a notice of the app, so nothing else may ride along.
*/
function sanitizeFirstName(name: string | null): string | null {
if (name === null) {
return null;
}
const clean = name
.replace(CONTROL_CHARACTERS, ' ')
.replace(INVISIBLE_CHARACTERS, ' ')
.replace(/\s+/g, ' ')
.trim();
const characters = [...clean].slice(0, MAX_FIRST_NAME_CHARS).join('').trim();
return characters === '' ? null : characters;
}
/** 2026-10-14 -> 14.10.2026 */
function germanDate(day: string): string {
const [year, month, date] = day.split('-');
return `${date}.${month}.${year}`;
}
function uniqueIds(ids: readonly (string | undefined)[]): string[] {
return [...new Set(ids.filter((id): id is string => id !== undefined))];
}
/**
* Connects and disconnects a user's private Telegram chat (Spec 9a.5).
* The one-time link token lives 10 minutes and is stored only as SHA-256.
* Every change of a connection takes the preference row of each user it
* touches first (the sender's 403 path does the same), in one statement in
* ascending key order, and reads link and preference only afterwards.
* Never logs a token, a link code or a chat id.
*/
@Injectable()
export class TelegramLinkService {
constructor(
private readonly prisma: PrismaService,
private readonly preferences: NotificationPreferencesService,
private readonly notifications: NotificationsService,
) {}
/**
* A fresh link token for the user; older open tokens end. 409 exactly
* when no bot username is configured: the API pod never has the token,
* so it cannot be what "set up" means here.
*/
async createLinkToken(
userId: string,
): Promise<{ deepLink: string; expiresAt: Date }> {
const { botUsername } = telegramConfig();
if (botUsername === null) {
throw new ConflictException(NOT_CONFIGURED_MESSAGE);
}
const token = randomBytes(LINK_TOKEN_BYTES).toString('base64url');
const expiresAt = await this.prisma.$transaction(async (tx) => {
await this.lockPreferences(tx, [userId]);
await tx.$executeRaw`UPDATE "TelegramLinkToken" SET "consumedAt" = now() WHERE "userId" = ${userId} AND "consumedAt" IS NULL`;
const { id } = await tx.telegramLinkToken.create({
data: { userId, tokenHash: hashToken(token), expiresAt: new Date() },
select: { id: true },
});
// The expiry comes from the database clock, like every comparison.
const [row] = await tx.$queryRaw<{ expiresAt: Date }[]>`
UPDATE "TelegramLinkToken"
SET "expiresAt" = now() + make_interval(secs => ${LINK_TOKEN_TTL_MS / 1000}::double precision)
WHERE "id" = ${id}
RETURNING "expiresAt"`;
return row.expiresAt;
});
return {
deepLink: `https://t.me/${botUsername}?start=${token}`,
expiresAt,
};
}
/** Disconnects in the app: the link goes, Telegram is off, the chat hears STOPPED. */
async unlink(userId: string): Promise<void> {
await this.prisma.$transaction(async (tx) => {
await this.lockPreferences(tx, [userId]);
const link = await tx.telegramLink.findUnique({ where: { userId } });
if (!link) {
return;
}
await removeLinkIn(tx, link.userId, 'unlinked');
await this.queueReplyIn(tx, link.chatId, 'STOPPED');
});
}
/**
* /start <token> from a private chat (receiver transaction). Consumes the
* token by compare-and-set, replaces the link of the user, takes the chat
* from a previous user if needed and tells the app. Anything unusable
* answers LINK_INVALID, never naming an account.
*/
async linkFromStartIn(
tx: Prisma.TransactionClient,
token: string,
chatId: bigint,
firstName: string | null,
): Promise<LinkResult> {
assertTransactionClient(tx, 'TelegramLinkService.linkFromStartIn');
if (!LINK_TOKEN_PATTERN.test(token)) {
return 'LINK_INVALID';
}
const tokenHash = hashToken(token);
const candidate = await tx.telegramLinkToken.findUnique({
where: { tokenHash },
select: { userId: true },
});
if (!candidate) {
return 'LINK_INVALID';
}
const peeked = await tx.telegramLink.findUnique({
where: { chatId },
select: { userId: true },
});
const userId = candidate.userId;
await this.lockPreferences(tx, uniqueIds([userId, peeked?.userId]));
// Everything below reads after the locks.
const [consumed] = await tx.$queryRaw<{ userId: string }[]>`
UPDATE "TelegramLinkToken" SET "consumedAt" = now()
WHERE "tokenHash" = ${tokenHash} AND "consumedAt" IS NULL AND "expiresAt" > now()
RETURNING "userId"`;
if (!consumed) {
return 'LINK_INVALID';
}
const owner = await tx.telegramLink.findUnique({ where: { chatId } });
if (owner && owner.userId !== userId && owner.userId !== peeked?.userId) {
// Not locked: the transaction fails and the update is handled again.
throw new Error('Telegram chat changed owner while linking');
}
const active = await tx.user.findFirst({
where: { id: userId, isActive: true, role: { status: 'ACTIVE' } },
select: { id: true },
});
if (!active) {
return 'LINK_INVALID';
}
if (owner && owner.userId !== userId) {
await removeLinkIn(tx, owner.userId, 'unlinked');
await this.notifications.record(
{
userId: owner.userId,
priority: 'HINWEIS',
category: 'SYSTEM',
message: UNLINKED_NOTICE,
link: PROFILE_LINK,
},
tx,
);
}
const name = sanitizeFirstName(firstName);
// The user's own earlier chat is replaced; linkedAt starts again. What
// waited for another chat is dropped; the same chat again keeps it.
const earlier = await tx.telegramLink.findUnique({
where: { userId },
select: { chatId: true },
});
if (earlier && earlier.chatId !== chatId) {
await skipPendingDeliveriesIn(tx, userId, 'relinked');
}
await tx.telegramLink.deleteMany({ where: { userId } });
await tx.telegramLink.create({
data: { userId, chatId, telegramFirstName: name },
});
await tx.notificationPreference.updateMany({
where: { userId },
data: { telegramEnabled: true },
});
// The notice that exposes a link someone else used (Spec 9a.5).
await this.notifications.record(
{
userId,
priority: 'HINWEIS',
category: 'SYSTEM',
message: `Telegram verbunden mit „${name ?? UNKNOWN_FIRST_NAME}“ am ${germanDate(berlinToday())}. Warst du das nicht? In den Einstellungen trennen.`,
link: PROFILE_LINK,
},
tx,
);
return 'LINKED';
}
/** /stop from a chat (receiver transaction): link gone, Telegram off. */
async stopIn(tx: Prisma.TransactionClient, chatId: bigint): Promise<void> {
assertTransactionClient(tx, 'TelegramLinkService.stopIn');
const peeked = await tx.telegramLink.findUnique({
where: { chatId },
select: { userId: true },
});
if (!peeked) {
return;
}
await this.lockPreferences(tx, [peeked.userId]);
const link = await tx.telegramLink.findUnique({
where: { chatId },
select: { userId: true },
});
if (link) {
await removeLinkIn(tx, link.userId, 'unlinked');
}
}
/** Queues a bot reply to a chat (it ignores quiet hours) and wakes the sender. */
async queueReplyIn(
tx: Prisma.TransactionClient,
chatId: bigint,
code: TelegramTemplateCode,
): Promise<void> {
assertTransactionClient(tx, 'TelegramLinkService.queueReplyIn');
await tx.notificationDelivery.create({
data: { channel: 'TELEGRAM', chatId, templateCode: code },
});
await notifyWorker(tx, WAKE_CHANNELS.notificationDeliveries);
}
/** Rows must exist, then one statement locks them all in key order. */
private async lockPreferences(
tx: Prisma.TransactionClient,
userIds: readonly string[],
): Promise<void> {
for (const userId of userIds) {
await this.preferences.getOrCreate(userId, tx);
}
await lockRowsForUpdate(tx, 'NotificationPreference', userIds);
}
}
@@ -82,6 +82,7 @@ describe('TelegramSender', () => {
let tx: { let tx: {
telegramLink: { deleteMany: jest.Mock }; telegramLink: { deleteMany: jest.Mock };
notificationPreference: { updateMany: jest.Mock }; notificationPreference: { updateMany: jest.Mock };
notificationDelivery: { updateMany: jest.Mock };
}; };
let client: { let client: {
sendMessage: jest.Mock<Promise<void>, [bigint, string, AbortSignal?]>; sendMessage: jest.Mock<Promise<void>, [bigint, string, AbortSignal?]>;
@@ -115,6 +116,9 @@ describe('TelegramSender', () => {
notificationPreference: { notificationPreference: {
updateMany: jest.fn().mockResolvedValue({ count: 1 }), updateMany: jest.fn().mockResolvedValue({ count: 1 }),
}, },
notificationDelivery: {
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
},
}; };
prisma.$transaction.mockImplementation((work: (t: unknown) => unknown) => prisma.$transaction.mockImplementation((work: (t: unknown) => unknown) =>
Promise.resolve(work(tx)), Promise.resolve(work(tx)),
@@ -483,6 +487,19 @@ describe('TelegramSender', () => {
where: { userId: 'u1' }, where: { userId: 'u1' },
data: { telegramEnabled: false }, data: { telegramEnabled: false },
}); });
// The backlog of the dead chat is dropped with the link.
expect(tx.notificationDelivery.updateMany).toHaveBeenCalledWith({
where: {
userId: 'u1',
channel: 'TELEGRAM',
status: { in: ['PENDING', 'PROCESSING'] },
},
data: {
status: 'SKIPPED',
claimedAt: null,
lastError: 'chat unavailable',
},
});
expect(notifications.record).toHaveBeenCalledWith( expect(notifications.record).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
userId: 'u1', userId: 'u1',
@@ -15,6 +15,7 @@ import { DRAIN_MAX_ROUNDS } from '../domain-events/domain-event-dispatcher';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate } from '../prisma/row-lock'; import { lockRowForUpdate } from '../prisma/row-lock';
import { removeLinkIn } from './telegram-link-removal';
import { TelegramApiError } from './telegram-api-error'; import { TelegramApiError } from './telegram-api-error';
import { TelegramClient } from './telegram-client'; import { TelegramClient } from './telegram-client';
import { import {
@@ -504,14 +505,9 @@ export class TelegramSender {
} }
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
await lockRowForUpdate(tx, 'NotificationPreference', userId); await lockRowForUpdate(tx, 'NotificationPreference', userId);
const { count } = await tx.telegramLink.deleteMany({ // Link, switch and the waiting backlog end together (only if the link
where: { userId, chatId }, // still points to this chat).
}); if (await removeLinkIn(tx, userId, 'chat unavailable', chatId)) {
if (count === 1) {
await tx.notificationPreference.updateMany({
where: { userId },
data: { telegramEnabled: false },
});
await this.notifications.record( await this.notifications.record(
{ {
userId, userId,
@@ -0,0 +1,681 @@
import { Logger } from '@nestjs/common';
import { Prisma } from '../../generated/prisma';
import type { PrismaService } from '../prisma/prisma.service';
import { TelegramApiError } from './telegram-api-error';
import type {
TelegramClient,
TelegramUpdate,
TelegramUpdateBatch,
} from './telegram-client';
import type { TelegramLinkService } from './telegram-link.service';
import {
HELP_REPLIES_PER_CHAT,
MAX_UPDATE_ATTEMPTS,
MAX_UPDATE_RETRY_MS,
SKIPPED_UPDATE_TEXT,
HELP_WINDOW_MS,
LEASE_SECONDS,
NO_LEASE_WAIT_MS,
TelegramUpdatesService,
} from './telegram-updates.service';
const TOKEN = 'B'.repeat(43);
const PRIVATE_CHAT = 5_000_000_001n;
const GROUP_CHAT = -1_000_000_000_001n;
function update(
updateId: number,
text: string | null,
options: { chatId?: bigint; chatType?: string; firstName?: string } = {},
): TelegramUpdate {
return {
updateId: BigInt(updateId),
chatId: options.chatId ?? PRIVATE_CHAT,
chatType: options.chatType ?? 'private',
text,
firstName: options.firstName ?? 'Anna',
};
}
/** Flattens a tagged-template call to its SQL text and bound values. */
function sqlOf(call: unknown[]): { text: string; values: unknown[] } {
const [first, ...values] = call as [unknown, ...unknown[]];
if (Array.isArray(first)) {
return { text: (first as string[]).join('?'), values };
}
const sql = first as { strings: string[]; values: unknown[] };
return { text: sql.strings.join('?'), values: sql.values };
}
/** The arguments of one call of a mock (negative: counted from the end). */
function callOf(mock: jest.Mock, index: number): unknown[] {
const calls = mock.mock.calls as unknown[][];
return calls.at(index) ?? [];
}
describe('TelegramUpdatesService', () => {
let prisma: {
$queryRaw: jest.Mock;
$executeRaw: jest.Mock;
$transaction: jest.Mock;
};
let tx: { $executeRaw: jest.Mock };
let client: { isConfigured: jest.Mock; getUpdates: jest.Mock };
let links: {
linkFromStartIn: jest.Mock;
stopIn: jest.Mock;
queueReplyIn: jest.Mock;
};
let service: TelegramUpdatesService;
let signal: AbortSignal;
/** The lease is free and the stored offset is `offset`. */
function leaseGranted(offset: bigint | null = 7n): void {
prisma.$queryRaw.mockResolvedValue(
offset === null ? [] : [{ updateOffset: offset }],
);
}
/** What the next getUpdates calls return; the highest id counts too. */
function giveUpdates(updates: TelegramUpdate[], highest?: number): void {
const ids = updates.map((entry) => entry.updateId);
const batch: TelegramUpdateBatch = {
updates,
received:
updates.length +
(highest !== undefined && updates.length === 0 ? 1 : 0),
highestUpdateId:
highest !== undefined
? BigInt(highest)
: ids.length > 0
? ids.reduce((a, b) => (a > b ? a : b))
: null,
};
client.getUpdates.mockResolvedValue(batch);
}
const replies = (): string[] =>
(links.queueReplyIn.mock.calls as unknown[][]).map(
(call) => call[2] as string,
);
beforeEach(() => {
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
signal = new AbortController().signal;
tx = { $executeRaw: jest.fn().mockResolvedValue(1) };
prisma = {
$queryRaw: jest.fn().mockResolvedValue([]),
$executeRaw: jest.fn().mockResolvedValue(1),
$transaction: jest
.fn()
.mockImplementation((work: (t: unknown) => unknown) =>
Promise.resolve(work(tx)),
),
};
client = {
isConfigured: jest.fn().mockReturnValue(true),
getUpdates: jest
.fn()
.mockResolvedValue({ updates: [], highestUpdateId: null, received: 0 }),
};
links = {
linkFromStartIn: jest.fn().mockResolvedValue('LINKED'),
stopIn: jest.fn().mockResolvedValue(undefined),
queueReplyIn: jest.fn().mockResolvedValue(undefined),
};
service = new TelegramUpdatesService(
prisma as unknown as PrismaService,
client as unknown as TelegramClient,
links as unknown as TelegramLinkService,
);
leaseGranted();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('exposes the documented constants', () => {
expect(LEASE_SECONDS).toBe(60);
expect(NO_LEASE_WAIT_MS).toBe(30_000);
expect(HELP_REPLIES_PER_CHAT).toBe(3);
expect(HELP_WINDOW_MS).toBe(600_000);
});
it('does nothing without a configured bot', async () => {
client.isConfigured.mockReturnValue(false);
await expect(service.pollOnce(signal)).resolves.toBe('disabled');
expect(prisma.$queryRaw).not.toHaveBeenCalled();
expect(client.getUpdates).not.toHaveBeenCalled();
});
it('takes the lease with the database clock only', async () => {
await service.pollOnce(signal);
const lease = sqlOf(callOf(prisma.$queryRaw, 0));
expect(lease.text).toMatch(/"leaseUntil" = now\(\) \+ make_interval/);
expect(lease.text).toMatch(/"id" = 'singleton'/);
expect(lease.text).toMatch(
/\("leaseUntil" IS NULL OR "leaseUntil" < now\(\) OR "leaseOwner" = /,
);
expect(lease.text).toMatch(/RETURNING "updateOffset"/);
// Owner and lease seconds are the only parameters: no Date, no clock.
expect(lease.values).toHaveLength(3);
expect(lease.values.some((value) => value instanceof Date)).toBe(false);
expect(lease.values).toContain(LEASE_SECONDS);
});
it('waits without the lease and does not call Telegram', async () => {
leaseGranted(null);
await expect(service.pollOnce(signal)).resolves.toBe('no-lease');
expect(client.getUpdates).not.toHaveBeenCalled();
});
it('asks Telegram from the stored offset and reports polled', async () => {
leaseGranted(42n);
await expect(service.pollOnce(signal)).resolves.toBe('polled');
expect(client.getUpdates).toHaveBeenCalledWith(42n, signal);
// lastPollAt is written with the database clock, lastError is cleared
// only after the batch was applied.
const stamp = sqlOf(callOf(prisma.$executeRaw, 0));
expect(stamp.text).toMatch(/"lastPollAt" = now\(\)/);
expect(stamp.text).not.toMatch(/lastError/);
const cleared = sqlOf(callOf(prisma.$executeRaw, 1));
expect(cleared.text).toMatch(/"lastError" = NULL/);
});
it('handles /start, /stop and /hilfe and ignores groups', async () => {
giveUpdates([
update(10, `/start ${TOKEN}`, { firstName: 'Anna' }),
update(11, '/stop'),
update(12, '/hilfe'),
update(13, '/help'),
update(14, 'hallo'),
update(15, '/start'),
update(16, `/start ${TOKEN}`, {
chatId: GROUP_CHAT,
chatType: 'group',
}),
update(17, '/stop', { chatId: GROUP_CHAT, chatType: 'supergroup' }),
]);
links.linkFromStartIn.mockResolvedValueOnce('LINKED');
await expect(service.pollOnce(signal)).resolves.toBe('polled');
expect(links.linkFromStartIn).toHaveBeenCalledTimes(1);
expect(links.linkFromStartIn).toHaveBeenCalledWith(
tx,
TOKEN,
PRIVATE_CHAT,
'Anna',
);
expect(links.stopIn).toHaveBeenCalledTimes(1);
expect(links.stopIn).toHaveBeenCalledWith(tx, PRIVATE_CHAT);
// LINKED, STOPPED, then HELP for /hilfe, /help (3rd reply of the
// window) - the next ones stay silent; groups get nothing at all.
expect(replies()).toEqual(['LINKED', 'STOPPED', 'HELP', 'HELP']);
expect(
(links.queueReplyIn.mock.calls as unknown[][]).every(
(call) => call[1] === PRIVATE_CHAT,
),
).toBe(true);
});
it('answers a start with an unusable token with LINK_INVALID and /start@bot with a token like /start', async () => {
links.linkFromStartIn.mockResolvedValue('LINK_INVALID');
giveUpdates([update(10, `/start@apicore_test_bot ${TOKEN}`)]);
await service.pollOnce(signal);
expect(links.linkFromStartIn).toHaveBeenCalledWith(
tx,
TOKEN,
PRIVATE_CHAT,
'Anna',
);
expect(replies()).toEqual(['LINK_INVALID']);
});
it('answers at most 3 times per chat in 10 minutes', async () => {
jest.useFakeTimers();
try {
giveUpdates([20, 21, 22, 23, 24].map((id) => update(id, 'hallo')));
await service.pollOnce(signal);
expect(replies()).toEqual(['HELP', 'HELP', 'HELP']);
// Another chat has its own count.
links.queueReplyIn.mockClear();
giveUpdates([update(25, 'hallo', { chatId: 5_000_000_002n })]);
await service.pollOnce(signal);
expect(replies()).toEqual(['HELP']);
// After the window the chat is answered again.
links.queueReplyIn.mockClear();
jest.advanceTimersByTime(HELP_WINDOW_MS + 1);
giveUpdates([update(26, 'hallo')]);
await service.pollOnce(signal);
expect(replies()).toEqual(['HELP']);
} finally {
jest.useRealTimers();
}
});
it('moves the offset in the same transaction as the effect', async () => {
giveUpdates([update(10, '/stop'), update(11, 'x')]);
leaseGranted(10n);
await service.pollOnce(signal);
expect(prisma.$transaction).toHaveBeenCalledTimes(2);
// Per update: offset first, then the effect, both on the same tx.
const offsets = (tx.$executeRaw.mock.calls as unknown[][]).map((call) =>
sqlOf(call),
);
expect(offsets).toHaveLength(2);
expect(offsets[0].values).toEqual(expect.arrayContaining([11n, 10n]));
expect(offsets[1].values).toEqual(expect.arrayContaining([12n, 11n]));
expect(tx.$executeRaw.mock.invocationCallOrder[0]).toBeLessThan(
links.stopIn.mock.invocationCallOrder[0],
);
expect(links.stopIn).toHaveBeenCalledWith(tx, PRIVATE_CHAT);
});
it('the offset advance is a compare-and-set on leaseOwner and the old offset; a lost lease rolls the effect back', async () => {
giveUpdates([update(10, '/stop'), update(11, 'x')]);
leaseGranted(10n);
await service.pollOnce(signal);
const cas = sqlOf(callOf(tx.$executeRaw, 0));
expect(cas.text).toMatch(/UPDATE "TelegramPollState"/);
expect(cas.text).toMatch(/SET "updateOffset" = /);
expect(cas.text).toMatch(/"id" = 'singleton'/);
expect(cas.text).toMatch(/"leaseOwner" = /);
expect(cas.text).toMatch(/"leaseUntil" > now\(\)/);
expect(cas.text).toMatch(/"updateOffset" = /);
expect(cas.values.some((value) => value instanceof Date)).toBe(false);
// Lease lost (count 0): the transaction throws - effect and offset roll
// back together - and the cycle ends; the rest is not touched.
tx.$executeRaw.mockReset().mockResolvedValue(0);
links.stopIn.mockClear();
links.queueReplyIn.mockClear();
prisma.$transaction.mockClear();
await expect(service.pollOnce(signal)).resolves.toBe('no-lease');
expect(links.stopIn).not.toHaveBeenCalled();
expect(links.queueReplyIn).not.toHaveBeenCalled();
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
});
it('a failing effect keeps the offset and ends the cycle with error', async () => {
giveUpdates([update(10, '/stop'), update(11, 'x')]);
links.stopIn.mockRejectedValue(new Error('db down'));
await expect(service.pollOnce(signal)).resolves.toBe('error');
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
// The failure is recorded for the system page, without ids.
const failure = sqlOf(callOf(prisma.$executeRaw, -1));
expect(failure.text).toMatch(/"lastError" = /);
expect(JSON.stringify(failure.values)).not.toContain('5000000001');
// Fixed text plus the error class: never the message of the error.
expect(failure.values).toContain('update not handled: Error');
expect(JSON.stringify(failure.values)).not.toContain('db down');
});
it('answers conflict on 409 and stores the description as lastError', async () => {
client.getUpdates.mockRejectedValue(
new TelegramApiError(
'getUpdates',
409,
'Conflict: terminated by other getUpdates request',
null,
),
);
await expect(service.pollOnce(signal)).resolves.toBe('conflict');
const stored = sqlOf(callOf(prisma.$executeRaw, 0));
expect(stored.text).toMatch(/"lastError" = /);
// Fixed text plus the error class, not the description from Telegram.
expect(stored.values).toContain(
'getUpdates conflict (409): TelegramApiError',
);
expect(JSON.stringify(stored.values)).not.toContain('terminated');
});
it('answers error on other failures and never stores a token', async () => {
client.getUpdates.mockRejectedValue(
new TelegramApiError('getUpdates', null, 'timeout', null),
);
await expect(service.pollOnce(signal)).resolves.toBe('error');
expect(JSON.stringify(prisma.$executeRaw.mock.calls)).not.toMatch(
/\d{5,}:[A-Za-z0-9_-]{20,}/,
);
});
it('an aborted poll (shutdown) records no error', async () => {
const controller = new AbortController();
client.getUpdates.mockImplementation(() => {
controller.abort();
return Promise.reject(
new TelegramApiError('getUpdates', null, 'aborted', null),
);
});
await expect(service.pollOnce(controller.signal)).resolves.toBe('error');
expect(prisma.$executeRaw).not.toHaveBeenCalled();
});
describe('releaseLease', () => {
it('hands the lease back with the database clock, once, also after a quiet wait', async () => {
await service.pollOnce(signal);
prisma.$executeRaw.mockClear();
await service.releaseLease();
await service.releaseLease();
expect(prisma.$executeRaw).toHaveBeenCalledTimes(1);
const release = sqlOf(callOf(prisma.$executeRaw, 0));
expect(release.text).toMatch(/SET "leaseUntil" = now\(\)/);
expect(release.text).toMatch(/"leaseOwner" = /);
expect(release.text).not.toMatch(/lastError/);
});
it('does nothing when the lease was never held or not granted', async () => {
await service.releaseLease();
leaseGranted(null);
await service.pollOnce(signal);
await service.releaseLease();
expect(prisma.$executeRaw).not.toHaveBeenCalled();
});
});
it('answers error and keeps the offset when the answer has entries but no update ids', async () => {
client.getUpdates.mockResolvedValue({
updates: [],
highestUpdateId: null,
received: 2,
});
await expect(service.pollOnce(signal)).resolves.toBe('error');
expect(prisma.$transaction).not.toHaveBeenCalled();
const stored = sqlOf(callOf(prisma.$executeRaw, 0));
expect(stored.values).toContain('getUpdates answer without update ids');
});
describe('poison updates', () => {
it('exposes the documented limits', () => {
expect(MAX_UPDATE_ATTEMPTS).toBe(5);
expect(MAX_UPDATE_RETRY_MS).toBe(600_000);
expect(SKIPPED_UPDATE_TEXT).toBe('Telegram-Nachricht übersprungen');
});
it('retries a failing update and skips it after 5 attempts in its own transaction', async () => {
const warn = jest.spyOn(Logger.prototype, 'warn');
giveUpdates([update(10, '/stop'), update(11, 'x')]);
links.stopIn.mockRejectedValue(new Error('private text of Anna'));
for (let attempt = 1; attempt < MAX_UPDATE_ATTEMPTS; attempt++) {
await expect(service.pollOnce(signal)).resolves.toBe('error');
}
expect(links.stopIn).toHaveBeenCalledTimes(MAX_UPDATE_ATTEMPTS - 1);
tx.$executeRaw.mockClear();
prisma.$executeRaw.mockClear();
// The fifth failure skips: offset past it, fixed lastError, the next one runs.
await expect(service.pollOnce(signal)).resolves.toBe('polled');
const statements = (tx.$executeRaw.mock.calls as unknown[][]).map(
(call) => sqlOf(call),
);
const skipOffset = statements.find(
(statement) =>
/SET "updateOffset"/.test(statement.text) &&
statement.values.includes(11n),
);
expect(skipOffset).toBeDefined();
expect(
statements.some((statement) =>
statement.values.includes(SKIPPED_UPDATE_TEXT),
),
).toBe(true);
expect(warn).toHaveBeenCalledWith(
'Telegram update skipped after repeated failures',
);
// No id and no text anywhere in the log.
expect(JSON.stringify(warn.mock.calls)).not.toMatch(/10|Anna/);
// The following update was handled, and the skip keeps its lastError.
expect(replies()).toContain('HELP');
expect(
(prisma.$executeRaw.mock.calls as unknown[][]).some((call) =>
sqlOf(call).text.includes('"lastError" = NULL'),
),
).toBe(false);
});
it('skips after the time limit even with fewer attempts', async () => {
jest.useFakeTimers();
try {
giveUpdates([update(10, '/stop')]);
links.stopIn.mockRejectedValue(new Error('boom'));
await expect(service.pollOnce(signal)).resolves.toBe('error');
jest.advanceTimersByTime(MAX_UPDATE_RETRY_MS + 1);
await expect(service.pollOnce(signal)).resolves.toBe('polled');
expect(links.stopIn).toHaveBeenCalledTimes(2);
} finally {
jest.useRealTimers();
}
});
it('a lost lease is no failure of the update', async () => {
giveUpdates([update(10, '/stop')]);
tx.$executeRaw.mockResolvedValue(0);
for (let attempt = 0; attempt < MAX_UPDATE_ATTEMPTS + 2; attempt++) {
await expect(service.pollOnce(signal)).resolves.toBe('no-lease');
}
expect(links.stopIn).not.toHaveBeenCalled();
});
});
describe('failure bookkeeping', () => {
const make = (options: {
attempts?: number;
windowMs?: number;
now?: () => number;
}) =>
new TelegramUpdatesService(
prisma as unknown as PrismaService,
client as unknown as TelegramClient,
links as unknown as TelegramLinkService,
options,
);
it('forgets failures of updates behind the offset and failures older than the window', async () => {
let clock = 0;
const svc = make({ attempts: 2, windowMs: 1_000, now: () => clock });
giveUpdates([update(10, '/stop')]);
links.stopIn.mockRejectedValue(new Error('boom'));
await expect(svc.pollOnce(signal)).resolves.toBe('error');
// A clean batch behind the old update prunes it ...
links.stopIn.mockResolvedValue(undefined);
leaseGranted(20n);
giveUpdates([update(21, 'x')]);
await svc.pollOnce(signal);
// ... so it counts from one again when it comes back.
links.stopIn.mockRejectedValue(new Error('boom'));
leaseGranted(7n);
giveUpdates([update(10, '/stop')]);
await expect(svc.pollOnce(signal)).resolves.toBe('error');
// Old by the injected clock: dropped by the next clean batch.
clock = 5_000;
links.stopIn.mockResolvedValue(undefined);
leaseGranted(7n);
giveUpdates([update(30, 'x')]);
await svc.pollOnce(signal);
links.stopIn.mockRejectedValue(new Error('boom'));
leaseGranted(7n);
giveUpdates([update(10, '/stop')]);
await expect(svc.pollOnce(signal)).resolves.toBe('error');
});
it('the injected limits and clock decide when an update is skipped', async () => {
let clock = 0;
const svc = make({ attempts: 99, windowMs: 1_000, now: () => clock });
giveUpdates([update(10, '/stop')]);
links.stopIn.mockRejectedValue(new Error('boom'));
await expect(svc.pollOnce(signal)).resolves.toBe('error');
clock = 1_000;
await expect(svc.pollOnce(signal)).resolves.toBe('polled');
});
it('connection errors do not count toward the limit', async () => {
const svc = make({ attempts: 2 });
giveUpdates([update(10, '/stop')]);
const down = [
new Prisma.PrismaClientKnownRequestError('x', {
code: 'P1001',
clientVersion: 'test',
}),
new Prisma.PrismaClientKnownRequestError('x', {
code: 'P1002',
clientVersion: 'test',
}),
new Prisma.PrismaClientKnownRequestError('x', {
code: 'P1008',
clientVersion: 'test',
}),
new Prisma.PrismaClientKnownRequestError('x', {
code: 'P1017',
clientVersion: 'test',
}),
Object.assign(new Error('refused'), { code: 'ECONNREFUSED' }),
new TelegramApiError('sendMessage', null, 'timeout', null),
];
for (const error of down) {
links.stopIn.mockRejectedValueOnce(error);
await expect(svc.pollOnce(signal)).resolves.toBe('error');
}
expect(links.stopIn).toHaveBeenCalledTimes(down.length);
// Other database errors still count: the second one skips.
const other = new Prisma.PrismaClientKnownRequestError('x', {
code: 'P2002',
clientVersion: 'test',
});
links.stopIn.mockRejectedValue(other);
await expect(svc.pollOnce(signal)).resolves.toBe('error');
await expect(svc.pollOnce(signal)).resolves.toBe('polled');
});
});
it('moves the offset past a batch of only unparseable updates', async () => {
leaseGranted(7n);
giveUpdates([], 12);
await expect(service.pollOnce(signal)).resolves.toBe('polled');
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
const cas = sqlOf(callOf(tx.$executeRaw, 0));
expect(cas.text).toMatch(/SET "updateOffset"/);
expect(cas.values).toEqual(expect.arrayContaining([13n, 7n]));
expect(links.queueReplyIn).not.toHaveBeenCalled();
});
it('moves the offset past dropped updates behind the last handled one, once', async () => {
leaseGranted(10n);
giveUpdates([update(10, 'x')], 14);
await service.pollOnce(signal);
const offsets = (tx.$executeRaw.mock.calls as unknown[][]).map((call) =>
sqlOf(call),
);
expect(offsets).toHaveLength(2);
expect(offsets[0].values).toEqual(expect.arrayContaining([11n, 10n]));
expect(offsets[1].values).toEqual(expect.arrayContaining([15n, 11n]));
});
it('counts the reply quota only after the transaction committed', async () => {
giveUpdates([update(10, 'hallo')]);
// The commit fails after the reply was queued.
prisma.$transaction.mockImplementationOnce(
async (work: (t: unknown) => Promise<unknown>) => {
await work(tx);
throw new Error('commit failed');
},
);
await service.pollOnce(signal);
links.queueReplyIn.mockClear();
// Three helps are still allowed: the rolled-back one was not counted.
for (const id of [11, 12, 13, 14]) {
leaseGranted(BigInt(id));
giveUpdates([update(id, 'hallo')]);
await service.pollOnce(signal);
}
expect(replies()).toEqual(['HELP', 'HELP', 'HELP']);
});
describe('429 from Telegram', () => {
it('stays away until retry_after has passed', async () => {
jest.useFakeTimers();
try {
client.getUpdates.mockRejectedValueOnce(
new TelegramApiError('getUpdates', 429, 'Too Many Requests', 120),
);
await expect(service.pollOnce(signal)).resolves.toBe('error');
const stored = sqlOf(callOf(prisma.$executeRaw, 0));
expect(stored.values).toContain(
'getUpdates rate limited (429): TelegramApiError',
);
// Cycles inside the pause do not even ask for the lease.
prisma.$queryRaw.mockClear();
client.getUpdates.mockClear();
jest.advanceTimersByTime(119_000);
await expect(service.pollOnce(signal)).resolves.toBe('error');
expect(prisma.$queryRaw).not.toHaveBeenCalled();
expect(client.getUpdates).not.toHaveBeenCalled();
jest.advanceTimersByTime(1_001);
await expect(service.pollOnce(signal)).resolves.toBe('polled');
expect(client.getUpdates).toHaveBeenCalledTimes(1);
} finally {
jest.useRealTimers();
}
});
it('caps the pause at one hour', async () => {
jest.useFakeTimers();
try {
client.getUpdates.mockRejectedValueOnce(
new TelegramApiError('getUpdates', 429, 'x', 86_400),
);
await service.pollOnce(signal);
jest.advanceTimersByTime(3_600_001);
client.getUpdates.mockClear();
await expect(service.pollOnce(signal)).resolves.toBe('polled');
expect(client.getUpdates).toHaveBeenCalledTimes(1);
} finally {
jest.useRealTimers();
}
});
});
});
@@ -0,0 +1,514 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
import { Prisma } from '../../generated/prisma';
import { loggableError } from '../common/loggable-error';
import { PrismaService } from '../prisma/prisma.service';
import { TelegramApiError } from './telegram-api-error';
import {
TelegramClient,
TelegramUpdate,
TelegramUpdateBatch,
} from './telegram-client';
import { TelegramLinkService } from './telegram-link.service';
import type { TelegramTemplateCode } from './telegram-texts';
export const LEASE_SECONDS = 60;
/** Loop interval: also how long a receiver without the lease (or after a 409) waits. */
export const NO_LEASE_WAIT_MS = 30_000;
export const HELP_REPLIES_PER_CHAT = 3;
export const HELP_WINDOW_MS = 600_000;
/** Tunables of the receiver; tests inject their own (limits, clock). */
export interface TelegramUpdatesOptions {
/** Attempts after which a failing update is skipped. */
attempts: number;
/** Time after which a failing update is skipped, in ms of `now()`. */
windowMs: number;
/** Monotonic clock in ms. */
now: () => number;
}
export const TELEGRAM_UPDATES_OPTIONS = Symbol('TELEGRAM_UPDATES_OPTIONS');
/** An update that keeps failing is skipped after this many attempts or this long. */
export const MAX_UPDATE_ATTEMPTS = 5;
export const MAX_UPDATE_RETRY_MS = 600_000;
const MAX_RATE_LIMIT_PAUSE_MS = 3_600_000;
/** Fixed texts for the system page: no ids, no message text. */
export const SKIPPED_UPDATE_TEXT = 'Telegram-Nachricht übersprungen';
const MAX_LAST_ERROR_CHARS = 300;
const START_COMMAND = /^\/([A-Za-z]+)(?:@[A-Za-z0-9_]+)?(?:\s+(\S+))?/;
export type PollOutcome =
'disabled' | 'no-lease' | 'polled' | 'conflict' | 'error';
type Command =
{ name: 'start'; token: string | null } | { name: 'stop' } | { name: 'help' };
/** The lease or the stored offset changed: nothing of this update may stay. */
class LeaseLostError extends Error {
constructor() {
super('Telegram poll lease lost');
this.name = 'LeaseLostError';
}
}
/**
* What may go to a log or lastError: Prisma errors by name, code and model
* (loggableError), every other error by its class name only - a message can
* quote user data.
*/
function describeError(error: unknown): string {
if (
error instanceof Prisma.PrismaClientKnownRequestError ||
error instanceof Prisma.PrismaClientUnknownRequestError ||
error instanceof Prisma.PrismaClientValidationError ||
error instanceof Prisma.PrismaClientInitializationError ||
error instanceof Prisma.PrismaClientRustPanicError
) {
return loggableError(error).message;
}
return error instanceof Error ? error.name : 'unknown error';
}
const CONNECTION_ERROR_CODES = new Set(['P1001', 'P1002', 'P1008', 'P1017']);
const SOCKET_ERROR_CODES = new Set([
'ECONNREFUSED',
'ECONNRESET',
'ETIMEDOUT',
'EHOSTUNREACH',
]);
/**
* The database or the network is unreachable (Prisma P1001, P1002, P1008,
* P1017, an initialization error, a refused or timed-out socket, a Telegram
* call without an answer): that says nothing about the update itself, so it
* does not count toward skipping it.
*/
function isConnectionError(error: unknown): boolean {
if (error instanceof Prisma.PrismaClientInitializationError) {
return true;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
return CONNECTION_ERROR_CODES.has(error.code);
}
if (error instanceof TelegramApiError) {
return error.statusCode === null;
}
const code = (error as { code?: unknown } | null)?.code;
return typeof code === 'string' && SOCKET_ERROR_CODES.has(code);
}
function parseCommand(text: string | null): Command {
const match = START_COMMAND.exec(text?.trim() ?? '');
const name = match?.[1].toLowerCase();
if (name === 'start') {
return { name: 'start', token: match?.[2] ?? null };
}
return name === 'stop' ? { name: 'stop' } : { name: 'help' };
}
/**
* At most HELP_REPLIES_PER_CHAT replies per chat in HELP_WINDOW_MS, counted
* in memory (there is exactly one receiver, the lease sees to that). A
* stranger writing to the bot cannot make it talk without end. Monotonic
* clock, so a clock step changes nothing.
*/
class ReplyLimiter {
private readonly sentAt = new Map<string, number[]>();
constructor(private readonly now: () => number) {}
/** Would one more reply to this chat be within the limit? Counts nothing. */
allows(chatId: bigint): boolean {
this.prune();
return (
(this.sentAt.get(String(chatId))?.length ?? 0) < HELP_REPLIES_PER_CHAT
);
}
/** Counts a reply that was queued and committed. */
record(chatId: bigint): void {
const key = String(chatId);
this.sentAt.set(key, [...(this.sentAt.get(key) ?? []), this.now()]);
}
private prune(): void {
const now = this.now();
for (const [chat, times] of this.sentAt) {
const recent = times.filter((time) => now - time < HELP_WINDOW_MS);
if (recent.length === 0) {
this.sentAt.delete(chat);
} else {
this.sentAt.set(chat, recent);
}
}
}
}
/**
* Receives what people write to the bot (Spec 9a.5, 9a.7): long polling
* getUpdates in the worker, no public endpoint. Exactly one receiver per
* bot: the lease in TelegramPollState (database clock only) says who. Each
* update runs in its own transaction together with the offset advance, which
* is a compare-and-set on lease owner and old offset - a receiver that lost
* the lease changes nothing, so every update takes effect once. Replies are
* queued as deliveries (the sender talks to Telegram, not this class).
* Never logs a token, a link code, a chat id or a message text.
*/
@Injectable()
export class TelegramUpdatesService {
private readonly logger = new Logger(TelegramUpdatesService.name);
/** Names this receiver in the lease. */
private readonly instanceId = randomUUID();
private readonly replies: ReplyLimiter;
private readonly limits: Pick<
TelegramUpdatesOptions,
'attempts' | 'windowMs'
>;
private readonly now: () => number;
private holdsLease = false;
/** Monotonic time until which Telegram asked us to stay away (429). */
private rateLimitedUntil = 0;
/** Failed attempts per update, in memory: a restart gives them a fresh start. */
private readonly failures = new Map<
bigint,
{ attempts: number; firstAt: number }
>();
constructor(
private readonly prisma: PrismaService,
private readonly client: TelegramClient,
private readonly links: TelegramLinkService,
@Optional()
@Inject(TELEGRAM_UPDATES_OPTIONS)
options?: Partial<TelegramUpdatesOptions>,
) {
this.now = options?.now ?? (() => performance.now());
this.limits = {
attempts: options?.attempts ?? MAX_UPDATE_ATTEMPTS,
windowMs: options?.windowMs ?? MAX_UPDATE_RETRY_MS,
};
this.replies = new ReplyLimiter(this.now);
}
/**
* One cycle: take or extend the lease, fetch updates (long poll), handle
* them one by one. 'polled' means the next cycle may start at once.
*/
async pollOnce(signal: AbortSignal): Promise<PollOutcome> {
if (!this.client.isConfigured()) {
return 'disabled';
}
if (signal.aborted || this.now() < this.rateLimitedUntil) {
return 'error';
}
const offset = await this.acquireLease();
if (offset === null) {
return 'no-lease';
}
let batch: TelegramUpdateBatch;
try {
batch = await this.client.getUpdates(offset, signal);
} catch (error: unknown) {
return this.onFetchError(error, signal);
}
if (batch.received > 0 && batch.highestUpdateId === null) {
// An answer nobody can move the offset past would be fetched again at
// once: report it, so the 30 s wait applies.
await this.setLastError('getUpdates answer without update ids');
return 'error';
}
await this.markPolled();
return this.applyBatch(batch, offset, signal);
}
/** The updates in order, then the offset past dropped ones; a clean batch clears lastError. */
private async applyBatch(
batch: TelegramUpdateBatch,
offset: bigint,
signal: AbortSignal,
): Promise<PollOutcome> {
let current = offset;
let skipped = false;
for (const update of batch.updates) {
if (signal.aborted) {
return 'polled';
}
try {
const reply = await this.prisma.$transaction((tx) =>
this.applyUpdate(tx, update, current),
);
// Counted only now: a rolled-back reply was never queued.
if (reply !== null) {
this.replies.record(reply);
}
this.failures.delete(update.updateId);
} catch (error: unknown) {
if (error instanceof LeaseLostError) {
return 'no-lease';
}
if (!(await this.shouldSkip(update, error))) {
return 'error';
}
try {
await this.skipUpdate(update, current);
} catch (skipError: unknown) {
return this.onUpdateError(skipError);
}
skipped = true;
}
current = update.updateId + 1n;
}
const dropped = await this.skipDropped(batch, current, signal);
if (dropped !== 'ok') {
return dropped;
}
this.pruneFailures(current);
if (!skipped) {
await this.clearLastError();
}
return 'polled';
}
/** Forgets updates behind the offset and failures older than the window. */
private pruneFailures(offset: bigint): void {
const now = this.now();
for (const [updateId, state] of this.failures) {
if (updateId < offset || now - state.firstAt >= this.limits.windowMs) {
this.failures.delete(updateId);
}
}
}
/** Dropped updates (no chat, wrong shape): move the offset past them, never fetch them again. */
private async skipDropped(
batch: TelegramUpdateBatch,
current: bigint,
signal: AbortSignal,
): Promise<'ok' | PollOutcome> {
const highest = batch.highestUpdateId;
if (highest === null || highest < current || signal.aborted) {
return 'ok';
}
try {
await this.prisma.$transaction((tx) =>
this.advanceOffsetIn(tx, current, highest + 1n),
);
} catch (error: unknown) {
return this.onUpdateError(error);
}
return 'ok';
}
/** Lease SQL, database clock only. The stored offset, or null without the lease. */
private async acquireLease(): Promise<bigint | null> {
const rows = await this.prisma.$queryRaw<{ updateOffset: bigint }[]>`
UPDATE "TelegramPollState"
SET "leaseOwner" = ${this.instanceId},
"leaseUntil" = now() + make_interval(secs => ${LEASE_SECONDS})
WHERE "id" = 'singleton'
AND ("leaseUntil" IS NULL OR "leaseUntil" < now() OR "leaseOwner" = ${this.instanceId})
RETURNING "updateOffset"`;
this.holdsLease = rows.length > 0;
return rows[0]?.updateOffset ?? null;
}
/**
* Shutdown: the next receiver need not wait for the lease to run out.
* Call it after the loop has stopped; it does nothing without a lease.
*/
async releaseLease(): Promise<void> {
if (!this.holdsLease) {
return;
}
this.holdsLease = false;
try {
await this.prisma.$executeRaw`
UPDATE "TelegramPollState"
SET "leaseUntil" = now()
WHERE "id" = 'singleton' AND "leaseOwner" = ${this.instanceId}`;
} catch (error: unknown) {
this.logger.warn(`Telegram lease not released: ${describeError(error)}`);
}
}
private async markPolled(): Promise<void> {
await this.prisma.$executeRaw`
UPDATE "TelegramPollState"
SET "lastPollAt" = now()
WHERE "id" = 'singleton' AND "leaseOwner" = ${this.instanceId}`;
}
private async clearLastError(): Promise<void> {
await this.prisma.$executeRaw`
UPDATE "TelegramPollState"
SET "lastError" = NULL
WHERE "id" = 'singleton' AND "leaseOwner" = ${this.instanceId}`;
}
private async setLastError(text: string): Promise<void> {
await this.prisma.$executeRaw`
UPDATE "TelegramPollState"
SET "lastError" = ${text.slice(0, MAX_LAST_ERROR_CHARS)}
WHERE "id" = 'singleton' AND "leaseOwner" = ${this.instanceId}`;
}
/**
* 409: another getUpdates or a webhook; wait. 429: stay away until
* retry_after has passed (at most an hour). The texts are fixed plus the
* error class: nothing from the answer, no token, no URL.
*/
private async onFetchError(
error: unknown,
signal: AbortSignal,
): Promise<PollOutcome> {
if (signal.aborted) {
return 'error';
}
const status = error instanceof TelegramApiError ? error.statusCode : null;
if (status === 429) {
const seconds =
error instanceof TelegramApiError ? error.retryAfterSec : null;
this.rateLimitedUntil =
this.now() +
Math.min(
(seconds ?? NO_LEASE_WAIT_MS / 1_000) * 1_000,
MAX_RATE_LIMIT_PAUSE_MS,
);
}
let label = 'getUpdates failed';
if (status === 409) {
label = 'getUpdates conflict (409)';
} else if (status === 429) {
label = 'getUpdates rate limited (429)';
}
await this.setLastError(`${label}: ${describeError(error)}`);
return status === 409 ? 'conflict' : 'error';
}
private async onUpdateError(error: unknown): Promise<PollOutcome> {
if (error instanceof LeaseLostError) {
return 'no-lease';
}
// The offset stays: the update is tried again in the next cycle.
const text = `update not handled: ${describeError(error)}`;
this.logger.error(`Telegram ${text}`);
await this.setLastError(text);
return 'error';
}
/**
* Counts a failed attempt of an update; true once it failed too often or
* for too long and has to be skipped (otherwise the failure is recorded).
*/
private async shouldSkip(
update: TelegramUpdate,
error: unknown,
): Promise<boolean> {
if (isConnectionError(error)) {
// The database or network is down: the update is not at fault.
await this.onUpdateError(error);
return false;
}
const now = this.now();
const known = this.failures.get(update.updateId);
const state = {
attempts: (known?.attempts ?? 0) + 1,
firstAt: known?.firstAt ?? now,
};
this.failures.set(update.updateId, state);
const exhausted =
state.attempts >= this.limits.attempts ||
now - state.firstAt >= this.limits.windowMs;
if (!exhausted) {
await this.onUpdateError(error);
}
return exhausted;
}
/** Offset past a poison update in its own transaction, so the queue moves on. */
private async skipUpdate(
update: TelegramUpdate,
oldOffset: bigint,
): Promise<void> {
await this.prisma.$transaction(async (tx) => {
await this.advanceOffsetIn(tx, oldOffset, update.updateId + 1n);
await tx.$executeRaw`
UPDATE "TelegramPollState"
SET "lastError" = ${SKIPPED_UPDATE_TEXT}
WHERE "id" = 'singleton' AND "leaseOwner" = ${this.instanceId}`;
});
this.failures.delete(update.updateId);
this.logger.warn('Telegram update skipped after repeated failures');
}
/** Offset compare-and-set on lease owner and old offset; count 0: lease lost. */
private async advanceOffsetIn(
tx: Prisma.TransactionClient,
oldOffset: bigint,
newOffset: bigint,
): Promise<void> {
const advanced = await tx.$executeRaw`
UPDATE "TelegramPollState"
SET "updateOffset" = ${newOffset}
WHERE "id" = 'singleton'
AND "leaseOwner" = ${this.instanceId}
AND "leaseUntil" > now()
AND "updateOffset" = ${oldOffset}`;
if (advanced === 0) {
throw new LeaseLostError();
}
}
/**
* Offset first (a lost lease ends here), then the effect, all in `tx`.
* Returns the chat whose reply quota must be counted once `tx` commits.
*/
private async applyUpdate(
tx: Prisma.TransactionClient,
update: TelegramUpdate,
oldOffset: bigint,
): Promise<bigint | null> {
await this.advanceOffsetIn(tx, oldOffset, update.updateId + 1n);
// Groups and channels: the bot is not for them.
if (update.chatType !== 'private') {
return null;
}
const command = parseCommand(update.text);
if (command.name === 'stop') {
await this.links.stopIn(tx, update.chatId);
return this.reply(tx, update.chatId, 'STOPPED');
}
if (command.name === 'start' && command.token !== null) {
const result = await this.links.linkFromStartIn(
tx,
command.token,
update.chatId,
update.firstName,
);
return this.reply(tx, update.chatId, result);
}
return this.reply(tx, update.chatId, 'HELP');
}
/**
* LINKED always goes out (it needs a valid one-time token, so no
* stranger can trigger it); every other reply counts against the limit.
* Returns the chat to count after the commit, or null.
*/
private async reply(
tx: Prisma.TransactionClient,
chatId: bigint,
code: TelegramTemplateCode,
): Promise<bigint | null> {
if (code === 'LINKED') {
await this.links.queueReplyIn(tx, chatId, code);
return null;
}
if (!this.replies.allows(chatId)) {
return null;
}
await this.links.queueReplyIn(tx, chatId, code);
return chatId;
}
}
@@ -2,10 +2,13 @@ import { Module } from '@nestjs/common';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { telegramConfig } from './telegram-config'; import { telegramConfig } from './telegram-config';
import { TELEGRAM_CONFIG, TelegramClient } from './telegram-client'; import { TELEGRAM_CONFIG, TelegramClient } from './telegram-client';
import { TelegramLinkService } from './telegram-link.service';
import { TelegramSender } from './telegram-sender'; import { TelegramSender } from './telegram-sender';
import { TelegramUpdatesService } from './telegram-updates.service';
/** /**
* The Bot API client for the worker's Telegram loops. Reads the token from * The Bot API client for the worker's Telegram loops (send, receive) and the
* connect/disconnect logic the profile routes use. Reads the token from
* the environment once; without it the client reports isConfigured() false * the environment once; without it the client reports isConfigured() false
* and the loops do not run. The sender needs the permission resolver, which * and the loops do not run. The sender needs the permission resolver, which
* the global JobPermissionsModule (worker) or PermissionsModule (API) provides: * the global JobPermissionsModule (worker) or PermissionsModule (API) provides:
@@ -18,7 +21,14 @@ import { TelegramSender } from './telegram-sender';
{ provide: TELEGRAM_CONFIG, useFactory: () => telegramConfig() }, { provide: TELEGRAM_CONFIG, useFactory: () => telegramConfig() },
TelegramClient, TelegramClient,
TelegramSender, TelegramSender,
TelegramLinkService,
TelegramUpdatesService,
],
exports: [
TelegramClient,
TelegramSender,
TelegramLinkService,
TelegramUpdatesService,
], ],
exports: [TelegramClient, TelegramSender],
}) })
export class TelegramModule {} export class TelegramModule {}
@@ -12,6 +12,10 @@ import {
TELEGRAM_SEND_POLL_MS, TELEGRAM_SEND_POLL_MS,
type TelegramSender, type TelegramSender,
} from '../telegram/telegram-sender'; } from '../telegram/telegram-sender';
import {
NO_LEASE_WAIT_MS,
type TelegramUpdatesService,
} from '../telegram/telegram-updates.service';
import type { WeatherHistoryService } from '../weather/history/weather-history.service'; import type { WeatherHistoryService } from '../weather/history/weather-history.service';
import { import {
WEATHER_FIRST_TICK_DELAY_MS, WEATHER_FIRST_TICK_DELAY_MS,
@@ -69,6 +73,13 @@ class FakeTelegramSender {
drain = jest.fn<Promise<number>, unknown[]>().mockResolvedValue(0); drain = jest.fn<Promise<number>, unknown[]>().mockResolvedValue(0);
} }
class FakeTelegramUpdates {
releaseLease = jest.fn<Promise<void>, []>().mockResolvedValue(undefined);
pollOnce = jest
.fn<Promise<string>, [AbortSignal]>()
.mockResolvedValue('no-lease');
}
describe('WorkerRuntime', () => { describe('WorkerRuntime', () => {
const ListenerMock = PgListener as unknown as jest.Mock; const ListenerMock = PgListener as unknown as jest.Mock;
const originalDatabaseUrl = process.env.DATABASE_URL; const originalDatabaseUrl = process.env.DATABASE_URL;
@@ -82,6 +93,7 @@ describe('WorkerRuntime', () => {
let weatherHistory: { syncOnce: jest.Mock }; let weatherHistory: { syncOnce: jest.Mock };
let telegramConfigured: boolean; let telegramConfigured: boolean;
let telegramSender: FakeTelegramSender; let telegramSender: FakeTelegramSender;
let telegramUpdates: FakeTelegramUpdates;
let runtime: WorkerRuntime; let runtime: WorkerRuntime;
let logError: jest.SpyInstance; let logError: jest.SpyInstance;
@@ -122,6 +134,7 @@ describe('WorkerRuntime', () => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
telegramConfigured = false; telegramConfigured = false;
telegramSender = new FakeTelegramSender(); telegramSender = new FakeTelegramSender();
telegramUpdates = new FakeTelegramUpdates();
runtime = createRuntime(); runtime = createRuntime();
}); });
@@ -134,6 +147,7 @@ describe('WorkerRuntime', () => {
weatherHistory as unknown as WeatherHistoryService, weatherHistory as unknown as WeatherHistoryService,
{ isConfigured: () => telegramConfigured } as unknown as TelegramClient, { isConfigured: () => telegramConfigured } as unknown as TelegramClient,
telegramSender as unknown as TelegramSender, telegramSender as unknown as TelegramSender,
telegramUpdates as unknown as TelegramUpdatesService,
); );
} }
@@ -269,6 +283,94 @@ describe('WorkerRuntime', () => {
}); });
}); });
describe('telegram-updates loop', () => {
it('is not registered without a bot token', async () => {
await bootstrap();
await jest.advanceTimersByTimeAsync(NO_LEASE_WAIT_MS * 2);
expect(telegramUpdates.pollOnce).not.toHaveBeenCalled();
});
describe('with a bot token', () => {
beforeEach(async () => {
await runtime.onModuleDestroy();
telegramConfigured = true;
runtime = createRuntime();
});
it('polls with the stop signal and has no wake channel', async () => {
await bootstrap();
expect(telegramUpdates.pollOnce).toHaveBeenCalledTimes(1);
expect(telegramUpdates.pollOnce).toHaveBeenCalledWith(
expect.any(AbortSignal),
);
expect(listenerOptions().channels).toEqual([
'domain_event',
'calendar_sync',
'webhook_delivery',
'notification_delivery',
]);
});
it('waits 30 s after no-lease, conflict and error', async () => {
for (const outcome of ['no-lease', 'conflict', 'error']) {
telegramUpdates.pollOnce.mockClear().mockResolvedValue(outcome);
await runtime.onModuleDestroy();
runtime = createRuntime();
await bootstrap();
expect(telegramUpdates.pollOnce).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(NO_LEASE_WAIT_MS - 1);
expect(telegramUpdates.pollOnce).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1);
expect(telegramUpdates.pollOnce).toHaveBeenCalledTimes(2);
}
});
it('polls again at once after a successful poll', async () => {
telegramUpdates.pollOnce
.mockResolvedValueOnce('polled')
.mockResolvedValueOnce('polled')
.mockResolvedValue('no-lease');
await bootstrap();
await jest.advanceTimersByTimeAsync(1);
await jest.advanceTimersByTimeAsync(1);
expect(telegramUpdates.pollOnce).toHaveBeenCalledTimes(3);
await jest.advanceTimersByTimeAsync(NO_LEASE_WAIT_MS - 1);
expect(telegramUpdates.pollOnce).toHaveBeenCalledTimes(3);
});
it('a failing poll does not make the worker unhealthy', async () => {
telegramUpdates.pollOnce.mockRejectedValue(new Error('boom'));
await bootstrap();
await expect(runtime.health()).resolves.toEqual({ ok: true });
expect(logError).toHaveBeenCalled();
});
it('hands the lease back at shutdown, after the loops stopped', async () => {
await bootstrap();
telegramUpdates.releaseLease.mockClear();
await runtime.onModuleDestroy();
expect(telegramUpdates.releaseLease).toHaveBeenCalledTimes(1);
});
it('aborts the running poll at shutdown', async () => {
await bootstrap();
const [signal] = telegramUpdates.pollOnce.mock.calls[0];
await runtime.onModuleDestroy();
expect(signal.aborted).toBe(true);
});
});
});
it('polls webhook deliveries every 5 s', async () => { it('polls webhook deliveries every 5 s', async () => {
await bootstrap(); await bootstrap();
expect(webhookDeliveries.drain).toHaveBeenCalledTimes(1); expect(webhookDeliveries.drain).toHaveBeenCalledTimes(1);
@@ -19,6 +19,10 @@ import {
TELEGRAM_SEND_POLL_MS, TELEGRAM_SEND_POLL_MS,
TelegramSender, TelegramSender,
} from '../telegram/telegram-sender'; } from '../telegram/telegram-sender';
import {
NO_LEASE_WAIT_MS,
TelegramUpdatesService,
} from '../telegram/telegram-updates.service';
import { import {
WEATHER_FIRST_TICK_DELAY_MS, WEATHER_FIRST_TICK_DELAY_MS,
WEATHER_HISTORY_POLL_MS, WEATHER_HISTORY_POLL_MS,
@@ -32,6 +36,7 @@ import { HEALTH_STALE_MS, WorkerHealth } from './worker-health';
export const DOMAIN_EVENT_POLL_MS = 5_000; export const DOMAIN_EVENT_POLL_MS = 5_000;
export const CALENDAR_OUTBOX_POLL_MS = 60_000; export const CALENDAR_OUTBOX_POLL_MS = 60_000;
export const WEBHOOK_DELIVERY_POLL_MS = 5_000; export const WEBHOOK_DELIVERY_POLL_MS = 5_000;
const TELEGRAM_UPDATES_LOOP = 'telegram-updates';
/** The health check's SELECT 1 must answer within this. */ /** The health check's SELECT 1 must answer within this. */
export const HEALTH_QUERY_TIMEOUT_MS = 5_000; export const HEALTH_QUERY_TIMEOUT_MS = 5_000;
@@ -84,6 +89,7 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
private readonly weatherHistory: WeatherHistoryService, private readonly weatherHistory: WeatherHistoryService,
private readonly telegramClient: TelegramClient, private readonly telegramClient: TelegramClient,
private readonly telegramSender: TelegramSender, private readonly telegramSender: TelegramSender,
private readonly telegramUpdates: TelegramUpdatesService,
) { ) {
const loops = this.workerLoops().map((definition) => ({ const loops = this.workerLoops().map((definition) => ({
definition, definition,
@@ -135,6 +141,14 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
channel: WAKE_CHANNELS.notificationDeliveries, channel: WAKE_CHANNELS.notificationDeliveries,
tick: () => this.sendTelegram(), tick: () => this.sendTelegram(),
}, },
{
// Long polling getUpdates; no wake channel. After a poll the
// next one starts at once, otherwise the interval is the wait
// (no lease, 409, error).
name: TELEGRAM_UPDATES_LOOP,
intervalMs: NO_LEASE_WAIT_MS,
tick: () => this.receiveTelegram(),
},
] ]
: []), : []),
{ {
@@ -174,6 +188,9 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
this.listener = undefined; this.listener = undefined;
await listener?.stop(); await listener?.stop();
await Promise.all([...this.loops.values()].map((loop) => loop.stop())); await Promise.all([...this.loops.values()].map((loop) => loop.stop()));
// Also covers a stop during the 30 s wait: the next receiver need not
// wait for the lease to run out.
await this.telegramUpdates.releaseLease();
} }
/** /**
@@ -239,6 +256,13 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
await this.telegramSender.drain(this.stopping.signal); await this.telegramSender.drain(this.stopping.signal);
} }
private async receiveTelegram(): Promise<void> {
const outcome = await this.telegramUpdates.pollOnce(this.stopping.signal);
if (outcome === 'polled') {
this.loops.get(TELEGRAM_UPDATES_LOOP)?.wake();
}
}
private async deliverWebhooks(): Promise<void> { private async deliverWebhooks(): Promise<void> {
await this.webhookDeliveries.releaseStaleClaims(); await this.webhookDeliveries.releaseStaleClaims();
// No filter: every due delivery of the database. // No filter: every due delivery of the database.
@@ -0,0 +1,563 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import { randomUUID } from 'node:crypto';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
interface Warning {
code: string;
severity: string;
message: string;
refs: { type: string; id: string }[];
}
interface BottlingBody {
id: string;
jarSize: number;
bestBeforeDate: string;
warnings: Warning[];
}
interface ConfirmationBody {
statusCode: number;
code: string;
waterContent: number;
message: string;
warnings: Warning[];
}
interface PrefillBody {
jarSize: number | null;
jarSizeSource: string | null;
bestBeforeDate: string | null;
shelfLifeMonths: number | null;
waterContent: number | null;
warnings: Warning[];
}
describe('Bottling quality (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const stamp = Date.now();
const email = `bottling-quality-e2e-${stamp}@example.com`;
const password = 'SuperSecret123!';
let userId: string;
let token: string;
let staffId: string;
let staffToken: string;
let extractionId: string;
const honeyBatchIds: string[] = [];
const productIds: string[] = [];
const bottlingIds: string[] = [];
const api = () => request(app.getHttpServer());
const auth = (req: request.Test) =>
req.set('Authorization', `Bearer ${token}`);
async function newBatch(
waterContent: number | null,
honeyType: string | null = null,
): Promise<string> {
const batch = await prisma.honeyBatch.create({
data: {
batchNumber: `BQ-${stamp}-${honeyBatchIds.length}`,
extractionId,
amount: 25,
initialAmount: 25,
waterContent,
honeyType,
},
});
honeyBatchIds.push(batch.id);
return batch.id;
}
async function newProduct(data: {
packagingSize?: number;
shelfLifeMonths?: number;
}): Promise<string> {
const product = await prisma.product.create({
data: {
name: `Qualität ${stamp}-${productIds.length}`,
kind: 'BOTTLED_HONEY',
...data,
},
});
productIds.push(product.id);
return product.id;
}
/** What a write would leave behind for the batch: movements, events, audit. */
async function writtenCounts(honeyBatchId: string) {
return {
movements: await prisma.batchMovement.count({ where: { honeyBatchId } }),
events: await prisma.domainEvent.count({
where: {
aggregateType: 'BottlingBatch',
payload: { path: ['honeyBatchId'], equals: honeyBatchId },
},
}),
audits: await prisma.auditLog.count({
where: { userId, entityType: 'BottlingBatch', action: 'CREATE' },
}),
};
}
function create(body: Record<string, unknown>, key?: string): request.Test {
const req = auth(api().post('/api/v1/bottling-batches'));
if (key) {
req.set('Idempotency-Key', key);
}
return req.send({
date: '2026-01-31',
jarCount: 2,
lotNumber: `BQ-L-${stamp}-${randomUUID()}`,
...body,
});
}
function remember(response: { body: unknown }): BottlingBody {
const body = response.body as BottlingBody;
bottlingIds.push(body.id);
return body;
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const user = await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 12),
name: 'Quality',
role: { connect: { key: 'BEEKEEPER' } },
},
});
userId = user.id;
token = (await login(app, email, password)).accessToken;
const extraction = await prisma.extraction.create({
data: { date: new Date(), userId },
});
extractionId = extraction.id;
// STAFF has no bottling:create.
const staff = await prisma.user.create({
data: {
email: `bottling-quality-staff-${stamp}@example.com`,
passwordHash: await bcrypt.hash(password, 12),
name: 'Staff',
role: { connect: { key: 'STAFF' } },
},
});
staffId = staff.id;
staffToken = (await login(app, staff.email, password)).accessToken;
});
afterAll(async () => {
await prisma.domainEvent.deleteMany({
where: {
aggregateType: 'BottlingBatch',
aggregateId: { in: bottlingIds },
},
});
await prisma.bottlingBatch.deleteMany({
where: { id: { in: bottlingIds } },
});
await prisma.batchMovement.deleteMany({
where: { honeyBatchId: { in: honeyBatchIds } },
});
await prisma.honeyMeasurement.deleteMany({
where: { honeyBatchId: { in: honeyBatchIds } },
});
await prisma.honeyBatch.deleteMany({
where: { id: { in: honeyBatchIds } },
});
await prisma.extraction.deleteMany({ where: { id: extractionId } });
await prisma.product.deleteMany({ where: { id: { in: productIds } } });
await prisma.idempotencyRecord.deleteMany({ where: { userId } });
const userIds = [userId, staffId];
await prisma.refreshToken.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await app.close();
});
it('17 % -> 201 without a quality warning', async () => {
const honeyBatchId = await newBatch(17);
const response = await create({
honeyBatchId,
jarSize: 500,
bestBeforeDate: '2027-01-31',
}).expect(201);
expect(remember(response).warnings).toEqual([]);
});
it('19 % -> 201 with WATER_CONTENT_HIGH', async () => {
const honeyBatchId = await newBatch(19);
const response = await create({
honeyBatchId,
jarSize: 500,
bestBeforeDate: '2027-01-31',
}).expect(201);
const warnings = remember(response).warnings;
expect(warnings.map((w) => w.code)).toEqual(['WATER_CONTENT_HIGH']);
expect(warnings[0].severity).toBe('WARNUNG');
});
it('no water content at all -> 201 with the hint WATER_CONTENT_MISSING', async () => {
const honeyBatchId = await newBatch(null);
const response = await create({
honeyBatchId,
jarSize: 500,
bestBeforeDate: '2027-01-31',
}).expect(201);
expect(remember(response).warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_MISSING',
]);
});
it('20.5 % -> 409 CONFIRMATION_REQUIRED; confirmed retry with the same key -> 201', async () => {
const honeyBatchId = await newBatch(20.5);
const key = randomUUID();
const body = { honeyBatchId, jarSize: 500, bestBeforeDate: '2027-01-31' };
const countsBefore = await writtenCounts(honeyBatchId);
const refused = await create(body, key).expect(409);
const refusal = refused.body as ConfirmationBody;
expect(refusal.code).toBe('CONFIRMATION_REQUIRED');
expect(refusal.warnings.map((w) => [w.code, w.severity])).toEqual([
['WATER_CONTENT_ABOVE_LEGAL', 'BESTAETIGEN'],
]);
expect(refusal.warnings[0].refs).toEqual([
{ type: 'HoneyBatch', id: honeyBatchId },
]);
expect(refusal.waterContent).toBe(20.5);
// Nothing was written: no bottling, the rest is untouched.
expect(await prisma.bottlingBatch.count({ where: { honeyBatchId } })).toBe(
0,
);
expect(
(
await prisma.honeyBatch.findUniqueOrThrow({
where: { id: honeyBatchId },
})
).amount,
).toBe(25);
expect(await writtenCounts(honeyBatchId)).toEqual(countsBefore);
const confirmed = await create(
{
...body,
acknowledgedWarnings: ['WATER_CONTENT_ABOVE_LEGAL'],
acknowledgedWaterContent: 20.5,
},
key,
).expect(201);
expect(remember(confirmed).warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_ABOVE_LEGAL',
]);
expect(await prisma.bottlingBatch.count({ where: { honeyBatchId } })).toBe(
1,
);
// A third call with the same key replays the stored 201.
const replay = await create(
{
...body,
acknowledgedWarnings: ['WATER_CONTENT_ABOVE_LEGAL'],
acknowledgedWaterContent: 20.5,
},
key,
).expect(201);
expect((replay.body as BottlingBody).id).toBe(
(confirmed.body as BottlingBody).id,
);
expect(await prisma.bottlingBatch.count({ where: { honeyBatchId } })).toBe(
1,
);
});
it('a confirmation is bound to the value: another value gets a fresh 409', async () => {
const honeyBatchId = await newBatch(21);
const body = {
honeyBatchId,
jarSize: 500,
bestBeforeDate: '2027-01-31',
acknowledgedWarnings: ['WATER_CONTENT_ABOVE_LEGAL'],
};
// Confirmed for 20.5 %, but the batch holds 21 %.
const stale = await create({
...body,
acknowledgedWaterContent: 20.5,
}).expect(409);
expect((stale.body as ConfirmationBody).waterContent).toBe(21);
// The code alone is no confirmation either.
await create(body).expect(409);
await create({ ...body, acknowledgedWaterContent: 101 }).expect(400);
expect(await prisma.bottlingBatch.count({ where: { honeyBatchId } })).toBe(
0,
);
remember(
await create({ ...body, acknowledgedWaterContent: 21 }).expect(201),
);
});
it('exactly 20.0 % only warns: 201 with WATER_CONTENT_HIGH', async () => {
const honeyBatchId = await newBatch(20);
const response = await create({
honeyBatchId,
jarSize: 500,
bestBeforeDate: '2027-01-31',
}).expect(201);
expect(remember(response).warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_HIGH',
]);
});
it('an impossible day is a 400, never a 500', async () => {
const honeyBatchId = await newBatch(17);
await create({
honeyBatchId,
jarSize: 500,
date: '2026-02-30',
bestBeforeDate: '2027-01-31',
}).expect(400);
await create({
honeyBatchId,
jarSize: 500,
bestBeforeDate: '2027-02-30',
}).expect(400);
await auth(api().get('/api/v1/bottling-batches/prefill'))
.query({ honeyBatchId, date: '2026-02-30' })
.expect(400);
});
it('a newer low measurement lifts the stop; a newer high one sets it', async () => {
const honeyBatchId = await newBatch(21);
const userRow = { honeyBatchId, userId };
await prisma.honeyMeasurement.create({
data: {
...userRow,
waterContent: 16.8,
measuredAt: new Date('2026-08-01'),
},
});
const body = { honeyBatchId, jarSize: 500, bestBeforeDate: '2027-01-31' };
remember(await create(body).expect(201));
await prisma.honeyMeasurement.create({
data: {
...userRow,
waterContent: 22,
measuredAt: new Date('2026-09-01'),
},
});
const refused = await create(body).expect(409);
expect((refused.body as ConfirmationBody).code).toBe(
'CONFIRMATION_REQUIRED',
);
});
it('rejects an unknown acknowledged code and more than one', async () => {
const honeyBatchId = await newBatch(17);
const body = { honeyBatchId, jarSize: 500, bestBeforeDate: '2027-01-31' };
await create({ ...body, acknowledgedWarnings: ['SOMETHING'] }).expect(400);
await create({
...body,
acknowledgedWarnings: [
'WATER_CONTENT_ABOVE_LEGAL',
'WATER_CONTENT_ABOVE_LEGAL',
],
}).expect(400);
});
it('a correction reports quality findings only when it changes the jar size or honey batch', async () => {
const honeyBatchId = await newBatch(17);
const created = remember(
await create({
honeyBatchId,
jarSize: 500,
bestBeforeDate: '2027-01-31',
}).expect(201),
);
await prisma.honeyBatch.update({
where: { id: honeyBatchId },
data: { waterContent: 21 },
});
const lotOnly = await auth(
api().patch(`/api/v1/bottling-batches/${created.id}`),
)
.send({ lotNumber: `BQ-L-${stamp}-${randomUUID()}` })
.expect(200);
expect((lotOnly.body as BottlingBody).warnings).toEqual([]);
const patched = await auth(
api().patch(`/api/v1/bottling-batches/${created.id}`),
)
.send({ jarSize: 250 })
.expect(200);
expect((patched.body as BottlingBody).warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_ABOVE_LEGAL',
]);
});
it('a honey measurement above the limit answers warnings and is never refused', async () => {
const honeyBatchId = await newBatch(17);
const response = await auth(
api().post(`/api/v1/honey-batches/${honeyBatchId}/measurements`),
)
.send({ waterContent: 21, measuredAt: '2026-10-01T09:00:00.000Z' })
.expect(201);
const warnings = (response.body as { warnings: Warning[] }).warnings;
expect(warnings.map((w) => w.code)).toEqual(['WATER_CONTENT_ABOVE_LEGAL']);
expect(
await prisma.honeyMeasurement.count({ where: { honeyBatchId } }),
).toBe(1);
});
it('best before comes from shelfLifeMonths and a different jar size gives a hint', async () => {
const honeyBatchId = await newBatch(17);
const productId = await newProduct({
packagingSize: 500,
shelfLifeMonths: 1,
});
const fromArticle = remember(
await create({ honeyBatchId, productId }).expect(201),
);
expect(fromArticle.jarSize).toBe(500);
// 2026-01-31 plus one month, cut at the month end.
expect(fromArticle.bestBeforeDate.slice(0, 10)).toBe('2026-02-28');
expect(fromArticle.warnings).toEqual([]);
const otherJar = remember(
await create({ honeyBatchId, productId, jarSize: 250 }).expect(201),
);
expect(otherJar.jarSize).toBe(250);
expect(otherJar.warnings.map((w) => w.code)).toEqual(['JAR_SIZE_DIFFERS']);
const sentDate = remember(
await create({
honeyBatchId,
productId,
bestBeforeDate: '2030-05-05',
}).expect(201),
);
expect(sentDate.bestBeforeDate.slice(0, 10)).toBe('2030-05-05');
});
it('refuses with a clear text when jar size and best before are unknown', async () => {
const honeyBatchId = await newBatch(17);
const noShelfLife = await newProduct({ packagingSize: 500 });
const none = await create({ honeyBatchId }).expect(400);
expect((none.body as { message: string }).message).toBe(
'Glasgröße und MHD fehlen. Bitte einen Artikel wählen oder beides eintragen.',
);
const noDate = await create({
honeyBatchId,
productId: noShelfLife,
}).expect(400);
expect((noDate.body as { message: string }).message).toBe(
'Ohne Haltbarkeit am Artikel bitte ein MHD eintragen.',
);
});
describe('GET /bottling-batches/prefill', () => {
it('prefill returns jar size and best before from the product', async () => {
const honeyBatchId = await newBatch(19);
const productId = await newProduct({
packagingSize: 250,
shelfLifeMonths: 24,
});
const response = await auth(api().get('/api/v1/bottling-batches/prefill'))
.query({ honeyBatchId, productId, date: '2026-05-10' })
.expect(200);
const prefill = response.body as PrefillBody;
expect(prefill).toMatchObject({
jarSize: 250,
jarSizeSource: 'PRODUCT',
bestBeforeDate: '2028-05-10',
shelfLifeMonths: 24,
waterContent: 19,
});
expect(prefill.warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_HIGH',
]);
// Read only.
expect(
await prisma.bottlingBatch.count({ where: { honeyBatchId } }),
).toBe(0);
});
it('without an article it still reports the water content', async () => {
const honeyBatchId = await newBatch(null);
const response = await auth(api().get('/api/v1/bottling-batches/prefill'))
.query({ honeyBatchId })
.expect(200);
const prefill = response.body as PrefillBody;
expect(prefill.jarSize).toBeNull();
expect(prefill.bestBeforeDate).toBeNull();
expect(prefill.warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_MISSING',
]);
});
it('needs bottling:create: 403 for STAFF', async () => {
const honeyBatchId = await newBatch(17);
await api()
.get('/api/v1/bottling-batches/prefill')
.set('Authorization', `Bearer ${staffToken}`)
.query({ honeyBatchId })
.expect(403);
});
it('refuses an article that is not bottled honey with 400', async () => {
const honeyBatchId = await newBatch(17);
const product = await prisma.product.create({
data: { name: `Kerze ${stamp}`, kind: 'MANUFACTURED' },
});
productIds.push(product.id);
await auth(api().get('/api/v1/bottling-batches/prefill'))
.query({ honeyBatchId, productId: product.id })
.expect(400);
});
it('answers 404 for unknown ids and 400 without a honey batch', async () => {
await auth(api().get('/api/v1/bottling-batches/prefill'))
.query({ honeyBatchId: 'missing' })
.expect(404);
await auth(api().get('/api/v1/bottling-batches/prefill')).expect(400);
});
});
});
@@ -132,6 +132,8 @@ describe('Bottling (e2e)', () => {
amount: 25, amount: 25,
// The movement book keeps this batch: bookings need a start amount. // The movement book keeps this batch: bookings need a start amount.
initialAmount: 25, initialAmount: 25,
// Below the warning limit: this file expects no quality warning.
waterContent: 17,
}, },
}); });
honeyBatchId = honeyBatch.id; honeyBatchId = honeyBatch.id;
+12 -1
View File
@@ -348,7 +348,10 @@ describe('Notifications (e2e)', () => {
.set(as('bare')) .set(as('bare'))
.expect(200); .expect(200);
expect(response.body).toEqual({ const { telegram, ...settings } = response.body as {
telegram: Record<string, unknown>;
};
expect(settings).toEqual({
taskDueEnabled: true, taskDueEnabled: true,
digestEnabled: true, digestEnabled: true,
digestTime: '06:30', digestTime: '06:30',
@@ -357,6 +360,14 @@ describe('Notifications (e2e)', () => {
telegramCategories: ['TASK_DUE', 'DIGEST'], telegramCategories: ['TASK_DUE', 'DIGEST'],
telegramDetail: true, telegramDetail: true,
}); });
// configured / botUsername follow the environment of the process.
expect(telegram).toMatchObject({
linked: false,
firstName: null,
linkedAt: null,
enabled: false,
});
expect(typeof telegram.botNameSet).toBe('boolean');
}); });
it('PATCH notification-settings stores digest time, quiet hours and Telegram categories', async () => { it('PATCH notification-settings stores digest time, quiet hours and Telegram categories', async () => {
+40
View File
@@ -104,4 +104,44 @@ describe('Products (e2e)', () => {
const product = response.body as ProductBody; const product = response.body as ProductBody;
expect(product.status).toBe('DISCONTINUED'); expect(product.status).toBe('DISCONTINUED');
}); });
it('keeps the shelf life in months and lets it be cleared', async () => {
const created = await request(app.getHttpServer())
.post('/api/v1/products')
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
name: `Haltbar ${Date.now()}`,
packagingSize: 500,
shelfLifeMonths: 24,
})
.expect(201);
const id = (created.body as ProductBody).id;
try {
expect(
(created.body as ProductBody & { shelfLifeMonths: number })
.shelfLifeMonths,
).toBe(24);
const cleared = await request(app.getHttpServer())
.patch(`/api/v1/products/${id}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ shelfLifeMonths: null })
.expect(200);
expect(
(cleared.body as { shelfLifeMonths: number | null }).shelfLifeMonths,
).toBeNull();
} finally {
await prisma.product.deleteMany({ where: { id } });
}
});
it('rejects a shelf life outside 1 to 60 months', async () => {
for (const shelfLifeMonths of [0, 61, 1.5]) {
await request(app.getHttpServer())
.post('/api/v1/products')
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ name: `Ungültig ${Date.now()}`, shelfLifeMonths })
.expect(400);
}
});
}); });
+224
View File
@@ -19,6 +19,14 @@ interface FailedDeliveryBody {
[key: string]: unknown; [key: string]: unknown;
} }
interface DeliveriesBody {
telegram: {
botNameSet: boolean;
sender: { failed24h: number };
};
deliveries: { id: string; [key: string]: unknown }[];
}
interface OverviewBody { interface OverviewBody {
jobs: unknown[]; jobs: unknown[];
domainEvents: { failed: number; oldestPendingAgeSeconds: number | null }; domainEvents: { failed: number; oldestPendingAgeSeconds: number | null };
@@ -234,4 +242,220 @@ describe('System API (e2e)', () => {
await prisma.webhookSubscription.delete({ where: { id: subscription.id } }); await prisma.webhookSubscription.delete({ where: { id: subscription.id } });
}); });
describe('notification deliveries (Telegram card)', () => {
const CHAT_ID = 987654321n;
let userId: string;
const createdNotificationIds: string[] = [];
const templateDeliveryIds: string[] = [];
/** A bot reply: addressed by chat id, no user, no notification. */
async function createTemplateDelivery(): Promise<string> {
const delivery = await prisma.notificationDelivery.create({
data: {
channel: 'TELEGRAM',
chatId: CHAT_ID,
templateCode: 'HELP',
status: 'FAILED',
attempts: 8,
lastError: 'HTTP 502',
},
});
templateDeliveryIds.push(delivery.id);
return delivery.id;
}
async function createDelivery(
status: 'FAILED' | 'DELIVERED',
): Promise<string> {
const notification = await prisma.notification.create({
data: {
userId,
priority: 'HINWEIS',
category: 'TASK_DUE',
message: 'private reminder text',
dedupeKey: `system-e2e:${suffix}:${createdNotificationIds.length}:${Date.now()}`,
},
});
createdNotificationIds.push(notification.id);
const delivery = await prisma.notificationDelivery.create({
data: {
channel: 'TELEGRAM',
userId,
notificationId: notification.id,
status,
attempts: status === 'FAILED' ? 8 : 1,
lastStatusCode: status === 'FAILED' ? 502 : null,
lastError: status === 'FAILED' ? 'HTTP 502' : null,
},
});
return delivery.id;
}
beforeAll(async () => {
const admin = await prisma.user.findUniqueOrThrow({
where: { email: `system-admin-${suffix}@example.com` },
select: { id: true },
});
userId = admin.id;
});
afterAll(async () => {
// Only this block's own notifications (deliveries cascade).
await prisma.notification.deleteMany({
where: { id: { in: createdNotificationIds } },
});
await prisma.notificationDelivery.deleteMany({
where: { id: { in: templateDeliveryIds } },
});
});
it('GET /system/notification-deliveries shows FAILED entries and retry moves them back to PENDING', async () => {
const failedId = await createDelivery('FAILED');
const deliveredId = await createDelivery('DELIVERED');
const templateId = await createTemplateDelivery();
const response = await request(server())
.get('/api/v1/system/notification-deliveries?status=FAILED')
.set(auth(adminToken))
.expect(200);
const body = response.body as DeliveriesBody;
const listed = body.deliveries.find((d) => d.id === failedId);
expect(listed).toMatchObject({
status: 'FAILED',
category: 'TASK_DUE',
attempts: 8,
lastStatusCode: 502,
});
expect(body.deliveries.some((d) => d.id === deliveredId)).toBe(false);
expect(body.deliveries.find((d) => d.id === templateId)).toMatchObject({
category: null,
templateCode: 'HELP',
});
expect(typeof body.telegram.botNameSet).toBe('boolean');
expect(body.telegram.sender.failed24h).toBeGreaterThanOrEqual(1);
const raw = JSON.stringify(body);
expect(raw).not.toContain(CHAT_ID.toString());
expect(raw).not.toContain('private reminder text');
expect(listed).not.toHaveProperty('chatId');
await request(server())
.get('/api/v1/system/notification-deliveries?status=NOPE')
.set(auth(adminToken))
.expect(400);
await request(server())
.post(`/api/v1/system/notification-deliveries/${failedId}/retry`)
.set(auth(adminToken))
.expect(204);
const reset = await prisma.notificationDelivery.findUniqueOrThrow({
where: { id: failedId },
});
expect(reset).toMatchObject({
status: 'PENDING',
attempts: 0,
lastError: null,
lastStatusCode: null,
claimedAt: null,
});
expect(reset.availableAt.getTime()).toBeLessThanOrEqual(
Date.now() + 5_000,
);
await expect(
prisma.auditLog.count({
where: { entityType: 'NotificationDelivery', entityId: failedId },
}),
).resolves.toBe(1);
// PENDING is not FAILED any more, a DELIVERED one never was.
await request(server())
.post(`/api/v1/system/notification-deliveries/${failedId}/retry`)
.set(auth(adminToken))
.expect(409);
await request(server())
.post(`/api/v1/system/notification-deliveries/${deliveredId}/retry`)
.set(auth(adminToken))
.expect(409);
await request(server())
.post('/api/v1/system/notification-deliveries/does-not-exist/retry')
.set(auth(adminToken))
.expect(404);
});
it('retry-failed requeues the FAILED entries and reports how many', async () => {
const own = [
await createDelivery('FAILED'),
await createDelivery('FAILED'),
];
// Other files share this DB: remember their FAILED rows to restore them.
const others = await prisma.notificationDelivery.findMany({
where: { status: 'FAILED', id: { notIn: own } },
});
const ownAudits = () =>
prisma.auditLog.count({
where: { entityType: 'NotificationDelivery', entityId: { in: own } },
});
try {
const response = await request(server())
.post('/api/v1/system/notification-deliveries/retry-failed')
.set(auth(adminToken))
.expect(200);
expect(
(response.body as { retried: number }).retried,
).toBeGreaterThanOrEqual(2);
const rows = await prisma.notificationDelivery.findMany({
where: { id: { in: own } },
select: { status: true, attempts: true },
});
expect(rows).toEqual([
{ status: 'PENDING', attempts: 0 },
{ status: 'PENDING', attempts: 0 },
]);
await expect(ownAudits()).resolves.toBe(2);
// A second call leaves the own rows alone: no new audit rows.
await request(server())
.post('/api/v1/system/notification-deliveries/retry-failed')
.set(auth(adminToken))
.expect(200);
await expect(
prisma.notificationDelivery.count({
where: { id: { in: own }, status: 'PENDING' },
}),
).resolves.toBe(2);
await expect(ownAudits()).resolves.toBe(2);
} finally {
for (const row of others) {
await prisma.notificationDelivery.updateMany({
where: { id: row.id },
data: {
status: row.status,
attempts: row.attempts,
availableAt: row.availableAt,
claimedAt: row.claimedAt,
lastStatusCode: row.lastStatusCode,
lastError: row.lastError,
},
});
}
}
});
it('staff without system:manage gets 403', async () => {
await request(server())
.get('/api/v1/system/notification-deliveries')
.set(auth(staffToken))
.expect(403);
await request(server())
.post('/api/v1/system/notification-deliveries/x/retry')
.set(auth(staffToken))
.expect(403);
await request(server())
.post('/api/v1/system/notification-deliveries/retry-failed')
.set(auth(staffToken))
.expect(403);
});
});
}); });
+567
View File
@@ -1,5 +1,7 @@
import { createHash } from 'node:crypto';
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types'; import { App } from 'supertest/types';
import type { NotificationCategory } from '../generated/prisma'; import type { NotificationCategory } from '../generated/prisma';
import { berlinDateTime, berlinToday, dayToDate } from '../src/common/dates'; import { berlinDateTime, berlinToday, dayToDate } from '../src/common/dates';
@@ -11,7 +13,11 @@ import {
} from '../src/notifications/delivery-time'; } from '../src/notifications/delivery-time';
import { NotificationsService } from '../src/notifications/notifications.service'; import { NotificationsService } from '../src/notifications/notifications.service';
import { PrismaService } from '../src/prisma/prisma.service'; import { PrismaService } from '../src/prisma/prisma.service';
import { TelegramClient } from '../src/telegram/telegram-client';
import { TelegramLinkService } from '../src/telegram/telegram-link.service';
import { TelegramSender } from '../src/telegram/telegram-sender'; import { TelegramSender } from '../src/telegram/telegram-sender';
import { TelegramUpdatesService } from '../src/telegram/telegram-updates.service';
import { login } from './utils/api';
import { drainDomainEvents } from './utils/domain-events'; import { drainDomainEvents } from './utils/domain-events';
import { createTelegramTestApp, FakeTelegramClient } from './utils/telegram'; import { createTelegramTestApp, FakeTelegramClient } from './utils/telegram';
@@ -489,4 +495,565 @@ describe('Telegram delivery (e2e)', () => {
).toBeGreaterThanOrEqual(1_000); ).toBeGreaterThanOrEqual(1_000);
}); });
}); });
describe('connect, disconnect and receive', () => {
const PASSWORD = 'SuperSecret123!';
const API = '/api/v1';
const BOT = 'apicore_e2e_bot';
const OFFSET_BASE = 9_000;
const originalUsername = process.env.TELEGRAM_BOT_USERNAME;
let updates: TelegramUpdatesService;
let nextUpdateId = OFFSET_BASE;
let savedPollState: {
updateOffset: bigint;
leaseOwner: string | null;
leaseUntil: Date | null;
};
const emailOf = (label: string) =>
`telegram-e2e-${label}-${suffix}@example.com`;
async function tokenOf(label: string): Promise<string> {
return (await login(app, emailOf(label), PASSWORD)).accessToken;
}
function requestLinkToken(
accessToken: string,
body: object = { password: PASSWORD },
) {
return request(app.getHttpServer())
.post(`${API}/profile/telegram/link-token`)
.set('Authorization', `Bearer ${accessToken}`)
.send(body);
}
/**
* A link token for the user, made by the service (the route's budget of
* 10 per minute is kept for the tests of the route itself).
*/
async function newLinkToken(userId: string): Promise<string> {
const { deepLink } = await app
.get(TelegramLinkService)
.createLinkToken(userId);
return deepLink.slice(`https://t.me/${BOT}?start=`.length);
}
function say(
chatId: bigint,
text: string,
options: { chatType?: string; firstName?: string } = {},
): void {
telegram.queueUpdate({
updateId: BigInt(nextUpdateId++),
chatId,
chatType: options.chatType ?? 'private',
text,
firstName: options.firstName ?? 'Anna',
});
}
/** Fresh poll state: the offset where the next update id starts, no lease. */
async function resetPollState(): Promise<void> {
await prisma.telegramPollState.update({
where: { id: 'singleton' },
data: {
updateOffset: BigInt(nextUpdateId),
leaseOwner: null,
leaseUntil: null,
},
});
}
const repliesTo = async (chatId: bigint) =>
(
await prisma.notificationDelivery.findMany({
where: { chatId },
orderBy: { createdAt: 'asc' },
})
).map((row) => row.templateCode);
const poll = () => updates.pollOnce(NEVER_ABORTED);
/** A user without Telegram link and with Telegram switched off. */
async function createUnlinkedUser(label: string) {
const user = await createUser(label, [], {}, false);
await prisma.notificationPreference.update({
where: { userId: user.id },
data: { telegramEnabled: false },
});
return user;
}
beforeAll(async () => {
process.env.TELEGRAM_BOT_USERNAME = BOT;
updates = app.get(TelegramUpdatesService);
savedPollState = await prisma.telegramPollState.findUniqueOrThrow({
where: { id: 'singleton' },
select: { updateOffset: true, leaseOwner: true, leaseUntil: true },
});
});
afterAll(async () => {
if (originalUsername === undefined) {
delete process.env.TELEGRAM_BOT_USERNAME;
} else {
process.env.TELEGRAM_BOT_USERNAME = originalUsername;
}
await prisma.telegramPollState.update({
where: { id: 'singleton' },
data: { ...savedPollState, lastError: null },
});
await prisma.notificationDelivery.deleteMany({
where: { chatId: { gte: chatBase, lte: chatBase + 10_000n } },
});
// Logins of this block leave refresh tokens behind.
await prisma.refreshToken.deleteMany({
where: { userId: { in: userIds } },
});
});
beforeEach(async () => {
await resetPollState();
telegram.sent.length = 0;
});
it('link token -> /start links the chat once; a second /start with the same token answers LINK_INVALID', async () => {
const user = await createUnlinkedUser('connect');
const accessToken = await tokenOf('connect');
const response = await requestLinkToken(accessToken).expect(201);
// The answer holds a secret: never cached.
expect(response.headers['cache-control']).toBe('no-store');
const { deepLink, expiresAt } = response.body as {
deepLink: string;
expiresAt: string;
};
expect(deepLink).toMatch(
new RegExp(`^https://t\\.me/${BOT}\\?start=[A-Za-z0-9_-]{43}$`),
);
const token = deepLink.slice(`https://t.me/${BOT}?start=`.length);
// Ten minutes from the database clock; only the hash is stored.
const ttlMs = new Date(expiresAt).getTime() - Date.now();
expect(ttlMs).toBeGreaterThan(9 * 60_000);
expect(ttlMs).toBeLessThanOrEqual(10 * 60_000 + CLOCK_TOLERANCE_MS);
const stored = await prisma.telegramLinkToken.findMany({
where: { userId: user.id },
});
expect(stored).toHaveLength(1);
expect(stored[0].tokenHash).toBe(
createHash('sha256').update(token).digest('hex'),
);
expect(stored[0].tokenHash).not.toBe(token);
say(user.chatId, `/start ${token}`, { firstName: 'Anna' });
await expect(poll()).resolves.toBe('polled');
const link = await prisma.telegramLink.findUniqueOrThrow({
where: { userId: user.id },
});
expect(link.chatId).toBe(user.chatId);
expect(link.telegramFirstName).toBe('Anna');
const preference = await prisma.notificationPreference.findUniqueOrThrow({
where: { userId: user.id },
});
expect(preference.telegramEnabled).toBe(true);
const notice = await prisma.notification.findFirstOrThrow({
where: { userId: user.id, category: 'SYSTEM' },
});
expect(notice.message).toMatch(
/^Telegram verbunden mit „Anna“ am \d{2}\.\d{2}\.\d{4}\. Warst du das nicht\? In den Einstellungen trennen\.$/,
);
expect(notice.link).toBe('/profile');
expect(await repliesTo(user.chatId)).toEqual(['LINKED']);
const state = await prisma.telegramPollState.findUniqueOrThrow({
where: { id: 'singleton' },
});
expect(state.updateOffset).toBe(BigInt(nextUpdateId));
expect(state.lastPollAt).not.toBeNull();
// The same token again - from the same or another chat - is spent.
const stranger = chatBase + 9_001n;
say(user.chatId, `/start ${token}`);
say(stranger, `/start ${token}`);
await poll();
expect(await repliesTo(user.chatId)).toEqual(['LINKED', 'LINK_INVALID']);
expect(await repliesTo(stranger)).toEqual(['LINK_INVALID']);
expect(
await prisma.telegramLink.count({ where: { chatId: stranger } }),
).toBe(0);
});
it('a new link token makes the older open one invalid, and a chat that moves unlinks the previous user', async () => {
const first = await createUnlinkedUser('move-a');
const second = await createUnlinkedUser('move-b');
const old = await newLinkToken(first.id);
const fresh = await newLinkToken(first.id);
const other = await newLinkToken(second.id);
say(first.chatId, `/start ${old}`);
say(first.chatId, `/start ${fresh}`);
await poll();
expect(await repliesTo(first.chatId)).toEqual(['LINK_INVALID', 'LINKED']);
// User B connects from the chat of user A.
say(first.chatId, `/start ${other}`);
await poll();
const links = await prisma.telegramLink.findMany({
where: { userId: { in: [first.id, second.id] } },
});
expect(links).toHaveLength(1);
expect(links[0].userId).toBe(second.id);
expect(links[0].chatId).toBe(first.chatId);
const previous = await prisma.notificationPreference.findUniqueOrThrow({
where: { userId: first.id },
});
expect(previous.telegramEnabled).toBe(false);
const notice = await prisma.notification.findFirstOrThrow({
where: { userId: first.id, message: { contains: 'getrennt' } },
});
expect(notice.message).toBe(
'Deine Telegram-Verbindung wurde getrennt, weil der Chat mit einem anderen Konto verbunden wurde.',
);
});
it('link-token answers 409 when the bot username is not set', async () => {
await createUnlinkedUser('nobot');
const accessToken = await tokenOf('nobot');
process.env.TELEGRAM_BOT_USERNAME = '';
try {
const response = await requestLinkToken(accessToken).expect(409);
expect((response.body as { message: string }).message).toBe(
'Telegram ist nicht eingerichtet.',
);
} finally {
process.env.TELEGRAM_BOT_USERNAME = BOT;
}
});
it('only the lease holder polls; a second worker waits', async () => {
const links = app.get(TelegramLinkService);
const workerA = new TelegramUpdatesService(
prisma,
telegram as unknown as TelegramClient,
links,
);
const workerB = new TelegramUpdatesService(
prisma,
telegram as unknown as TelegramClient,
links,
);
telegram.polledOffsets.length = 0;
await expect(workerA.pollOnce(NEVER_ABORTED)).resolves.toBe('polled');
await expect(workerB.pollOnce(NEVER_ABORTED)).resolves.toBe('no-lease');
expect(telegram.polledOffsets).toHaveLength(1);
// Every round extends the holder's lease.
await expect(workerA.pollOnce(NEVER_ABORTED)).resolves.toBe('polled');
await expect(workerB.pollOnce(NEVER_ABORTED)).resolves.toBe('no-lease');
// An expired lease (database clock) passes to the next one.
await prisma.$executeRaw`UPDATE "TelegramPollState" SET "leaseUntil" = now() - interval '1 second' WHERE "id" = 'singleton'`;
await expect(workerB.pollOnce(NEVER_ABORTED)).resolves.toBe('polled');
await expect(workerA.pollOnce(NEVER_ABORTED)).resolves.toBe('no-lease');
});
it('a lease lost during the cycle rolls the update back and the next holder handles it once', async () => {
const user = await createUnlinkedUser('lostlease');
const token = await newLinkToken(user.id);
say(user.chatId, `/start ${token}`);
// Another receiver takes over right after the updates were fetched.
telegram.onGetUpdates = async () => {
await prisma.telegramPollState.update({
where: { id: 'singleton' },
data: { leaseOwner: 'someone-else' },
});
};
try {
await expect(poll()).resolves.toBe('no-lease');
} finally {
telegram.onGetUpdates = null;
}
expect(
await prisma.telegramLink.count({ where: { userId: user.id } }),
).toBe(0);
expect(await repliesTo(user.chatId)).toEqual([]);
const open = await prisma.telegramLinkToken.findFirstOrThrow({
where: { userId: user.id },
});
expect(open.consumedAt).toBeNull();
const state = await prisma.telegramPollState.findUniqueOrThrow({
where: { id: 'singleton' },
});
expect(state.updateOffset).toBe(BigInt(nextUpdateId - 1));
// The other lease ran out: the update is handled, exactly once.
await prisma.$executeRaw`UPDATE "TelegramPollState" SET "leaseUntil" = now() - interval '1 second' WHERE "id" = 'singleton'`;
await expect(poll()).resolves.toBe('polled');
await expect(poll()).resolves.toBe('polled');
expect(await repliesTo(user.chatId)).toEqual(['LINKED']);
});
it('/stop in the chat unlinks, and groups are ignored', async () => {
const user = await createUser('stopchat', []);
const group = chatBase + 9_100n;
say(group, '/stop', { chatType: 'group' });
say(group, '/start abc', { chatType: 'supergroup' });
say(user.chatId, '/stop');
await poll();
expect(
await prisma.telegramLink.count({ where: { userId: user.id } }),
).toBe(0);
const preference = await prisma.notificationPreference.findUniqueOrThrow({
where: { userId: user.id },
});
expect(preference.telegramEnabled).toBe(false);
expect(await repliesTo(user.chatId)).toEqual(['STOPPED']);
expect(await repliesTo(group)).toEqual([]);
});
it('unlink sends STOPPED and switches Telegram off', async () => {
const user = await createUser('unlink', []);
const accessToken = await tokenOf('unlink');
const view = await request(app.getHttpServer())
.get(`${API}/profile/notification-settings`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
expect((view.body as { telegram: object }).telegram).toMatchObject({
botNameSet: true,
botUsername: BOT,
linked: true,
firstName: 'unlink',
enabled: true,
});
expect(JSON.stringify(view.body)).not.toContain(String(user.chatId));
// A message still waiting for the old chat must never arrive later.
const waiting = await queue(user.id, { message: 'wartet noch' });
await request(app.getHttpServer())
.post(`${API}/profile/telegram/unlink`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(204);
expect(
await prisma.telegramLink.count({ where: { userId: user.id } }),
).toBe(0);
const dropped = await prisma.notificationDelivery.findUniqueOrThrow({
where: { id: waiting.deliveryId },
});
expect(dropped.status).toBe('SKIPPED');
expect(dropped.lastError).toBe('unlinked');
await drainFor(user.id);
expect(telegram.sentTo(user.chatId)).toHaveLength(0);
const preference = await prisma.notificationPreference.findUniqueOrThrow({
where: { userId: user.id },
});
expect(preference.telegramEnabled).toBe(false);
expect(await repliesTo(user.chatId)).toEqual(['STOPPED']);
// Unlinking again changes nothing.
await request(app.getHttpServer())
.post(`${API}/profile/telegram/unlink`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(204);
expect(await repliesTo(user.chatId)).toEqual(['STOPPED']);
await request(app.getHttpServer())
.post(`${API}/profile/telegram/unlink`)
.expect(401);
});
it('the same token claimed at once by two different chats links exactly one', async () => {
const user = await createUnlinkedUser('race-chats');
const token = await newLinkToken(user.id);
const links = app.get(TelegramLinkService);
const chatA = chatBase + 9_200n;
const chatB = chatBase + 9_201n;
const results = await Promise.all(
[chatA, chatB].map((chat) =>
prisma.$transaction((tx) =>
links.linkFromStartIn(tx, token, chat, 'Racer'),
),
),
);
expect([...results].sort()).toEqual(['LINKED', 'LINK_INVALID']);
const stored = await prisma.telegramLink.findMany({
where: { chatId: { in: [chatA, chatB] } },
});
expect(stored).toHaveLength(1);
expect(stored[0].userId).toBe(user.id);
});
it('two /start with the same token and chat in separate transactions: exactly one LINKED', async () => {
const user = await createUnlinkedUser('race-same');
const token = await newLinkToken(user.id);
const links = app.get(TelegramLinkService);
const results = await Promise.all(
[1, 2].map(() =>
prisma.$transaction((tx) =>
links.linkFromStartIn(tx, token, user.chatId, 'Racer'),
),
),
);
expect([...results].sort()).toEqual(['LINKED', 'LINK_INVALID']);
expect(
await prisma.telegramLink.count({ where: { userId: user.id } }),
).toBe(1);
expect(
await prisma.notification.count({
where: {
userId: user.id,
message: { startsWith: 'Telegram verbunden' },
},
}),
).toBe(1);
});
it('an expired token answers LINK_INVALID and links nothing', async () => {
const user = await createUnlinkedUser('expired');
const token = await newLinkToken(user.id);
await prisma.$executeRaw`UPDATE "TelegramLinkToken" SET "expiresAt" = now() - interval '1 minute' WHERE "userId" = ${user.id}`;
say(user.chatId, `/start ${token}`);
await poll();
expect(await repliesTo(user.chatId)).toEqual(['LINK_INVALID']);
expect(
await prisma.telegramLink.count({ where: { userId: user.id } }),
).toBe(0);
const row = await prisma.telegramLinkToken.findFirstOrThrow({
where: { userId: user.id },
});
expect(row.consumedAt).toBeNull();
});
it('a poison update is skipped after the attempt limit and the queue moves on', async () => {
const user = await createUser('poison', []);
const other = chatBase + 9_300n;
const links = app.get(TelegramLinkService);
const stop = jest
.spyOn(links, 'stopIn')
.mockRejectedValue(new Error('boom'));
// A receiver with an injected limit of two attempts.
const strict = new TelegramUpdatesService(
prisma,
telegram as unknown as TelegramClient,
links,
{ attempts: 2 },
);
const first = nextUpdateId;
say(user.chatId, '/stop');
say(other, 'hallo');
try {
await expect(strict.pollOnce(NEVER_ABORTED)).resolves.toBe('error');
const failing = await prisma.telegramPollState.findUniqueOrThrow({
where: { id: 'singleton' },
});
expect(failing.updateOffset).toBe(BigInt(first));
expect(failing.lastError).toBe('update not handled: Error');
await expect(strict.pollOnce(NEVER_ABORTED)).resolves.toBe('polled');
} finally {
stop.mockRestore();
}
const state = await prisma.telegramPollState.findUniqueOrThrow({
where: { id: 'singleton' },
});
expect(state.updateOffset).toBe(BigInt(nextUpdateId));
expect(state.lastError).toBe('Telegram-Nachricht übersprungen');
// The skipped /stop did nothing; the update behind it was answered.
expect(
await prisma.telegramLink.count({ where: { userId: user.id } }),
).toBe(1);
expect(await repliesTo(other)).toEqual(['HELP']);
// A clean batch afterwards clears the notice.
await strict.pollOnce(NEVER_ABORTED);
const cleared = await prisma.telegramPollState.findUniqueOrThrow({
where: { id: 'singleton' },
});
expect(cleared.lastError).toBeNull();
});
it('releasing the lease at shutdown lets the next receiver in at once', async () => {
const links = app.get(TelegramLinkService);
const client = telegram as unknown as TelegramClient;
const first = new TelegramUpdatesService(prisma, client, links);
const second = new TelegramUpdatesService(prisma, client, links);
await expect(first.pollOnce(NEVER_ABORTED)).resolves.toBe('polled');
await expect(second.pollOnce(NEVER_ABORTED)).resolves.toBe('no-lease');
await first.releaseLease();
await expect(second.pollOnce(NEVER_ABORTED)).resolves.toBe('polled');
});
it('an old backlog never reaches a chat linked later', async () => {
const user = await createUnlinkedUser('backlog');
const waiting = await queue(user.id, { message: 'alt' });
await prisma.telegramLink.create({
data: { userId: user.id, chatId: user.chatId, telegramFirstName: 'x' },
});
await prisma.notificationPreference.update({
where: { userId: user.id },
data: { telegramEnabled: true },
});
const token = await newLinkToken(user.id);
const newChat = chatBase + 9_400n;
say(newChat, `/start ${token}`);
await poll();
expect((await deliveryOf(waiting.deliveryId)).status).toBe('SKIPPED');
await drainFor(user.id);
expect(telegram.sentTo(newChat)).toHaveLength(0);
expect(telegram.sentTo(user.chatId)).toHaveLength(0);
});
// Last on purpose: it uses up the 10-per-minute budget of the route.
it('link-token requires step-up and is throttled', async () => {
await createUnlinkedUser('stepup');
const accessToken = await tokenOf('stepup');
await request(app.getHttpServer())
.post(`${API}/profile/telegram/link-token`)
.send({ password: PASSWORD })
.expect(401);
// A missing password is a malformed request.
await requestLinkToken(accessToken, {}).expect(400);
await requestLinkToken(accessToken, {
password: 'wrong-password',
}).expect(401);
expect(
await prisma.telegramLinkToken.count({
where: { user: { email: emailOf('stepup') } },
}),
).toBe(0);
// 10 per minute: the 429 arrives within 11 attempts, after 401s only.
const statuses: number[] = [];
for (
let attempt = 0;
attempt < 11 && !statuses.includes(429);
attempt++
) {
const { status } = await requestLinkToken(accessToken, {
password: 'wrong-password',
});
statuses.push(status);
}
expect(statuses.at(-1)).toBe(429);
expect(statuses.slice(0, -1).every((status) => status === 401)).toBe(
true,
);
});
});
}); });
+39 -1
View File
@@ -7,7 +7,11 @@ import { AppModule } from '../../src/app.module';
import { configureApp } from '../../src/app.config'; import { configureApp } from '../../src/app.config';
import { THROTTLER_NAMESPACE } from '../../src/common/throttler/prisma-throttler.storage'; import { THROTTLER_NAMESPACE } from '../../src/common/throttler/prisma-throttler.storage';
import { TelegramApiError } from '../../src/telegram/telegram-api-error'; import { TelegramApiError } from '../../src/telegram/telegram-api-error';
import { TelegramClient } from '../../src/telegram/telegram-client'; import {
TelegramClient,
type TelegramUpdate,
type TelegramUpdateBatch,
} from '../../src/telegram/telegram-client';
import { TelegramModule } from '../../src/telegram/telegram.module'; import { TelegramModule } from '../../src/telegram/telegram.module';
export interface SentMessage { export interface SentMessage {
@@ -23,6 +27,12 @@ export interface SentMessage {
export class FakeTelegramClient { export class FakeTelegramClient {
readonly sent: SentMessage[] = []; readonly sent: SentMessage[] = [];
private readonly failures: { chatId: bigint | null; error: Error }[] = []; private readonly failures: { chatId: bigint | null; error: Error }[] = [];
private readonly updates: TelegramUpdate[] = [];
/** Every offset getUpdates was asked for, in order. */
readonly polledOffsets: bigint[] = [];
/** Runs inside getUpdates, after the updates are chosen (e.g. steal the lease). */
onGetUpdates: (() => Promise<void>) | null = null;
private getUpdatesError: Error | null = null;
isConfigured(): boolean { isConfigured(): boolean {
return true; return true;
@@ -47,6 +57,34 @@ export class FakeTelegramClient {
return Promise.resolve(); return Promise.resolve();
} }
/** What the next getUpdates calls return (from `offset` on, like Telegram). */
queueUpdate(update: TelegramUpdate): void {
this.updates.push(update);
}
failNextGetUpdates(error: Error): void {
this.getUpdatesError = error;
}
async getUpdates(offset: bigint): Promise<TelegramUpdateBatch> {
this.polledOffsets.push(offset);
if (this.getUpdatesError !== null) {
const error = this.getUpdatesError;
this.getUpdatesError = null;
throw error;
}
const due = this.updates.filter((update) => update.updateId >= offset);
await this.onGetUpdates?.();
const highestUpdateId = due.reduce<bigint | null>(
(highest, update) =>
highest === null || update.updateId > highest
? update.updateId
: highest,
null,
);
return { updates: due, highestUpdateId, received: due.length };
}
sentTo(chatId: bigint): SentMessage[] { sentTo(chatId: bigint): SentMessage[] {
return this.sent.filter((message) => message.chatId === chatId); return this.sent.filter((message) => message.chatId === chatId);
} }
@@ -308,6 +308,7 @@ describe('Withdrawal chain (e2e)', () => {
batchNumber: `WD-HC-${suffix}`, batchNumber: `WD-HC-${suffix}`,
extractionId: extraction.id, extractionId: extraction.id,
amount: 20, amount: 20,
waterContent: 17,
}, },
}); });
honeyBatchIds.push(honeyBatch.id); honeyBatchIds.push(honeyBatch.id);
+5
View File
@@ -58,6 +58,11 @@
// names (Bestandsbuch), megabytes // names (Bestandsbuch), megabytes
/\/api\/v1\/export\//i, /\/api\/v1\/export\//i,
/\/api\/v1\/backup(\/|$)/i, /\/api\/v1\/backup(\/|$)/i,
// Telegram link and settings (one-time link, chat state), and the System
// page (queue and delivery details): current state or nothing
/\/api\/v1\/profile\/telegram(\/|$)/i,
/\/api\/v1\/profile\/notification-settings(\/|$)/i,
/\/api\/v1\/system(\/|$)/i,
// what the offline packs answer: without network they must fail fast, // what the offline packs answer: without network they must fail fast,
// so the pack - with its age shown - answers instead of an old copy // so the pack - with its age shown - answers instead of an old copy
/\/api\/v1\/colonies\/?$/i, /\/api\/v1\/colonies\/?$/i,
+1 -1
View File
@@ -19,7 +19,7 @@
// Content version of sw-routes.js (sha256 of its LF text, first 12 hex // Content version of sw-routes.js (sha256 of its LF text, first 12 hex
// characters; sw-routes.spec.ts checks it). A changed value changes this // characters; sw-routes.spec.ts checks it). A changed value changes this
// file, so every browser - Safari too - installs the worker with new rules. // file, so every browser - Safari too - installs the worker with new rules.
const SW_ROUTES_VERSION = "3b534be6a6bf"; const SW_ROUTES_VERSION = "6a6f9dcbc99f";
importScripts(`/sw-routes.js?v=${SW_ROUTES_VERSION}`); importScripts(`/sw-routes.js?v=${SW_ROUTES_VERSION}`);
const { const {
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fetchApiary } from "@/lib/apiaries/api";
import ApiaryDetailPage from "./page";
vi.mock("next/navigation", () => ({ useParams: () => ({ id: "a-1" }) }));
vi.mock("@/lib/apiaries/api", () => ({ fetchApiary: vi.fn() }));
vi.mock("@/lib/export/api", () => ({
exportApiaryAnnualReportPdf: vi.fn(),
exportTreatmentLogPdf: vi.fn(),
}));
vi.mock("@/components/qr-code-dialog", () => ({ QrCodeDialog: () => null }));
vi.mock("@/components/document-upload-card", () => ({ DocumentUploadCard: () => null }));
vi.mock("@/components/detail-header", () => ({
DetailHeader: ({ title }: { title: string }) => <h1>{title}</h1>,
DetailQueryFallback: () => <p>Lädt</p>,
DetailStatus: () => null,
}));
vi.mock("./apiary-colonies-card", () => ({
ApiaryColoniesCard: () => null,
StartRoundButton: () => null,
}));
vi.mock("./apiary-edit-card", () => ({ ApiaryEditCard: () => null }));
vi.mock("./weather-card", () => ({ WeatherCard: () => null }));
vi.mock("@/components/phenology/apiary-season", () => ({
ApiarySeason: ({ apiary }: { apiary: { id: string; name: string } }) => (
<p>Saison von {apiary.name}</p>
),
}));
describe("ApiaryDetailPage", () => {
it("shows the season of the apiary below the header", async () => {
vi.mocked(fetchApiary).mockResolvedValue({
id: "a-1",
name: "Obstwiese",
location: null,
status: "ACTIVE",
latitude: null,
longitude: null,
} as never);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<ApiaryDetailPage />
</QueryClientProvider>,
);
expect(await screen.findByText("Saison von Obstwiese")).toBeInTheDocument();
});
});
@@ -8,6 +8,7 @@ import { fetchApiary } from "@/lib/apiaries/api";
import { exportApiaryAnnualReportPdf, exportTreatmentLogPdf } from "@/lib/export/api"; import { exportApiaryAnnualReportPdf, exportTreatmentLogPdf } from "@/lib/export/api";
import { QrCodeDialog } from "@/components/qr-code-dialog"; import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card"; import { DocumentUploadCard } from "@/components/document-upload-card";
import { ApiarySeason } from "@/components/phenology/apiary-season";
import { DetailHeader, DetailQueryFallback, DetailStatus } from "@/components/detail-header"; import { DetailHeader, DetailQueryFallback, DetailStatus } from "@/components/detail-header";
import { ApiaryColoniesCard, StartRoundButton } from "./apiary-colonies-card"; import { ApiaryColoniesCard, StartRoundButton } from "./apiary-colonies-card";
import { ApiaryEditCard } from "./apiary-edit-card"; import { ApiaryEditCard } from "./apiary-edit-card";
@@ -76,6 +77,7 @@ export default function ApiaryDetailPage() {
]} ]}
{...BACK} {...BACK}
/> />
<ApiarySeason apiary={{ id: apiary.id, name: apiary.name }} />
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2"> <div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<ApiaryColoniesCard apiary={{ id: apiary.id, name: apiary.name }} /> <ApiaryColoniesCard apiary={{ id: apiary.id, name: apiary.name }} />
@@ -1,9 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import NewBottlingBatchPage from "./page"; import NewBottlingBatchPage from "./page";
import { createBottlingBatch } from "@/lib/bottling/api"; import { WaterContentConfirmDialog } from "./water-content-confirm-dialog";
import { createBottlingBatch, fetchBottlingPrefill } from "@/lib/bottling/api";
import { ApiError } from "@/lib/auth/api-client";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import { fetchHoneyBatches, fetchHoneyBatchTraceability } from "@/lib/honey-batches/api"; import { fetchHoneyBatches, fetchHoneyBatchTraceability } from "@/lib/honey-batches/api";
import { fetchWithdrawalStatus } from "@/lib/treatments/api"; import { fetchWithdrawalStatus } from "@/lib/treatments/api";
import { fetchProducts } from "@/lib/products/api"; import { fetchProducts } from "@/lib/products/api";
@@ -20,11 +23,41 @@ vi.mock("@/lib/honey-batches/api", () => ({
})); }));
vi.mock("@/lib/treatments/api", () => ({ fetchWithdrawalStatus: vi.fn() })); vi.mock("@/lib/treatments/api", () => ({ fetchWithdrawalStatus: vi.fn() }));
vi.mock("@/lib/products/api", () => ({ fetchProducts: vi.fn() })); vi.mock("@/lib/products/api", () => ({ fetchProducts: vi.fn() }));
vi.mock("@/lib/bottling/api", () => ({ createBottlingBatch: vi.fn() })); vi.mock("@/lib/bottling/api", () => ({
createBottlingBatch: vi.fn(),
fetchBottlingPrefill: vi.fn(),
}));
vi.mock("sonner", () => ({ vi.mock("sonner", () => ({
toast: { success: vi.fn(), warning: vi.fn(), info: vi.fn(), error: vi.fn() }, toast: { success: vi.fn(), warning: vi.fn(), info: vi.fn(), error: vi.fn() },
})); }));
const EMPTY_PREFILL = {
jarSize: null,
jarSizeSource: null,
bestBeforeDate: null,
shelfLifeMonths: null,
waterContent: null,
warnings: [],
};
const PRODUCT_PREFILL = {
...EMPTY_PREFILL,
jarSize: 250,
jarSizeSource: "PRODUCT" as const,
bestBeforeDate: "2028-10-14",
shelfLifeMonths: 24,
};
function confirmationRequired(waterContent: number) {
return new ApiError(409, "Wassergehalt zu hoch.", {
statusCode: 409,
code: "CONFIRMATION_REQUIRED",
message: "Wassergehalt zu hoch.",
waterContent,
warnings: [],
});
}
function renderPage() { function renderPage() {
const client = new QueryClient({ const client = new QueryClient({
defaultOptions: { queries: { retry: false } }, defaultOptions: { queries: { retry: false } },
@@ -47,6 +80,8 @@ describe("NewBottlingBatchPage", () => {
vi.mocked(fetchHoneyBatchTraceability).mockResolvedValue({ harvests: [] } as never); vi.mocked(fetchHoneyBatchTraceability).mockResolvedValue({ harvests: [] } as never);
vi.mocked(fetchWithdrawalStatus).mockResolvedValue([]); vi.mocked(fetchWithdrawalStatus).mockResolvedValue([]);
vi.mocked(createBottlingBatch).mockReset(); vi.mocked(createBottlingBatch).mockReset();
vi.mocked(fetchBottlingPrefill).mockReset();
vi.mocked(fetchBottlingPrefill).mockResolvedValue(EMPTY_PREFILL);
vi.mocked(toast.success).mockReset(); vi.mocked(toast.success).mockReset();
vi.mocked(toast.warning).mockReset(); vi.mocked(toast.warning).mockReset();
}); });
@@ -162,4 +197,425 @@ describe("NewBottlingBatchPage", () => {
renderPage(); renderPage();
expect(await screen.findByText("Wartezeit konnte nicht geprüft werden")).toBeInTheDocument(); expect(await screen.findByText("Wartezeit konnte nicht geprüft werden")).toBeInTheDocument();
}); });
describe("values from the product", () => {
beforeEach(() => {
vi.mocked(fetchProducts).mockResolvedValue([
{ id: "p-1", name: "Frühtracht 250 g", kind: "BOTTLED_HONEY" },
{ id: "p-2", name: "Sommer 125 g", kind: "BOTTLED_HONEY" },
] as never);
vi.mocked(fetchBottlingPrefill).mockImplementation(async (query) => {
if (query.productId === "p-1") return PRODUCT_PREFILL;
if (query.productId === "p-2") {
return { ...PRODUCT_PREFILL, jarSize: 125, bestBeforeDate: "2028-01-01", shelfLifeMonths: 12 };
}
return EMPTY_PREFILL;
});
});
async function chooseProduct(name = "Frühtracht 250 g") {
await userEvent.click(screen.getByRole("combobox", { name: /Produkt/ }));
await userEvent.click(await screen.findByRole("option", { name }));
}
it("a product change after a manual edit brings the new product's values and source", async () => {
renderPage();
await chooseProduct();
await waitFor(() => expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(250));
const jarSize = screen.getByLabelText(/Glasgröße/);
await userEvent.clear(jarSize);
await userEvent.type(jarSize, "500");
await chooseProduct("Sommer 125 g");
await waitFor(() => expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(125));
expect(screen.getByLabelText(/Mindesthaltbarkeitsdatum/)).toHaveValue("2028-01-01");
expect(screen.getByText(/aus Artikel: 125 g/)).toBeInTheDocument();
expect(screen.getByText(/aus Artikel: 12 Monate/)).toBeInTheDocument();
});
it("does not label values of the previous day as article values while the new ones load", async () => {
renderPage();
await chooseProduct();
await waitFor(() => expect(screen.getByText(/aus Artikel: 250 g/)).toBeInTheDocument());
vi.mocked(fetchBottlingPrefill).mockReturnValue(new Promise(() => undefined));
fireEvent.change(screen.getByLabelText(/^Datum/), { target: { value: "2026-11-01" } });
await screen.findByText("Artikelwerte werden geladen …");
expect(screen.queryByText(/aus Artikel/)).not.toBeInTheDocument();
expect(screen.queryByText(/weicht vom Artikel ab/)).not.toBeInTheDocument();
});
it("hints when the entered best before differs from the product", async () => {
renderPage();
await chooseProduct();
await waitFor(() =>
expect(screen.getByLabelText(/Mindesthaltbarkeitsdatum/)).toHaveValue("2028-10-14"),
);
fireEvent.change(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), {
target: { value: "2029-01-01" },
});
expect(
screen.getByText("MHD weicht vom Artikel ab (Artikel: 14.10.2028)."),
).toBeInTheDocument();
});
it("hints when the entered jar size differs from the product", async () => {
renderPage();
await chooseProduct();
await waitFor(() => expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(250));
const jarSize = screen.getByLabelText(/Glasgröße/);
await userEvent.clear(jarSize);
await userEvent.type(jarSize, "500");
expect(
screen.getByText("Glasgröße weicht vom Artikel ab (Artikel: 250 g)."),
).toBeInTheDocument();
expect(screen.getByText("Hinweis")).toBeInTheDocument();
});
it("fills jar size and best before from the product and names the source", async () => {
renderPage();
await chooseProduct();
await waitFor(() => expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(250));
expect(screen.getByLabelText(/Mindesthaltbarkeitsdatum/)).toHaveValue("2028-10-14");
expect(screen.getByText(/aus Artikel: 24 Monate/)).toBeInTheDocument();
expect(screen.getByText(/aus Artikel: 250 g/)).toBeInTheDocument();
expect(fetchBottlingPrefill).toHaveBeenCalledWith(
expect.objectContaining({ honeyBatchId: "hb-1", productId: "p-1" }),
);
});
it("keeps a manual edit when the prefill is fetched again", async () => {
renderPage();
await chooseProduct();
await waitFor(() => expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(250));
const jarSize = screen.getByLabelText(/Glasgröße/);
await userEvent.clear(jarSize);
await userEvent.type(jarSize, "500");
fireEvent.change(screen.getByLabelText(/^Datum/), { target: { value: "2026-11-01" } });
await waitFor(() =>
expect(fetchBottlingPrefill).toHaveBeenLastCalledWith(
expect.objectContaining({ date: "2026-11-01" }),
),
);
expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(500);
expect(screen.queryByText(/aus Artikel: 250 g/)).not.toBeInTheDocument();
});
it("shows the measured water content and its notes as information", async () => {
vi.mocked(fetchBottlingPrefill).mockResolvedValue({
...EMPTY_PREFILL,
waterContent: 17.5,
warnings: [
{
code: "JAR_SIZE_DIFFERS",
severity: "HINWEIS",
message: "Die Glasgröße weicht vom Artikel ab.",
refs: [],
},
],
});
renderPage();
expect(await screen.findByText(/Wassergehalt der Charge: 17,5 %/)).toBeInTheDocument();
expect(screen.getByText(/Die Glasgröße weicht vom Artikel ab\./)).toBeInTheDocument();
expect(screen.getByText("Hinweis")).toBeInTheDocument();
});
});
describe("confirmation above the legal water content", () => {
async function fillAndSubmit() {
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24");
await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01");
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
}
it("a 409 opens the confirmation and the confirmed retry sends acknowledgedWarnings with the same key", async () => {
vi.mocked(createBottlingBatch)
.mockRejectedValueOnce(confirmationRequired(20.5))
.mockResolvedValueOnce({ id: "b-1" } as never);
renderPage();
await fillAndSubmit();
const dialog = await screen.findByRole("dialog");
expect(dialog).toHaveTextContent(
"Wassergehalt 20,5 % über der gesetzlichen Grenze. Trotzdem abfüllen?",
);
await userEvent.click(screen.getByRole("button", { name: "Trotzdem abfüllen" }));
await waitFor(() => expect(createBottlingBatch).toHaveBeenCalledTimes(2));
const [first, firstOptions] = vi.mocked(createBottlingBatch).mock.calls[0];
const [second, secondOptions] = vi.mocked(createBottlingBatch).mock.calls[1];
expect(first.acknowledgedWarnings).toBeUndefined();
expect(second).toMatchObject({
honeyBatchId: "hb-1",
jarCount: 24,
acknowledgedWarnings: ["WATER_CONTENT_ABOVE_LEGAL"],
acknowledgedWaterContent: 20.5,
});
expect(firstOptions?.idempotencyKey).toBeTruthy();
expect(secondOptions?.idempotencyKey).toBe(firstOptions?.idempotencyKey);
await waitFor(() => expect(push).toHaveBeenCalledWith("/bottling/b-1"));
});
it("cancel writes nothing, keeps the form and puts the focus back on the button", async () => {
vi.mocked(createBottlingBatch).mockRejectedValueOnce(confirmationRequired(20.5));
renderPage();
await fillAndSubmit();
await screen.findByRole("dialog");
await userEvent.click(screen.getByRole("button", { name: "Abbrechen" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(createBottlingBatch).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText(/Anzahl Gläser/)).toHaveValue(24);
expect(push).not.toHaveBeenCalled();
await waitFor(() =>
expect(screen.getByRole("button", { name: "Abfüllung anlegen" })).toHaveFocus(),
);
});
it("asks again with the new value when the server answers a fresh 409", async () => {
vi.mocked(createBottlingBatch)
.mockRejectedValueOnce(confirmationRequired(20.5))
.mockRejectedValueOnce(confirmationRequired(21))
.mockResolvedValueOnce({ id: "b-1" } as never);
renderPage();
await fillAndSubmit();
await screen.findByRole("dialog");
await userEvent.click(screen.getByRole("button", { name: "Trotzdem abfüllen" }));
await waitFor(() =>
expect(screen.getByRole("dialog")).toHaveTextContent(
"Wassergehalt 21 % über der gesetzlichen Grenze.",
),
);
await userEvent.click(screen.getByRole("button", { name: "Trotzdem abfüllen" }));
await waitFor(() => expect(createBottlingBatch).toHaveBeenCalledTimes(3));
expect(vi.mocked(createBottlingBatch).mock.calls[2][0].acknowledgedWaterContent).toBe(21);
});
});
it("shows a server message per field in German", async () => {
vi.mocked(createBottlingBatch).mockRejectedValue(
new ApiError(400, "Bad Request", {
issues: [{ field: "date", message: "Das Datum ist ungültig." }],
}),
);
renderPage();
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24");
await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01");
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
expect(await screen.findByText("Das Datum ist ungültig.")).toBeInTheDocument();
});
it("is off offline and says why", () => {
reportRequestOutcome("network-error");
try {
renderPage();
const button = screen.getByRole("button", { name: "Abfüllung anlegen" });
expect(button).toBeDisabled();
expect(screen.getByText("Nur mit Verbindung")).toBeInTheDocument();
expect(button).toHaveAttribute("aria-describedby");
} finally {
reportRequestOutcome("reached-server");
}
});
describe("prefill status", () => {
it("disables the submit with a visible reason while the values load", async () => {
vi.mocked(fetchBottlingPrefill).mockReturnValue(new Promise(() => undefined));
renderPage();
const button = screen.getByRole("button", { name: "Abfüllung anlegen" });
expect(button).toBeDisabled();
const reason = await screen.findByText("Artikelwerte werden geladen …");
expect(button.getAttribute("aria-describedby")).toContain(reason.id);
});
it("the retry button asks the server again and the note goes away", async () => {
vi.mocked(fetchBottlingPrefill).mockRejectedValueOnce(new Error("down"));
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Erneut versuchen" }));
await waitFor(() => expect(fetchBottlingPrefill).toHaveBeenCalledTimes(2));
await waitFor(() =>
expect(screen.queryByRole("button", { name: "Erneut versuchen" })).not.toBeInTheDocument(),
);
});
it("shows one loading line, not two", async () => {
vi.mocked(fetchBottlingPrefill).mockReturnValue(new Promise(() => undefined));
renderPage();
await screen.findByText("Artikelwerte werden geladen …");
expect(screen.queryByText("Hinweise werden geladen …")).not.toBeInTheDocument();
});
it("on a failed prefill keeps the form values and says they are not from the product", async () => {
vi.mocked(fetchBottlingPrefill).mockRejectedValue(new Error("down"));
renderPage();
expect(await screen.findByRole("button", { name: "Erneut versuchen" })).toBeInTheDocument();
expect(screen.getAllByText(/nicht aus Artikel/)).toHaveLength(2);
expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(500);
expect(screen.queryByText(/aus Artikel: 500/)).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Abfüllung anlegen" })).toBeEnabled();
});
});
describe("idempotency key and confirmation edge cases", () => {
it("uses a fresh key after a changed field following an attempted submit, the same key when unchanged", async () => {
vi.mocked(createBottlingBatch)
.mockRejectedValueOnce(new ApiError(500, "Fehler", {}))
.mockRejectedValueOnce(new ApiError(500, "Fehler", {}))
.mockRejectedValueOnce(new ApiError(500, "Fehler", {}));
renderPage();
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24");
await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01");
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
await waitFor(() => expect(createBottlingBatch).toHaveBeenCalledTimes(1));
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
await waitFor(() => expect(createBottlingBatch).toHaveBeenCalledTimes(2));
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "0");
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
await waitFor(() => expect(createBottlingBatch).toHaveBeenCalledTimes(3));
const keys = vi.mocked(createBottlingBatch).mock.calls.map((call) => call[1]?.idempotencyKey);
expect(keys[1]).toBe(keys[0]);
expect(keys[2]).not.toBe(keys[0]);
});
it("a prefill refetch that changes the jar size after a network error leads to a new key", async () => {
vi.mocked(fetchBottlingPrefill).mockRejectedValueOnce(new Error("down"));
vi.mocked(createBottlingBatch).mockRejectedValue(new TypeError("Failed to fetch"));
renderPage();
await screen.findByRole("button", { name: "Erneut versuchen" });
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24");
await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01");
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
await waitFor(() => expect(createBottlingBatch).toHaveBeenCalledTimes(1));
vi.mocked(fetchBottlingPrefill).mockResolvedValue(PRODUCT_PREFILL);
await userEvent.click(screen.getByRole("button", { name: "Erneut versuchen" }));
await waitFor(() => expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(250));
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
await waitFor(() => expect(createBottlingBatch).toHaveBeenCalledTimes(2));
const [first, second] = vi.mocked(createBottlingBatch).mock.calls;
expect(second[1]?.idempotencyKey).not.toBe(first[1]?.idempotencyKey);
});
it("shows the message of a duplicate 409 without a code inside the dialog", async () => {
vi.mocked(createBottlingBatch)
.mockRejectedValueOnce(confirmationRequired(20.5))
.mockRejectedValueOnce(new ApiError(409, "Die Anfrage läuft noch.", {}));
renderPage();
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24");
await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01");
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
const dialog = await screen.findByRole("dialog");
await userEvent.click(screen.getByRole("button", { name: "Trotzdem abfüllen" }));
await waitFor(() => expect(dialog).toHaveTextContent("Die Anfrage läuft noch."));
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
it("a double click on Trotzdem abfüllen sends exactly one request", async () => {
vi.mocked(createBottlingBatch).mockRejectedValueOnce(confirmationRequired(20.5));
vi.mocked(createBottlingBatch).mockReturnValue(new Promise(() => undefined));
renderPage();
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24");
await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01");
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
await screen.findByRole("dialog");
await userEvent.dblClick(screen.getByRole("button", { name: "Trotzdem abfüllen" }));
expect(createBottlingBatch).toHaveBeenCalledTimes(2);
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
it("Tab stays inside the dialog", async () => {
vi.mocked(createBottlingBatch).mockRejectedValueOnce(confirmationRequired(20.5));
renderPage();
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24");
await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01");
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
const dialog = await screen.findByRole("dialog");
const outside = [
screen.getByLabelText(/Anzahl Gläser/),
screen.getByLabelText(/Glasgröße/),
document.querySelector<HTMLElement>("main a"),
];
for (let index = 0; index < 8; index += 1) {
await userEvent.tab();
expect(outside).not.toContain(document.activeElement);
}
expect(dialog).toBeInTheDocument();
});
});
it("keeps the confirmation text while the dialog closes", () => {
const { rerender } = render(
<WaterContentConfirmDialog
waterContent={20.5}
isPending={false}
message={null}
returnFocusTo={{ current: null }}
onConfirm={() => undefined}
onCancel={() => undefined}
/>,
);
expect(screen.getByRole("dialog")).toHaveTextContent("Wassergehalt 20,5 %");
rerender(
<WaterContentConfirmDialog
waterContent={null}
isPending={false}
message={null}
returnFocusTo={{ current: null }}
onConfirm={() => undefined}
onCancel={() => undefined}
/>,
);
const closing = screen.queryByRole("dialog");
if (closing) expect(closing).toHaveTextContent("Wassergehalt 20,5 %");
});
it("restoring a draft keeps the typed flags and marks the values as from the draft", async () => {
window.localStorage.setItem(
"apicore:draft:anonymous:bottling-new",
JSON.stringify({
savedAt: Date.now(),
values: {
honeyBatchId: "hb-1",
productId: "",
date: "2026-10-14",
jarSize: "250",
jarCount: "12",
bestBeforeDate: "2028-01-01",
lotNumber: "",
manual: { jarSize: true, bestBeforeDate: false },
},
}),
);
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Weitermachen" }));
expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(250);
expect(screen.getByText(/aus Entwurf/)).toBeInTheDocument();
expect(screen.getAllByText(/aus Entwurf/)).toHaveLength(1);
});
}); });
@@ -1,16 +1,22 @@
"use client"; "use client";
import { FormEvent, useState } from "react"; import { FormEvent, useId, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { AlertTriangle, Package } from "lucide-react"; import { AlertTriangle, Package } from "lucide-react";
import { fetchHoneyBatches } from "@/lib/honey-batches/api"; import { fetchHoneyBatches } from "@/lib/honey-batches/api";
import { fetchProducts } from "@/lib/products/api"; import { fetchProducts } from "@/lib/products/api";
import { createBottlingBatch } from "@/lib/bottling/api"; import { createBottlingBatch, fetchBottlingPrefill } from "@/lib/bottling/api";
import { WATER_CONTENT_ABOVE_LEGAL, type BottlingPrefill } from "@/lib/bottling/types";
import { ApiError } from "@/lib/auth/api-client";
import type { PlausibilityWarning } from "@/lib/warnings/types";
import { PrefillStatus } from "@/components/warnings/prefill-status";
import { useBottlingWithdrawalHints } from "@/lib/bottling/use-withdrawal-hints"; import { useBottlingWithdrawalHints } from "@/lib/bottling/use-withdrawal-hints";
import { useFormDraft } from "@/lib/forms/use-form-draft"; import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message"; import { confirmationRequired, describeSaveError } from "@/lib/honey-batches/errors";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { toastWarnings } from "@/lib/warnings/toast-warnings"; import { toastWarnings } from "@/lib/warnings/toast-warnings";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -23,6 +29,8 @@ import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section"; import { FormSection } from "@/components/form/form-section";
import { StickySubmit } from "@/components/form/sticky-submit"; import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner"; import { DraftBanner } from "@/components/form/draft-banner";
import { WaterContentConfirmDialog } from "./water-content-confirm-dialog";
import { WaterContentNotes } from "./water-content-notes";
import { import {
BottlingFormValues, BottlingFormValues,
initialBottlingValues, initialBottlingValues,
@@ -67,18 +75,91 @@ function validate(values: BottlingFormValues): FieldErrors {
return errors; return errors;
} }
/** Jar size and best before may come from the product until the person types their own. */
interface ManualEdits {
jarSize: boolean;
bestBeforeDate: boolean;
}
const NO_MANUAL_EDITS: ManualEdits = { jarSize: false, bestBeforeDate: false };
/** What the draft keeps: the values as shown plus which of them the person typed. */
type BottlingDraft = BottlingFormValues & { manual?: ManualEdits };
/** The prefill with the batch and product it was asked for. */
type PrefillResult = BottlingPrefill & { honeyBatchId: string; productId: string; date: string };
function germanDay(day: string): string {
return day.split("-").reverse().join(".");
}
export default function NewBottlingBatchPage() { export default function NewBottlingBatchPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [values, setValues] = useState<BottlingFormValues>(() => const [formValues, setValues] = useState<BottlingFormValues>(() =>
initialBottlingValues(searchParams.get("honeyBatchId") ?? ""), initialBottlingValues(searchParams.get("honeyBatchId") ?? ""),
); );
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({}); const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const draft = useFormDraft("bottling-new", values, dirty); const [manual, setManual] = useState<ManualEdits>(NO_MANUAL_EDITS);
const [fromDraft, setFromDraft] = useState<ManualEdits>(NO_MANUAL_EDITS);
const [confirmation, setConfirmation] = useState<number | null>(null);
const idempotencyKey = useRef(newIdempotencyKey());
// The body of the last request sent (without key and confirmation): a different body gets a new key.
const lastBody = useRef<string | null>(null);
const [dialogMessage, setDialogMessage] = useState<string | null>(null);
const confirmInFlight = useRef(false);
const submitButton = useRef<HTMLButtonElement>(null);
const loadingId = useId();
const { isOffline, hintId, describedBy: offlineDescribedBy } = useOnlineOnly();
const prefillEnabled = Boolean(formValues.honeyBatchId && formValues.date);
const prefillQuery = useQuery<PrefillResult>({
queryKey: ["bottling-prefill", formValues.honeyBatchId, formValues.productId, formValues.date],
queryFn: async () => ({
...(await fetchBottlingPrefill({
honeyBatchId: formValues.honeyBatchId,
productId: formValues.productId || null,
date: formValues.date,
})),
honeyBatchId: formValues.honeyBatchId,
productId: formValues.productId,
date: formValues.date,
}),
enabled: prefillEnabled,
placeholderData: keepPreviousData,
});
// Values of another product (kept while the new ones load) are never shown as the article's.
const fetched = prefillQuery.isError ? undefined : prefillQuery.data;
const sameProduct =
fetched &&
fetched.honeyBatchId === formValues.honeyBatchId &&
fetched.productId === formValues.productId
? fetched
: undefined;
// Labels, hints and notes only for the answer to exactly this question (batch, product, day).
const prefill = sameProduct && sameProduct.date === formValues.date ? sameProduct : undefined;
// Master data of the product fills jar size and best before until the person edits them.
const values = useMemo<BottlingFormValues>(
() => ({
...formValues,
jarSize:
!manual.jarSize && sameProduct?.jarSize != null
? String(sameProduct.jarSize)
: formValues.jarSize,
bestBeforeDate:
!manual.bestBeforeDate && sameProduct?.bestBeforeDate
? sameProduct.bestBeforeDate
: formValues.bestBeforeDate,
}),
[formValues, manual, sameProduct],
);
const draftValues = useMemo<BottlingDraft>(() => ({ ...values, manual }), [values, manual]);
const draft = useFormDraft<BottlingDraft>("bottling-new", draftValues, dirty);
const { data: honeyBatches, isLoading: honeyBatchesLoading } = useQuery({ const { data: honeyBatches, isLoading: honeyBatchesLoading } = useQuery({
queryKey: ["honey-batches"], queryKey: ["honey-batches"],
@@ -98,6 +179,14 @@ export default function NewBottlingBatchPage() {
value: BottlingFormValues[K], value: BottlingFormValues[K],
) { ) {
setValues((current) => ({ ...current, [key]: value })); setValues((current) => ({ ...current, [key]: value }));
if (key === "productId") {
// A new product brings new master data; earlier typing no longer applies.
setManual(NO_MANUAL_EDITS);
setFromDraft(NO_MANUAL_EDITS);
} else if (key === "jarSize" || key === "bestBeforeDate") {
setManual((current) => ({ ...current, [key]: true }));
setFromDraft((current) => ({ ...current, [key]: false }));
}
setDirty(true); setDirty(true);
if ((REQUIRED_KEYS as string[]).includes(key)) { if ((REQUIRED_KEYS as string[]).includes(key)) {
setFieldErrors((current) => ({ ...current, [key]: undefined })); setFieldErrors((current) => ({ ...current, [key]: undefined }));
@@ -105,9 +194,23 @@ export default function NewBottlingBatchPage() {
} }
const mutation = useMutation({ const mutation = useMutation({
mutationFn: createBottlingBatch, mutationFn: (acknowledged: boolean) =>
createBottlingBatch(
{
...toBottlingInput(values),
...(acknowledged && confirmation !== null
? {
acknowledgedWarnings: [WATER_CONTENT_ABOVE_LEGAL],
acknowledgedWaterContent: confirmation,
}
: {}),
},
{ idempotencyKey: idempotencyKey.current },
),
onSuccess: (bottlingBatch) => { onSuccess: (bottlingBatch) => {
draft.clear(); draft.clear();
idempotencyKey.current = newIdempotencyKey();
lastBody.current = null;
void queryClient.invalidateQueries({ queryKey: ["bottling-batches"] }); void queryClient.invalidateQueries({ queryKey: ["bottling-batches"] });
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] }); void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
if (bottlingBatch.warnings?.length) { if (bottlingBatch.warnings?.length) {
@@ -118,12 +221,71 @@ export default function NewBottlingBatchPage() {
router.push(`/bottling/${bottlingBatch.id}`); router.push(`/bottling/${bottlingBatch.id}`);
}, },
onError: (mutationError: Error) => { onError: (mutationError: Error) => {
setSubmitError( if (
errorMessage(mutationError, "Abfüllung konnte nicht angelegt werden."), mutationError instanceof ApiError &&
); mutationError.status === 409 &&
!confirmationRequired(mutationError) &&
confirmation !== null
) {
// A duplicate request in flight: the question stays, the answer of the first one counts.
setDialogMessage(mutationError.message);
return;
}
const needsConfirmation = confirmationRequired(mutationError);
if (needsConfirmation) {
// First ask, or ask again: the value changed meanwhile.
setDialogMessage(null);
setConfirmation(needsConfirmation.waterContent);
return;
}
setConfirmation(null);
showSaveError(mutationError);
}, },
}); });
function showSaveError(error: Error) {
const { general, fields } = describeSaveError(error, "Abfüllung konnte nicht angelegt werden.");
const known: FieldErrors = {};
const other: string[] = general ? [general] : [];
for (const [field, message] of Object.entries(fields)) {
if ((REQUIRED_KEYS as string[]).includes(field)) known[field as RequiredKey] = message;
else other.push(message);
}
setFieldErrors(known);
setSubmitError(other.length > 0 ? other.join(" ") : null);
const firstInvalid = REQUIRED_KEYS.find((key) => known[key]);
if (firstInvalid) {
document.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)?.focus();
}
}
function renewKeyIfBodyChanged() {
const body = JSON.stringify(toBottlingInput(values));
if (lastBody.current !== null && lastBody.current !== body) {
idempotencyKey.current = newIdempotencyKey();
}
lastBody.current = body;
}
function confirmWaterContent() {
if (confirmInFlight.current || mutation.isPending) return;
renewKeyIfBodyChanged();
setDialogMessage(null);
confirmInFlight.current = true;
mutation.mutate(true, {
onSettled: () => {
confirmInFlight.current = false;
},
});
}
function cancelConfirmation() {
// Nothing was written. A new key for whatever the person changes next.
setConfirmation(null);
setDialogMessage(null);
idempotencyKey.current = newIdempotencyKey();
}
function handleSubmit(event: FormEvent<HTMLFormElement>) { function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setSubmitError(null); setSubmitError(null);
@@ -136,13 +298,69 @@ export default function NewBottlingBatchPage() {
?.focus(); ?.focus();
return; return;
} }
mutation.mutate(toBottlingInput(values)); renewKeyIfBodyChanged();
mutation.mutate(false);
} }
const jarSizeFromProduct =
!manual.jarSize && prefill?.jarSizeSource === "PRODUCT" && prefill.jarSize != null;
const jarSizeBase = "Honig pro Glas in Gramm, z. B. 500 oder 250.";
const jarSizeHint = fromDraft.jarSize
? `aus Entwurf. ${jarSizeBase}`
: jarSizeFromProduct
? `aus Artikel: ${prefill.jarSize} g. Du kannst ihn ändern.`
: prefillQuery.isError
? `nicht aus Artikel. ${jarSizeBase}`
: jarSizeBase;
const bestBeforeFromProduct =
!manual.bestBeforeDate && prefill?.bestBeforeDate != null && prefill.shelfLifeMonths != null;
const bestBeforeBase = "Kommt aufs Etikett (MHD) – bei Honig meist 2 Jahre nach dem Abfüllen.";
const bestBeforeHint = fromDraft.bestBeforeDate
? `aus Entwurf. ${bestBeforeBase}`
: bestBeforeFromProduct
? `aus Artikel: ${prefill.shelfLifeMonths} Monate. Du kannst es ändern.`
: prefillQuery.isError
? `nicht aus Artikel. ${bestBeforeBase}`
: formValues.productId && prefill && prefill.bestBeforeDate == null
? "Am Artikel ist keine Haltbarkeit hinterlegt. Bitte das MHD eintragen."
: bestBeforeBase;
// The entered values against the product, before saving (hints, never a block).
const localNotes: PlausibilityWarning[] = [];
if (prefill && formValues.productId) {
if (prefill.jarSize != null && values.jarSize.trim() !== "" && Number(values.jarSize) !== prefill.jarSize) {
localNotes.push({
code: "JAR_SIZE_DIFFERS",
severity: "HINWEIS",
message: `Glasgröße weicht vom Artikel ab (Artikel: ${prefill.jarSize} g).`,
refs: [],
});
}
if (prefill.bestBeforeDate && values.bestBeforeDate && values.bestBeforeDate !== prefill.bestBeforeDate) {
localNotes.push({
code: "BEST_BEFORE_DIFFERS",
severity: "HINWEIS",
message: `MHD weicht vom Artikel ab (Artikel: ${germanDay(prefill.bestBeforeDate)}).`,
refs: [],
});
}
}
const prefillLoading = prefillQuery.isFetching;
const submitDescribedBy =
[offlineDescribedBy, prefillLoading ? loadingId : undefined].filter(Boolean).join(" ") || undefined;
function restoreDraft() { function restoreDraft() {
const restored = draft.restore(); const restored = draft.restore();
if (restored) { if (restored) {
setValues(restored); const { manual: savedManual, ...savedValues } = restored;
// A draft from before the flags were kept: treat both as typed. Anything odd counts as not typed.
const flags = savedManual
? { jarSize: !!savedManual.jarSize, bestBeforeDate: !!savedManual.bestBeforeDate }
: { jarSize: true, bestBeforeDate: true };
setValues(savedValues);
setManual(flags);
setFromDraft(flags);
setDirty(true); setDirty(true);
} }
} }
@@ -202,6 +420,15 @@ export default function NewBottlingBatchPage() {
/> />
)} )}
</Field> </Field>
<WaterContentNotes prefill={prefill} localNotes={localNotes} />
<PrefillStatus
enabled={prefillEnabled}
isLoading={prefillQuery.isLoading}
isError={prefillQuery.isError}
isRetrying={prefillQuery.isFetching}
onRetry={() => void prefillQuery.refetch()}
showLoading={false}
/>
{withdrawalCheckFailed ? ( {withdrawalCheckFailed ? (
<p role="alert" className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm"> <p role="alert" className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden /> <AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
@@ -223,6 +450,18 @@ export default function NewBottlingBatchPage() {
</ul> </ul>
</div> </div>
) : null} ) : null}
<Field label="Produkt">
{({ id }) => (
<RecordSelect
id={id}
value={values.productId}
onChange={(value) => set("productId", value)}
items={products?.filter((product) => product.kind !== "MANUFACTURED")}
getLabel={(product) => product.name}
placeholder="Produkt wählen"
/>
)}
</Field>
<Field label="Datum" required error={fieldErrors.date}> <Field label="Datum" required error={fieldErrors.date}>
{({ id, describedBy, invalid }) => ( {({ id, describedBy, invalid }) => (
<Input <Input
@@ -245,7 +484,7 @@ export default function NewBottlingBatchPage() {
label="Glasgröße (g)" label="Glasgröße (g)"
required required
error={fieldErrors.jarSize} error={fieldErrors.jarSize}
hint="Honig pro Glas in Gramm, z. B. 500 oder 250." hint={jarSizeHint}
> >
{({ id, describedBy, invalid }) => ( {({ id, describedBy, invalid }) => (
<Input <Input
@@ -289,7 +528,7 @@ export default function NewBottlingBatchPage() {
label="Mindesthaltbarkeitsdatum" label="Mindesthaltbarkeitsdatum"
required required
error={fieldErrors.bestBeforeDate} error={fieldErrors.bestBeforeDate}
hint="Kommt aufs Etikett (MHD) – bei Honig meist 2 Jahre nach dem Abfüllen." hint={bestBeforeHint}
> >
{({ id, describedBy, invalid }) => ( {({ id, describedBy, invalid }) => (
<Input <Input
@@ -314,20 +553,8 @@ export default function NewBottlingBatchPage() {
title="Weitere Angaben" title="Weitere Angaben"
collapsible collapsible
defaultOpen={false} defaultOpen={false}
fieldCount={2} fieldCount={1}
> >
<Field label="Produkt">
{({ id }) => (
<RecordSelect
id={id}
value={values.productId}
onChange={(value) => set("productId", value)}
items={products?.filter((product) => product.kind !== "MANUFACTURED")}
getLabel={(product) => product.name}
placeholder="Produkt wählen"
/>
)}
</Field>
<Field <Field
label="Los-/Chargennummer" label="Los-/Chargennummer"
hint="Steht aufs Etikett, damit jedes Glas seiner Charge zugeordnet werden kann." hint="Steht aufs Etikett, damit jedes Glas seiner Charge zugeordnet werden kann."
@@ -347,25 +574,42 @@ export default function NewBottlingBatchPage() {
<StickySubmit <StickySubmit
note={ note={
submitError ? ( <>
<p <div role="status" aria-live="polite">
role="alert" <OnlineOnlyHint id={hintId} />
className="text-sm font-medium text-destructive" {prefillLoading ? (
> <p id={loadingId} className="text-sm text-muted-foreground">
{submitError} Artikelwerte werden geladen …
</p> </p>
) : null ) : null}
</div>
<div role="alert">
{submitError ? (
<p className="text-sm font-medium text-destructive">{submitError}</p>
) : null}
</div>
</>
} }
> >
<Button <Button
ref={submitButton}
type="submit" type="submit"
className="h-12 text-base" className="h-12 text-base"
disabled={mutation.isPending} disabled={mutation.isPending || isOffline || prefillLoading}
aria-describedby={submitDescribedBy}
> >
{mutation.isPending ? "Wird angelegt…" : "Abfüllung anlegen"} {mutation.isPending && confirmation === null ? "Wird angelegt…" : "Abfüllung anlegen"}
</Button> </Button>
</StickySubmit> </StickySubmit>
</form> </form>
<WaterContentConfirmDialog
waterContent={confirmation}
isPending={mutation.isPending}
returnFocusTo={submitButton}
message={dialogMessage}
onConfirm={confirmWaterContent}
onCancel={cancelConfirmation}
/>
</CardContent> </CardContent>
</Card> </Card>
</main> </main>
@@ -0,0 +1,73 @@
"use client";
import { useState, type RefObject } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { formatWaterContent } from "@/lib/bottling/format";
interface WaterContentConfirmDialogProps {
/** The measured value from the 409; the dialog is open while it is set. */
waterContent: number | null;
isPending: boolean;
/** Why the last confirm did not go through (shown inside the dialog, not behind it). */
message: string | null;
/** Where the focus goes when the dialog closes: the button that opened the question. */
returnFocusTo: RefObject<HTMLElement | null>;
onConfirm: () => void;
onCancel: () => void;
}
/**
* The server will not bottle honey over the legal water content without a
* yes. Base UI's dialog traps the focus; closing it hands the focus back.
*/
export function WaterContentConfirmDialog({
waterContent,
isPending,
message,
returnFocusTo,
onConfirm,
onCancel,
}: WaterContentConfirmDialogProps) {
// Keep the last value so the text does not go empty while the dialog fades out.
const [lastValue, setLastValue] = useState<number | null>(waterContent);
if (waterContent !== null && waterContent !== lastValue) setLastValue(waterContent);
const shown = waterContent ?? lastValue;
return (
<Dialog
open={waterContent !== null}
onOpenChange={(open) => {
if (!open && !isPending) onCancel();
}}
>
<DialogContent finalFocus={returnFocusTo}>
<DialogHeader>
<DialogTitle>Wassergehalt zu hoch</DialogTitle>
<DialogDescription>
{shown === null
? ""
: `Wassergehalt ${formatWaterContent(shown)} über der gesetzlichen Grenze. Trotzdem abfüllen?`}
</DialogDescription>
</DialogHeader>
<div role="alert">
{message ? <p className="text-sm font-medium text-destructive">{message}</p> : null}
</div>
<DialogFooter>
<Button type="button" variant="outline" size="touch" disabled={isPending} onClick={onCancel}>
Abbrechen
</Button>
<Button type="button" variant="destructive" size="touch" disabled={isPending} onClick={onConfirm}>
{isPending ? "Wird abgefüllt…" : "Trotzdem abfüllen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,57 @@
import { AlertTriangle, Info, ShieldAlert } from "lucide-react";
import type { ComponentType } from "react";
import { formatWaterContent } from "@/lib/bottling/format";
import type { BottlingPrefill } from "@/lib/bottling/types";
import type { PlausibilityWarning, WarningSeverity } from "@/lib/warnings/types";
const SEVERITY_LOOK: Record<WarningSeverity, { label: string; Icon: ComponentType<{ className?: string }> }> = {
HINWEIS: { label: "Hinweis", Icon: Info },
WARNUNG: { label: "Warnung", Icon: AlertTriangle },
BESTAETIGEN: { label: "Bestätigung nötig", Icon: ShieldAlert },
};
/**
* The batch's measured water content and what the server notes about it, as
* information before saving. The live region stays mounted so a screen
* reader hears the text when it arrives.
*/
export function WaterContentNotes({
prefill,
localNotes = [],
}: {
prefill: BottlingPrefill | undefined;
/** Findings the form works out itself (entered value vs product), shown as hints. */
localNotes?: PlausibilityWarning[];
}) {
const localCodes = new Set(localNotes.map((note) => note.code));
const warnings = [
...(prefill?.warnings ?? []).filter((warning) => !localCodes.has(warning.code)),
...localNotes,
];
const hasContent = (prefill !== undefined && prefill.waterContent !== null) || warnings.length > 0;
return (
<div role="status" aria-live="polite">
{hasContent ? (
<div className="flex flex-col gap-1.5 rounded-lg bg-muted p-3 text-sm">
{prefill !== undefined && prefill.waterContent !== null ? (
<p>Wassergehalt der Charge: {formatWaterContent(prefill.waterContent)}</p>
) : null}
{warnings.length > 0 ? (
<ul className="flex flex-col gap-1">
{warnings.map((warning) => {
const { label, Icon } = SEVERITY_LOOK[warning.severity];
return (
<li key={warning.code} className="flex items-start gap-2">
<Icon className="mt-0.5 size-4 shrink-0" aria-hidden />
<span className="font-medium">{label}</span>
<span>{warning.message}</span>
</li>
);
})}
</ul>
) : null}
</div>
) : null}
</div>
);
}
@@ -3,8 +3,9 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import NewExtractionPage from "./page"; import NewExtractionPage from "./page";
import { createExtraction } from "@/lib/extractions/api"; import { createExtraction, fetchExtractionPrefill } from "@/lib/extractions/api";
import { fetchHarvests } from "@/lib/harvests/api"; import { fetchHarvests } from "@/lib/harvests/api";
import { toastWarnings } from "@/lib/warnings/toast-warnings";
const push = vi.fn(); const push = vi.fn();
vi.mock("next/navigation", () => ({ vi.mock("next/navigation", () => ({
@@ -12,7 +13,11 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(), useSearchParams: () => new URLSearchParams(),
})); }));
vi.mock("@/lib/harvests/api", () => ({ fetchHarvests: vi.fn() })); vi.mock("@/lib/harvests/api", () => ({ fetchHarvests: vi.fn() }));
vi.mock("@/lib/extractions/api", () => ({ createExtraction: vi.fn() })); vi.mock("@/lib/extractions/api", () => ({
createExtraction: vi.fn(),
fetchExtractionPrefill: vi.fn(),
}));
vi.mock("@/lib/warnings/toast-warnings", () => ({ toastWarnings: vi.fn() }));
function renderPage() { function renderPage() {
const client = new QueryClient({ const client = new QueryClient({
@@ -35,7 +40,20 @@ describe("NewExtractionPage", () => {
date: "2026-07-01T00:00:00.000Z", date: "2026-07-01T00:00:00.000Z",
colonies: [{ colony: { code: "V-01" } }], colonies: [{ colony: { code: "V-01" } }],
}, },
{
id: "harvest-2",
date: "2026-07-02T00:00:00.000Z",
colonies: [{ colony: { code: "V-02" } }],
},
] as never); ] as never);
vi.mocked(fetchExtractionPrefill).mockReset();
vi.mocked(fetchExtractionPrefill).mockResolvedValue({
amountKg: null,
honeyType: null,
sources: [],
warnings: [],
});
vi.mocked(toastWarnings).mockReset();
vi.mocked(createExtraction).mockReset(); vi.mocked(createExtraction).mockReset();
}); });
@@ -80,4 +98,75 @@ describe("NewExtractionPage", () => {
expect(screen.getByRole("group", { name: /^Ernten/ })).toHaveFocus(); expect(screen.getByRole("group", { name: /^Ernten/ })).toHaveFocus();
expect(createExtraction).not.toHaveBeenCalled(); expect(createExtraction).not.toHaveBeenCalled();
}); });
it("offers „Vorschlag aus 2 Ernten: 42,5 kg · übernehmen“ and fills only on tap", async () => {
vi.mocked(fetchExtractionPrefill).mockResolvedValue({
amountKg: 42.5,
honeyType: null,
sources: [],
warnings: [],
});
renderPage();
await userEvent.click(await screen.findByRole("checkbox", { name: /V-01/ }));
await userEvent.click(screen.getByRole("checkbox", { name: /V-02/ }));
expect(await screen.findByText(/Vorschlag aus 2 Ernten: 42,5 kg/)).toBeInTheDocument();
expect(fetchExtractionPrefill).toHaveBeenLastCalledWith(["harvest-1", "harvest-2"]);
expect(screen.getByLabelText(/Menge/)).toHaveValue(null);
await userEvent.click(screen.getByRole("button", { name: /übernehmen/ }));
expect(screen.getByLabelText(/Menge/)).toHaveValue(42.5);
});
it("keeps a note when the hints could not be loaded and lets the save through", async () => {
vi.mocked(fetchExtractionPrefill).mockRejectedValue(new Error("down"));
renderPage();
await userEvent.click(await screen.findByRole("checkbox", { name: /V-01/ }));
expect(
await screen.findByText(/Hinweise \(z\. B\. Wartezeit\) konnten nicht geladen werden/),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Erneut versuchen" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Schleuderung speichern" })).toBeEnabled();
});
it("shows the plausibility warnings above „Schleuderung speichern“", async () => {
vi.mocked(fetchExtractionPrefill).mockResolvedValue({
amountKg: null,
honeyType: null,
sources: [],
warnings: [
{
code: "WITHDRAWAL_ACTIVE",
severity: "WARNUNG",
message: "Volk V-01 ist noch in der Wartezeit.",
refs: [],
},
],
});
renderPage();
await userEvent.click(await screen.findByRole("checkbox", { name: /V-01/ }));
const warning = await screen.findByText("Volk V-01 ist noch in der Wartezeit.");
const save = screen.getByRole("button", { name: "Schleuderung speichern" });
expect(
warning.compareDocumentPosition(save) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("keeps the warnings of the save in a toast that stays", async () => {
const warnings = [
{ code: "WITHDRAWAL_ACTIVE", severity: "WARNUNG", message: "Wartezeit", refs: [] },
];
vi.mocked(createExtraction).mockResolvedValue({ id: "e-1", warnings } as never);
renderPage();
await userEvent.click(await screen.findByRole("checkbox", { name: /V-01/ }));
await userEvent.click(screen.getByRole("button", { name: "Schleuderung speichern" }));
expect(toastWarnings).toHaveBeenCalledWith(warnings, "Schleuderung gespeichert");
});
}); });
@@ -6,7 +6,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { Droplet } from "lucide-react"; import { Droplet } from "lucide-react";
import { fetchHarvests } from "@/lib/harvests/api"; import { fetchHarvests } from "@/lib/harvests/api";
import { createExtraction } from "@/lib/extractions/api"; import {
createExtraction,
fetchExtractionPrefill,
} from "@/lib/extractions/api";
import { formatKg } from "@/lib/honey-batches/movements";
import { toastWarnings } from "@/lib/warnings/toast-warnings";
import { WarningList } from "@/components/warnings/warning-list";
import { PrefillStatus } from "@/components/warnings/prefill-status";
import { PreviousValueHint } from "@/components/form/previous-value-hint";
import { useFormDraft } from "@/lib/forms/use-form-draft"; import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message"; import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -58,6 +66,16 @@ export default function NewExtractionPage() {
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const draft = useFormDraft("extraction-new", values, dirty); const draft = useFormDraft("extraction-new", values, dirty);
// A proposal from the chosen harvests: shown with a take-over button, never
// written into the field by itself. Its warnings are the ones the save
// will answer, so they are visible before "Speichern".
const prefillQuery = useQuery({
queryKey: ["extractions", "prefill", values.harvestIds],
queryFn: () => fetchExtractionPrefill(values.harvestIds),
enabled: values.harvestIds.length > 0,
});
const prefill = values.harvestIds.length > 0 ? prefillQuery.data : undefined;
function set<K extends keyof ExtractionFormValues>( function set<K extends keyof ExtractionFormValues>(
key: K, key: K,
value: ExtractionFormValues[K], value: ExtractionFormValues[K],
@@ -83,7 +101,11 @@ export default function NewExtractionPage() {
onSuccess: (extraction) => { onSuccess: (extraction) => {
draft.clear(); draft.clear();
void queryClient.invalidateQueries({ queryKey: ["extractions"] }); void queryClient.invalidateQueries({ queryKey: ["extractions"] });
toast.success("Schleuderung gespeichert"); if (extraction.warnings?.length) {
toastWarnings(extraction.warnings, "Schleuderung gespeichert");
} else {
toast.success("Schleuderung gespeichert");
}
router.push(`/extractions/${extraction.id}`); router.push(`/extractions/${extraction.id}`);
}, },
onError: (mutationError) => { onError: (mutationError) => {
@@ -203,17 +225,32 @@ export default function NewExtractionPage() {
<FormSection step={2} title="Ergebnis"> <FormSection step={2} title="Ergebnis">
<Field label="Menge (kg)" hint="Geschleuderter Honig, ohne Eimer."> <Field label="Menge (kg)" hint="Geschleuderter Honig, ohne Eimer.">
{({ id, describedBy }) => ( {({ id, describedBy }) => (
<Input <>
id={id} <Input
aria-describedby={describedBy} id={id}
type="number" aria-describedby={describedBy}
inputMode="decimal" type="number"
min={0} inputMode="decimal"
step="any" min={0}
className="h-11" step="any"
value={values.amount} className="h-11"
onChange={(event) => set("amount", event.target.value)} value={values.amount}
/> onChange={(event) => set("amount", event.target.value)}
/>
{prefill?.amountKg != null &&
values.amount !== String(prefill.amountKg) ? (
<PreviousValueHint
fieldLabel="Menge"
valueLabel={formatKg(prefill.amountKg)}
prefix={`Vorschlag aus ${values.harvestIds.length} ${
values.harvestIds.length === 1 ? "Ernte" : "Ernten"
}`}
onTakeOver={() =>
set("amount", String(prefill.amountKg))
}
/>
) : null}
</>
)} )}
</Field> </Field>
<Field <Field
@@ -267,6 +304,15 @@ export default function NewExtractionPage() {
</Field> </Field>
</FormSection> </FormSection>
<PrefillStatus
enabled={values.harvestIds.length > 0}
isLoading={prefillQuery.isLoading}
isError={prefillQuery.isError}
isRetrying={prefillQuery.isFetching}
onRetry={() => void prefillQuery.refetch()}
/>
<WarningList warnings={prefill?.warnings ?? []} />
<StickySubmit <StickySubmit
note={ note={
submitError ? ( submitError ? (
@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { CorrectAmountDialog } from "./correct-amount-dialog";
import { bookHoneyBatchMovement } from "@/lib/honey-batches/api";
import { ApiError } from "@/lib/auth/api-client";
vi.mock("@/lib/honey-batches/api", () => ({ bookHoneyBatchMovement: vi.fn() }));
function renderDialog(amount = 20) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<CorrectAmountDialog honeyBatchId="h1" amount={amount} />
</QueryClientProvider>,
);
}
describe("CorrectAmountDialog", () => {
beforeEach(() => {
vi.mocked(bookHoneyBatchMovement).mockReset();
});
it("books nothing while the weighed amount equals the booked one", async () => {
renderDialog();
await userEvent.click(screen.getByRole("button", { name: /Menge korrigieren/ }));
expect(await screen.findByRole("button", { name: "Korrektur buchen" })).toBeDisabled();
});
it("says Keine Abweichung when nothing differs", async () => {
renderDialog();
await userEvent.click(screen.getByRole("button", { name: /Menge korrigieren/ }));
expect(await screen.findByText("Keine Abweichung")).toBeInTheDocument();
});
it("shows the message of a 409 and the field text of a 400", async () => {
renderDialog();
await userEvent.click(screen.getByRole("button", { name: /Menge korrigieren/ }));
const input = await screen.findByLabelText(/Neu gewogen/);
await userEvent.clear(input);
await userEvent.type(input, "19");
vi.mocked(bookHoneyBatchMovement).mockRejectedValueOnce(new ApiError(409, "Gerade geändert.", {}));
await userEvent.click(screen.getByRole("button", { name: "Korrektur buchen" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Gerade geändert.");
vi.mocked(bookHoneyBatchMovement).mockRejectedValueOnce(
new ApiError(400, "Bad", { issues: [{ field: "deltaKg", message: "Menge nicht erlaubt." }] }),
);
await userEvent.click(screen.getByRole("button", { name: "Korrektur buchen" }));
expect(await screen.findByText("Menge nicht erlaubt.")).toBeInTheDocument();
});
it("books a positive difference with the reason and shows a failed booking in the dialog", async () => {
vi.mocked(bookHoneyBatchMovement).mockRejectedValue(new Error("Boom"));
renderDialog();
await userEvent.click(screen.getByRole("button", { name: /Menge korrigieren/ }));
const input = await screen.findByLabelText(/Neu gewogen/);
await userEvent.clear(input);
await userEvent.type(input, "21");
await userEvent.type(screen.getByLabelText(/Grund/), "Eimer nachgewogen");
await userEvent.click(screen.getByRole("button", { name: "Korrektur buchen" }));
expect(bookHoneyBatchMovement).toHaveBeenCalledWith(
"h1",
{ type: "CORRECTION", deltaKg: 1, notes: "Eimer nachgewogen" },
expect.anything(),
);
expect(await screen.findByRole("alert")).toHaveTextContent(/nicht gebucht/);
});
});
@@ -0,0 +1,172 @@
"use client";
import { FormEvent, useRef, useState } from "react";
import { Scale } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { bookHoneyBatchMovement } from "@/lib/honey-batches/api";
import { differenceKg, formatKg, formatSignedKg } from "@/lib/honey-batches/movements";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { saveErrorText } from "@/lib/honey-batches/errors";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { MovementNotesField } from "./movement-notes-field";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
interface CorrectAmountDialogProps {
honeyBatchId: string;
/** The booked rest in kg. */
amount: number;
/** Switched off from outside, e.g. offline; `describedBy` names the reason. */
disabled?: boolean;
describedBy?: string;
}
/**
* „Menge korrigieren“: the beekeeper types what the scale shows now, the
* booking is the difference to the booked rest (CORRECTION). Nothing is
* overwritten - the movement book keeps both numbers.
*/
export function CorrectAmountDialog({
honeyBatchId,
amount,
disabled = false,
describedBy: triggerDescribedBy,
}: CorrectAmountDialogProps) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
// The rest the difference is computed against: fixed when the dialog opens,
// so a reload behind it cannot move the booking.
const [baseline, setBaseline] = useState(amount);
const [weighed, setWeighed] = useState(String(amount));
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
const idempotencyKey = useRef(newIdempotencyKey());
const { isOffline, hintId, describedBy } = useOnlineOnly();
const weighedKg = weighed === "" ? Number.NaN : Number(weighed);
const isNumber = Number.isFinite(weighedKg);
const isNegative = isNumber && weighedKg < 0;
const difference = isNumber && !isNegative ? differenceKg(weighedKg, baseline) : 0;
const differenceText = !isNumber
? "Bitte das Gewicht eingeben."
: isNegative
? "Das Gewicht kann nicht negativ sein."
: difference === 0
? "Keine Abweichung"
: `Differenz: ${formatSignedKg(difference)}`;
const mutation = useMutation({
mutationFn: () =>
bookHoneyBatchMovement(
honeyBatchId,
{
type: "CORRECTION",
deltaKg: difference,
...(emptyToNull(notes) ? { notes: notes.trim() } : {}),
},
{ idempotencyKey: idempotencyKey.current },
),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
toast.success(`Menge korrigiert: ${formatSignedKg(difference)}`);
idempotencyKey.current = newIdempotencyKey();
setNotes("");
setOpen(false);
},
onError: (mutationError) => {
setError(saveErrorText(mutationError, "Die Korrektur wurde nicht gebucht."));
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (difference === 0 || isOffline) return;
mutation.mutate();
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) {
setBaseline(amount);
setWeighed(String(amount));
setNotes("");
setError(null);
}
}}
>
<DialogTrigger
render={
<Button
variant="outline"
size="touch"
disabled={disabled}
aria-describedby={triggerDescribedBy}
>
<Scale className="size-4" aria-hidden="true" />
Menge korrigieren
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Menge korrigieren</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
<p className="text-sm text-muted-foreground">
Gebucht sind {formatKg(baseline)}. Trag ein, was die Waage jetzt zeigt – gebucht wird
die Differenz.
</p>
<div className="flex flex-col gap-2">
<Label htmlFor="correct-amount-weighed">Neu gewogen (kg)</Label>
<Input
id="correct-amount-weighed"
type="number"
inputMode="decimal"
step="any"
className="h-11"
value={weighed}
onChange={(event) => setWeighed(event.target.value)}
/>
<p aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
{differenceText}
</p>
</div>
<MovementNotesField
id="correct-amount-reason"
label="Grund (optional)"
value={notes}
onChange={setNotes}
/>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<OnlineOnlyHint id={hintId} />
<Button
type="submit"
size="touch"
disabled={mutation.isPending || difference === 0 || isOffline}
aria-describedby={describedBy}
>
{mutation.isPending ? "Bucht…" : "Korrektur buchen"}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,147 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { HoneyBatchMovementsCard } from "./honey-batch-movements-card";
import { bookHoneyBatchMovement, fetchHoneyBatchMovementPage } from "@/lib/honey-batches/api";
vi.mock("@/lib/honey-batches/api", () => ({
fetchHoneyBatchMovementPage: vi.fn(),
bookHoneyBatchMovement: vi.fn(),
}));
const PAGE = {
hasMore: false,
items: [
{
id: "m2",
type: "CORRECTION",
quantityGrams: -500,
reason: null,
notes: "Nachgewogen",
sourceBottlingBatchId: null,
createdAt: "2026-10-02T10:00:00.000Z",
userId: "u1",
},
{
id: "m1",
type: "BOTTLING",
quantityGrams: -12500,
reason: null,
notes: null,
sourceBottlingBatchId: "bb1",
createdAt: "2026-10-01T10:00:00.000Z",
userId: "u1",
},
{
id: "m0",
type: "WITHDRAWAL",
quantityGrams: -1000,
reason: "PROBE",
notes: null,
sourceBottlingBatchId: null,
createdAt: "2026-10-01T09:00:00.000Z",
userId: "u1",
},
],
};
function renderCard(props: { canBook?: boolean; amount?: number | null } = {}) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<HoneyBatchMovementsCard
honeyBatchId="h1"
amount={props.amount === undefined ? 20 : props.amount}
canBook={props.canBook ?? true}
/>
</QueryClientProvider>,
);
}
describe("HoneyBatchMovementsCard", () => {
afterEach(() => act(() => reportRequestOutcome("reached-server")));
beforeEach(() => {
vi.mocked(fetchHoneyBatchMovementPage).mockReset();
vi.mocked(fetchHoneyBatchMovementPage).mockResolvedValue(PAGE as never);
vi.mocked(bookHoneyBatchMovement).mockReset();
vi.mocked(bookHoneyBatchMovement).mockResolvedValue({} as never);
});
it("lists bottlings, withdrawals and corrections with signed kilograms", async () => {
renderCard();
expect(await screen.findByText("−12,5 kg")).toBeInTheDocument();
expect(screen.getByText("Entnahme · Probe / Muster")).toBeInTheDocument();
expect(screen.getByText("Korrektur")).toBeInTheDocument();
expect(screen.getByText("Nachgewogen")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /Abfüllung/ })).toHaveAttribute("href", "/bottling/bb1");
});
it("„Menge korrigieren“ books the difference to the weighed amount as CORRECTION", async () => {
renderCard();
await screen.findByText("−12,5 kg");
await userEvent.click(screen.getByRole("button", { name: /Menge korrigieren/ }));
const input = await screen.findByLabelText(/Neu gewogen/);
await userEvent.clear(input);
await userEvent.type(input, "18.5");
expect(screen.getByText(/Differenz: −1,5 kg/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Korrektur buchen" }));
expect(bookHoneyBatchMovement).toHaveBeenCalledWith(
"h1",
expect.objectContaining({ type: "CORRECTION", deltaKg: -1.5 }),
expect.anything(),
);
});
it("„Entnahme“ books a negative WITHDRAWAL with its reason", async () => {
renderCard();
await screen.findByText("−12,5 kg");
await userEvent.click(screen.getByRole("button", { name: /^Entnahme$/ }));
await userEvent.type(await screen.findByLabelText(/Menge \(kg\)/), "2");
await userEvent.click(screen.getByRole("combobox"));
await userEvent.click(await screen.findByRole("option", { name: "Hofladen" }));
await userEvent.click(screen.getByRole("button", { name: "Entnahme buchen" }));
expect(bookHoneyBatchMovement).toHaveBeenCalledWith(
"h1",
expect.objectContaining({ type: "WITHDRAWAL", deltaKg: -2, reason: "HOFLADEN" }),
expect.anything(),
);
});
it("offers no booking without permission or without a tracked amount", async () => {
const { unmount } = renderCard({ canBook: false });
await screen.findByText("−12,5 kg");
expect(screen.queryByRole("button", { name: /Menge korrigieren/ })).not.toBeInTheDocument();
unmount();
renderCard({ amount: null });
await screen.findByText("−12,5 kg");
expect(screen.queryByRole("button", { name: /Menge korrigieren/ })).not.toBeInTheDocument();
});
it("switches the booking buttons off offline and says why", async () => {
renderCard();
await screen.findByText("−12,5 kg");
act(() => reportRequestOutcome("network-error"));
expect(await screen.findByText("Nur mit Verbindung")).toBeInTheDocument();
for (const name of [/Menge korrigieren/, /^Entnahme$/]) {
const button = screen.getByRole("button", { name });
expect(button).toBeDisabled();
expect(button).toHaveAccessibleDescription("Nur mit Verbindung");
}
});
it("says when older bookings are not shown", async () => {
vi.mocked(fetchHoneyBatchMovementPage).mockResolvedValue({ ...PAGE, hasMore: true } as never);
renderCard();
expect(await screen.findByText(/Ältere Buchungen werden nicht angezeigt/)).toBeInTheDocument();
});
});
@@ -0,0 +1,141 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { fetchHoneyBatchMovementPage } from "@/lib/honey-batches/api";
import {
formatSignedKg,
gramsToKg,
movementTitle,
} from "@/lib/honey-batches/movements";
import type { HoneyBatchMovement } from "@/lib/honey-batches/types";
import { ErrorState, LoadingState } from "@/components/query-state";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CorrectAmountDialog } from "./correct-amount-dialog";
import { HoneyBatchWithdrawDialog } from "./honey-batch-withdraw-dialog";
interface HoneyBatchMovementsCardProps {
honeyBatchId: string;
/** The booked rest in kg; `null` = not recorded, so nothing can be booked. */
amount: number | null;
canBook: boolean;
}
/**
* The movement book of a honey batch: every change of the rest, newest
* first - bottlings book themselves, loose goods and weighing again are
* booked by hand. The rest is the sum of these lines, so it cannot drift.
*/
export function HoneyBatchMovementsCard({
honeyBatchId,
amount,
canBook,
}: HoneyBatchMovementsCardProps) {
const query = useQuery({
queryKey: ["honey-batches", honeyBatchId, "movements"],
queryFn: () => fetchHoneyBatchMovementPage(honeyBatchId),
});
const page = query.data;
const { isOffline, hintId, describedBy } = useOnlineOnly();
return (
<Card>
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2.5">
<CardTitle>Bewegungen</CardTitle>
{canBook && amount !== null ? (
<div className="flex flex-wrap items-center gap-2">
<HoneyBatchWithdrawDialog
honeyBatchId={honeyBatchId}
amount={amount}
disabled={isOffline}
describedBy={describedBy}
/>
<CorrectAmountDialog
honeyBatchId={honeyBatchId}
amount={amount}
disabled={isOffline}
describedBy={describedBy}
/>
</div>
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{canBook && amount !== null ? <OnlineOnlyHint id={hintId} /> : null}
{amount === null ? (
<p className="text-muted-foreground">
Für diese Charge ist keine Menge erfasst – es wird nichts gebucht.
</p>
) : null}
{query.isLoading ? <LoadingState what="Bewegungen" /> : null}
{query.isError ? (
<ErrorState
what="Bewegungen"
onRetry={() => void query.refetch()}
isRetrying={query.isFetching}
/>
) : null}
{page ? <MovementList movements={page.items} /> : null}
{page?.hasMore ? (
<p className="text-muted-foreground">
Ältere Buchungen werden nicht angezeigt.
</p>
) : null}
</CardContent>
</Card>
);
}
function MovementList({ movements }: { movements: HoneyBatchMovement[] }) {
if (movements.length === 0) {
return (
<p className="text-muted-foreground">
Noch keine Bewegungen – der Rest entspricht der Ausgangsmenge.
</p>
);
}
return (
<ul className="flex flex-col divide-y rounded-lg border">
{movements.map((movement) => (
<li
key={movement.id}
className="flex flex-wrap items-baseline justify-between gap-2 p-3"
>
<div className="flex flex-col">
<MovementTitle movement={movement} />
<span className="text-xs text-muted-foreground">
{new Date(movement.createdAt).toLocaleString("de-DE")}
</span>
{movement.notes ? (
<span className="text-xs text-muted-foreground">{movement.notes}</span>
) : null}
</div>
<span
className={
movement.quantityGrams < 0
? "font-semibold tabular-nums text-destructive"
: "font-semibold tabular-nums text-primary"
}
>
{formatSignedKg(gramsToKg(movement.quantityGrams))}
</span>
</li>
))}
</ul>
);
}
function MovementTitle({ movement }: { movement: HoneyBatchMovement }) {
const title = movementTitle(movement);
if (movement.sourceBottlingBatchId) {
return (
<Link
href={`/bottling/${movement.sourceBottlingBatchId}`}
className="min-h-11 content-center font-medium underline underline-offset-4"
>
{title}
</Link>
);
}
return <span className="font-medium">{title}</span>;
}
@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { HoneyBatchWithdrawDialog } from "./honey-batch-withdraw-dialog";
import { bookHoneyBatchMovement } from "@/lib/honey-batches/api";
import { ApiError } from "@/lib/auth/api-client";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
vi.mock("@/lib/honey-batches/api", () => ({ bookHoneyBatchMovement: vi.fn() }));
function renderDialog() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<HoneyBatchWithdrawDialog honeyBatchId="h1" amount={20} />
</QueryClientProvider>,
);
}
async function open() {
await userEvent.click(screen.getByRole("button", { name: /^Entnahme$/ }));
return screen.findByLabelText(/Menge \(kg\)/);
}
describe("HoneyBatchWithdrawDialog", () => {
beforeEach(() => vi.mocked(bookHoneyBatchMovement).mockReset());
afterEach(() => act(() => reportRequestOutcome("reached-server")));
it("has no preselected reason and says what is missing", async () => {
renderDialog();
const quantity = await open();
expect(screen.getByRole("combobox")).toHaveTextContent("Grund wählen");
await userEvent.type(quantity, "2");
expect(screen.getByText("Bitte einen Grund wählen.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Entnahme buchen" })).toBeDisabled();
});
it("refuses more than the rest and less than a gram in words", async () => {
renderDialog();
const quantity = await open();
await userEvent.type(quantity, "25");
expect(screen.getByText("Mehr als der Rest (20 kg)")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Entnahme buchen" })).toBeDisabled();
await userEvent.clear(quantity);
await userEvent.type(quantity, "0.0001");
expect(screen.getByText("Mindestens 1 g")).toBeInTheDocument();
});
it("shows the field texts of a 400 and the message of a 409", async () => {
renderDialog();
const quantity = await open();
await userEvent.type(quantity, "2");
await userEvent.click(screen.getByRole("combobox"));
await userEvent.click(await screen.findByRole("option", { name: "Hofladen" }));
vi.mocked(bookHoneyBatchMovement).mockRejectedValueOnce(
new ApiError(400, "Bad", { issues: [{ field: "deltaKg", message: "Nicht genug Rest." }] }),
);
await userEvent.click(screen.getByRole("button", { name: "Entnahme buchen" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Nicht genug Rest.");
vi.mocked(bookHoneyBatchMovement).mockRejectedValueOnce(
new ApiError(409, "Die Charge wurde gerade geändert.", {}),
);
await userEvent.click(screen.getByRole("button", { name: "Entnahme buchen" }));
expect(await screen.findByText("Die Charge wurde gerade geändert.")).toBeInTheDocument();
});
it("is off while offline and says why", async () => {
renderDialog();
const quantity = await open();
await userEvent.type(quantity, "2");
act(() => reportRequestOutcome("network-error"));
expect(await screen.findByText("Nur mit Verbindung")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Entnahme buchen" })).toBeDisabled();
});
it("starts empty again when it is reopened", async () => {
renderDialog();
const quantity = await open();
await userEvent.type(quantity, "2");
await userEvent.keyboard("{Escape}");
expect(await open()).toHaveValue(null);
});
});
@@ -0,0 +1,180 @@
"use client";
import { FormEvent, useRef, useState } from "react";
import { PackageMinus } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { bookHoneyBatchMovement } from "@/lib/honey-batches/api";
import { saveErrorText } from "@/lib/honey-batches/errors";
import { formatKg } from "@/lib/honey-batches/movements";
import { WITHDRAWAL_REASON_LABELS } from "@/lib/stock/labels";
import type { WithdrawalReason } from "@/lib/stock/types";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { EnumSelect } from "@/components/form/enum-select";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { MovementNotesField } from "./movement-notes-field";
interface HoneyBatchWithdrawDialogProps {
honeyBatchId: string;
/** The booked rest in kg. */
amount: number;
/** Switched off from outside, e.g. offline; `describedBy` names the reason. */
disabled?: boolean;
describedBy?: string;
}
const GRAMS_PER_KG = 1000;
/** What keeps the booking from being sent, in words; null = nothing. */
function problemWith(quantity: string, amount: number, reason: WithdrawalReason | ""): string | null {
const kg = Number(quantity);
if (quantity === "" || !Number.isFinite(kg)) return "Bitte eine Menge eingeben.";
if (Math.round(kg * GRAMS_PER_KG) < 1) return "Mindestens 1 g";
if (Math.round(kg * GRAMS_PER_KG) > Math.round(amount * GRAMS_PER_KG)) {
return `Mehr als der Rest (${formatKg(amount)})`;
}
if (reason === "") return "Bitte einen Grund wählen.";
return null;
}
/** „Entnahme“: loose goods, samples, breakage - books a negative WITHDRAWAL with its reason. */
export function HoneyBatchWithdrawDialog({
honeyBatchId,
amount,
disabled = false,
describedBy: triggerDescribedBy,
}: HoneyBatchWithdrawDialogProps) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [quantity, setQuantity] = useState("");
const [reason, setReason] = useState<WithdrawalReason | "">("");
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
const idempotencyKey = useRef(newIdempotencyKey());
const { isOffline, hintId, describedBy } = useOnlineOnly();
const problem = problemWith(quantity, amount, reason);
const mutation = useMutation({
mutationFn: (chosenReason: WithdrawalReason) =>
bookHoneyBatchMovement(
honeyBatchId,
{
type: "WITHDRAWAL",
deltaKg: -Number(quantity),
reason: chosenReason,
...(emptyToNull(notes) ? { notes: notes.trim() } : {}),
},
{ idempotencyKey: idempotencyKey.current },
),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
toast.success(`${formatKg(Number(quantity))} entnommen`);
idempotencyKey.current = newIdempotencyKey();
setOpen(false);
},
onError: (mutationError) => {
setError(saveErrorText(mutationError, "Die Entnahme wurde nicht gebucht."));
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (problem !== null || reason === "" || isOffline) return;
mutation.mutate(reason);
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) {
setQuantity("");
setReason("");
setNotes("");
setError(null);
}
}}
>
<DialogTrigger
render={
<Button
variant="outline"
size="touch"
disabled={disabled || amount <= 0}
aria-describedby={triggerDescribedBy}
>
<PackageMinus className="size-4" aria-hidden="true" />
Entnahme
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Entnahme</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
<p className="text-sm text-muted-foreground">Noch da: {formatKg(amount)}.</p>
<div className="flex flex-col gap-2">
<Label htmlFor="honey-withdraw-quantity">Menge (kg)</Label>
<Input
id="honey-withdraw-quantity"
type="number"
inputMode="decimal"
step="any"
className="h-11"
value={quantity}
onChange={(event) => setQuantity(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="honey-withdraw-reason">Grund</Label>
<EnumSelect
id="honey-withdraw-reason"
value={reason}
onChange={setReason}
noneLabel="Grund wählen"
labels={WITHDRAWAL_REASON_LABELS}
/>
</div>
<MovementNotesField
id="honey-withdraw-notes"
label="Notiz (optional)"
value={notes}
onChange={setNotes}
/>
<p aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
{problem}
</p>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<OnlineOnlyHint id={hintId} />
<Button
type="submit"
size="touch"
disabled={mutation.isPending || problem !== null || isOffline}
aria-describedby={describedBy}
>
{mutation.isPending ? "Bucht…" : "Entnahme buchen"}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,33 @@
"use client";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
/** The server accepts at most 500 characters of notes per booking. */
export const MOVEMENT_NOTES_MAX = 500;
interface MovementNotesFieldProps {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
}
/** A note for a booking with a visible counter, so the limit is no surprise. */
export function MovementNotesField({ id, label, value, onChange }: MovementNotesFieldProps) {
return (
<div className="flex flex-col gap-2">
<Label htmlFor={id}>{label}</Label>
<Textarea
id={id}
maxLength={MOVEMENT_NOTES_MAX}
aria-describedby={`${id}-count`}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
<p id={`${id}-count`} className="text-xs text-muted-foreground">
{value.length} / {MOVEMENT_NOTES_MAX}
</p>
</div>
);
}
@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import HoneyBatchDetailPage from "./page";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchHoneyBatch, fetchHoneyBatchMovementPage } from "@/lib/honey-batches/api";
vi.mock("next/navigation", () => ({ useParams: () => ({ id: "h1" }) }));
vi.mock("@/lib/auth/auth-context", () => ({ useAuth: vi.fn() }));
vi.mock("@/lib/honey-batches/api", () => ({
fetchHoneyBatch: vi.fn(),
fetchHoneyBatchTraceability: vi.fn(() => new Promise(() => undefined)),
fetchHoneyBatchMovementPage: vi.fn(),
bookHoneyBatchMovement: vi.fn(),
}));
vi.mock("./honey-batch-edit-dialog", () => ({ HoneyBatchEditDialog: () => null }));
vi.mock("./honey-batch-storage-card", () => ({ HoneyBatchStorageCard: () => null }));
vi.mock("./honey-batch-container-allocations-card", () => ({
HoneyBatchContainerAllocationsCard: () => null,
}));
vi.mock("./add-measurement-dialog", () => ({ AddMeasurementDialog: () => null }));
vi.mock("./edit-measurement-dialog", () => ({ EditMeasurementDialog: () => null }));
vi.mock("./extraction-edit-dialog", () => ({ ExtractionEditDialog: () => null }));
vi.mock("@/components/qr-code-dialog", () => ({ QrCodeDialog: () => null }));
vi.mock("@/components/document-upload-card", () => ({ DocumentUploadCard: () => null }));
vi.mock("@/lib/export/api", () => ({ exportHoneyBatchTraceabilityPdf: vi.fn() }));
function renderPage(permissions: string[]) {
vi.mocked(useAuth).mockReturnValue({
hasPermission: (permission: string) => permissions.includes(permission),
} as never);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<HoneyBatchDetailPage />
</QueryClientProvider>,
);
}
describe("HoneyBatchDetailPage movements", () => {
beforeEach(() => {
vi.mocked(fetchHoneyBatch).mockResolvedValue({
id: "h1",
batchNumber: "H-1",
honeyType: null,
amount: 20,
waterContent: null,
status: "STORED",
measurements: [],
} as never);
vi.mocked(fetchHoneyBatchMovementPage).mockResolvedValue({ items: [], hasMore: false });
});
it("offers the bookings only with honey-batches:update", async () => {
const { unmount } = renderPage(["honey-batches:read", "honey-batches:update"]);
expect(await screen.findByRole("button", { name: /Menge korrigieren/ })).toBeInTheDocument();
unmount();
renderPage(["honey-batches:read"]);
await screen.findByText("Bewegungen");
expect(screen.queryByRole("button", { name: /Menge korrigieren/ })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /^Entnahme$/ })).not.toBeInTheDocument();
});
});
@@ -22,11 +22,15 @@ import { AddMeasurementDialog } from "./add-measurement-dialog";
import { EditMeasurementDialog } from "./edit-measurement-dialog"; import { EditMeasurementDialog } from "./edit-measurement-dialog";
import { ExtractionEditDialog } from "./extraction-edit-dialog"; import { ExtractionEditDialog } from "./extraction-edit-dialog";
import { HoneyBatchEditDialog } from "./honey-batch-edit-dialog"; import { HoneyBatchEditDialog } from "./honey-batch-edit-dialog";
import { HoneyBatchMovementsCard } from "./honey-batch-movements-card";
import { useAuth } from "@/lib/auth/auth-context";
import { formatKg } from "@/lib/honey-batches/movements";
const BACK = { backHref: "/honey-batches", backLabel: "Alle Honigchargen" }; const BACK = { backHref: "/honey-batches", backLabel: "Alle Honigchargen" };
export default function HoneyBatchDetailPage() { export default function HoneyBatchDetailPage() {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const { hasPermission } = useAuth();
const honeyBatchQuery = useQuery({ const honeyBatchQuery = useQuery({
queryKey: ["honey-batches", params.id], queryKey: ["honey-batches", params.id],
queryFn: () => fetchHoneyBatch(params.id), queryFn: () => fetchHoneyBatch(params.id),
@@ -91,12 +95,17 @@ export default function HoneyBatchDetailPage() {
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-3 text-sm"> <CardContent className="flex flex-col gap-3 text-sm">
{honeyBatch.honeyType ? <p>Sorte: {honeyBatch.honeyType}</p> : null} {honeyBatch.honeyType ? <p>Sorte: {honeyBatch.honeyType}</p> : null}
{honeyBatch.amount !== null ? <p>Menge: {honeyBatch.amount} kg</p> : null} {honeyBatch.amount !== null ? <p>Rest: {formatKg(honeyBatch.amount)}</p> : null}
{honeyBatch.waterContent !== null ? ( {honeyBatch.waterContent !== null ? (
<p>Wassergehalt: {honeyBatch.waterContent}%</p> <p>Wassergehalt: {honeyBatch.waterContent}%</p>
) : null} ) : null}
</CardContent> </CardContent>
</Card> </Card>
<HoneyBatchMovementsCard
honeyBatchId={honeyBatch.id}
amount={honeyBatch.amount}
canBook={hasPermission("honey-batches:update")}
/>
<HoneyBatchStorageCard honeyBatch={honeyBatch} /> <HoneyBatchStorageCard honeyBatch={honeyBatch} />
<HoneyBatchContainerAllocationsCard honeyBatch={honeyBatch} /> <HoneyBatchContainerAllocationsCard honeyBatch={honeyBatch} />
</div> </div>
@@ -0,0 +1,137 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { CreateHoneyBatchDialog } from "./create-honey-batch-dialog";
import { fetchExtractions } from "@/lib/extractions/api";
import { createHoneyBatch, fetchHoneyBatchPrefill } from "@/lib/honey-batches/api";
import { toastWarnings } from "@/lib/warnings/toast-warnings";
import { ApiError } from "@/lib/auth/api-client";
vi.mock("@/lib/extractions/api", () => ({ fetchExtractions: vi.fn() }));
vi.mock("@/lib/honey-batches/api", () => ({
createHoneyBatch: vi.fn(),
fetchHoneyBatchPrefill: vi.fn(),
}));
vi.mock("@/lib/warnings/toast-warnings", () => ({ toastWarnings: vi.fn() }));
function renderDialog() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<CreateHoneyBatchDialog />
</QueryClientProvider>,
);
}
async function openAndChooseExtraction() {
await userEvent.click(screen.getByRole("button", { name: "+ Neue Charge" }));
await userEvent.click(await screen.findByRole("combobox"));
await userEvent.click(await screen.findByRole("option", { name: /V-01/ }));
}
describe("CreateHoneyBatchDialog", () => {
beforeEach(() => {
vi.mocked(fetchExtractions).mockResolvedValue([
{
id: "x1",
date: "2026-07-03T00:00:00.000Z",
harvests: [{ harvest: { colonies: [{ colony: { code: "V-01" } }] } }],
},
] as never);
vi.mocked(fetchHoneyBatchPrefill).mockReset();
vi.mocked(fetchHoneyBatchPrefill).mockResolvedValue({
remainingKg: 30,
honeyType: "Raps",
waterContent: 17.2,
warnings: [
{
code: "BATCHES_EXCEED_EXTRACTION",
severity: "WARNUNG",
message: "Die Chargen übersteigen die Schleuderung.",
refs: [],
},
],
});
vi.mocked(createHoneyBatch).mockReset();
vi.mocked(toastWarnings).mockReset();
});
it("offers the rest of the extraction and the honey type and fills only on tap", async () => {
renderDialog();
await openAndChooseExtraction();
expect(await screen.findByText(/Vorschlag aus der Schleuderung \(Rest\): 30 kg/)).toBeInTheDocument();
expect(screen.getByLabelText(/Menge/)).toHaveValue(null);
expect(screen.getByLabelText(/Honigsorte/)).toHaveValue("");
await userEvent.click(screen.getByRole("button", { name: /übernehmen.*Menge/ }));
await userEvent.click(screen.getByRole("button", { name: /übernehmen.*Honigsorte/ }));
expect(screen.getByLabelText(/Menge/)).toHaveValue(30);
expect(screen.getByLabelText(/Honigsorte/)).toHaveValue("Raps");
});
it("offers the water content as a proposal with its own take-over", async () => {
renderDialog();
await openAndChooseExtraction();
expect(await screen.findByText(/Vorschlag aus der Schleuderung: 17,2 %/)).toBeInTheDocument();
expect(screen.getByLabelText(/Wassergehalt/)).toHaveValue(null);
await userEvent.click(screen.getByRole("button", { name: /übernehmen.*Wassergehalt/ }));
expect(screen.getByLabelText(/Wassergehalt/)).toHaveValue(17.2);
});
it("does not hide a failed prefill: note stays, retry loads again, saving is possible", async () => {
vi.mocked(fetchHoneyBatchPrefill).mockRejectedValueOnce(new Error("down"));
renderDialog();
await openAndChooseExtraction();
expect(
await screen.findByText(/Hinweise \(z\. B\. Wartezeit\) konnten nicht geladen werden/),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Anlegen" })).toBeEnabled();
await userEvent.click(screen.getByRole("button", { name: "Erneut versuchen" }));
expect(await screen.findByText(/Vorschlag aus der Schleuderung \(Rest\)/)).toBeInTheDocument();
});
it("says that the hints are loading", async () => {
vi.mocked(fetchHoneyBatchPrefill).mockReturnValue(new Promise(() => undefined));
renderDialog();
await openAndChooseExtraction();
expect(await screen.findByText("Hinweise werden geladen …")).toBeInTheDocument();
});
it("puts a 400 issue next to its field and shows a 409 message", async () => {
renderDialog();
await openAndChooseExtraction();
vi.mocked(createHoneyBatch).mockRejectedValueOnce(
new ApiError(400, "Bad", { issues: [{ field: "amount", message: "Menge zu groß." }] }),
);
await userEvent.click(screen.getByRole("button", { name: "Anlegen" }));
expect(await screen.findByText("Menge zu groß.")).toBeInTheDocument();
vi.mocked(createHoneyBatch).mockRejectedValueOnce(new ApiError(409, "Chargennummer vergeben.", {}));
await userEvent.click(screen.getByRole("button", { name: "Anlegen" }));
expect(await screen.findByText("Chargennummer vergeben.")).toBeInTheDocument();
});
it("shows the warnings above „Anlegen“ and keeps those of the save in a toast", async () => {
vi.mocked(createHoneyBatch).mockResolvedValue({ id: "b1", warnings: [{ code: "X" }] } as never);
renderDialog();
await openAndChooseExtraction();
const warning = await screen.findByText("Die Chargen übersteigen die Schleuderung.");
const save = screen.getByRole("button", { name: "Anlegen" });
expect(
warning.compareDocumentPosition(save) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
await userEvent.click(save);
expect(createHoneyBatch).toHaveBeenCalled();
expect(toastWarnings).toHaveBeenCalledWith([{ code: "X" }], "Honigcharge angelegt");
});
});
@@ -3,7 +3,13 @@
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchExtractions } from "@/lib/extractions/api"; import { fetchExtractions } from "@/lib/extractions/api";
import { createHoneyBatch } from "@/lib/honey-batches/api"; import { createHoneyBatch, fetchHoneyBatchPrefill } from "@/lib/honey-batches/api";
import { formatKg } from "@/lib/honey-batches/movements";
import { toastWarnings } from "@/lib/warnings/toast-warnings";
import { WarningList } from "@/components/warnings/warning-list";
import { PreviousValueHint } from "@/components/form/previous-value-hint";
import { PrefillStatus } from "@/components/warnings/prefill-status";
import { describeSaveError, type SaveError } from "@/lib/honey-batches/errors";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -18,6 +24,14 @@ import {
import { RecordSelect } from "@/components/form/record-select"; import { RecordSelect } from "@/components/form/record-select";
import { emptyToNull } from "@/lib/forms/empty-to-null"; import { emptyToNull } from "@/lib/forms/empty-to-null";
function FieldIssue({ message }: { message: string | undefined }) {
return message ? (
<p role="alert" className="text-sm font-medium text-destructive">
{message}
</p>
) : null;
}
export function CreateHoneyBatchDialog() { export function CreateHoneyBatchDialog() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -25,34 +39,50 @@ export function CreateHoneyBatchDialog() {
const [extractionId, setExtractionId] = useState(""); const [extractionId, setExtractionId] = useState("");
const [honeyType, setHoneyType] = useState(""); const [honeyType, setHoneyType] = useState("");
const [amount, setAmount] = useState(""); const [amount, setAmount] = useState("");
const [error, setError] = useState<string | null>(null); const [waterContent, setWaterContent] = useState("");
const [saveError, setSaveError] = useState<SaveError | null>(null);
const fieldError = (field: string) => saveError?.fields[field];
const { data: extractions } = useQuery({ const { data: extractions } = useQuery({
queryKey: ["extractions"], queryKey: ["extractions"],
queryFn: fetchExtractions, queryFn: fetchExtractions,
}); });
// Proposals from the extraction: shown with a take-over button, never
// written into a field by themselves.
const prefillQuery = useQuery({
queryKey: ["honey-batches", "prefill", extractionId],
queryFn: () => fetchHoneyBatchPrefill(extractionId),
enabled: extractionId !== "",
});
const prefill = extractionId ? prefillQuery.data : undefined;
const mutation = useMutation({ const mutation = useMutation({
mutationFn: createHoneyBatch, mutationFn: createHoneyBatch,
onSuccess: () => { onSuccess: (honeyBatch) => {
if (honeyBatch.warnings?.length) {
toastWarnings(honeyBatch.warnings, "Honigcharge angelegt");
}
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] }); void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
setOpen(false); setOpen(false);
setBatchNumber(""); setBatchNumber("");
setExtractionId(""); setExtractionId("");
setHoneyType(""); setHoneyType("");
setAmount(""); setAmount("");
setError(null); setWaterContent("");
setSaveError(null);
}, },
onError: () => { onError: (mutationError) => {
setError("Honigcharge konnte nicht angelegt werden."); setSaveError(describeSaveError(mutationError, "Honigcharge konnte nicht angelegt werden."));
}, },
}); });
function handleSubmit(event: FormEvent<HTMLFormElement>) { function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setError(null); setSaveError(null);
if (!extractionId) { if (!extractionId) {
setError("Bitte eine Schleuderung auswählen."); setSaveError({ general: null, fields: { extractionId: "Bitte eine Schleuderung auswählen." } });
return; return;
} }
mutation.mutate({ mutation.mutate({
@@ -61,6 +91,7 @@ export function CreateHoneyBatchDialog() {
extractionId, extractionId,
honeyType: emptyToNull(honeyType), honeyType: emptyToNull(honeyType),
amount: emptyToNull(amount, Number), amount: emptyToNull(amount, Number),
waterContent: emptyToNull(waterContent, Number),
}); });
} }
@@ -80,6 +111,7 @@ export function CreateHoneyBatchDialog() {
value={batchNumber} value={batchNumber}
onChange={(event) => setBatchNumber(event.target.value)} onChange={(event) => setBatchNumber(event.target.value)}
/> />
<FieldIssue message={fieldError("batchNumber")} />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="batch-extraction">Schleuderung</Label> <Label htmlFor="batch-extraction">Schleuderung</Label>
@@ -95,6 +127,7 @@ export function CreateHoneyBatchDialog() {
} }
placeholder="Schleuderung wählen" placeholder="Schleuderung wählen"
/> />
<FieldIssue message={fieldError("extractionId")} />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="batch-honey-type">Honigsorte</Label> <Label htmlFor="batch-honey-type">Honigsorte</Label>
@@ -103,6 +136,15 @@ export function CreateHoneyBatchDialog() {
value={honeyType} value={honeyType}
onChange={(event) => setHoneyType(event.target.value)} onChange={(event) => setHoneyType(event.target.value)}
/> />
{prefill?.honeyType && honeyType !== prefill.honeyType ? (
<PreviousValueHint
fieldLabel="Honigsorte"
valueLabel={prefill.honeyType}
prefix="Vorschlag aus den Ernten"
onTakeOver={() => setHoneyType(prefill.honeyType ?? "")}
/>
) : null}
<FieldIssue message={fieldError("honeyType")} />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="batch-amount">Menge (kg)</Label> <Label htmlFor="batch-amount">Menge (kg)</Label>
@@ -113,10 +155,47 @@ export function CreateHoneyBatchDialog() {
value={amount} value={amount}
onChange={(event) => setAmount(event.target.value)} onChange={(event) => setAmount(event.target.value)}
/> />
{prefill?.remainingKg != null && amount !== String(prefill.remainingKg) ? (
<PreviousValueHint
fieldLabel="Menge"
valueLabel={formatKg(prefill.remainingKg)}
prefix="Vorschlag aus der Schleuderung (Rest)"
onTakeOver={() => setAmount(String(prefill.remainingKg))}
/>
) : null}
<FieldIssue message={fieldError("amount")} />
</div> </div>
{error ? ( <div className="flex flex-col gap-2">
<Label htmlFor="batch-water-content">Wassergehalt (%)</Label>
<Input
id="batch-water-content"
type="number"
inputMode="decimal"
step="any"
value={waterContent}
onChange={(event) => setWaterContent(event.target.value)}
/>
{prefill?.waterContent != null && waterContent !== String(prefill.waterContent) ? (
<PreviousValueHint
fieldLabel="Wassergehalt"
valueLabel={`${prefill.waterContent.toLocaleString("de-DE")} %`}
prefix="Vorschlag aus der Schleuderung"
onTakeOver={() => setWaterContent(String(prefill.waterContent))}
/>
) : null}
<FieldIssue message={fieldError("waterContent")} />
</div>
<PrefillStatus
enabled={extractionId !== ""}
isLoading={prefillQuery.isLoading}
isError={prefillQuery.isError}
isRetrying={prefillQuery.isFetching}
onRetry={() => void prefillQuery.refetch()}
/>
<WarningList warnings={prefill?.warnings ?? []} />
{saveError?.general ? (
<p role="alert" className="text-sm text-destructive"> <p role="alert" className="text-sm text-destructive">
{error} {saveError.general}
</p> </p>
) : null} ) : null}
<DialogFooter> <DialogFooter>
@@ -42,6 +42,7 @@ describe("ProductDetailPage", () => {
description: null, description: null,
sku: null, sku: null,
packagingSize: null, packagingSize: null,
shelfLifeMonths: null,
weight: null, weight: null,
salesUnit: null, salesUnit: null,
status: "ACTIVE", status: "ACTIVE",
@@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { EditProductDialog } from "./edit-product-dialog";
import { updateProduct } from "@/lib/products/api";
import type { Product } from "@/lib/products/types";
vi.mock("@/lib/products/api", () => ({ updateProduct: vi.fn() }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const PRODUCT: Product = {
id: "p-1",
name: "Frühtracht",
description: null,
sku: null,
packagingSize: 500,
shelfLifeMonths: 24,
weight: null,
salesUnit: null,
status: "ACTIVE",
kind: "BOTTLED_HONEY",
shopCategory: "HONIG",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
async function openDialog(product: Product = PRODUCT) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<EditProductDialog product={product} />
</QueryClientProvider>,
);
await userEvent.click(screen.getByRole("button", { name: /Bearbeiten/ }));
}
describe("EditProductDialog", () => {
beforeEach(() => {
vi.mocked(updateProduct).mockReset();
vi.mocked(updateProduct).mockResolvedValue(PRODUCT);
});
it("the product form saves and clears shelfLifeMonths", async () => {
await openDialog();
const field = screen.getByLabelText(/Haltbarkeit \(Monate\)/);
expect(field).toHaveValue(24);
await userEvent.clear(field);
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateProduct).toHaveBeenCalled());
expect(vi.mocked(updateProduct).mock.calls[0][1]).toMatchObject({ shelfLifeMonths: null });
});
it("the product form labels packagingSize as grams and rejects 0 and values above 10000", async () => {
await openDialog();
const field = screen.getByLabelText(/Gewicht pro Glas \(g\)/);
for (const bad of ["0", "10001"]) {
await userEvent.clear(field);
await userEvent.type(field, bad);
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(screen.getByText("Bitte ganze Gramm von 1 bis 10000 angeben.")).toBeInTheDocument();
}
expect(updateProduct).not.toHaveBeenCalled();
await userEvent.clear(field);
await userEvent.type(field, "250");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateProduct).toHaveBeenCalled());
expect(vi.mocked(updateProduct).mock.calls[0][1]).toMatchObject({
packagingSize: 250,
shelfLifeMonths: 24,
});
});
it("renames a product with a legacy decimal packagingSize without converting it", async () => {
await openDialog({ ...PRODUCT, packagingSize: 250.5 });
const name = screen.getByLabelText("Name");
await userEvent.clear(name);
await userEvent.type(name, "Frühtracht neu");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateProduct).toHaveBeenCalled());
expect(vi.mocked(updateProduct).mock.calls[0][1]).toMatchObject({
name: "Frühtracht neu",
packagingSize: 250.5,
});
});
it("starts from the stored product each time it opens", async () => {
await openDialog();
const name = screen.getByLabelText("Name");
await userEvent.clear(name);
await userEvent.type(name, "Etwas anderes");
await userEvent.keyboard("{Escape}");
await waitFor(() => expect(screen.queryByLabelText("Name")).not.toBeInTheDocument());
await userEvent.click(screen.getByRole("button", { name: /Bearbeiten/ }));
expect(screen.getByLabelText("Name")).toHaveValue("Frühtracht");
});
});
@@ -6,6 +6,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { updateProduct } from "@/lib/products/api"; import { updateProduct } from "@/lib/products/api";
import { emptyToNull } from "@/lib/forms/empty-to-null"; import { emptyToNull } from "@/lib/forms/empty-to-null";
import { packagingSizeProblem, shelfLifeProblem } from "@/lib/products/validation";
import { Product, ProductStatus, ShopCategory } from "@/lib/products/types"; import { Product, ProductStatus, ShopCategory } from "@/lib/products/types";
import { import {
PRODUCT_KIND_LABELS, PRODUCT_KIND_LABELS,
@@ -33,11 +34,30 @@ export function EditProductDialog({ product }: { product: Product }) {
const [description, setDescription] = useState(product.description ?? ""); const [description, setDescription] = useState(product.description ?? "");
const [sku, setSku] = useState(product.sku ?? ""); const [sku, setSku] = useState(product.sku ?? "");
const [packagingSize, setPackagingSize] = useState(product.packagingSize?.toString() ?? ""); const [packagingSize, setPackagingSize] = useState(product.packagingSize?.toString() ?? "");
const [shelfLifeMonths, setShelfLifeMonths] = useState(product.shelfLifeMonths?.toString() ?? "");
const [nameError, setNameError] = useState<string | null>(null);
const [packagingError, setPackagingError] = useState<string | null>(null);
const [shelfLifeError, setShelfLifeError] = useState<string | null>(null);
const [weight, setWeight] = useState(product.weight?.toString() ?? ""); const [weight, setWeight] = useState(product.weight?.toString() ?? "");
const [salesUnit, setSalesUnit] = useState(product.salesUnit ?? ""); const [salesUnit, setSalesUnit] = useState(product.salesUnit ?? "");
const [status, setStatus] = useState<ProductStatus>(product.status); const [status, setStatus] = useState<ProductStatus>(product.status);
const [shopCategory, setShopCategory] = useState<ShopCategory>(product.shopCategory); const [shopCategory, setShopCategory] = useState<ShopCategory>(product.shopCategory);
function resetToProduct() {
setName(product.name);
setDescription(product.description ?? "");
setSku(product.sku ?? "");
setPackagingSize(product.packagingSize?.toString() ?? "");
setShelfLifeMonths(product.shelfLifeMonths?.toString() ?? "");
setWeight(product.weight?.toString() ?? "");
setSalesUnit(product.salesUnit ?? "");
setStatus(product.status);
setShopCategory(product.shopCategory);
setNameError(null);
setPackagingError(null);
setShelfLifeError(null);
}
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => mutationFn: () =>
updateProduct(product.id, { updateProduct(product.id, {
@@ -45,6 +65,7 @@ export function EditProductDialog({ product }: { product: Product }) {
description: emptyToNull(description), description: emptyToNull(description),
sku: emptyToNull(sku), sku: emptyToNull(sku),
packagingSize: emptyToNull(packagingSize, Number), packagingSize: emptyToNull(packagingSize, Number),
shelfLifeMonths: emptyToNull(shelfLifeMonths, Number),
weight: emptyToNull(weight, Number), weight: emptyToNull(weight, Number),
salesUnit: emptyToNull(salesUnit), salesUnit: emptyToNull(salesUnit),
status, status,
@@ -59,11 +80,36 @@ export function EditProductDialog({ product }: { product: Product }) {
function handleSubmit(event: FormEvent<HTMLFormElement>) { function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
// An unchanged legacy value (e.g. 250.5) must not block a rename; it is never converted.
const packagingChanged = packagingSize.trim() !== (product.packagingSize?.toString() ?? "");
const packaging = packagingChanged ? packagingSizeProblem(packagingSize) : null;
const shelfLife = shelfLifeProblem(shelfLifeMonths);
const missingName = name.trim() === "" ? "Bitte einen Namen eingeben." : null;
setNameError(missingName);
setPackagingError(packaging);
setShelfLifeError(shelfLife);
const firstInvalid = missingName
? "product-edit-name"
: packaging
? "product-edit-packaging-size"
: shelfLife
? "product-edit-shelf-life"
: null;
if (firstInvalid) {
document.getElementById(firstInvalid)?.focus();
return;
}
mutation.mutate(); mutation.mutate();
} }
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog
open={open}
onOpenChange={(next) => {
if (next) resetToProduct();
setOpen(next);
}}
>
<DialogTrigger <DialogTrigger
render={ render={
<Button variant="ghost" size="sm"> <Button variant="ghost" size="sm">
@@ -76,15 +122,25 @@ export function EditProductDialog({ product }: { product: Product }) {
<DialogHeader> <DialogHeader>
<DialogTitle>Produkt bearbeiten</DialogTitle> <DialogTitle>Produkt bearbeiten</DialogTitle>
</DialogHeader> </DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}> <form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="product-edit-name">Name</Label> <Label htmlFor="product-edit-name">Name</Label>
<Input <Input
id="product-edit-name" id="product-edit-name"
required required
aria-invalid={nameError ? true : undefined}
aria-describedby={nameError ? "product-edit-name-error" : undefined}
value={name} value={name}
onChange={(event) => setName(event.target.value)} onChange={(event) => {
setName(event.target.value);
setNameError(null);
}}
/> />
{nameError ? (
<p id="product-edit-name-error" role="alert" className="text-sm font-medium text-destructive">
{nameError}
</p>
) : null}
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="product-edit-description">Beschreibung</Label> <Label htmlFor="product-edit-description">Beschreibung</Label>
@@ -103,15 +159,50 @@ export function EditProductDialog({ product }: { product: Product }) {
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="product-edit-packaging-size">Gebindegröße</Label> <Label htmlFor="product-edit-packaging-size">Gewicht pro Glas (g)</Label>
<Input <Input
id="product-edit-packaging-size" id="product-edit-packaging-size"
type="number" type="number"
inputMode="decimal" inputMode="numeric"
step="0.01" step={1}
min={1}
max={10000}
aria-invalid={packagingError ? true : undefined}
aria-describedby={packagingError ? "product-edit-packaging-size-error" : undefined}
value={packagingSize} value={packagingSize}
onChange={(event) => setPackagingSize(event.target.value)} onChange={(event) => {
setPackagingSize(event.target.value);
setPackagingError(null);
}}
/> />
{packagingError ? (
<p id="product-edit-packaging-size-error" role="alert" className="text-sm font-medium text-destructive">
{packagingError}
</p>
) : null}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="product-edit-shelf-life">Haltbarkeit (Monate)</Label>
<Input
id="product-edit-shelf-life"
type="number"
inputMode="numeric"
step={1}
min={1}
max={60}
aria-invalid={shelfLifeError ? true : undefined}
aria-describedby={shelfLifeError ? "product-edit-shelf-life-error" : undefined}
value={shelfLifeMonths}
onChange={(event) => {
setShelfLifeMonths(event.target.value);
setShelfLifeError(null);
}}
/>
{shelfLifeError ? (
<p id="product-edit-shelf-life-error" role="alert" className="text-sm font-medium text-destructive">
{shelfLifeError}
</p>
) : null}
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="product-edit-weight">Gewicht</Label> <Label htmlFor="product-edit-weight">Gewicht</Label>
@@ -44,7 +44,7 @@ describe("NewProductPage", () => {
renderPage(); renderPage();
await userEvent.type(screen.getByLabelText(/Name/), "Frühtracht"); await userEvent.type(screen.getByLabelText(/Name/), "Frühtracht");
await userEvent.type(screen.getByLabelText(/Gebindegröße/), "500"); await userEvent.type(screen.getByLabelText(/Gewicht pro Glas/), "500");
await userEvent.click( await userEvent.click(
screen.getByRole("button", { name: /Weitere Angaben/ }), screen.getByRole("button", { name: /Weitere Angaben/ }),
); );
@@ -59,6 +59,7 @@ describe("NewProductPage", () => {
packagingSize: 500, packagingSize: 500,
weight: null, weight: null,
salesUnit: null, salesUnit: null,
shelfLifeMonths: null,
kind: "BOTTLED_HONEY", kind: "BOTTLED_HONEY",
shopCategory: "HONIG", shopCategory: "HONIG",
}); });
@@ -73,4 +74,45 @@ describe("NewProductPage", () => {
expect(screen.getByLabelText(/Name/)).toHaveFocus(); expect(screen.getByLabelText(/Name/)).toHaveFocus();
expect(createProduct).not.toHaveBeenCalled(); expect(createProduct).not.toHaveBeenCalled();
}); });
it("the product form saves shelfLifeMonths", async () => {
vi.mocked(createProduct).mockResolvedValue({ id: "p-1" } as never);
renderPage();
await userEvent.type(screen.getByLabelText(/Name/), "Frühtracht");
await userEvent.type(screen.getByLabelText(/Haltbarkeit \(Monate\)/), "24");
await userEvent.click(screen.getByRole("button", { name: "Produkt anlegen" }));
expect(vi.mocked(createProduct).mock.calls[0][0]).toMatchObject({ shelfLifeMonths: 24 });
});
it("the product form labels packagingSize as grams and rejects 0 and values above 10000", async () => {
renderPage();
const field = screen.getByLabelText(/Gewicht pro Glas \(g\)/);
await userEvent.type(screen.getByLabelText(/Name/), "Frühtracht");
for (const bad of ["0", "10001", "250.5"]) {
await userEvent.clear(field);
await userEvent.type(field, bad);
await userEvent.click(screen.getByRole("button", { name: "Produkt anlegen" }));
expect(screen.getByText("Bitte ganze Gramm von 1 bis 10000 angeben.")).toBeInTheDocument();
expect(field).toHaveFocus();
}
expect(createProduct).not.toHaveBeenCalled();
await userEvent.clear(field);
await userEvent.type(field, "10000");
await userEvent.click(screen.getByRole("button", { name: "Produkt anlegen" }));
expect(vi.mocked(createProduct).mock.calls[0][0]).toMatchObject({ packagingSize: 10000 });
});
it("rejects a shelf life outside 1 to 60 months", async () => {
renderPage();
await userEvent.type(screen.getByLabelText(/Name/), "Frühtracht");
await userEvent.type(screen.getByLabelText(/Haltbarkeit \(Monate\)/), "61");
await userEvent.click(screen.getByRole("button", { name: "Produkt anlegen" }));
expect(screen.getByText("Bitte ganze Monate von 1 bis 60 angeben.")).toBeInTheDocument();
expect(createProduct).not.toHaveBeenCalled();
});
}); });
@@ -166,17 +166,21 @@ export default function NewProductPage() {
)} )}
</Field> </Field>
<Field <Field
label="Gebindegröße (g)" label="Gewicht pro Glas (g)"
hint="Füllmenge in Gramm, z. B. 500" hint="Honig pro Glas in Gramm, z. B. 500. Die Abfüllung übernimmt den Wert."
error={errors.packagingSize}
> >
{({ id, describedBy }) => ( {({ id, describedBy, invalid }) => (
<Input <Input
id={id} id={id}
data-field="packagingSize"
aria-describedby={describedBy} aria-describedby={describedBy}
aria-invalid={invalid}
type="number" type="number"
inputMode="decimal" inputMode="numeric"
step="0.01" step={1}
min={0} min={1}
max={10000}
value={values.packagingSize} value={values.packagingSize}
onChange={(event) => onChange={(event) =>
set("packagingSize", event.target.value) set("packagingSize", event.target.value)
@@ -184,6 +188,29 @@ export default function NewProductPage() {
/> />
)} )}
</Field> </Field>
<Field
label="Haltbarkeit (Monate)"
hint="Daraus rechnet die Abfüllung das MHD. Leer lassen, wenn es keine feste Haltbarkeit gibt."
error={errors.shelfLifeMonths}
>
{({ id, describedBy, invalid }) => (
<Input
id={id}
data-field="shelfLifeMonths"
aria-describedby={describedBy}
aria-invalid={invalid}
type="number"
inputMode="numeric"
step={1}
min={1}
max={60}
value={values.shelfLifeMonths}
onChange={(event) =>
set("shelfLifeMonths", event.target.value)
}
/>
)}
</Field>
</FormSection> </FormSection>
<FormSection <FormSection
@@ -4,12 +4,14 @@ import type {
ShopCategory, ShopCategory,
} from "@/lib/products/types"; } from "@/lib/products/types";
import { emptyToNull } from "@/lib/forms/empty-to-null"; import { emptyToNull } from "@/lib/forms/empty-to-null";
import { packagingSizeProblem, shelfLifeProblem } from "@/lib/products/validation";
/** Everything the form holds, as plain values that survive a draft save. */ /** Everything the form holds, as plain values that survive a draft save. */
export interface ProductFormValues { export interface ProductFormValues {
name: string; name: string;
salesUnit: string; salesUnit: string;
packagingSize: string; packagingSize: string;
shelfLifeMonths: string;
weight: string; weight: string;
sku: string; sku: string;
description: string; description: string;
@@ -24,6 +26,7 @@ export function initialProductValues(): ProductFormValues {
name: "", name: "",
salesUnit: "", salesUnit: "",
packagingSize: "", packagingSize: "",
shelfLifeMonths: "",
weight: "", weight: "",
sku: "", sku: "",
description: "", description: "",
@@ -36,6 +39,10 @@ export function initialProductValues(): ProductFormValues {
export function validateProduct(values: ProductFormValues): ProductFormErrors { export function validateProduct(values: ProductFormValues): ProductFormErrors {
const errors: ProductFormErrors = {}; const errors: ProductFormErrors = {};
if (!values.name.trim()) errors.name = "Bitte einen Namen eingeben."; if (!values.name.trim()) errors.name = "Bitte einen Namen eingeben.";
const packaging = packagingSizeProblem(values.packagingSize);
if (packaging) errors.packagingSize = packaging;
const shelfLife = shelfLifeProblem(values.shelfLifeMonths);
if (shelfLife) errors.shelfLifeMonths = shelfLife;
return errors; return errors;
} }
@@ -45,6 +52,7 @@ export function toProductInput(values: ProductFormValues): CreateProductInput {
description: emptyToNull(values.description), description: emptyToNull(values.description),
sku: emptyToNull(values.sku), sku: emptyToNull(values.sku),
packagingSize: emptyToNull(values.packagingSize, Number), packagingSize: emptyToNull(values.packagingSize, Number),
shelfLifeMonths: emptyToNull(values.shelfLifeMonths, Number),
weight: emptyToNull(values.weight, Number), weight: emptyToNull(values.weight, Number),
salesUnit: emptyToNull(values.salesUnit), salesUnit: emptyToNull(values.salesUnit),
kind: values.kind, kind: values.kind,
@@ -23,6 +23,14 @@ const settings: NotificationSettings = {
quietUntil: "06:30", quietUntil: "06:30",
telegramCategories: ["TASK_DUE", "DIGEST"], telegramCategories: ["TASK_DUE", "DIGEST"],
telegramDetail: true, telegramDetail: true,
telegram: {
botNameSet: true,
botUsername: "apicore_bot",
linked: false,
firstName: null,
linkedAt: null,
enabled: false,
},
}; };
let client: QueryClient; let client: QueryClient;
@@ -1,19 +1,24 @@
"use client"; "use client";
import { FormEvent, useId, useRef, useState } from "react"; import { FormEvent, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
fetchNotificationSettings, fetchNotificationSettings,
updateNotificationSettings, updateNotificationSettings,
} from "@/lib/notification-settings/api"; } from "@/lib/notification-settings/api";
import type { NotificationSettings } from "@/lib/notification-settings/types"; import { NOTIFICATION_SETTINGS_QUERY_KEY as QUERY_KEY } from "@/lib/notification-settings/query-key";
import type {
NotificationSettings,
NotificationSettingsPatch,
} from "@/lib/notification-settings/types";
import { import {
settingsPatch, settingsPatch,
validateDigestTime, validateDigestTime,
validateQuietHours, validateQuietHours,
} from "@/lib/notification-settings/validation"; } from "@/lib/notification-settings/validation";
import { errorMessage } from "@/lib/forms/error-message"; import { errorMessage } from "@/lib/forms/error-message";
import { SwitchRow } from "@/components/form/switch-row";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -26,45 +31,6 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { ErrorState, LoadingState } from "@/components/query-state"; import { ErrorState, LoadingState } from "@/components/query-state";
const QUERY_KEY = ["notification-settings"] as const;
interface SwitchRowProps {
label: string;
description?: string;
checked: boolean;
onChange: (checked: boolean) => void;
}
function SwitchRow({ label, description, checked, onChange }: SwitchRowProps) {
const id = useId();
return (
<label className="flex min-h-11 cursor-pointer items-start gap-3 rounded-xl border p-3 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-ring">
<input
type="checkbox"
role="switch"
aria-labelledby={`${id}-label`}
aria-describedby={description ? `${id}-description` : undefined}
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="mt-1 size-4 accent-[var(--primary)]"
/>
<span className="flex flex-col">
<span id={`${id}-label`} className="font-medium">
{label}
</span>
{description ? (
<span
id={`${id}-description`}
className="text-sm text-muted-foreground"
>
{description}
</span>
) : null}
</span>
</label>
);
}
interface FormErrors { interface FormErrors {
digestTime?: string; digestTime?: string;
quiet?: string; quiet?: string;
@@ -84,7 +50,7 @@ function NotificationSettingsForm({
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (patch: Partial<NotificationSettings>) => mutationFn: (patch: NotificationSettingsPatch) =>
updateNotificationSettings(patch), updateNotificationSettings(patch),
onSuccess: (saved) => { onSuccess: (saved) => {
queryClient.setQueryData(QUERY_KEY, saved); queryClient.setQueryData(QUERY_KEY, saved);
@@ -5,6 +5,7 @@ import { UserRound } from "lucide-react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { useAuth } from "@/lib/auth/auth-context"; import { useAuth } from "@/lib/auth/auth-context";
import { stepUpErrorText } from "@/lib/auth/step-up-error";
import { import {
changePassword, changePassword,
deleteAvatar, deleteAvatar,
@@ -28,6 +29,7 @@ import { MfaSection } from "./mfa-section";
import { DangerZoneSection } from "./danger-zone-section"; import { DangerZoneSection } from "./danger-zone-section";
import { MotionSection } from "./motion-section"; import { MotionSection } from "./motion-section";
import { NotificationSettingsCard } from "./notification-settings-card"; import { NotificationSettingsCard } from "./notification-settings-card";
import { TelegramLinkCard } from "./telegram-link-card";
function splitName(fullName: string): { firstName: string; lastName: string } { function splitName(fullName: string): { firstName: string; lastName: string } {
const [firstName, ...rest] = fullName.trim().split(/\s+/); const [firstName, ...rest] = fullName.trim().split(/\s+/);
@@ -226,7 +228,7 @@ function PasswordSection() {
description: "Andere angemeldete Geräte wurden abgemeldet.", description: "Andere angemeldete Geräte wurden abgemeldet.",
}); });
}, },
onError: () => setError("Aktuelles Passwort ist falsch."), onError: (cause) => setError(stepUpErrorText(cause, "Aktuelles Passwort ist falsch.")),
}); });
function handleSubmit(event: FormEvent<HTMLFormElement>) { function handleSubmit(event: FormEvent<HTMLFormElement>) {
@@ -304,6 +306,7 @@ export default function ProfilePage() {
<PasswordSection /> <PasswordSection />
<MfaSection mfaEnabled={user.mfaEnabled} onChanged={() => void refreshUser()} /> <MfaSection mfaEnabled={user.mfaEnabled} onChanged={() => void refreshUser()} />
<NotificationSettingsCard /> <NotificationSettingsCard />
<TelegramLinkCard />
<MotionSection /> <MotionSection />
<DangerZoneSection /> <DangerZoneSection />
</div> </div>
@@ -0,0 +1,282 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TelegramLinkCard } from "./telegram-link-card";
import {
fetchNotificationSettings,
updateNotificationSettings,
} from "@/lib/notification-settings/api";
import { createTelegramLinkToken, unlinkTelegram } from "@/lib/telegram/api";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import type { NotificationSettings } from "@/lib/notification-settings/types";
vi.mock("@/lib/notification-settings/api", () => ({
fetchNotificationSettings: vi.fn(),
updateNotificationSettings: vi.fn(),
}));
vi.mock("@/lib/telegram/api", () => ({
createTelegramLinkToken: vi.fn(),
unlinkTelegram: vi.fn(),
}));
let mfaEnabled = false;
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ user: { mfaEnabled } }),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const notLinked: NotificationSettings = {
taskDueEnabled: true,
digestEnabled: true,
digestTime: "06:30",
quietFrom: null,
quietUntil: null,
telegramCategories: ["TASK_DUE", "DIGEST"],
telegramDetail: true,
telegram: {
botNameSet: true,
botUsername: "apicore_bot",
linked: false,
firstName: null,
linkedAt: null,
enabled: false,
},
};
const linked: NotificationSettings = {
...notLinked,
telegram: {
...notLinked.telegram,
linked: true,
firstName: "Nick",
linkedAt: "2026-10-14T10:03:00.000Z",
enabled: true,
},
};
const token = {
deepLink: "https://t.me/apicore_bot?start=abc",
// 12:10 in Berlin (CEST).
expiresAt: "2026-10-14T10:10:00.000Z",
};
function renderCard() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<TelegramLinkCard />
</QueryClientProvider>,
);
}
async function connect(user: ReturnType<typeof userEvent.setup>) {
await user.click(await screen.findByRole("button", { name: "Telegram verbinden" }));
const dialog = await screen.findByRole("dialog");
await user.type(within(dialog).getByLabelText("Ihr Passwort"), "geheim");
await user.click(within(dialog).getByRole("button", { name: "Link erzeugen" }));
}
describe("TelegramLinkCard", () => {
beforeEach(() => {
mfaEnabled = false;
vi.mocked(fetchNotificationSettings).mockReset().mockResolvedValue(notLinked);
vi.mocked(updateNotificationSettings).mockReset().mockResolvedValue(notLinked);
vi.mocked(createTelegramLinkToken).mockReset().mockResolvedValue(token);
vi.mocked(unlinkTelegram).mockReset().mockResolvedValue(undefined);
});
afterEach(() => {
vi.useRealTimers();
act(() => reportRequestOutcome("reached-server"));
});
it("asks for the password before creating the link", async () => {
const user = userEvent.setup();
renderCard();
await connect(user);
await waitFor(() =>
expect(createTelegramLinkToken).toHaveBeenCalledWith({ password: "geheim" }),
);
});
it("asks for the TOTP code too when MFA is on", async () => {
mfaEnabled = true;
const user = userEvent.setup();
renderCard();
await user.click(await screen.findByRole("button", { name: "Telegram verbinden" }));
const dialog = await screen.findByRole("dialog");
await user.type(within(dialog).getByLabelText("Ihr Passwort"), "geheim");
await user.type(within(dialog).getByLabelText("Code aus der Authenticator-App"), "123456");
await user.click(within(dialog).getByRole("button", { name: "Link erzeugen" }));
await waitFor(() =>
expect(createTelegramLinkToken).toHaveBeenCalledWith({
password: "geheim",
mfaCode: "123456",
}),
);
});
it("shows „In Telegram öffnen“ with the expiry time and later „Verbunden mit „Nick““", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.setSystemTime(new Date("2026-10-14T10:00:00.000Z"));
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
renderCard();
await connect(user);
const open = await screen.findByRole("link", { name: /In Telegram öffnen/ });
expect(open).toHaveAttribute("href", token.deepLink);
expect(open).toHaveAttribute("target", "_blank");
expect(open).toHaveAttribute("rel", "noopener noreferrer");
expect(screen.getByText("Gültig bis 12:10")).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("Nicht verbunden");
vi.mocked(fetchNotificationSettings).mockResolvedValue(linked);
await act(async () => {
await vi.advanceTimersByTimeAsync(5000);
});
await waitFor(() =>
expect(screen.getByRole("status")).toHaveTextContent("Verbunden mit „Nick“ seit 14.10.2026"),
);
expect(screen.queryByRole("link", { name: /In Telegram öffnen/ })).not.toBeInTheDocument();
});
it("moves focus to the open-in-Telegram link once the link exists", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.setSystemTime(new Date("2026-10-14T10:00:00.000Z"));
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
renderCard();
await connect(user);
const open = await screen.findByRole("link", { name: /In Telegram öffnen/ });
await waitFor(() => expect(open).toHaveFocus());
});
it("refuses a link that does not point to Telegram and says so", async () => {
vi.mocked(createTelegramLinkToken).mockResolvedValue({
...token,
deepLink: "https://evil.example/?start=abc",
});
const user = userEvent.setup();
renderCard();
await connect(user);
expect(await screen.findByText(/Der Telegram-Link ist ungültig/)).toBeInTheDocument();
expect(screen.queryByRole("link", { name: /In Telegram öffnen/ })).not.toBeInTheDocument();
});
it("disables Trennen with the offline reason", async () => {
vi.mocked(fetchNotificationSettings).mockResolvedValue(linked);
renderCard();
const trennen = await screen.findByRole("button", { name: "Trennen" });
expect(trennen).not.toHaveAttribute("aria-describedby");
act(() => reportRequestOutcome("network-error"));
await waitFor(() => expect(screen.getByRole("button", { name: "Trennen" })).toBeDisabled());
const described = screen.getByRole("button", { name: "Trennen" }).getAttribute("aria-describedby");
expect(described).toBeTruthy();
expect(document.getElementById(described as string)).toHaveTextContent("Nur mit Verbindung");
});
it("disables the category chips while a save runs", async () => {
vi.mocked(fetchNotificationSettings).mockResolvedValue(linked);
vi.mocked(updateNotificationSettings).mockReturnValue(new Promise(() => {}));
const user = userEvent.setup();
renderCard();
await user.click(await screen.findByRole("checkbox", { name: "System" }));
await waitFor(() => expect(screen.getByRole("checkbox", { name: "Vorschläge" })).toBeDisabled());
});
it("„Trennen“ asks and unlinks", async () => {
vi.mocked(fetchNotificationSettings).mockResolvedValue(linked);
const user = userEvent.setup();
renderCard();
await user.click(await screen.findByRole("button", { name: "Trennen" }));
const dialog = await screen.findByRole("dialog");
expect(unlinkTelegram).not.toHaveBeenCalled();
await user.click(within(dialog).getByRole("button", { name: "Trennen" }));
await waitFor(() => expect(unlinkTelegram).toHaveBeenCalled());
});
it("shows no old link after linking and then Trennen", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.setSystemTime(new Date("2026-10-14T10:00:00.000Z"));
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
renderCard();
await connect(user);
await screen.findByRole("link", { name: /In Telegram öffnen/ });
vi.mocked(fetchNotificationSettings).mockResolvedValue(linked);
await act(async () => {
await vi.advanceTimersByTimeAsync(5000);
});
await waitFor(() =>
expect(screen.queryByRole("link", { name: /In Telegram öffnen/ })).not.toBeInTheDocument(),
);
vi.mocked(fetchNotificationSettings).mockResolvedValue(notLinked);
await user.click(await screen.findByRole("button", { name: "Trennen" }));
const dialog = await screen.findByRole("dialog");
await user.click(within(dialog).getByRole("button", { name: "Trennen" }));
await screen.findByRole("button", { name: "Telegram verbinden" });
expect(screen.queryByRole("link", { name: /In Telegram öffnen/ })).not.toBeInTheDocument();
});
it("after Trennen focus lands on Telegram verbinden", async () => {
vi.mocked(fetchNotificationSettings).mockResolvedValueOnce(linked).mockResolvedValue(notLinked);
const user = userEvent.setup();
renderCard();
await user.click(await screen.findByRole("button", { name: "Trennen" }));
const dialog = await screen.findByRole("dialog");
await user.click(within(dialog).getByRole("button", { name: "Trennen" }));
const connectButton = await screen.findByRole("button", { name: "Telegram verbinden" });
await waitFor(() => expect(connectButton).toHaveFocus());
});
it("without a bot shows „Telegram ist nicht eingerichtet.“ and no button", async () => {
vi.mocked(fetchNotificationSettings).mockResolvedValue({
...notLinked,
telegram: { ...notLinked.telegram, botNameSet: false, botUsername: null },
});
renderCard();
expect(await screen.findByText("Telegram ist nicht eingerichtet.")).toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("saves the categories and „Mit Details“ separately when connected", async () => {
vi.mocked(fetchNotificationSettings).mockResolvedValue(linked);
vi.mocked(updateNotificationSettings).mockResolvedValue({
...linked,
telegramCategories: ["TASK_DUE", "DIGEST", "SYSTEM"],
});
const user = userEvent.setup();
renderCard();
await user.click(await screen.findByRole("checkbox", { name: "System" }));
await waitFor(() =>
expect(updateNotificationSettings).toHaveBeenCalledWith({
telegramCategories: ["TASK_DUE", "DIGEST", "SYSTEM"],
}),
);
expect(
screen.getByText(
"Nachrichten laufen über Server von Telegram und sind dort nicht Ende-zu-Ende verschlüsselt.",
),
).toBeInTheDocument();
await user.click(screen.getByRole("switch", { name: "Mit Details" }));
await waitFor(() =>
expect(updateNotificationSettings).toHaveBeenCalledWith({ telegramDetail: false }),
);
});
});
@@ -0,0 +1,293 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
import { ExternalLink, Send } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useAuth } from "@/lib/auth/auth-context";
import {
fetchNotificationSettings,
updateNotificationSettings,
} from "@/lib/notification-settings/api";
import { NOTIFICATION_SETTINGS_QUERY_KEY as QUERY_KEY } from "@/lib/notification-settings/query-key";
import type {
NotificationSettings,
NotificationSettingsPatch,
} from "@/lib/notification-settings/types";
import {
NOTIFICATION_CATEGORIES,
NOTIFICATION_CATEGORY_LABELS,
} from "@/lib/notifications/labels";
import type { NotificationCategory } from "@/lib/notifications/types";
import { saveErrorText } from "@/lib/honey-batches/errors";
import { createTelegramLinkToken, unlinkTelegram } from "@/lib/telegram/api";
import { formatBerlinDate, formatBerlinTime } from "@/lib/telegram/format";
import type { StepUpProof, TelegramLinkToken } from "@/lib/telegram/types";
import { StepUpDialog } from "@/components/auth/step-up-dialog";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { SwitchRow } from "@/components/form/switch-row";
import { ToggleChip } from "@/components/form/toggle-chip";
import { ErrorState } from "@/components/query-state";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
/** While a link is open, the status is reloaded this often to notice "/start". */
export const LINK_STATUS_POLL_MS = 5000;
/** Only a link to Telegram itself is ever shown as the way out of the app. */
const TELEGRAM_LINK_PREFIX = "https://t.me/";
const INVALID_LINK_TEXT = "Der Telegram-Link ist ungültig. Bitte später noch einmal versuchen.";
const PRIVACY_NOTE =
"Nachrichten laufen über Server von Telegram und sind dort nicht Ende-zu-Ende verschlüsselt.";
function isOpenLink(link: TelegramLinkToken | null): link is TelegramLinkToken {
return link !== null && Date.parse(link.expiresAt) > Date.now();
}
function statusText(settings: NotificationSettings | undefined): string {
if (!settings) return "Status wird geladen…";
const { telegram } = settings;
if (!telegram.botNameSet) return "Telegram ist nicht eingerichtet.";
if (!telegram.linked) return "Nicht verbunden";
const name = telegram.firstName ? `„${telegram.firstName}“` : "Telegram";
const since = telegram.linkedAt ? ` seit ${formatBerlinDate(telegram.linkedAt)}` : "";
return `Verbunden mit ${name}${since}`;
}
interface DeliverySettingsProps {
settings: NotificationSettings;
isOffline: boolean;
/** Id of the „Nur mit Verbindung“ hint the card renders once. */
hintId: string;
}
/** What goes to Telegram. Saved on its own, like the other settings. */
function DeliverySettings({ settings, isOffline, hintId }: DeliverySettingsProps) {
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: (patch: NotificationSettingsPatch) => updateNotificationSettings(patch),
onSuccess: (saved) => {
queryClient.setQueryData(QUERY_KEY, saved);
setError(null);
},
onError: (cause) => setError(saveErrorText(cause, "Die Einstellung wurde nicht gespeichert.")),
});
function toggleCategory(category: NotificationCategory, checked: boolean) {
if (mutation.isPending || isOffline) return;
const selected = new Set(settings.telegramCategories);
if (checked) selected.add(category);
else selected.delete(category);
mutation.mutate({
telegramCategories: NOTIFICATION_CATEGORIES.filter((entry) => selected.has(entry)),
});
}
return (
<div className="flex flex-col gap-3">
<fieldset className="flex flex-col gap-2">
<legend className="text-sm font-medium">Das kommt per Telegram</legend>
<div className="flex flex-wrap gap-2">
{NOTIFICATION_CATEGORIES.map((category) => (
<ToggleChip
key={category}
label={NOTIFICATION_CATEGORY_LABELS[category]}
checked={settings.telegramCategories.includes(category)}
onChange={(checked) => toggleCategory(category, checked)}
describedBy={isOffline ? hintId : undefined}
disabled={mutation.isPending || isOffline}
/>
))}
</div>
</fieldset>
<SwitchRow
label="Mit Details"
description={PRIVACY_NOTE}
checked={settings.telegramDetail}
disabled={mutation.isPending || isOffline}
onChange={(checked) => mutation.mutate({ telegramDetail: checked })}
/>
{error ? (
<p role="alert" className="text-sm font-medium text-destructive">
{error}
</p>
) : null}
</div>
);
}
interface OpenLinkProps {
link: TelegramLinkToken;
anchorRef: RefObject<HTMLAnchorElement | null>;
}
/** The one-time link as the big primary action; it holds a secret, so it only lives here. */
function OpenLink({ link, anchorRef }: OpenLinkProps) {
return (
<div className="flex flex-col items-start gap-2">
<a
ref={anchorRef}
href={link.deepLink}
target="_blank"
rel="noopener noreferrer"
className={buttonVariants({ size: "touch", className: "w-full sm:w-auto" })}
>
<ExternalLink aria-hidden />
In Telegram öffnen
</a>
<p className="text-sm text-muted-foreground">Gültig bis {formatBerlinTime(link.expiresAt)}</p>
</div>
);
}
export function TelegramLinkCard() {
const { user } = useAuth();
const queryClient = useQueryClient();
const [link, setLink] = useState<TelegramLinkToken | null>(null);
const [isStepUpOpen, setIsStepUpOpen] = useState(false);
const [linkError, setLinkError] = useState<string | null>(null);
const { isOffline, hintId, describedBy } = useOnlineOnly();
const anchorRef = useRef<HTMLAnchorElement>(null);
const connectRef = useRef<HTMLButtonElement>(null);
const focusConnectNext = useRef(false);
const query = useQuery({
queryKey: QUERY_KEY,
queryFn: fetchNotificationSettings,
// Only while a link can still be used: then "/start" in Telegram shows up here.
refetchInterval: (current) =>
isOpenLink(link) && !current.state.data?.telegram.linked ? LINK_STATUS_POLL_MS : false,
});
// The link stops being shown at its expiry.
useEffect(() => {
if (!link) return;
const timer = setTimeout(() => setLink(null), Math.max(Date.parse(link.expiresAt) - Date.now(), 0));
return () => clearTimeout(timer);
}, [link]);
const unlink = useMutation({
mutationFn: unlinkTelegram,
onSuccess: async () => {
setLink(null);
toast.success("Telegram getrennt");
focusConnectNext.current = true;
await queryClient.invalidateQueries({ queryKey: QUERY_KEY });
},
onError: (cause) => toast.error(saveErrorText(cause, "Telegram konnte nicht getrennt werden.")),
});
async function createLink(proof: StepUpProof): Promise<void> {
// Not a React Query mutation: its result would stay in the cache.
const created = await createTelegramLinkToken(proof);
if (!created.deepLink.startsWith(TELEGRAM_LINK_PREFIX)) {
setLinkError(INVALID_LINK_TEXT);
return;
}
setLinkError(null);
setLink(created);
}
const settings = query.data;
const botNameSet = settings?.telegram.botNameSet ?? false;
const isLinked = settings?.telegram.linked ?? false;
const openLink = !isLinked && isOpenLink(link) ? link : null;
const canConnect = Boolean(settings) && botNameSet && !isLinked && !openLink;
// Connected (e.g. via /start): the one-time link is spent.
// Adjusted while rendering (the documented alternative to an effect that sets state).
const settingsLinked = Boolean(query.data?.telegram.linked);
const [wasLinked, setWasLinked] = useState(false);
if (settingsLinked !== wasLinked) {
setWasLinked(settingsLinked);
if (settingsLinked) setLink(null);
}
// After „Trennen“ the person lands on the way back in, not on a vanished button.
useEffect(() => {
if (canConnect && focusConnectNext.current) {
focusConnectNext.current = false;
connectRef.current?.focus();
}
}, [canConnect]);
return (
<Card>
<CardHeader>
<CardTitle>Telegram</CardTitle>
<CardDescription>Erinnerungen auch aufs Handy, wenn die App zu ist.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<p role="status" className="flex items-center gap-2 text-sm font-medium">
<Send className="size-4 shrink-0" aria-hidden />
{statusText(settings)}
</p>
{query.isError ? (
<ErrorState
what="Telegram-Einstellungen"
onRetry={() => void query.refetch()}
isRetrying={query.isFetching}
/>
) : null}
{linkError ? (
<p role="alert" className="text-sm font-medium text-destructive">
{linkError}
</p>
) : null}
{openLink ? <OpenLink link={openLink} anchorRef={anchorRef} /> : null}
{canConnect ? (
<Button
ref={connectRef}
type="button"
size="touch"
className="self-start"
disabled={isOffline}
aria-describedby={describedBy}
onClick={() => setIsStepUpOpen(true)}
>
Telegram verbinden
</Button>
) : null}
{settings && botNameSet && isLinked ? (
<>
<DeliverySettings settings={settings} isOffline={isOffline} hintId={hintId} />
<ConfirmDialog
triggerLabel="Trennen"
triggerSize="touch"
triggerClassName="self-start"
triggerDisabled={isOffline || unlink.isPending}
triggerDescribedBy={describedBy}
title="Telegram trennen?"
description="Du bekommst dann keine Nachrichten mehr per Telegram. Verbinden geht jederzeit wieder."
confirmLabel="Trennen"
isPending={unlink.isPending}
onConfirm={() => unlink.mutate()}
/>
</>
) : null}
{botNameSet ? <OnlineOnlyHint id={hintId} /> : null}
<StepUpDialog
open={isStepUpOpen}
onOpenChange={setIsStepUpOpen}
title="Telegram verbinden?"
description="Zur Sicherheit fragen wir noch einmal nach Ihrem Passwort. Danach erscheint ein Link, der 10 Minuten gilt."
confirmLabel="Link erzeugen"
needsMfa={Boolean(user?.mfaEnabled)}
onConfirm={createLink}
finalFocusRef={anchorRef}
/>
</CardContent>
</Card>
);
}
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactElement } from "react"; import type { ReactElement } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import BackupPage from "./page"; import BackupPage from "./page";
import { ApiError } from "@/lib/auth/api-client";
import { import {
exportBackup, exportBackup,
getBackupSchedule, getBackupSchedule,
@@ -175,12 +176,58 @@ describe("BackupPage", () => {
fireEvent.change(screen.getByLabelText("Backup-Datei einspielen"), { fireEvent.change(screen.getByLabelText("Backup-Datei einspielen"), {
target: { files: [new File(["{}"], "backup.json", { type: "application/json" })] }, target: { files: [new File(["{}"], "backup.json", { type: "application/json" })] },
}); });
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "geheim");
await userEvent.click(screen.getByRole("button", { name: "Backup importieren" })); await userEvent.click(screen.getByRole("button", { name: "Backup importieren" }));
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
await userEvent.type(within(dialog).getByLabelText("Ihr Passwort"), "geheim");
await userEvent.click(within(dialog).getByRole("button", { name: "Einspielen" })); await userEvent.click(within(dialog).getByRole("button", { name: "Einspielen" }));
} }
it("the backup import still asks for the password through StepUpDialog", async () => {
vi.mocked(getBackupSchedule).mockResolvedValue(schedule());
vi.mocked(importBackup).mockResolvedValue({ imported: { apiary: 1 }, skipped: [], sealed: true });
renderWithClient(<BackupPage />);
expect(await screen.findByText("Aktiv")).toBeInTheDocument();
// No loose password field on the page any more.
expect(screen.queryByLabelText("Ihr Passwort")).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Backup-Datei einspielen"), {
target: { files: [new File(["{}"], "backup.json", { type: "application/json" })] },
});
await userEvent.click(screen.getByRole("button", { name: "Backup importieren" }));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByRole("button", { name: "Einspielen" })).toBeDisabled();
await userEvent.type(within(dialog).getByLabelText("Ihr Passwort"), "geheim");
await userEvent.click(within(dialog).getByRole("button", { name: "Einspielen" }));
await vi.waitFor(() =>
expect(importBackup).toHaveBeenCalledWith(expect.any(File), {
password: "geheim",
mfaCode: undefined,
}),
);
});
it("shows why an import failed inside the dialog and keeps the file selected", async () => {
vi.mocked(getBackupSchedule).mockResolvedValue(schedule());
vi.mocked(importBackup)
.mockRejectedValueOnce(new ApiError(401, "Das Passwort ist falsch."))
.mockResolvedValue({ imported: { apiary: 1 }, skipped: [], sealed: true });
renderWithClient(<BackupPage />);
await importFile();
const dialog = await screen.findByRole("dialog");
await vi.waitFor(() =>
expect(within(dialog).getByRole("alert")).toHaveTextContent("Das Passwort ist falsch."),
);
expect(toast.error).not.toHaveBeenCalled();
// Still open, file still chosen: a second try needs only the password.
await userEvent.type(within(dialog).getByLabelText("Ihr Passwort"), "richtig");
await userEvent.click(within(dialog).getByRole("button", { name: "Einspielen" }));
await vi.waitFor(() => expect(importBackup).toHaveBeenCalledTimes(2));
expect(vi.mocked(importBackup).mock.calls[1]?.[0]).toBeInstanceOf(File);
});
it("lists what an old, unencrypted backup did not restore and keeps that visible", async () => { it("lists what an old, unencrypted backup did not restore and keeps that visible", async () => {
vi.mocked(getBackupSchedule).mockResolvedValue(schedule()); vi.mocked(getBackupSchedule).mockResolvedValue(schedule());
vi.mocked(importBackup).mockResolvedValue({ vi.mocked(importBackup).mockResolvedValue({
@@ -12,17 +12,18 @@ import {
runBackupNow, runBackupNow,
updateBackupSettings, updateBackupSettings,
} from "@/lib/backup/api"; } from "@/lib/backup/api";
import { Input } from "@/components/ui/input";
import { useAuth } from "@/lib/auth/auth-context"; import { useAuth } from "@/lib/auth/auth-context";
import { BackupJobRunStatus, BackupScheduleStatus, BackupSettings } from "@/lib/backup/types"; import { BackupJobRunStatus, BackupScheduleStatus, BackupSettings } from "@/lib/backup/types";
import { unsealedImportWarning } from "@/lib/backup/import-summary"; import { unsealedImportWarning } from "@/lib/backup/import-summary";
import { importCsv } from "@/lib/import/api"; import { importCsv } from "@/lib/import/api";
import { ImportEntity, ImportReport, ImportRowStatus } from "@/lib/import/types"; import { ImportEntity, ImportReport, ImportRowStatus } from "@/lib/import/types";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { ConfirmDialog } from "@/components/confirm-dialog"; import { StepUpDialog } from "@/components/auth/step-up-dialog";
import type { StepUpProof } from "@/lib/telegram/types";
import { ErrorState } from "@/components/query-state"; import { ErrorState } from "@/components/query-state";
import { EnumSelect } from "@/components/form/enum-select"; import { EnumSelect } from "@/components/form/enum-select";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -79,10 +80,8 @@ function ImportLimitWarning({ backup, what }: { backup: OversizedBackup; what: s
function BackupSection() { function BackupSection() {
const { user } = useAuth(); const { user } = useAuth();
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [password, setPassword] = useState(""); const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
const [mfaCode, setMfaCode] = useState("");
const needsMfa = Boolean(user?.mfaEnabled); const needsMfa = Boolean(user?.mfaEnabled);
const proofComplete = password.length > 0 && (!needsMfa || /^\d{6}$/.test(mfaCode));
const [oversizedExport, setOversizedExport] = useState<OversizedBackup | null>(null); const [oversizedExport, setOversizedExport] = useState<OversizedBackup | null>(null);
// Kept on screen - the list of what was left out is too long for a toast alone. // Kept on screen - the list of what was left out is too long for a toast alone.
const [importNotice, setImportNotice] = useState<string | null>(null); const [importNotice, setImportNotice] = useState<string | null>(null);
@@ -109,11 +108,11 @@ function BackupSection() {
}); });
const importMutation = useMutation({ const importMutation = useMutation({
mutationFn: () => { mutationFn: (proof: StepUpProof) => {
if (!file) { if (!file) {
throw new Error("Keine Datei ausgewählt"); throw new Error("Keine Datei ausgewählt");
} }
return importBackup(file, { password, mfaCode: needsMfa ? mfaCode : undefined }); return importBackup(file, { password: proof.password, mfaCode: proof.mfaCode });
}, },
onSuccess: (summary) => { onSuccess: (summary) => {
const total = Object.values(summary.imported).reduce((sum, count) => sum + count, 0); const total = Object.values(summary.imported).reduce((sum, count) => sum + count, 0);
@@ -124,15 +123,7 @@ function BackupSection() {
toast.warning(notice); toast.warning(notice);
} }
setFile(null); setFile(null);
setPassword("");
setMfaCode("");
}, },
onError: (error) =>
toast.error(
error instanceof Error && error.message
? error.message
: "Backup konnte nicht eingespielt werden",
),
}); });
function handleFileChange(event: ChangeEvent<HTMLInputElement>) { function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
@@ -174,39 +165,28 @@ function BackupSection() {
className="text-sm file:mr-3 file:rounded-lg file:border-0 file:bg-muted file:px-3 file:py-2 file:text-sm file:font-medium" className="text-sm file:mr-3 file:rounded-lg file:border-0 file:bg-muted file:px-3 file:py-2 file:text-sm file:font-medium"
/> />
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Einspielen überschreibt Konten und Daten. Zur Bestätigung Ihr Passwort Einspielen überschreibt Konten und Daten. Zur Bestätigung fragen wir Ihr Passwort
{needsMfa ? " und den aktuellen Code aus der Authenticator-App" : ""} eingeben. {needsMfa ? " und den aktuellen Code aus der Authenticator-App" : ""}.
</p> </p>
<Label htmlFor="backup-password">Ihr Passwort</Label> <Button
<Input type="button"
id="backup-password" variant="outline"
type="password" disabled={!file || importMutation.isPending}
autoComplete="current-password" onClick={() => setIsImportDialogOpen(true)}
value={password} >
onChange={(event) => setPassword(event.target.value)} Backup importieren
/> </Button>
{needsMfa && ( <StepUpDialog
<> open={isImportDialogOpen}
<Label htmlFor="backup-mfa">Code aus der Authenticator-App</Label> onOpenChange={setIsImportDialogOpen}
<Input
id="backup-mfa"
inputMode="numeric"
autoComplete="one-time-code"
pattern="\d{6}"
maxLength={6}
value={mfaCode}
onChange={(event) => setMfaCode(event.target.value.replace(/\D/g, ""))}
/>
</>
)}
<ConfirmDialog
triggerLabel="Backup importieren"
triggerDisabled={!file || !proofComplete || importMutation.isPending}
title="Backup einspielen?" title="Backup einspielen?"
description="Datensätze aus der Datei werden angelegt oder mit bestehenden gleicher ID überschrieben. Bereits vorhandene Daten, die in der Datei nicht enthalten sind, bleiben unverändert. Das kann bei einer großen Datei einen Moment dauern." description="Datensätze aus der Datei werden angelegt oder mit bestehenden gleicher ID überschrieben. Bereits vorhandene Daten, die in der Datei nicht enthalten sind, bleiben unverändert. Das kann bei einer großen Datei einen Moment dauern."
confirmLabel="Einspielen" confirmLabel="Einspielen"
isPending={importMutation.isPending} pendingLabel="Wird eingespielt…"
onConfirm={() => importMutation.mutate()} needsMfa={needsMfa}
onConfirm={async (proof) => {
await importMutation.mutateAsync(proof);
}}
/> />
{importNotice ? ( {importNotice ? (
<p <p
@@ -13,6 +13,7 @@ import {
retryWebhookDelivery, retryWebhookDelivery,
} from "@/lib/system/api"; } from "@/lib/system/api";
import { JOB_NAMES, type SystemOverview } from "@/lib/system/types"; import { JOB_NAMES, type SystemOverview } from "@/lib/system/types";
import { fetchNotificationDeliveries } from "@/lib/telegram/api";
vi.mock("@/lib/system/api", () => ({ vi.mock("@/lib/system/api", () => ({
fetchSystemOverview: vi.fn(), fetchSystemOverview: vi.fn(),
@@ -21,6 +22,11 @@ vi.mock("@/lib/system/api", () => ({
fetchFailedWebhookDeliveries: vi.fn(), fetchFailedWebhookDeliveries: vi.fn(),
retryWebhookDelivery: vi.fn(), retryWebhookDelivery: vi.fn(),
})); }));
vi.mock("@/lib/telegram/api", () => ({
fetchNotificationDeliveries: vi.fn(),
retryNotificationDelivery: vi.fn(),
retryFailedNotificationDeliveries: vi.fn(),
}));
let permissions: string[] = []; let permissions: string[] = [];
vi.mock("@/lib/auth/auth-context", () => ({ vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ user: { permissions } }), useAuth: () => ({ user: { permissions } }),
@@ -93,10 +99,33 @@ describe("SystemPage", () => {
vi.mocked(fetchFailedWebhookDeliveries).mockResolvedValue([]); vi.mocked(fetchFailedWebhookDeliveries).mockResolvedValue([]);
vi.mocked(retryDomainEvent).mockResolvedValue(undefined); vi.mocked(retryDomainEvent).mockResolvedValue(undefined);
vi.mocked(retryWebhookDelivery).mockResolvedValue(undefined); vi.mocked(retryWebhookDelivery).mockResolvedValue(undefined);
vi.mocked(fetchNotificationDeliveries).mockResolvedValue({
telegram: {
botNameSet: true,
botUsername: "apicore_bot",
receiver: { leaseOwner: "pod-1", lastPollAgeSeconds: 5, lastError: null },
sender: { pending: 0, oldestPendingAgeSeconds: null, failed24h: 0, lastErrors: [] },
},
deliveries: [],
});
}); });
afterEach(() => vi.clearAllMocks()); afterEach(() => vi.clearAllMocks());
it("shows the Telegram card only with system:manage (the endpoint needs it)", async () => {
renderPage();
expect(await screen.findByText("Telegram")).toBeInTheDocument();
});
it("does not show or load the Telegram card with system:read only", async () => {
permissions = ["system:read"];
renderPage();
await screen.findByText("Offen: 3");
expect(screen.queryByText("Telegram")).not.toBeInTheDocument();
expect(fetchNotificationDeliveries).not.toHaveBeenCalled();
});
it("lists every job with its last run and status label", async () => { it("lists every job with its last run and status label", async () => {
renderPage(); renderPage();
@@ -29,6 +29,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { QueryState } from "@/components/query-state"; import { QueryState } from "@/components/query-state";
import { TelegramCard } from "./telegram-card";
const SYSTEM_QUERY_KEY = ["system"] as const; const SYSTEM_QUERY_KEY = ["system"] as const;
const OVERVIEW_QUERY_KEY = [...SYSTEM_QUERY_KEY, "overview"] as const; const OVERVIEW_QUERY_KEY = [...SYSTEM_QUERY_KEY, "overview"] as const;
@@ -302,6 +303,8 @@ export default function SystemPage() {
<FailedDeliveries canManage={canManage} /> <FailedDeliveries canManage={canManage} />
</CardContent> </CardContent>
</Card> </Card>
{/* The deliveries endpoint needs system:manage; a reader would only get a 403. */}
{canManage ? <TelegramCard /> : null}
</> </>
)} )}
</QueryState> </QueryState>
@@ -0,0 +1,197 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { toast } from "sonner";
import { TelegramCard } from "./telegram-card";
import {
fetchNotificationDeliveries,
retryFailedNotificationDeliveries,
retryNotificationDelivery,
} from "@/lib/telegram/api";
import type { NotificationDeliveriesOverview } from "@/lib/telegram/types";
vi.mock("@/lib/telegram/api", () => ({
fetchNotificationDeliveries: vi.fn(),
retryNotificationDelivery: vi.fn(),
retryFailedNotificationDeliveries: vi.fn(),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
function overview(
sender: Partial<NotificationDeliveriesOverview["telegram"]["sender"]> = {},
receiver: Partial<NotificationDeliveriesOverview["telegram"]["receiver"]> = {},
): NotificationDeliveriesOverview {
return {
telegram: {
botNameSet: true,
botUsername: "apicore_bot",
receiver: { leaseOwner: "pod-1", lastPollAgeSeconds: 12, lastError: null, ...receiver },
sender: {
pending: 2,
oldestPendingAgeSeconds: 90,
failed24h: 1,
lastErrors: [{ statusCode: 502, error: "Telegram nicht erreichbar", createdAt: "2026-10-14T08:00:00.000Z" }],
...sender,
},
},
deliveries: [
{
id: "d1",
status: "FAILED",
category: "TASK_DUE",
templateCode: null,
attempts: 8,
availableAt: "2026-10-14T07:00:00.000Z",
lastStatusCode: 502,
lastError: "Telegram nicht erreichbar",
createdAt: "2026-10-14T07:00:00.000Z",
deliveredAt: null,
},
{
id: "d2",
status: "DELIVERED",
category: "DIGEST",
templateCode: null,
attempts: 1,
availableAt: "2026-10-14T05:00:00.000Z",
lastStatusCode: null,
lastError: null,
createdAt: "2026-10-14T05:00:00.000Z",
deliveredAt: "2026-10-14T05:00:02.000Z",
},
],
};
}
function renderCard() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<TelegramCard />
</QueryClientProvider>,
);
}
describe("TelegramCard", () => {
beforeEach(() => {
vi.mocked(fetchNotificationDeliveries).mockReset().mockResolvedValue(overview());
vi.mocked(retryNotificationDelivery).mockReset().mockResolvedValue(undefined);
vi.mocked(retryFailedNotificationDeliveries)
.mockReset()
.mockResolvedValue({ retried: 1 });
});
afterEach(() => vi.clearAllMocks());
it("shows the bot, the receiver and the sender without warning while the queue keeps up", async () => {
renderCard();
expect(await screen.findByText("Bot eingerichtet: @apicore_bot")).toBeInTheDocument();
expect(screen.getByText("Empfang läuft")).toBeInTheDocument();
expect(screen.getByText("Letzter Abruf: vor wenigen Sekunden")).toBeInTheDocument();
expect(screen.getByText("Offen: 2")).toBeInTheDocument();
expect(screen.getByText("Fehlgeschlagen (24 Std.): 1")).toBeInTheDocument();
expect(screen.getByText(/HTTP 502 · Telegram nicht erreichbar/)).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("says that sending runs only in the worker and shows an unset bot", async () => {
vi.mocked(fetchNotificationDeliveries).mockResolvedValue({
...overview(),
telegram: { ...overview().telegram, botNameSet: false, botUsername: null },
});
renderCard();
expect(await screen.findByText("Bot nicht eingerichtet")).toBeInTheDocument();
expect(
screen.getByText(/Der Versand läuft nur im Worker, wenn das Geheimnis vorhanden ist\./),
).toBeInTheDocument();
});
it("warns when the oldest delivery waits longer than 10 minutes", async () => {
vi.mocked(fetchNotificationDeliveries).mockResolvedValue(
overview({ oldestPendingAgeSeconds: 601 }),
);
renderCard();
expect(await screen.findByRole("alert")).toHaveTextContent(
"Eine Telegram-Nachricht wartet länger als 10 Minuten.",
);
});
it("does not warn at exactly 10 minutes", async () => {
vi.mocked(fetchNotificationDeliveries).mockResolvedValue(
overview({ oldestPendingAgeSeconds: 600 }),
);
renderCard();
await screen.findByText("Offen: 2");
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("shows the last receiver error as given", async () => {
vi.mocked(fetchNotificationDeliveries).mockResolvedValue(
overview({}, { lastError: "Telegram-Nachricht übersprungen", leaseOwner: null }),
);
renderCard();
expect(await screen.findByText("Kein Empfänger aktiv")).toBeInTheDocument();
expect(screen.getByText("Telegram-Nachricht übersprungen")).toBeInTheDocument();
});
it("retries one failed delivery and all failed ones", async () => {
const user = userEvent.setup();
renderCard();
const row = (await screen.findByText("Aufgaben fällig")).closest("li") as HTMLElement;
await user.click(within(row).getByRole("button", { name: "Erneut versuchen" }));
await waitFor(() => expect(retryNotificationDelivery).toHaveBeenCalledWith("d1"));
expect(toast.success).toHaveBeenCalledWith("Wird erneut versucht");
await user.click(screen.getByRole("button", { name: "Alle fehlgeschlagenen erneut versuchen" }));
const dialog = await screen.findByRole("dialog");
expect(dialog).toHaveTextContent("Fehlgeschlagene Nachrichten erneut senden?");
expect(dialog).toHaveTextContent("Mindestens 1 sind fehlgeschlagen.");
expect(dialog).toHaveTextContent("Es werden höchstens 100 gesendet, neueste zuerst.");
expect(retryFailedNotificationDeliveries).not.toHaveBeenCalled();
await user.click(within(dialog).getByRole("button", { name: "Erneut senden" }));
await waitFor(() => expect(retryFailedNotificationDeliveries).toHaveBeenCalled());
expect(toast.success).toHaveBeenCalledWith("1 Zustellung wird erneut versucht");
});
it("names the count and the limit of 100 when more failed", async () => {
vi.mocked(fetchNotificationDeliveries).mockResolvedValue(overview({ failed24h: 150 }));
const user = userEvent.setup();
renderCard();
await user.click(
await screen.findByRole("button", { name: "Alle fehlgeschlagenen erneut versuchen" }),
);
const dialog = await screen.findByRole("dialog");
expect(dialog).toHaveTextContent("Fehlgeschlagene Nachrichten erneut senden?");
expect(dialog).toHaveTextContent("Mindestens 150 sind fehlgeschlagen.");
expect(dialog).toHaveTextContent("Es werden höchstens 100 gesendet, neueste zuerst.");
});
it("warns when the last poll is older than two minutes, even with a lease", async () => {
vi.mocked(fetchNotificationDeliveries).mockResolvedValue(
overview({}, { leaseOwner: "pod-1", lastPollAgeSeconds: 300 }),
);
renderCard();
expect(await screen.findByText("Kein Abruf seit über 2 Minuten")).toHaveAttribute(
"role",
"status",
);
expect(screen.getByText("Letzter Abruf vor 5 Min.")).toBeInTheDocument();
expect(screen.getByText("Letzter Abruf vor 5 Min.")).not.toHaveAttribute("role");
});
it("offers no retry for deliveries that did not fail", async () => {
renderCard();
const row = (await screen.findByText("Morgenübersicht")).closest("li") as HTMLElement;
expect(within(row).queryByRole("button")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,277 @@
"use client";
import { AlertTriangle, CircleCheck, CircleX } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
fetchNotificationDeliveries,
retryFailedNotificationDeliveries,
retryNotificationDelivery,
} from "@/lib/telegram/api";
import {
NOTIFICATION_DELIVERY_STATUS_LABELS,
retriedLabel,
} from "@/lib/telegram/labels";
import type {
NotificationDeliveryView,
TelegramStatusView,
} from "@/lib/telegram/types";
import { NOTIFICATION_CATEGORY_LABELS } from "@/lib/notifications/labels";
import { QUEUE_STALE_WARNING_SECONDS, attemptsLabel, formatAge } from "@/lib/system/labels";
import { errorMessage } from "@/lib/forms/error-message";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { QueryState } from "@/components/query-state";
/** The receiver polls every ~25 s; a longer gap means it is not working, lease or not. */
const RECEIVER_STALE_SECONDS = 120;
/** The backend re-queues at most this many failed deliveries per call, newest first. */
const RETRY_ALL_LIMIT = 100;
const DELIVERIES_QUERY_KEY = ["system", "notification-deliveries"] as const;
function formatDateTime(value: string): string {
return new Date(value).toLocaleString("de-DE");
}
function deliveryTitle(delivery: NotificationDeliveryView): string {
if (delivery.category) return NOTIFICATION_CATEGORY_LABELS[delivery.category];
return delivery.templateCode ? "Antwort des Bots" : "Telegram-Nachricht";
}
const RETRY_ALL_TITLE = "Fehlgeschlagene Nachrichten erneut senden?";
// The API gives no total of all FAILED rows, so the count is only a lower bound.
function retryAllDescription(count: number): string {
const lines = ["Die fehlgeschlagenen Nachrichten kommen wieder in die Warteschlange."];
if (count > 0) lines.push(`Mindestens ${count} sind fehlgeschlagen.`);
lines.push(`Es werden höchstens ${RETRY_ALL_LIMIT} gesendet, neueste zuerst.`);
return lines.join(" ");
}
function BotSection({ telegram }: { telegram: TelegramStatusView }) {
return (
<div className="flex flex-col gap-1 text-sm">
<p className="font-medium">
{telegram.botNameSet
? `Bot eingerichtet: @${telegram.botUsername ?? ""}`
: "Bot nicht eingerichtet"}
</p>
<p className="text-muted-foreground">
Die API kennt nur den Namen des Bots. Der Versand läuft nur im Worker, wenn das Geheimnis
vorhanden ist.
</p>
</div>
);
}
function ReceiverSection({ receiver }: { receiver: TelegramStatusView["receiver"] }) {
const isRunning = receiver.leaseOwner !== null;
const StatusIcon = isRunning ? CircleCheck : CircleX;
return (
<div className="flex flex-col gap-1 text-sm">
<h3 className="font-medium">Empfang</h3>
<p className="flex items-center gap-2">
<StatusIcon className="size-4 shrink-0" aria-hidden />
{isRunning ? "Empfang läuft" : "Kein Empfänger aktiv"}
</p>
{receiver.lastPollAgeSeconds !== null &&
receiver.lastPollAgeSeconds > RECEIVER_STALE_SECONDS ? (
<div className="flex flex-col gap-0.5 font-medium text-destructive">
<p role="status" className="flex items-center gap-2">
<AlertTriangle className="size-4 shrink-0" aria-hidden />
Kein Abruf seit über 2 Minuten
</p>
<p className="font-normal">Letzter Abruf {formatAge(receiver.lastPollAgeSeconds)}</p>
</div>
) : (
<p className="text-muted-foreground">
{receiver.lastPollAgeSeconds === null
? "Noch kein Abruf"
: `Letzter Abruf: ${formatAge(receiver.lastPollAgeSeconds)}`}
</p>
)}
{receiver.lastError ? <p className="break-words text-destructive">{receiver.lastError}</p> : null}
</div>
);
}
function StaleDeliveryWarning() {
return (
<div
role="alert"
className="flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/5 p-3 text-sm"
>
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-destructive" aria-hidden />
<p className="font-medium">Eine Telegram-Nachricht wartet länger als 10 Minuten.</p>
</div>
);
}
function SenderSection({ sender }: { sender: TelegramStatusView["sender"] }) {
const isStale =
sender.oldestPendingAgeSeconds !== null &&
sender.oldestPendingAgeSeconds > QUEUE_STALE_WARNING_SECONDS;
return (
<div className="flex flex-col gap-2 text-sm">
<h3 className="font-medium">Versand</h3>
{isStale ? <StaleDeliveryWarning /> : null}
<div className="flex flex-col gap-1">
<p>Offen: {sender.pending}</p>
{sender.oldestPendingAgeSeconds !== null ? (
<p className="text-muted-foreground">
Ältestes offenes: {formatAge(sender.oldestPendingAgeSeconds)}
</p>
) : null}
<p className="text-muted-foreground">Fehlgeschlagen (24 Std.): {sender.failed24h}</p>
</div>
{sender.lastErrors.length > 0 ? (
<ul className="flex flex-col gap-1">
{sender.lastErrors.map((entry) => (
<li key={`${entry.createdAt}-${entry.error}`} className="break-words text-destructive">
{entry.statusCode ? `HTTP ${entry.statusCode} · ` : ""}
{entry.error} · {formatDateTime(entry.createdAt)}
</li>
))}
</ul>
) : null}
</div>
);
}
interface DeliveryRowProps {
delivery: NotificationDeliveryView;
isRetrying: boolean;
isOffline: boolean;
describedBy: string | undefined;
onRetry: () => void;
}
function DeliveryRow({ delivery, isRetrying, isOffline, describedBy, onRetry }: DeliveryRowProps) {
const isFailed = delivery.status === "FAILED";
return (
<li className="flex flex-col gap-3 rounded-xl border border-border/60 bg-card p-3 sm:flex-row sm:items-start">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{deliveryTitle(delivery)}</span>
<Badge variant={isFailed ? "destructive" : "secondary"}>
{NOTIFICATION_DELIVERY_STATUS_LABELS[delivery.status]}
</Badge>
</div>
<span className="text-sm text-muted-foreground">
{formatDateTime(delivery.createdAt)} · {attemptsLabel(delivery.attempts)}
{delivery.lastStatusCode ? ` · HTTP ${delivery.lastStatusCode}` : ""}
</span>
{delivery.lastError ? (
<p className="break-words text-sm text-destructive">{delivery.lastError}</p>
) : null}
</div>
{isFailed ? (
<Button
type="button"
size="touch"
variant="outline"
disabled={isRetrying || isOffline}
aria-describedby={describedBy}
onClick={onRetry}
>
Erneut versuchen
</Button>
) : null}
</li>
);
}
/**
* Telegram on the System page. The endpoint needs `system:manage`, so the
* page mounts this card only for people who have it - every button here is
* therefore allowed.
*/
export function TelegramCard() {
const queryClient = useQueryClient();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const query = useQuery({
queryKey: DELIVERIES_QUERY_KEY,
queryFn: () => fetchNotificationDeliveries(),
});
const refetch = () => queryClient.invalidateQueries({ queryKey: DELIVERIES_QUERY_KEY });
const retryOne = useMutation({
mutationFn: (id: string) => retryNotificationDelivery(id),
onSuccess: () => {
toast.success("Wird erneut versucht");
return refetch();
},
onError: (error) => toast.error(errorMessage(error, "Erneuter Versuch fehlgeschlagen")),
});
const retryAll = useMutation({
mutationFn: retryFailedNotificationDeliveries,
onSuccess: ({ retried }) => {
toast.success(retriedLabel(retried));
return refetch();
},
onError: (error) => toast.error(errorMessage(error, "Erneuter Versuch fehlgeschlagen")),
});
return (
<Card>
<CardHeader>
<CardTitle>Telegram</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<QueryState query={query} what="Telegram-Status">
{({ telegram, deliveries }) => {
const failedCount = Math.max(
telegram.sender.failed24h,
deliveries.filter((delivery) => delivery.status === "FAILED").length,
);
return (
<>
<BotSection telegram={telegram} />
<ReceiverSection receiver={telegram.receiver} />
<SenderSection sender={telegram.sender} />
{failedCount > 0 ? (
<ConfirmDialog
triggerLabel="Alle fehlgeschlagenen erneut versuchen"
triggerSize="touch"
triggerClassName="self-start"
triggerDisabled={retryAll.isPending || isOffline}
triggerDescribedBy={describedBy}
title={RETRY_ALL_TITLE}
description={retryAllDescription(failedCount)}
confirmLabel="Erneut senden"
destructive={false}
isPending={retryAll.isPending}
onConfirm={() => retryAll.mutate()}
/>
) : null}
<OnlineOnlyHint id={hintId} />
{deliveries.length === 0 ? (
<p className="text-sm text-muted-foreground">Noch keine Zustellungen.</p>
) : (
<ul className="flex flex-col gap-2">
{deliveries.map((delivery) => (
<DeliveryRow
key={delivery.id}
delivery={delivery}
isRetrying={retryOne.isPending && retryOne.variables === delivery.id}
isOffline={isOffline}
describedBy={describedBy}
onRetry={() => retryOne.mutate(delivery.id)}
/>
))}
</ul>
)}
</>
);
}}
</QueryState>
</CardContent>
</Card>
);
}
@@ -0,0 +1,119 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { StepUpDialog } from "./step-up-dialog";
import { ApiError } from "@/lib/auth/api-client";
function renderDialog(overrides: Partial<Parameters<typeof StepUpDialog>[0]> = {}) {
const onConfirm = vi.fn().mockResolvedValue(undefined);
const onOpenChange = vi.fn();
render(
<StepUpDialog
open
title="Telegram verbinden?"
confirmLabel="Verbinden"
needsMfa={false}
onConfirm={onConfirm}
onOpenChange={onOpenChange}
{...overrides}
/>,
);
return { onConfirm, onOpenChange };
}
describe("StepUpDialog", () => {
it("asks for the password only without MFA and confirms with it", async () => {
const { onConfirm, onOpenChange } = renderDialog();
expect(screen.queryByLabelText("Code aus der Authenticator-App")).not.toBeInTheDocument();
const confirm = screen.getByRole("button", { name: "Verbinden" });
expect(confirm).toBeDisabled();
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "geheim");
await userEvent.click(confirm);
await waitFor(() => expect(onConfirm).toHaveBeenCalledWith({ password: "geheim" }));
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false));
});
it("asks for the six-digit code with MFA and sends both", async () => {
const { onConfirm } = renderDialog({ needsMfa: true });
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "geheim");
const confirm = screen.getByRole("button", { name: "Verbinden" });
await userEvent.type(screen.getByLabelText("Code aus der Authenticator-App"), "12ab34");
expect(confirm).toBeDisabled();
await userEvent.type(screen.getByLabelText("Code aus der Authenticator-App"), "56");
expect(confirm).toBeEnabled();
await userEvent.click(confirm);
await waitFor(() =>
expect(onConfirm).toHaveBeenCalledWith({ password: "geheim", mfaCode: "123456" }),
);
});
it("stays open and shows the server's German reason when the proof is refused", async () => {
const onConfirm = vi.fn().mockRejectedValue(new ApiError(401, "Das Passwort ist falsch."));
const { onOpenChange } = renderDialog({ onConfirm });
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "falsch");
await userEvent.click(screen.getByRole("button", { name: "Verbinden" }));
await waitFor(() =>
expect(screen.getByRole("alert")).toHaveTextContent("Das Passwort ist falsch."),
);
expect(onOpenChange).not.toHaveBeenCalledWith(false);
expect(screen.getByLabelText("Ihr Passwort")).toHaveValue("");
expect(screen.getByLabelText("Ihr Passwort")).toHaveFocus();
});
it("shows the German too-many-attempts text on 429", async () => {
renderDialog({ onConfirm: vi.fn().mockRejectedValue(new ApiError(429, "ThrottlerException")) });
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "x");
await userEvent.click(screen.getByRole("button", { name: "Verbinden" }));
await waitFor(() =>
expect(screen.getByRole("alert")).toHaveTextContent(
"Zu viele Versuche. Bitte in einer Minute noch einmal.",
),
);
});
it("says the session expired on a bare 401", async () => {
renderDialog({ onConfirm: vi.fn().mockRejectedValue(new ApiError(401, "Unauthorized")) });
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "x");
await userEvent.click(screen.getByRole("button", { name: "Verbinden" }));
await waitFor(() =>
expect(screen.getByRole("alert")).toHaveTextContent(
"Sitzung abgelaufen. Bitte die Seite neu laden und erneut anmelden.",
),
);
});
it("makes the inputs read-only while the action runs", async () => {
renderDialog({ onConfirm: vi.fn(() => new Promise<void>(() => {})) });
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "geheim");
await userEvent.click(screen.getByRole("button", { name: "Verbinden" }));
await waitFor(() => expect(screen.getByLabelText("Ihr Passwort")).toHaveAttribute("readonly"));
});
it("cannot be closed with Escape while the action runs, and shows the pending label", async () => {
const onConfirm = vi.fn(() => new Promise<void>(() => {}));
const { onOpenChange } = renderDialog({ onConfirm, pendingLabel: "Wird eingespielt…" });
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "geheim");
await userEvent.click(screen.getByRole("button", { name: "Verbinden" }));
expect(await screen.findByRole("button", { name: "Wird eingespielt…" })).toBeDisabled();
await userEvent.keyboard("{Escape}");
await userEvent.click(screen.getByRole("button", { name: /Close/ }));
expect(onOpenChange).not.toHaveBeenCalled();
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
});
@@ -0,0 +1,172 @@
"use client";
import { FormEvent, RefObject, useId, useRef, useState } from "react";
import { stepUpErrorText } from "@/lib/auth/step-up-error";
import type { StepUpProof } from "@/lib/telegram/types";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export type { StepUpProof };
export interface StepUpDialogProps {
open: boolean;
title: string;
/** What happens, in one or two short sentences. */
description?: string;
confirmLabel: string;
/** Shown on the button while `onConfirm` runs. */
pendingLabel?: string;
/** The account has MFA: the current TOTP code is needed as well. */
needsMfa: boolean;
/** Throws to keep the dialog open; the message is shown to the person. */
onConfirm: (proof: StepUpProof) => Promise<void>;
onOpenChange: (open: boolean) => void;
/** Where focus goes when the dialog closes (default: the element that opened it). */
finalFocusRef?: RefObject<HTMLElement | null>;
}
const MFA_CODE_PATTERN = /^\d{6}$/;
const FALLBACK_ERROR = "Die Bestätigung hat nicht geklappt. Bitte noch einmal versuchen.";
interface StepUpFormProps {
confirmLabel: string;
pendingLabel: string;
isPending: boolean;
setIsPending: (pending: boolean) => void;
needsMfa: boolean;
onConfirm: (proof: StepUpProof) => Promise<void>;
onDone: () => void;
}
// Mounted only while the dialog is open, so the password never outlives it.
function StepUpForm({
confirmLabel,
pendingLabel,
isPending,
setIsPending,
needsMfa,
onConfirm,
onDone,
}: StepUpFormProps) {
const passwordId = useId();
const mfaId = useId();
const [password, setPassword] = useState("");
const [mfaCode, setMfaCode] = useState("");
const [error, setError] = useState<string | null>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const { isOffline, hintId, describedBy } = useOnlineOnly();
const proofComplete = password.length > 0 && (!needsMfa || MFA_CODE_PATTERN.test(mfaCode));
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!proofComplete || isPending || isOffline) return;
setError(null);
setIsPending(true);
try {
await onConfirm(needsMfa ? { password, mfaCode } : { password });
setIsPending(false);
onDone();
} catch (cause) {
setError(stepUpErrorText(cause, FALLBACK_ERROR));
setPassword("");
setMfaCode("");
setIsPending(false);
passwordRef.current?.focus();
}
}
return (
<form className="flex flex-col gap-3" onSubmit={handleSubmit} noValidate>
<div className="flex flex-col gap-1.5">
<Label htmlFor={passwordId}>Ihr Passwort</Label>
<Input
id={passwordId}
ref={passwordRef}
type="password"
autoComplete="current-password"
className="h-11"
value={password}
readOnly={isPending}
onChange={(event) => setPassword(event.target.value)}
/>
</div>
{needsMfa ? (
<div className="flex flex-col gap-1.5">
<Label htmlFor={mfaId}>Code aus der Authenticator-App</Label>
<Input
id={mfaId}
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
className="h-11"
value={mfaCode}
readOnly={isPending}
onChange={(event) => setMfaCode(event.target.value.replace(/\D/g, ""))}
/>
</div>
) : null}
{/* Mounted all the time, so a screen reader announces the text when it appears. */}
<p role="alert" className="min-h-5 text-sm font-medium text-destructive">
{error}
</p>
<OnlineOnlyHint id={hintId} />
<Button
type="submit"
size="touch"
disabled={!proofComplete || isPending || isOffline}
aria-describedby={describedBy}
>
{isPending ? pendingLabel : confirmLabel}
</Button>
</form>
);
}
/**
* Asks for the current password (and the TOTP code with MFA) before a
* sensitive action. The proof goes to `onConfirm` and is forgotten when the
* dialog closes.
*/
export function StepUpDialog({
open,
title,
description,
confirmLabel,
pendingLabel = "Wird geprüft…",
needsMfa,
onConfirm,
onOpenChange,
finalFocusRef,
}: StepUpDialogProps) {
const [isPending, setIsPending] = useState(false);
return (
// Esc, the backdrop and the close button must not abandon a running action.
<Dialog open={open} onOpenChange={(next) => (next || !isPending) && onOpenChange(next)}>
<DialogContent finalFocus={finalFocusRef}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description ? <DialogDescription>{description}</DialogDescription> : null}
</DialogHeader>
<StepUpForm
confirmLabel={confirmLabel}
pendingLabel={pendingLabel}
isPending={isPending}
setIsPending={setIsPending}
needsMfa={needsMfa}
onConfirm={onConfirm}
onDone={() => onOpenChange(false)}
/>
</DialogContent>
</Dialog>
);
}
@@ -24,6 +24,8 @@ interface ConfirmDialogProps {
triggerSize?: VariantProps<typeof buttonVariants>["size"]; triggerSize?: VariantProps<typeof buttonVariants>["size"];
triggerClassName?: string; triggerClassName?: string;
triggerDisabled?: boolean; triggerDisabled?: boolean;
/** Id of a hint that explains why the trigger is off (e.g. „Nur mit Verbindung“). */
triggerDescribedBy?: string;
title: string; title: string;
description: string; description: string;
confirmLabel: string; confirmLabel: string;
@@ -39,6 +41,7 @@ export function ConfirmDialog({
triggerSize = "default", triggerSize = "default",
triggerClassName, triggerClassName,
triggerDisabled, triggerDisabled,
triggerDescribedBy,
title, title,
description, description,
confirmLabel, confirmLabel,
@@ -57,6 +60,7 @@ export function ConfirmDialog({
className={cn(triggerClassName)} className={cn(triggerClassName)}
aria-label={triggerAriaLabel} aria-label={triggerAriaLabel}
disabled={triggerDisabled} disabled={triggerDisabled}
aria-describedby={triggerDescribedBy}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
> >
{triggerLabel} {triggerLabel}
@@ -19,4 +19,22 @@ describe("PreviousValueHint", () => {
expect(onTakeOver).toHaveBeenCalledTimes(1); expect(onTakeOver).toHaveBeenCalledTimes(1);
}); });
it("keeps „Letztes Mal“ as default prefix and lets a caller name the source", () => {
const { rerender } = render(
<PreviousValueHint fieldLabel="Menge" valueLabel="42,5 kg" onTakeOver={() => undefined} />,
);
expect(screen.getByText(/Letztes Mal: 42,5 kg/)).toBeInTheDocument();
rerender(
<PreviousValueHint
fieldLabel="Menge"
valueLabel="42,5 kg"
prefix="Aus 2 Ernten"
onTakeOver={() => undefined}
/>,
);
expect(screen.getByText(/Aus 2 Ernten: 42,5 kg/)).toBeInTheDocument();
expect(screen.queryByText(/Letztes Mal/)).not.toBeInTheDocument();
});
}); });
@@ -8,6 +8,8 @@ export interface PreviousValueHintProps {
/** The earlier answer in words, e.g. "Ja" or "4 von 5". */ /** The earlier answer in words, e.g. "Ja" or "4 von 5". */
valueLabel: string; valueLabel: string;
onTakeOver: () => void; onTakeOver: () => void;
/** Where the value comes from; "Letztes Mal" unless the caller names a source, e.g. "Aus 2 Ernten". */
prefix?: string;
className?: string; className?: string;
} }
@@ -20,12 +22,13 @@ export function PreviousValueHint({
fieldLabel, fieldLabel,
valueLabel, valueLabel,
onTakeOver, onTakeOver,
prefix = "Letztes Mal",
className, className,
}: PreviousValueHintProps) { }: PreviousValueHintProps) {
return ( return (
<p className={cn("flex flex-wrap items-center gap-x-2 text-sm text-muted-foreground", className)}> <p className={cn("flex flex-wrap items-center gap-x-2 text-sm text-muted-foreground", className)}>
<span> <span>
Letztes Mal: {valueLabel} <span aria-hidden="true">·</span> {prefix}: {valueLabel} <span aria-hidden="true">·</span>
</span> </span>
<button <button
type="button" type="button"
@@ -0,0 +1,40 @@
"use client";
import { useId } from "react";
interface SwitchRowProps {
label: string;
description?: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}
/** A yes/no setting as a tall row (>= 44 px) with its explanation under the label. */
export function SwitchRow({ label, description, checked, onChange, disabled }: SwitchRowProps) {
const id = useId();
return (
<label className="flex min-h-11 cursor-pointer items-start gap-3 rounded-xl border p-3 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-ring">
<input
type="checkbox"
role="switch"
aria-labelledby={`${id}-label`}
aria-describedby={description ? `${id}-description` : undefined}
checked={checked}
disabled={disabled}
onChange={(event) => onChange(event.target.checked)}
className="mt-1 size-4 accent-[var(--primary)]"
/>
<span className="flex flex-col">
<span id={`${id}-label`} className="font-medium">
{label}
</span>
{description ? (
<span id={`${id}-description`} className="text-sm text-muted-foreground">
{description}
</span>
) : null}
</span>
</label>
);
}
@@ -9,19 +9,21 @@ interface ToggleChipProps {
onChange: (checked: boolean) => void; onChange: (checked: boolean) => void;
/** Ids of a hint or error shown next to the tile. */ /** Ids of a hint or error shown next to the tile. */
describedBy?: string; describedBy?: string;
disabled?: boolean;
} }
/** A yes/no observation as a large tile; state shown by tick AND colour. */ /** A yes/no observation as a large tile; state shown by tick AND colour. */
export function ToggleChip({ label, checked, onChange, describedBy }: ToggleChipProps) { export function ToggleChip({ label, checked, onChange, describedBy, disabled }: ToggleChipProps) {
return ( return (
<button <button
type="button" type="button"
role="checkbox" role="checkbox"
aria-checked={checked} aria-checked={checked}
aria-describedby={describedBy} aria-describedby={describedBy}
disabled={disabled}
onClick={() => onChange(!checked)} onClick={() => onChange(!checked)}
className={cn( className={cn(
"flex min-h-11 items-center gap-2 rounded-lg border px-3 text-left text-sm motion-safe:transition-colors", "flex min-h-11 items-center gap-2 rounded-lg border px-3 text-left text-sm motion-safe:transition-colors disabled:pointer-events-none disabled:opacity-50",
checked checked
? "border-primary bg-primary/10 font-medium" ? "border-primary bg-primary/10 font-medium"
: "border-border bg-background hover:bg-muted", : "border-border bg-background hover:bg-muted",
@@ -0,0 +1,158 @@
import "fake-indexeddb/auto";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fetchAutopilotToday } from "@/lib/autopilot/api";
import { createPhenologyReport, fetchPhenologyState } from "@/lib/phenology/api";
import type { PhenologyState } from "@/lib/phenology/types";
import { ApiarySeason } from "./apiary-season";
vi.mock("@/lib/phenology/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/phenology/api")>()),
fetchPhenologyState: vi.fn(),
fetchPhenologyReports: vi.fn().mockResolvedValue([]),
createPhenologyReport: vi.fn(),
}));
vi.mock("@/lib/autopilot/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/autopilot/api")>()),
fetchAutopilotToday: vi.fn(),
}));
const auth = vi.hoisted(() => ({ permissions: new Set<string>() }));
vi.mock("@/lib/auth/auth-context", () => ({
useCurrentUserId: () => "user-1",
useAuth: () => ({
hasPermission: (permission: string) => auth.permissions.has(permission),
}),
}));
const apiary = { id: "a-1", name: "Obstwiese" };
function state(overrides: Partial<PhenologyState>): PhenologyState {
return {
apiaryId: "a-1",
apiaryName: "Obstwiese",
phase: "KIRSCHE",
segment: "SCHWARMZEIT",
phaseSource: "REPORT",
reportedOn: "2026-04-18",
gts: null,
gtsThroughDay: null,
note: null,
...overrides,
};
}
function renderSeason() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<ApiarySeason apiary={apiary} />
</QueryClientProvider>,
);
}
beforeEach(() => {
auth.permissions = new Set(["phenology:create"]);
vi.mocked(fetchPhenologyState)
.mockReset()
.mockResolvedValue([
state({ apiaryId: "a-2", apiaryName: "Wald", segment: "SOMMER" }),
state({}),
]);
vi.mocked(fetchAutopilotToday)
.mockReset()
.mockResolvedValue({
nextStep: null,
inspectionDue: [
{
apiaryId: "a-1",
apiaryName: "Obstwiese",
segment: "SCHWARMZEIT",
intervalDays: 7,
colonies: [],
},
],
suggestionsOpen: 0,
seasons: [],
tasksVisible: true,
});
});
describe("ApiarySeason", () => {
it("shows the season with its source on the apiary page", async () => {
renderSeason();
expect(await screen.findByText("Schwarmzeit · alle 7 Tage")).toBeInTheDocument();
expect(screen.getByText("gemeldet am 18.04.")).toBeInTheDocument();
expect(screen.getByText("Kirsche blüht")).toBeInTheDocument();
});
it("names an estimated phase and a calendar phase by their source", async () => {
vi.mocked(fetchPhenologyState).mockResolvedValue([
state({ phaseSource: "GTS", reportedOn: null, phase: "APFEL" }),
]);
const { unmount } = renderSeason();
expect(await screen.findByText("geschätzt")).toBeInTheDocument();
unmount();
vi.mocked(fetchPhenologyState).mockResolvedValue([
state({ phaseSource: "CALENDAR", reportedOn: null, phase: null }),
]);
renderSeason();
expect(await screen.findByText("Kalender")).toBeInTheDocument();
});
it("falls back to the season without an interval when the interval is unknown", async () => {
vi.mocked(fetchAutopilotToday).mockRejectedValue(new Error("403"));
renderSeason();
expect(await screen.findByText("Schwarmzeit")).toBeInTheDocument();
});
it("shows „Pflanze blüht“ only with phenology:create and opens the sheet", async () => {
renderSeason();
await userEvent.click(await screen.findByRole("button", { name: "Pflanze blüht" }));
expect(await screen.findByRole("dialog")).toHaveTextContent("Entwicklung melden");
expect(screen.getByRole("button", { name: "Salweide blüht" })).toBeInTheDocument();
});
it("hides the button without phenology:create", async () => {
auth.permissions = new Set();
renderSeason();
await screen.findByText("Schwarmzeit · alle 7 Tage");
expect(screen.queryByRole("button", { name: "Pflanze blüht" })).toBeNull();
});
it("explains a missing weather basis in words", async () => {
vi.mocked(fetchPhenologyState).mockResolvedValue([
state({
phaseSource: "CALENDAR",
reportedOn: null,
note: "WEATHER_STALE",
}),
]);
renderSeason();
expect(await screen.findByText("Wetterdaten veraltet")).toBeInTheDocument();
});
it("closes the sheet after a report and returns the focus to „Pflanze blüht“", async () => {
vi.mocked(createPhenologyReport).mockResolvedValue({} as never);
renderSeason();
const trigger = await screen.findByRole("button", {
name: "Pflanze blüht",
});
await userEvent.click(trigger);
await userEvent.click(await screen.findByRole("button", { name: "Salweide blüht" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
await waitFor(() => expect(trigger).toHaveFocus());
});
});
Loaded 100 of 148 files, more files were not shown because too many files have changed in this diff. Show more