diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 55f49da..d783cc8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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/`, `/colonies/`) 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. **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. @@ -166,11 +168,14 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige 48. **Autopilot A4–A10: Annahmen**: A4 (Sommerbehandlung planen) und A8 (Umweiseln planen) sind `PLAN`-Vorschläge ohne gespeicherten Plan: Übernehmen legt genau eine Aufgabe an, heute fällig (Spec 8.2). Der gespeicherte Saisonzustand kennt nur den Beginn des aktuellen Abschnitts. Im Abschnitt `HERBST` ist der Beginn von `SPAETSOMMER` daher genähert: der Kalendertag von `TRACHTENDE` (`PHENOLOGY.calendarFallback`), höchstens der Beginn von `HERBST` (`lateSummerStart`). Wurde die Phase viel früher oder später gemeldet als der Kalender sagt, zählt eine Fütterung zwischen beiden Tagen für A5 falsch (nur A5 betrifft das). Der genaue Weg ist eine Spalte `lateSummerSince` an `ApiaryPhenology` (Migration, späteres Schema-Fenster; im Code als TODO vermerkt). A6 ohne Durchsicht zählt Fütterungen seit dem späteren von Anlagetag des Volkes und heute minus 60 Tage; A7 nutzt nur eine Durchsicht ab `springSince` des laufenden Jahres (sonst ab 1. Januar). „Gemeldet am TT.MM.“ im Grund von A4 steht nur, wenn `reportedOn` vorliegt (bei gespeicherten Zeilen nie), sonst „Tracht vorbei (gemeldet)“, bei Schätzung oder Kalender „(geschätzt)“ bzw. „(Kalender)“. `syncApiaryIn` gleicht alle Völker eines Stands in einer Transaktion ab (etwa 10 Abfragen je Volk); für sehr große Stände wäre eine Aufteilung nötig. Ein Wächter (`src/suggestions/suggesting-triggers.spec.ts`) verlangt für jeden vorschlagenden Trigger eine Textvorlage und für jeden im Quelltext gefundenen `reasonCode` einen Registereintrag; ein neuer Trigger fällt durch, bis er eine Vorlage hat oder bewusst ausgenommen wird. 49. **Telegram-Zustellung: Zustellzeit, Zeitumstellung, Prüfung beim Senden**: Zeiten rechnen auf der Berliner Wanduhr (Europe/Berlin, `src/notifications/delivery-time.ts`). Verfügbar ab dem späteren von jetzt und der Kategorie-Zeit (`TASK_DUE`: eine Minute nach der Morgenzeit, `DIGEST`: die Morgenzeit, jeweils heute); liegt das Ergebnis in der Ruhezeit (Standard 21:00 bis 06:30, über Mitternacht, Ende ausgeschlossen; fehlende oder gleiche Grenzen = keine Ruhezeit), rückt es an deren Ende. Eine Uhrzeit, die im Herbst doppelt vorkommt (Doppelstunde 02:xx), gilt als das erste Vorkommen (Sommerzeit); eine Uhrzeit in der Frühjahrslücke (02:30 existiert nicht) als der Zeitpunkt eine Stunde später (03:30 Sommerzeit). Antworten des Bots (`templateCode`) umgehen Ruhezeit und Prüfungen. Beim Senden gilt der Stand dieses Moments, nicht der der Planung: Nutzer und Rolle aktiv, Telegram an, Verbindung, Kategorie, jedes Recht aus `requiredPermissions` (frisch gelesen, nicht aus dem 30-s-Cache) und Frische. `TASK_DUE` und `DIGEST` sind nur am selben Berliner Tag frisch, alle anderen höchstens 24 Stunden; eine `TASK_DUE`-Zustellung braucht die Aufgabe noch offen. Sonst `SKIPPED` mit Grund. Der Batch ist auf 120 s begrenzt; der Rest geht ohne Fehlversuch zurück. Abschnitt 11.13. 50. **Sperrreihenfolge: Stand vor Volk**: Die Karte `LOCKABLE_TABLES` (`LOCK_ORDER`, `src/prisma/row-lock.ts`) gilt für jede Transaktion, die Zeilen mehrerer Tabellen sperrt (Tabellen in dieser Reihenfolge, innerhalb einer Tabelle Id aufsteigend), mit einer Ausnahme (Entscheidung zu Task 27): `Apiary` wird vor `Colony` gesperrt. Der Abgleich je Stand (`AutopilotService.syncApiaryIn`) sperrt die Standzeile, liest danach die Völker, sperrt alle in einer Anweisung (`lockRowsForUpdate`, Id aufsteigend) und erst dann Vorschläge und Regeln; der Handler `autopilot.colony-sync` sperrt die Völker eines Ereignisses ebenso in einer Anweisung. Kein Pfad sperrt ein Volk und danach einen Stand (die Neuberechnung der Pflanzenentwicklung sperrt nur den Stand); ein künftiger Pfad Volk → Stand würde mit dem Abgleich je Stand verklemmen. Die Karte im Code führt `Apiary` weiterhin weiter hinten (Stand dieses Dokuments): Die Ausnahme steht als Regel hier, nicht in der Karte. -51. **Telegram: fehlerhaftes Update wird übersprungen (Annahme der Umsetzung)**: Ein Update, dessen Verarbeitung scheitert, wird bis zu 5 Mal oder höchstens 10 Minuten wiederholt (Zähler und erste Fehlzeit je `update_id` im Speicher des Pods; `MAX_UPDATE_ATTEMPTS`, `MAX_UPDATE_RETRY_MS`). Danach überspringt es eine eigene Transaktion: Offset weiter, `lastError` = „Telegram-Nachricht übersprungen“, eine Warnung ohne IDs und Text im Log, der Rest des Batches läuft weiter. Ohne diese Grenze würde ein dauerhaft scheiternder Befehl alle späteren Befehle (etwa jedes `/start`) sperren. Kosten bei falscher Annahme: ein verlorenes `/start`, der Nutzer erzeugt den Link neu. Eine verlorene Lease zählt nie als Fehlversuch. Abschnitt 11.13. +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 @@ -435,6 +440,8 @@ Die Seite „Verwaltung > System“ (Rechte `system:read` zum Ansehen, `system:m **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) 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). @@ -603,7 +610,7 @@ Stand: Schema, Wetterquelle, Berechnung, Job-Schritt, die Meldungs-API (Melden, ### 11.12 Honig-Kette und Bewegungsbuch -Stand: Ereignisse, Vorbelegung, Warnungen, Wartezeit bei der Schleuderung, der Webhook, die Laufzeit-Buchungen des Bewegungsbuchs und die Qualitätsprüfung der Abfüllung (API) sind fertig, ebenso die Oberfläche von Schleuderung, Honigcharge und Bewegungsbuch. Offen ist die Abfüllungsmaske mit der Wassergehalt-Bestätigung (409), sie folgt als Task 33. +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. @@ -633,11 +640,17 @@ Stand: Ereignisse, Vorbelegung, Warnungen, Wartezeit bei der Schleuderung, der W - Die 201-Antwort trägt zuerst die Qualitätsbefunde (auch den bestätigten `WATER_CONTENT_ABOVE_LEGAL`), dann die Wartezeitwarnungen. `PATCH /bottling-batches/:id` und die Messungs-Routen der Honigcharge (`POST` und `PATCH …/measurements`, Antwort mit `warnings`) liefern nie 409. Das `PATCH` meldet Qualitätsbefunde nur, wenn sich Honigcharge oder Glasgröße ändern. - Vorbelegung `GET /bottling-batches/prefill?honeyBatchId=&productId=&date=` (`bottling:create`, vor `:id` deklariert): `{ jarSize, jarSizeSource: 'PRODUCT' | null, bestBeforeDate, shelfLifeMonths, waterContent, warnings }`. Die Glasgröße kommt vom Artikel (`packagingSize`), das MHD aus `Product.shelfLifeMonths` (1–60, beim Anlegen und Ändern des Artikels setzbar, `null` löscht): Abfülltag plus Monate, gekürzt auf das Monatsende (31.01. + 1 Monat = 28.02.); ein Zeitstempel zählt als sein Berliner Tag, `date` ist standardmäßig heute. `honeyBatchId` fehlt: 400; unbekannte Charge oder Artikel: 404; Artikel nicht `BOTTLED_HONEY`: 400. Beim Anlegen gewinnt ein gesendeter Wert, sonst der Artikelwert; fehlt beides, antwortet die API mit 400 („Glasgröße und MHD fehlen. …“, „Ohne Haltbarkeit am Artikel bitte ein MHD eintragen.“, „Ohne Packungsgröße am Artikel bitte eine Glasgröße eintragen.“). Unmögliche Tage (30.02.) in `date`, `bestBeforeDate` und der Vorbelegung ergeben 400 „Das Datum ist ungültig. …“. -**Oberfläche der Honig-Kette.** Vorbelegungen heißen „Vorschlag aus …“ („aus 2 Ernten“, „aus den Ernten: Raps“, „aus der Schleuderung (Rest)“) und werden nur durch einen Knopf übernommen. Laden die Hinweise nicht, zeigt `PrefillStatus` (feste `aria-live`-Zeile) „Hinweise (z. B. Wartezeit) konnten nicht geladen werden.“ mit „Erneut versuchen“; Speichern bleibt erlaubt, das Backend prüft (Annahme 53). `lib/honey-batches/errors.ts` (`describeSaveError`, `saveErrorText`) ist der einzige Ort, der einen fehlgeschlagenen Speichervorgang liest (400-`issues` je Feld, Nest-`message[]` durch deutschen Text ersetzt, 409-Meldung durchgereicht, Netzfehler). Das Anlegen einer Honigcharge bietet „Wassergehalt (%)“ mit dem Vorschlag aus der Schleuderung. Die Detailseite hat die Karte „Bewegungen“ (Buchungen mit Link zur Abfüllung, Grund und Korrektur mit Vorzeichen; bei `hasMore` der Hinweis „Ältere Buchungen werden nicht angezeigt“). „Entnahme“ (nur `WITHDRAWAL`, Grund Pflicht und nicht vorgewählt, Menge mindestens 1 g und höchstens der Rest) und „Menge korrigieren“ (Ausgangswert beim Öffnen gesperrt, Differenz sichtbar, bei 0 „Keine Abweichung“, Grund optional, Buchung als „Korrektur“ beschriftet) gibt es mit `honey-batches:update` und erfasster Menge. Beide Auslöser sind offline gesperrt („Nur mit Verbindung“) und buchen mit eigenem Schlüssel je Buchung. Alle Abfragen der Detailseite liegen unter dem Schlüsselpräfix `["honey-batches"]`, das beide Dialoge ungültig machen. Die Bestätigung beim Wassergehalt (409) gehört zur Abfüllungsmaske (Task 33), nicht zu diesen Dialogen. +**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 -Stand: Schema, Client, Prüfung beim Start, Planung, Senden, Verbinden, Empfangen und die Betriebsansicht sind fertig (`TelegramModule` importiert `WorkerRuntimeModule` und `WorkerModule`). `NotificationPreference.telegramEnabled` schaltet das Verbinden ein, das Trennen (und der Sender bei 403) aus (Abschnitt 11.9). +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). @@ -664,16 +677,20 @@ Stand: Schema, Client, Prüfung beim Start, Planung, Senden, Verbinden, Empfange **Antworten von Telegram.** - 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). -- 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:`). Scheitert die Meldung, wird das nur geloggt. - Alles andere (5xx, Zeitüberschreitung, Netzfehler, sonstige 4xx, unerwartete Ausnahmen): `settleFailure` mit `loggableError(...).message`, Wiederholung nach der Outbox-Regel (30 s bis 6 h, nach 8 Versuchen `FAILED`). **Verbinden.** `POST /profile/telegram/link-token` (jeder angemeldete Nutzer; Step-up mit aktuellem Passwort und bei MFA TOTP; 10 pro Minute; 201; nicht `@Idempotent`, weil die Antwort ein Geheimnis enthält; `Cache-Control: no-store`) antwortet `{ deepLink, expiresAt }` mit `https://t.me/?start=`. Ohne `TELEGRAM_BOT_USERNAME` ergibt es 409 „Telegram ist nicht eingerichtet.“; das Bot-Token kennt die api nie. Das Token besteht aus 32 Zufallsbytes (Base64url, 43 Zeichen), gespeichert wird nur der SHA-256. Es gilt 10 Minuten (Ablauf nach DB-Uhr) und einmal; ein neues entwertet ältere offene Token desselben Nutzers. Der Job `cleanup` löscht Token, die seit mehr als 24 h abgelaufen oder verbraucht sind. `POST /profile/telegram/unlink` (204) trennt. -**Empfangen** (`TelegramUpdatesService.pollOnce`, Schleife `telegram-updates`, Abschnitt 10). Long Polling (`getUpdates`, `timeout=25`, `allowed_updates ['message']`), kein öffentlicher Eingang, nur private Chats. Genau ein Empfänger: eine Lease in `TelegramPollState` (60 s, nur DB-Uhr, jede Runde verlängert); wer sie nicht hat, wartet 30 s, ebenso nach 409 und nach Fehlern. Das setzt ein einzelnes Worker-Replikat voraus (Abschnitt 7). Jedes Update läuft in einer eigenen Transaktion, in der zuerst der Offset (`update_id + 1`) per Compare-and-Set auf Lease-Inhaber und alten Stand weitergeschoben wird; geht die Lease verloren, bricht die Transaktion samt Wirkung ab, jedes Update wirkt genau einmal. Nicht auswertbare Updates (etwa ohne `message.chat`) schiebt der Offset ohne Wirkung weiter, auch am Ende eines Batches (`getUpdates` liefert `{ updates, highestUpdateId }`). Ein Update, dessen Wirkung immer scheitert, wird nach 5 Versuchen oder 10 Minuten übersprungen (Annahme 51). Auf 429 wartet der Empfang bis `retry_after` (ohne Angabe 30 s, höchstens 1 Stunde), ohne DB und Telegram anzufassen. Beim Beenden bricht das Stop-Signal den Abruf ab, und der Worker gibt die Lease sofort frei. `lastPollAt` steht nach dem Abruf; `lastError` (fester Text plus Klassenname, nie IDs oder Nachrichtentext) wird erst nach einem Batch ohne Fehler und ohne Überspringen gelöscht. +**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 ` (auch `/start@Bot `) verbraucht das Token per Compare-and-Set (nur offen und nicht abgelaufen), legt die Präferenz an, sperrt `NotificationPreference` aller betroffenen Nutzer in einer Anweisung (aufsteigend) und liest Verbindung und Präferenz erst danach (dieselbe Sperre wie der 403-Pfad des Senders). Es ersetzt eine frühere Verbindung desselben Nutzers (`linkedAt` beginnt neu), löst die Verbindung eines anderen Nutzers mit demselben Chat (Meldung „Deine Telegram-Verbindung wurde getrennt, weil der Chat mit einem anderen Konto verbunden wurde.“), schaltet Telegram ein und schreibt die Meldung „Telegram verbunden mit „{Vorname}“ am TT.MM.JJJJ. Warst du das nicht? In den Einstellungen trennen.“ (deckt einen mitgelesenen Link auf; Vorname bereinigt, höchstens 64 Zeichen). Ist Nutzer oder Rolle inaktiv, oder das Token ungültig, abgelaufen oder benutzt, antwortet der Bot `LINK_INVALID` ohne Hinweis auf Konten. `/stop` und „Trennen“ in der App löschen die Verbindung, schalten Telegram aus und senden `STOPPED`. `/hilfe`, `/help`, `/start` ohne Token und anderer Text ergeben `HELP`. Antworten des Bots werden als `NotificationDelivery` mit `templateCode` und `chatId` eingereiht (kein Netzaufruf in der Transaktion) und vom Sender verschickt. Höchstens 3 Antworten je Chat in 10 Minuten (Zähler im Speicher, gezählt erst nach dem Commit); `LINKED` ist ausgenommen (Annahme 54). Jede Trennung (App, `/stop`, Chat zieht zu einem anderen Konto um) markiert die wartenden Telegram-Zustellungen des Nutzers (`PENDING`, `PROCESSING`) in derselben Transaktion als `SKIPPED` („unlinked“). Datenschutz: Vorname, Chat-ID und Token stehen nie in Logs; die Chat-ID verlässt den Server nicht (auch die Einstellungs-Antwort enthält sie nicht). Je Umgebung ein eigener Bot, weil ein Bot nur einen Empfänger haben darf. +**Befehle.** `/start ` (auch `/start@Bot `) 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. Verdrahtung des Secrets und Einrichtung des Bots: Abschnitt 7. +**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. diff --git a/ApiCore-Backend/src/notifications/notification-preferences.service.spec.ts b/ApiCore-Backend/src/notifications/notification-preferences.service.spec.ts index a889ddb..520aaf3 100644 --- a/ApiCore-Backend/src/notifications/notification-preferences.service.spec.ts +++ b/ApiCore-Backend/src/notifications/notification-preferences.service.spec.ts @@ -199,14 +199,14 @@ describe('NotificationPreferencesService', () => { } }); - it('NotificationSettingsView.telegram.configured is true with the username set and no token in the API process', async () => { + 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({ - configured: true, + botNameSet: true, botUsername: 'apicore_test_bot', linked: false, firstName: null, @@ -223,7 +223,7 @@ describe('NotificationPreferencesService', () => { const view = await service.toView(stored as never); - expect(view.telegram.configured).toBe(false); + expect(view.telegram.botNameSet).toBe(false); expect(view.telegram.botUsername).toBeNull(); }); diff --git a/ApiCore-Backend/src/notifications/notification-preferences.service.ts b/ApiCore-Backend/src/notifications/notification-preferences.service.ts index b84edc6..5ecdcbb 100644 --- a/ApiCore-Backend/src/notifications/notification-preferences.service.ts +++ b/ApiCore-Backend/src/notifications/notification-preferences.service.ts @@ -19,11 +19,11 @@ const QUIET_PAIR_MESSAGE = 'Bitte Beginn und Ende der Ruhezeit zusammen leeren.'; /** - * Telegram as the API sees it. "configured" means the bot name is known + * 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 { - configured: boolean; + botNameSet: boolean; botUsername: string | null; linked: boolean; firstName: string | null; @@ -48,7 +48,7 @@ function toTelegramStatus( ): TelegramStatusView { const { botUsername } = telegramConfig(); return { - configured: botUsername !== null, + botNameSet: botUsername !== null, botUsername, linked: link !== null, firstName: link?.telegramFirstName ?? null, diff --git a/ApiCore-Backend/src/telegram/telegram-client.spec.ts b/ApiCore-Backend/src/telegram/telegram-client.spec.ts index cd00492..73a920f 100644 --- a/ApiCore-Backend/src/telegram/telegram-client.spec.ts +++ b/ApiCore-Backend/src/telegram/telegram-client.spec.ts @@ -329,6 +329,7 @@ describe('TelegramClient', () => { await expect(client.getUpdates(7n)).resolves.toEqual({ updates: [], highestUpdateId: 9n, + received: 2, }); }); @@ -337,6 +338,7 @@ describe('TelegramClient', () => { await expect(client.getUpdates(0n)).resolves.toEqual({ updates: [], highestUpdateId: null, + received: 0, }); }); }); diff --git a/ApiCore-Backend/src/telegram/telegram-client.ts b/ApiCore-Backend/src/telegram/telegram-client.ts index 44d8d55..45f39af 100644 --- a/ApiCore-Backend/src/telegram/telegram-client.ts +++ b/ApiCore-Backend/src/telegram/telegram-client.ts @@ -36,6 +36,8 @@ export interface TelegramUpdate { 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 @@ -207,6 +209,7 @@ export class TelegramClient { .map(parseUpdate) .filter((update): update is TelegramUpdate => update !== null), highestUpdateId: highestRawUpdateId(result), + received: result.length, }; } diff --git a/ApiCore-Backend/src/telegram/telegram-link-removal.spec.ts b/ApiCore-Backend/src/telegram/telegram-link-removal.spec.ts new file mode 100644 index 0000000..b6c1b7a --- /dev/null +++ b/ApiCore-Backend/src/telegram/telegram-link-removal.spec.ts @@ -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(); + }); +}); diff --git a/ApiCore-Backend/src/telegram/telegram-link-removal.ts b/ApiCore-Backend/src/telegram/telegram-link-removal.ts new file mode 100644 index 0000000..52b1992 --- /dev/null +++ b/ApiCore-Backend/src/telegram/telegram-link-removal.ts @@ -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 { + 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 { + 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; +} diff --git a/ApiCore-Backend/src/telegram/telegram-link.service.spec.ts b/ApiCore-Backend/src/telegram/telegram-link.service.spec.ts index 3c381e6..47b3c53 100644 --- a/ApiCore-Backend/src/telegram/telegram-link.service.spec.ts +++ b/ApiCore-Backend/src/telegram/telegram-link.service.spec.ts @@ -293,7 +293,7 @@ describe('TelegramLinkService', () => { 'NotificationPreference', expect.arrayContaining(['u1', 'u2']), ); - expect(tx.telegramLink.delete).toHaveBeenCalledWith({ + expect(tx.telegramLink.deleteMany).toHaveBeenCalledWith({ where: { userId: 'u1' }, }); expect(tx.notificationPreference.updateMany).toHaveBeenCalledWith({ @@ -310,9 +310,37 @@ describe('TelegramLinkService', () => { /^Telegram verbunden mit „Ben“/, ); // The old link goes before the new one: the chat id is unique. - expect(tx.telegramLink.delete.mock.invocationCallOrder[0]).toBeLessThan( - tx.telegramLink.create.mock.invocationCallOrder[0], - ); + 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 () => { @@ -362,7 +390,7 @@ describe('TelegramLinkService', () => { 'NotificationPreference', ['u1'], ); - expect(tx.telegramLink.delete).toHaveBeenCalledWith({ + expect(tx.telegramLink.deleteMany).toHaveBeenCalledWith({ where: { userId: 'u1' }, }); expect(tx.notificationPreference.updateMany).toHaveBeenCalledWith({ @@ -374,7 +402,7 @@ describe('TelegramLinkService', () => { it('stopIn for an unknown chat changes nothing', async () => { await service.stopIn(tx as never, CHAT); - expect(tx.telegramLink.delete).not.toHaveBeenCalled(); + expect(tx.telegramLink.deleteMany).not.toHaveBeenCalled(); expect(tx.notificationPreference.updateMany).not.toHaveBeenCalled(); expect(lockRowsForUpdate).not.toHaveBeenCalled(); }); @@ -393,7 +421,7 @@ describe('TelegramLinkService', () => { 'NotificationPreference', ['u1'], ); - expect(tx.telegramLink.delete).toHaveBeenCalledWith({ + expect(tx.telegramLink.deleteMany).toHaveBeenCalledWith({ where: { userId: 'u1' }, }); expect(tx.notificationPreference.updateMany).toHaveBeenCalledWith({ @@ -421,14 +449,14 @@ describe('TelegramLinkService', () => { channel: 'TELEGRAM', status: { in: ['PENDING', 'PROCESSING'] }, }, - data: { status: 'SKIPPED', lastError: 'unlinked' }, + data: { status: 'SKIPPED', claimedAt: null, lastError: 'unlinked' }, }); }); it('unlink without a link is a no-op', async () => { await service.unlink('u1'); - expect(tx.telegramLink.delete).not.toHaveBeenCalled(); + expect(tx.telegramLink.deleteMany).not.toHaveBeenCalled(); expect(tx.notificationDelivery.create).not.toHaveBeenCalled(); }); }); diff --git a/ApiCore-Backend/src/telegram/telegram-link.service.ts b/ApiCore-Backend/src/telegram/telegram-link.service.ts index 7dd3a6d..6eea568 100644 --- a/ApiCore-Backend/src/telegram/telegram-link.service.ts +++ b/ApiCore-Backend/src/telegram/telegram-link.service.ts @@ -6,6 +6,7 @@ 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'; @@ -119,7 +120,7 @@ export class TelegramLinkService { if (!link) { return; } - await this.removeLinkIn(tx, link.userId); + await removeLinkIn(tx, link.userId, 'unlinked'); await this.queueReplyIn(tx, link.chatId, 'STOPPED'); }); } @@ -175,7 +176,7 @@ export class TelegramLinkService { return 'LINK_INVALID'; } if (owner && owner.userId !== userId) { - await this.removeLinkIn(tx, owner.userId); + await removeLinkIn(tx, owner.userId, 'unlinked'); await this.notifications.record( { userId: owner.userId, @@ -188,7 +189,15 @@ export class TelegramLinkService { ); } const name = sanitizeFirstName(firstName); - // The user's own earlier chat is replaced; linkedAt starts again. + // 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 }, @@ -227,7 +236,7 @@ export class TelegramLinkService { select: { userId: true }, }); if (link) { - await this.removeLinkIn(tx, link.userId); + await removeLinkIn(tx, link.userId, 'unlinked'); } } @@ -244,26 +253,6 @@ export class TelegramLinkService { await notifyWorker(tx, WAKE_CHANNELS.notificationDeliveries); } - private async removeLinkIn( - tx: Prisma.TransactionClient, - userId: string, - ): Promise { - await tx.telegramLink.delete({ where: { userId } }); - await tx.notificationPreference.updateMany({ - where: { userId }, - data: { telegramEnabled: false }, - }); - // Whatever was queued for the old chat must never reach a chat linked later. - await tx.notificationDelivery.updateMany({ - where: { - userId, - channel: 'TELEGRAM', - status: { in: ['PENDING', 'PROCESSING'] }, - }, - data: { status: 'SKIPPED', lastError: 'unlinked' }, - }); - } - /** Rows must exist, then one statement locks them all in key order. */ private async lockPreferences( tx: Prisma.TransactionClient, diff --git a/ApiCore-Backend/src/telegram/telegram-sender.spec.ts b/ApiCore-Backend/src/telegram/telegram-sender.spec.ts index 05396f1..ffa1e65 100644 --- a/ApiCore-Backend/src/telegram/telegram-sender.spec.ts +++ b/ApiCore-Backend/src/telegram/telegram-sender.spec.ts @@ -82,6 +82,7 @@ describe('TelegramSender', () => { let tx: { telegramLink: { deleteMany: jest.Mock }; notificationPreference: { updateMany: jest.Mock }; + notificationDelivery: { updateMany: jest.Mock }; }; let client: { sendMessage: jest.Mock, [bigint, string, AbortSignal?]>; @@ -115,6 +116,9 @@ describe('TelegramSender', () => { notificationPreference: { updateMany: jest.fn().mockResolvedValue({ count: 1 }), }, + notificationDelivery: { + updateMany: jest.fn().mockResolvedValue({ count: 0 }), + }, }; prisma.$transaction.mockImplementation((work: (t: unknown) => unknown) => Promise.resolve(work(tx)), @@ -483,6 +487,19 @@ describe('TelegramSender', () => { where: { userId: 'u1' }, 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.objectContaining({ userId: 'u1', diff --git a/ApiCore-Backend/src/telegram/telegram-sender.ts b/ApiCore-Backend/src/telegram/telegram-sender.ts index a0cc0d5..8599b72 100644 --- a/ApiCore-Backend/src/telegram/telegram-sender.ts +++ b/ApiCore-Backend/src/telegram/telegram-sender.ts @@ -15,6 +15,7 @@ import { DRAIN_MAX_ROUNDS } from '../domain-events/domain-event-dispatcher'; import { NotificationsService } from '../notifications/notifications.service'; import { PrismaService } from '../prisma/prisma.service'; import { lockRowForUpdate } from '../prisma/row-lock'; +import { removeLinkIn } from './telegram-link-removal'; import { TelegramApiError } from './telegram-api-error'; import { TelegramClient } from './telegram-client'; import { @@ -504,14 +505,9 @@ export class TelegramSender { } await this.prisma.$transaction(async (tx) => { await lockRowForUpdate(tx, 'NotificationPreference', userId); - const { count } = await tx.telegramLink.deleteMany({ - where: { userId, chatId }, - }); - if (count === 1) { - await tx.notificationPreference.updateMany({ - where: { userId }, - data: { telegramEnabled: false }, - }); + // Link, switch and the waiting backlog end together (only if the link + // still points to this chat). + if (await removeLinkIn(tx, userId, 'chat unavailable', chatId)) { await this.notifications.record( { userId, diff --git a/ApiCore-Backend/src/telegram/telegram-updates.service.spec.ts b/ApiCore-Backend/src/telegram/telegram-updates.service.spec.ts index 1c534ac..8a36a8f 100644 --- a/ApiCore-Backend/src/telegram/telegram-updates.service.spec.ts +++ b/ApiCore-Backend/src/telegram/telegram-updates.service.spec.ts @@ -1,4 +1,5 @@ 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 { @@ -80,6 +81,9 @@ describe('TelegramUpdatesService', () => { 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) @@ -113,7 +117,7 @@ describe('TelegramUpdatesService', () => { isConfigured: jest.fn().mockReturnValue(true), getUpdates: jest .fn() - .mockResolvedValue({ updates: [], highestUpdateId: null }), + .mockResolvedValue({ updates: [], highestUpdateId: null, received: 0 }), }; links = { linkFromStartIn: jest.fn().mockResolvedValue('LINKED'), @@ -354,7 +358,7 @@ describe('TelegramUpdatesService', () => { ); }); - it('an aborted poll (shutdown) records no error and hands the lease back', async () => { + it('an aborted poll (shutdown) records no error', async () => { const controller = new AbortController(); client.getUpdates.mockImplementation(() => { controller.abort(); @@ -365,22 +369,46 @@ describe('TelegramUpdatesService', () => { await expect(service.pollOnce(controller.signal)).resolves.toBe('error'); - // Only the release: no lastError, no lastPollAt. - 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/); + expect(prisma.$executeRaw).not.toHaveBeenCalled(); }); - it('does not touch the lease on shutdown when it never held it', async () => { - leaseGranted(null); - const controller = new AbortController(); - controller.abort(); + 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.pollOnce(controller.signal); + await service.releaseLease(); + await service.releaseLease(); - expect(prisma.$executeRaw).not.toHaveBeenCalled(); + 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', () => { @@ -460,6 +488,102 @@ describe('TelegramUpdatesService', () => { }); }); + 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); diff --git a/ApiCore-Backend/src/telegram/telegram-updates.service.ts b/ApiCore-Backend/src/telegram/telegram-updates.service.ts index db41c32..2a9af11 100644 --- a/ApiCore-Backend/src/telegram/telegram-updates.service.ts +++ b/ApiCore-Backend/src/telegram/telegram-updates.service.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { Injectable, Logger } from '@nestjs/common'; +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'; @@ -18,6 +18,17 @@ 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; @@ -59,6 +70,34 @@ function describeError(error: unknown): string { 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(); @@ -77,6 +116,8 @@ function parseCommand(text: string | null): Command { class ReplyLimiter { private readonly sentAt = new Map(); + constructor(private readonly now: () => number) {} + /** Would one more reply to this chat be within the limit? Counts nothing. */ allows(chatId: bigint): boolean { this.prune(); @@ -88,11 +129,11 @@ class ReplyLimiter { /** Counts a reply that was queued and committed. */ record(chatId: bigint): void { const key = String(chatId); - this.sentAt.set(key, [...(this.sentAt.get(key) ?? []), performance.now()]); + this.sentAt.set(key, [...(this.sentAt.get(key) ?? []), this.now()]); } private prune(): void { - const now = performance.now(); + 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) { @@ -119,7 +160,12 @@ export class TelegramUpdatesService { private readonly logger = new Logger(TelegramUpdatesService.name); /** Names this receiver in the lease. */ private readonly instanceId = randomUUID(); - private readonly replies = new ReplyLimiter(); + 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; @@ -128,38 +174,32 @@ export class TelegramUpdatesService { bigint, { attempts: number; firstAt: number } >(); - /** Test seam: after how many attempts / how long a failing update is skipped. */ - retryLimits = { - attempts: MAX_UPDATE_ATTEMPTS, - windowMs: MAX_UPDATE_RETRY_MS, - }; constructor( private readonly prisma: PrismaService, private readonly client: TelegramClient, private readonly links: TelegramLinkService, - ) {} + @Optional() + @Inject(TELEGRAM_UPDATES_OPTIONS) + options?: Partial, + ) { + 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. A - * cycle that ends because of shutdown hands the lease back. + * them one by one. 'polled' means the next cycle may start at once. */ async pollOnce(signal: AbortSignal): Promise { - try { - return await this.cycle(signal); - } finally { - if (signal.aborted && this.holdsLease) { - await this.releaseLease(); - } - } - } - - private async cycle(signal: AbortSignal): Promise { if (!this.client.isConfigured()) { return 'disabled'; } - if (signal.aborted || performance.now() < this.rateLimitedUntil) { + if (signal.aborted || this.now() < this.rateLimitedUntil) { return 'error'; } const offset = await this.acquireLease(); @@ -172,6 +212,12 @@ export class TelegramUpdatesService { } 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); } @@ -217,12 +263,23 @@ export class TelegramUpdatesService { 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, @@ -256,8 +313,14 @@ export class TelegramUpdatesService { return rows[0]?.updateOffset ?? null; } - /** Shutdown: the next receiver need not wait for the lease to run out. */ - private async releaseLease(): Promise { + /** + * 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 { + if (!this.holdsLease) { + return; + } this.holdsLease = false; try { await this.prisma.$executeRaw` @@ -307,7 +370,7 @@ export class TelegramUpdatesService { const seconds = error instanceof TelegramApiError ? error.retryAfterSec : null; this.rateLimitedUntil = - performance.now() + + this.now() + Math.min( (seconds ?? NO_LEASE_WAIT_MS / 1_000) * 1_000, MAX_RATE_LIMIT_PAUSE_MS, @@ -342,7 +405,12 @@ export class TelegramUpdatesService { update: TelegramUpdate, error: unknown, ): Promise { - const now = performance.now(); + 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, @@ -350,8 +418,8 @@ export class TelegramUpdatesService { }; this.failures.set(update.updateId, state); const exhausted = - state.attempts >= this.retryLimits.attempts || - now - state.firstAt >= this.retryLimits.windowMs; + state.attempts >= this.limits.attempts || + now - state.firstAt >= this.limits.windowMs; if (!exhausted) { await this.onUpdateError(error); } diff --git a/ApiCore-Backend/src/worker/worker-runtime.service.spec.ts b/ApiCore-Backend/src/worker/worker-runtime.service.spec.ts index 2701c85..e5af4b9 100644 --- a/ApiCore-Backend/src/worker/worker-runtime.service.spec.ts +++ b/ApiCore-Backend/src/worker/worker-runtime.service.spec.ts @@ -74,6 +74,7 @@ class FakeTelegramSender { } class FakeTelegramUpdates { + releaseLease = jest.fn, []>().mockResolvedValue(undefined); pollOnce = jest .fn, [AbortSignal]>() .mockResolvedValue('no-lease'); @@ -350,6 +351,15 @@ describe('WorkerRuntime', () => { 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]; diff --git a/ApiCore-Backend/src/worker/worker-runtime.service.ts b/ApiCore-Backend/src/worker/worker-runtime.service.ts index 280305a..a108edb 100644 --- a/ApiCore-Backend/src/worker/worker-runtime.service.ts +++ b/ApiCore-Backend/src/worker/worker-runtime.service.ts @@ -188,6 +188,9 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy { this.listener = undefined; await listener?.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(); } /** diff --git a/ApiCore-Backend/test/notifications.e2e-spec.ts b/ApiCore-Backend/test/notifications.e2e-spec.ts index 59c79ff..0f03abb 100644 --- a/ApiCore-Backend/test/notifications.e2e-spec.ts +++ b/ApiCore-Backend/test/notifications.e2e-spec.ts @@ -367,7 +367,7 @@ describe('Notifications (e2e)', () => { linkedAt: null, enabled: false, }); - expect(typeof telegram.configured).toBe('boolean'); + expect(typeof telegram.botNameSet).toBe('boolean'); }); it('PATCH notification-settings stores digest time, quiet hours and Telegram categories', async () => { diff --git a/ApiCore-Backend/test/telegram.e2e-spec.ts b/ApiCore-Backend/test/telegram.e2e-spec.ts index b181e93..7de4007 100644 --- a/ApiCore-Backend/test/telegram.e2e-spec.ts +++ b/ApiCore-Backend/test/telegram.e2e-spec.ts @@ -823,7 +823,7 @@ describe('Telegram delivery (e2e)', () => { .set('Authorization', `Bearer ${accessToken}`) .expect(200); expect((view.body as { telegram: object }).telegram).toMatchObject({ - configured: true, + botNameSet: true, botUsername: BOT, linked: true, firstName: 'unlink', @@ -940,23 +940,28 @@ describe('Telegram delivery (e2e)', () => { const stop = jest .spyOn(links, 'stopIn') .mockRejectedValue(new Error('boom')); - updates.retryLimits = { attempts: 2, windowMs: 600_000 }; + // 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(poll()).resolves.toBe('error'); + 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(poll()).resolves.toBe('polled'); + await expect(strict.pollOnce(NEVER_ABORTED)).resolves.toBe('polled'); } finally { stop.mockRestore(); - updates.retryLimits = { attempts: 5, windowMs: 600_000 }; } const state = await prisma.telegramPollState.findUniqueOrThrow({ @@ -971,13 +976,48 @@ describe('Telegram delivery (e2e)', () => { expect(await repliesTo(other)).toEqual(['HELP']); // A clean batch afterwards clears the notice. - await poll(); + 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'); diff --git a/ApiCore-Backend/test/utils/telegram.ts b/ApiCore-Backend/test/utils/telegram.ts index e6e0cc0..fb6f719 100644 --- a/ApiCore-Backend/test/utils/telegram.ts +++ b/ApiCore-Backend/test/utils/telegram.ts @@ -82,7 +82,7 @@ export class FakeTelegramClient { : highest, null, ); - return { updates: due, highestUpdateId }; + return { updates: due, highestUpdateId, received: due.length }; } sentTo(chatId: bigint): SentMessage[] { diff --git a/ApiCore-Frontend/public/sw-routes.js b/ApiCore-Frontend/public/sw-routes.js index 00dacd3..2c23698 100644 --- a/ApiCore-Frontend/public/sw-routes.js +++ b/ApiCore-Frontend/public/sw-routes.js @@ -58,6 +58,11 @@ // names (Bestandsbuch), megabytes /\/api\/v1\/export\//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, // so the pack - with its age shown - answers instead of an old copy /\/api\/v1\/colonies\/?$/i, diff --git a/ApiCore-Frontend/public/sw.js b/ApiCore-Frontend/public/sw.js index 8ddbf83..178246c 100644 --- a/ApiCore-Frontend/public/sw.js +++ b/ApiCore-Frontend/public/sw.js @@ -19,7 +19,7 @@ // 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 // 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}`); const { diff --git a/ApiCore-Frontend/src/app/(app)/bottling/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/bottling/new/page.spec.tsx index 782d808..7625b03 100644 --- a/ApiCore-Frontend/src/app/(app)/bottling/new/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/bottling/new/page.spec.tsx @@ -1,9 +1,12 @@ 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 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 { fetchWithdrawalStatus } from "@/lib/treatments/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/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", () => ({ 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() { const client = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -47,6 +80,8 @@ describe("NewBottlingBatchPage", () => { vi.mocked(fetchHoneyBatchTraceability).mockResolvedValue({ harvests: [] } as never); vi.mocked(fetchWithdrawalStatus).mockResolvedValue([]); vi.mocked(createBottlingBatch).mockReset(); + vi.mocked(fetchBottlingPrefill).mockReset(); + vi.mocked(fetchBottlingPrefill).mockResolvedValue(EMPTY_PREFILL); vi.mocked(toast.success).mockReset(); vi.mocked(toast.warning).mockReset(); }); @@ -162,4 +197,425 @@ describe("NewBottlingBatchPage", () => { renderPage(); 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("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( + undefined} + onCancel={() => undefined} + />, + ); + expect(screen.getByRole("dialog")).toHaveTextContent("Wassergehalt 20,5 %"); + + rerender( + 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); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/bottling/new/page.tsx b/ApiCore-Frontend/src/app/(app)/bottling/new/page.tsx index d8f8fd0..f6565f7 100644 --- a/ApiCore-Frontend/src/app/(app)/bottling/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/bottling/new/page.tsx @@ -1,16 +1,22 @@ "use client"; -import { FormEvent, useState } from "react"; +import { FormEvent, useId, useMemo, useRef, useState } from "react"; 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 { AlertTriangle, Package } from "lucide-react"; import { fetchHoneyBatches } from "@/lib/honey-batches/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 { 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 { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -23,6 +29,8 @@ import { Field } from "@/components/form/field"; import { FormSection } from "@/components/form/form-section"; import { StickySubmit } from "@/components/form/sticky-submit"; import { DraftBanner } from "@/components/form/draft-banner"; +import { WaterContentConfirmDialog } from "./water-content-confirm-dialog"; +import { WaterContentNotes } from "./water-content-notes"; import { BottlingFormValues, initialBottlingValues, @@ -67,18 +75,91 @@ function validate(values: BottlingFormValues): FieldErrors { 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() { const router = useRouter(); const searchParams = useSearchParams(); const queryClient = useQueryClient(); - const [values, setValues] = useState(() => + const [formValues, setValues] = useState(() => initialBottlingValues(searchParams.get("honeyBatchId") ?? ""), ); const [dirty, setDirty] = useState(false); const [fieldErrors, setFieldErrors] = useState({}); const [submitError, setSubmitError] = useState(null); - const draft = useFormDraft("bottling-new", values, dirty); + const [manual, setManual] = useState(NO_MANUAL_EDITS); + const [fromDraft, setFromDraft] = useState(NO_MANUAL_EDITS); + const [confirmation, setConfirmation] = useState(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(null); + const [dialogMessage, setDialogMessage] = useState(null); + const confirmInFlight = useRef(false); + const submitButton = useRef(null); + const loadingId = useId(); + const { isOffline, hintId, describedBy: offlineDescribedBy } = useOnlineOnly(); + + const prefillEnabled = Boolean(formValues.honeyBatchId && formValues.date); + const prefillQuery = useQuery({ + 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( + () => ({ + ...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(() => ({ ...values, manual }), [values, manual]); + const draft = useFormDraft("bottling-new", draftValues, dirty); const { data: honeyBatches, isLoading: honeyBatchesLoading } = useQuery({ queryKey: ["honey-batches"], @@ -98,6 +179,14 @@ export default function NewBottlingBatchPage() { value: BottlingFormValues[K], ) { 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); if ((REQUIRED_KEYS as string[]).includes(key)) { setFieldErrors((current) => ({ ...current, [key]: undefined })); @@ -105,9 +194,23 @@ export default function NewBottlingBatchPage() { } 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) => { draft.clear(); + idempotencyKey.current = newIdempotencyKey(); + lastBody.current = null; void queryClient.invalidateQueries({ queryKey: ["bottling-batches"] }); void queryClient.invalidateQueries({ queryKey: ["honey-batches"] }); if (bottlingBatch.warnings?.length) { @@ -118,12 +221,71 @@ export default function NewBottlingBatchPage() { router.push(`/bottling/${bottlingBatch.id}`); }, onError: (mutationError: Error) => { - setSubmitError( - errorMessage(mutationError, "Abfüllung konnte nicht angelegt werden."), - ); + if ( + 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(`[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) { event.preventDefault(); setSubmitError(null); @@ -136,13 +298,69 @@ export default function NewBottlingBatchPage() { ?.focus(); 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() { const restored = draft.restore(); 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); } } @@ -202,6 +420,15 @@ export default function NewBottlingBatchPage() { /> )} + + void prefillQuery.refetch()} + showLoading={false} + /> {withdrawalCheckFailed ? (

@@ -223,6 +450,18 @@ export default function NewBottlingBatchPage() { ) : null} + + {({ id }) => ( + set("productId", value)} + items={products?.filter((product) => product.kind !== "MANUFACTURED")} + getLabel={(product) => product.name} + placeholder="Produkt wählen" + /> + )} + {({ id, describedBy, invalid }) => ( {({ id, describedBy, invalid }) => ( {({ id, describedBy, invalid }) => ( - - {({ id }) => ( - set("productId", value)} - items={products?.filter((product) => product.kind !== "MANUFACTURED")} - getLabel={(product) => product.name} - placeholder="Produkt wählen" - /> - )} - - {submitError} -

- ) : null + <> +
+ + {prefillLoading ? ( +

+ Artikelwerte werden geladen … +

+ ) : null} +
+
+ {submitError ? ( +

{submitError}

+ ) : null} +
+ } > + diff --git a/ApiCore-Frontend/src/app/(app)/bottling/new/water-content-confirm-dialog.tsx b/ApiCore-Frontend/src/app/(app)/bottling/new/water-content-confirm-dialog.tsx new file mode 100644 index 0000000..a197607 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/bottling/new/water-content-confirm-dialog.tsx @@ -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; + 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(waterContent); + if (waterContent !== null && waterContent !== lastValue) setLastValue(waterContent); + const shown = waterContent ?? lastValue; + return ( + { + if (!open && !isPending) onCancel(); + }} + > + + + Wassergehalt zu hoch + + {shown === null + ? "" + : `Wassergehalt ${formatWaterContent(shown)} über der gesetzlichen Grenze. Trotzdem abfüllen?`} + + +
+ {message ?

{message}

: null} +
+ + + + +
+
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/bottling/new/water-content-notes.tsx b/ApiCore-Frontend/src/app/(app)/bottling/new/water-content-notes.tsx new file mode 100644 index 0000000..764fa30 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/bottling/new/water-content-notes.tsx @@ -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 }> = { + 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 ( +
+ {hasContent ? ( +
+ {prefill !== undefined && prefill.waterContent !== null ? ( +

Wassergehalt der Charge: {formatWaterContent(prefill.waterContent)}

+ ) : null} + {warnings.length > 0 ? ( +
    + {warnings.map((warning) => { + const { label, Icon } = SEVERITY_LOOK[warning.severity]; + return ( +
  • + + {label} + {warning.message} +
  • + ); + })} +
+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/products/[id]/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/products/[id]/page.spec.tsx index abb5949..a06bc69 100644 --- a/ApiCore-Frontend/src/app/(app)/products/[id]/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/products/[id]/page.spec.tsx @@ -42,6 +42,7 @@ describe("ProductDetailPage", () => { description: null, sku: null, packagingSize: null, + shelfLifeMonths: null, weight: null, salesUnit: null, status: "ACTIVE", diff --git a/ApiCore-Frontend/src/app/(app)/products/edit-product-dialog.spec.tsx b/ApiCore-Frontend/src/app/(app)/products/edit-product-dialog.spec.tsx new file mode 100644 index 0000000..bc3ad48 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/products/edit-product-dialog.spec.tsx @@ -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( + + + , + ); + 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"); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/products/edit-product-dialog.tsx b/ApiCore-Frontend/src/app/(app)/products/edit-product-dialog.tsx index de0f256..323c1fd 100644 --- a/ApiCore-Frontend/src/app/(app)/products/edit-product-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/products/edit-product-dialog.tsx @@ -6,6 +6,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { updateProduct } from "@/lib/products/api"; 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_KIND_LABELS, @@ -33,11 +34,30 @@ export function EditProductDialog({ product }: { product: Product }) { const [description, setDescription] = useState(product.description ?? ""); const [sku, setSku] = useState(product.sku ?? ""); const [packagingSize, setPackagingSize] = useState(product.packagingSize?.toString() ?? ""); + const [shelfLifeMonths, setShelfLifeMonths] = useState(product.shelfLifeMonths?.toString() ?? ""); + const [nameError, setNameError] = useState(null); + const [packagingError, setPackagingError] = useState(null); + const [shelfLifeError, setShelfLifeError] = useState(null); const [weight, setWeight] = useState(product.weight?.toString() ?? ""); const [salesUnit, setSalesUnit] = useState(product.salesUnit ?? ""); const [status, setStatus] = useState(product.status); const [shopCategory, setShopCategory] = useState(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({ mutationFn: () => updateProduct(product.id, { @@ -45,6 +65,7 @@ export function EditProductDialog({ product }: { product: Product }) { description: emptyToNull(description), sku: emptyToNull(sku), packagingSize: emptyToNull(packagingSize, Number), + shelfLifeMonths: emptyToNull(shelfLifeMonths, Number), weight: emptyToNull(weight, Number), salesUnit: emptyToNull(salesUnit), status, @@ -59,11 +80,36 @@ export function EditProductDialog({ product }: { product: Product }) { function handleSubmit(event: FormEvent) { 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(); } return ( - + { + if (next) resetToProduct(); + setOpen(next); + }} + > @@ -76,15 +122,25 @@ export function EditProductDialog({ product }: { product: Product }) { Produkt bearbeiten -
+
setName(event.target.value)} + onChange={(event) => { + setName(event.target.value); + setNameError(null); + }} /> + {nameError ? ( + + ) : null}
@@ -103,15 +159,50 @@ export function EditProductDialog({ product }: { product: Product }) { />
- + setPackagingSize(event.target.value)} + onChange={(event) => { + setPackagingSize(event.target.value); + setPackagingError(null); + }} /> + {packagingError ? ( + + ) : null} +
+
+ + { + setShelfLifeMonths(event.target.value); + setShelfLifeError(null); + }} + /> + {shelfLifeError ? ( + + ) : null}
diff --git a/ApiCore-Frontend/src/app/(app)/products/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/products/new/page.spec.tsx index 41f6779..170fecd 100644 --- a/ApiCore-Frontend/src/app/(app)/products/new/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/products/new/page.spec.tsx @@ -44,7 +44,7 @@ describe("NewProductPage", () => { renderPage(); 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( screen.getByRole("button", { name: /Weitere Angaben/ }), ); @@ -59,6 +59,7 @@ describe("NewProductPage", () => { packagingSize: 500, weight: null, salesUnit: null, + shelfLifeMonths: null, kind: "BOTTLED_HONEY", shopCategory: "HONIG", }); @@ -73,4 +74,45 @@ describe("NewProductPage", () => { expect(screen.getByLabelText(/Name/)).toHaveFocus(); 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(); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/products/new/page.tsx b/ApiCore-Frontend/src/app/(app)/products/new/page.tsx index fdf7b36..305cec3 100644 --- a/ApiCore-Frontend/src/app/(app)/products/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/products/new/page.tsx @@ -166,17 +166,21 @@ export default function NewProductPage() { )} - {({ id, describedBy }) => ( + {({ id, describedBy, invalid }) => ( set("packagingSize", event.target.value) @@ -184,6 +188,29 @@ export default function NewProductPage() { /> )} + + {({ id, describedBy, invalid }) => ( + + set("shelfLifeMonths", event.target.value) + } + /> + )} + void; -} - -function SwitchRow({ label, description, checked, onChange }: SwitchRowProps) { - const id = useId(); - return ( - - ); -} - interface FormErrors { digestTime?: string; quiet?: string; @@ -84,7 +50,7 @@ function NotificationSettingsForm({ const [submitError, setSubmitError] = useState(null); const mutation = useMutation({ - mutationFn: (patch: Partial) => + mutationFn: (patch: NotificationSettingsPatch) => updateNotificationSettings(patch), onSuccess: (saved) => { queryClient.setQueryData(QUERY_KEY, saved); diff --git a/ApiCore-Frontend/src/app/(app)/profile/page.tsx b/ApiCore-Frontend/src/app/(app)/profile/page.tsx index 3ab53de..6658c6e 100644 --- a/ApiCore-Frontend/src/app/(app)/profile/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/profile/page.tsx @@ -5,6 +5,7 @@ import { UserRound } from "lucide-react"; import { useMutation } from "@tanstack/react-query"; import { toast } from "sonner"; import { useAuth } from "@/lib/auth/auth-context"; +import { stepUpErrorText } from "@/lib/auth/step-up-error"; import { changePassword, deleteAvatar, @@ -28,6 +29,7 @@ import { MfaSection } from "./mfa-section"; import { DangerZoneSection } from "./danger-zone-section"; import { MotionSection } from "./motion-section"; import { NotificationSettingsCard } from "./notification-settings-card"; +import { TelegramLinkCard } from "./telegram-link-card"; function splitName(fullName: string): { firstName: string; lastName: string } { const [firstName, ...rest] = fullName.trim().split(/\s+/); @@ -226,7 +228,7 @@ function PasswordSection() { 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) { @@ -304,6 +306,7 @@ export default function ProfilePage() { void refreshUser()} /> +
diff --git a/ApiCore-Frontend/src/app/(app)/profile/telegram-link-card.spec.tsx b/ApiCore-Frontend/src/app/(app)/profile/telegram-link-card.spec.tsx new file mode 100644 index 0000000..1eefbdc --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/profile/telegram-link-card.spec.tsx @@ -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( + + + , + ); +} + +async function connect(user: ReturnType) { + 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 }), + ); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/profile/telegram-link-card.tsx b/ApiCore-Frontend/src/app/(app)/profile/telegram-link-card.tsx new file mode 100644 index 0000000..ee3e732 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/profile/telegram-link-card.tsx @@ -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(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 ( +
+
+ Das kommt per Telegram +
+ {NOTIFICATION_CATEGORIES.map((category) => ( + toggleCategory(category, checked)} + describedBy={isOffline ? hintId : undefined} + disabled={mutation.isPending || isOffline} + /> + ))} +
+
+ mutation.mutate({ telegramDetail: checked })} + /> + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} + +interface OpenLinkProps { + link: TelegramLinkToken; + anchorRef: RefObject; +} + +/** The one-time link as the big primary action; it holds a secret, so it only lives here. */ +function OpenLink({ link, anchorRef }: OpenLinkProps) { + return ( +
+ + + In Telegram öffnen + +

Gültig bis {formatBerlinTime(link.expiresAt)}

+
+ ); +} + +export function TelegramLinkCard() { + const { user } = useAuth(); + const queryClient = useQueryClient(); + const [link, setLink] = useState(null); + const [isStepUpOpen, setIsStepUpOpen] = useState(false); + const [linkError, setLinkError] = useState(null); + const { isOffline, hintId, describedBy } = useOnlineOnly(); + const anchorRef = useRef(null); + const connectRef = useRef(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 { + // 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 ( + + + Telegram + Erinnerungen auch aufs Handy, wenn die App zu ist. + + +

+ + {statusText(settings)} +

+ {query.isError ? ( + void query.refetch()} + isRetrying={query.isFetching} + /> + ) : null} + {linkError ? ( +

+ {linkError} +

+ ) : null} + {openLink ? : null} + {canConnect ? ( + + ) : null} + {settings && botNameSet && isLinked ? ( + <> + + unlink.mutate()} + /> + + ) : null} + {botNameSet ? : null} + +
+
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/backup/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/settings/backup/page.spec.tsx index 9b4f1a5..3af0b23 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/backup/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/backup/page.spec.tsx @@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactElement } from "react"; import { toast } from "sonner"; import BackupPage from "./page"; +import { ApiError } from "@/lib/auth/api-client"; import { exportBackup, getBackupSchedule, @@ -175,12 +176,58 @@ describe("BackupPage", () => { fireEvent.change(screen.getByLabelText("Backup-Datei einspielen"), { 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" })); const dialog = await screen.findByRole("dialog"); + await userEvent.type(within(dialog).getByLabelText("Ihr Passwort"), "geheim"); 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(); + 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(); + 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 () => { vi.mocked(getBackupSchedule).mockResolvedValue(schedule()); vi.mocked(importBackup).mockResolvedValue({ diff --git a/ApiCore-Frontend/src/app/(app)/settings/backup/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/backup/page.tsx index 2e75e5d..996aaf5 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/backup/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/backup/page.tsx @@ -12,17 +12,18 @@ import { runBackupNow, updateBackupSettings, } from "@/lib/backup/api"; -import { Input } from "@/components/ui/input"; import { useAuth } from "@/lib/auth/auth-context"; import { BackupJobRunStatus, BackupScheduleStatus, BackupSettings } from "@/lib/backup/types"; import { unsealedImportWarning } from "@/lib/backup/import-summary"; import { importCsv } from "@/lib/import/api"; import { ImportEntity, ImportReport, ImportRowStatus } from "@/lib/import/types"; +import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; 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 { EnumSelect } from "@/components/form/enum-select"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -79,10 +80,8 @@ function ImportLimitWarning({ backup, what }: { backup: OversizedBackup; what: s function BackupSection() { const { user } = useAuth(); const [file, setFile] = useState(null); - const [password, setPassword] = useState(""); - const [mfaCode, setMfaCode] = useState(""); + const [isImportDialogOpen, setIsImportDialogOpen] = useState(false); const needsMfa = Boolean(user?.mfaEnabled); - const proofComplete = password.length > 0 && (!needsMfa || /^\d{6}$/.test(mfaCode)); const [oversizedExport, setOversizedExport] = useState(null); // Kept on screen - the list of what was left out is too long for a toast alone. const [importNotice, setImportNotice] = useState(null); @@ -109,11 +108,11 @@ function BackupSection() { }); const importMutation = useMutation({ - mutationFn: () => { + mutationFn: (proof: StepUpProof) => { if (!file) { 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) => { const total = Object.values(summary.imported).reduce((sum, count) => sum + count, 0); @@ -124,15 +123,7 @@ function BackupSection() { toast.warning(notice); } setFile(null); - setPassword(""); - setMfaCode(""); }, - onError: (error) => - toast.error( - error instanceof Error && error.message - ? error.message - : "Backup konnte nicht eingespielt werden", - ), }); function handleFileChange(event: ChangeEvent) { @@ -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" />

- Einspielen überschreibt Konten und Daten. Zur Bestätigung Ihr Passwort - {needsMfa ? " und den aktuellen Code aus der Authenticator-App" : ""} eingeben. + Einspielen überschreibt Konten und Daten. Zur Bestätigung fragen wir Ihr Passwort + {needsMfa ? " und den aktuellen Code aus der Authenticator-App" : ""}.

- - setPassword(event.target.value)} - /> - {needsMfa && ( - <> - - setMfaCode(event.target.value.replace(/\D/g, ""))} - /> - - )} - setIsImportDialogOpen(true)} + > + Backup importieren + + importMutation.mutate()} + pendingLabel="Wird eingespielt…" + needsMfa={needsMfa} + onConfirm={async (proof) => { + await importMutation.mutateAsync(proof); + }} /> {importNotice ? (

({ fetchSystemOverview: vi.fn(), @@ -21,6 +22,11 @@ vi.mock("@/lib/system/api", () => ({ fetchFailedWebhookDeliveries: vi.fn(), retryWebhookDelivery: vi.fn(), })); +vi.mock("@/lib/telegram/api", () => ({ + fetchNotificationDeliveries: vi.fn(), + retryNotificationDelivery: vi.fn(), + retryFailedNotificationDeliveries: vi.fn(), +})); let permissions: string[] = []; vi.mock("@/lib/auth/auth-context", () => ({ useAuth: () => ({ user: { permissions } }), @@ -93,10 +99,33 @@ describe("SystemPage", () => { vi.mocked(fetchFailedWebhookDeliveries).mockResolvedValue([]); vi.mocked(retryDomainEvent).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()); + 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 () => { renderPage(); diff --git a/ApiCore-Frontend/src/app/(app)/settings/system/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/system/page.tsx index c1f52d7..17bd956 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/system/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/system/page.tsx @@ -29,6 +29,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { PageHeader } from "@/components/page-header"; import { QueryState } from "@/components/query-state"; +import { TelegramCard } from "./telegram-card"; const SYSTEM_QUERY_KEY = ["system"] as const; const OVERVIEW_QUERY_KEY = [...SYSTEM_QUERY_KEY, "overview"] as const; @@ -302,6 +303,8 @@ export default function SystemPage() { + {/* The deliveries endpoint needs system:manage; a reader would only get a 403. */} + {canManage ? : null} )} diff --git a/ApiCore-Frontend/src/app/(app)/settings/system/telegram-card.spec.tsx b/ApiCore-Frontend/src/app/(app)/settings/system/telegram-card.spec.tsx new file mode 100644 index 0000000..2af08bd --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/system/telegram-card.spec.tsx @@ -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 = {}, + receiver: Partial = {}, +): 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( + + + , + ); +} + +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(); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/settings/system/telegram-card.tsx b/ApiCore-Frontend/src/app/(app)/settings/system/telegram-card.tsx new file mode 100644 index 0000000..7acc333 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/system/telegram-card.tsx @@ -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 ( +

+

+ {telegram.botNameSet + ? `Bot eingerichtet: @${telegram.botUsername ?? ""}` + : "Bot nicht eingerichtet"} +

+

+ Die API kennt nur den Namen des Bots. Der Versand läuft nur im Worker, wenn das Geheimnis + vorhanden ist. +

+
+ ); +} + +function ReceiverSection({ receiver }: { receiver: TelegramStatusView["receiver"] }) { + const isRunning = receiver.leaseOwner !== null; + const StatusIcon = isRunning ? CircleCheck : CircleX; + return ( +
+

Empfang

+

+ + {isRunning ? "Empfang läuft" : "Kein Empfänger aktiv"} +

+ {receiver.lastPollAgeSeconds !== null && + receiver.lastPollAgeSeconds > RECEIVER_STALE_SECONDS ? ( +
+

+ + Kein Abruf seit über 2 Minuten +

+

Letzter Abruf {formatAge(receiver.lastPollAgeSeconds)}

+
+ ) : ( +

+ {receiver.lastPollAgeSeconds === null + ? "Noch kein Abruf" + : `Letzter Abruf: ${formatAge(receiver.lastPollAgeSeconds)}`} +

+ )} + {receiver.lastError ?

{receiver.lastError}

: null} +
+ ); +} + +function StaleDeliveryWarning() { + return ( +
+ +

Eine Telegram-Nachricht wartet länger als 10 Minuten.

+
+ ); +} + +function SenderSection({ sender }: { sender: TelegramStatusView["sender"] }) { + const isStale = + sender.oldestPendingAgeSeconds !== null && + sender.oldestPendingAgeSeconds > QUEUE_STALE_WARNING_SECONDS; + return ( +
+

Versand

+ {isStale ? : null} +
+

Offen: {sender.pending}

+ {sender.oldestPendingAgeSeconds !== null ? ( +

+ Ältestes offenes: {formatAge(sender.oldestPendingAgeSeconds)} +

+ ) : null} +

Fehlgeschlagen (24 Std.): {sender.failed24h}

+
+ {sender.lastErrors.length > 0 ? ( +
    + {sender.lastErrors.map((entry) => ( +
  • + {entry.statusCode ? `HTTP ${entry.statusCode} · ` : ""} + {entry.error} · {formatDateTime(entry.createdAt)} +
  • + ))} +
+ ) : null} +
+ ); +} + +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 ( +
  • +
    +
    + {deliveryTitle(delivery)} + + {NOTIFICATION_DELIVERY_STATUS_LABELS[delivery.status]} + +
    + + {formatDateTime(delivery.createdAt)} · {attemptsLabel(delivery.attempts)} + {delivery.lastStatusCode ? ` · HTTP ${delivery.lastStatusCode}` : ""} + + {delivery.lastError ? ( +

    {delivery.lastError}

    + ) : null} +
    + {isFailed ? ( + + ) : null} +
  • + ); +} + +/** + * 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 ( + + + Telegram + + + + {({ telegram, deliveries }) => { + const failedCount = Math.max( + telegram.sender.failed24h, + deliveries.filter((delivery) => delivery.status === "FAILED").length, + ); + return ( + <> + + + + {failedCount > 0 ? ( + retryAll.mutate()} + /> + ) : null} + + {deliveries.length === 0 ? ( +

    Noch keine Zustellungen.

    + ) : ( +
      + {deliveries.map((delivery) => ( + retryOne.mutate(delivery.id)} + /> + ))} +
    + )} + + ); + }} +
    +
    +
    + ); +} diff --git a/ApiCore-Frontend/src/components/auth/step-up-dialog.spec.tsx b/ApiCore-Frontend/src/components/auth/step-up-dialog.spec.tsx new file mode 100644 index 0000000..88f6d03 --- /dev/null +++ b/ApiCore-Frontend/src/components/auth/step-up-dialog.spec.tsx @@ -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[0]> = {}) { + const onConfirm = vi.fn().mockResolvedValue(undefined); + const onOpenChange = vi.fn(); + render( + , + ); + 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(() => {})) }); + + 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(() => {})); + 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(); + }); +}); diff --git a/ApiCore-Frontend/src/components/auth/step-up-dialog.tsx b/ApiCore-Frontend/src/components/auth/step-up-dialog.tsx new file mode 100644 index 0000000..f580a4d --- /dev/null +++ b/ApiCore-Frontend/src/components/auth/step-up-dialog.tsx @@ -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; + onOpenChange: (open: boolean) => void; + /** Where focus goes when the dialog closes (default: the element that opened it). */ + finalFocusRef?: RefObject; +} + +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; + 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(null); + const passwordRef = useRef(null); + const { isOffline, hintId, describedBy } = useOnlineOnly(); + + const proofComplete = password.length > 0 && (!needsMfa || MFA_CODE_PATTERN.test(mfaCode)); + + async function handleSubmit(event: FormEvent) { + 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 ( + +
    + + setPassword(event.target.value)} + /> +
    + {needsMfa ? ( +
    + + setMfaCode(event.target.value.replace(/\D/g, ""))} + /> +
    + ) : null} + {/* Mounted all the time, so a screen reader announces the text when it appears. */} +

    + {error} +

    + + + + ); +} + +/** + * 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. + (next || !isPending) && onOpenChange(next)}> + + + {title} + {description ? {description} : null} + + onOpenChange(false)} + /> + + + ); +} diff --git a/ApiCore-Frontend/src/components/confirm-dialog.tsx b/ApiCore-Frontend/src/components/confirm-dialog.tsx index 8c5e6a5..582d43f 100644 --- a/ApiCore-Frontend/src/components/confirm-dialog.tsx +++ b/ApiCore-Frontend/src/components/confirm-dialog.tsx @@ -24,6 +24,8 @@ interface ConfirmDialogProps { triggerSize?: VariantProps["size"]; triggerClassName?: string; triggerDisabled?: boolean; + /** Id of a hint that explains why the trigger is off (e.g. „Nur mit Verbindung“). */ + triggerDescribedBy?: string; title: string; description: string; confirmLabel: string; @@ -39,6 +41,7 @@ export function ConfirmDialog({ triggerSize = "default", triggerClassName, triggerDisabled, + triggerDescribedBy, title, description, confirmLabel, @@ -57,6 +60,7 @@ export function ConfirmDialog({ className={cn(triggerClassName)} aria-label={triggerAriaLabel} disabled={triggerDisabled} + aria-describedby={triggerDescribedBy} onClick={() => setOpen(true)} > {triggerLabel} diff --git a/ApiCore-Frontend/src/components/form/switch-row.tsx b/ApiCore-Frontend/src/components/form/switch-row.tsx new file mode 100644 index 0000000..df9e59d --- /dev/null +++ b/ApiCore-Frontend/src/components/form/switch-row.tsx @@ -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 ( + + ); +} diff --git a/ApiCore-Frontend/src/components/form/toggle-chip.tsx b/ApiCore-Frontend/src/components/form/toggle-chip.tsx index 3209814..a034aaa 100644 --- a/ApiCore-Frontend/src/components/form/toggle-chip.tsx +++ b/ApiCore-Frontend/src/components/form/toggle-chip.tsx @@ -9,19 +9,21 @@ interface ToggleChipProps { onChange: (checked: boolean) => void; /** Ids of a hint or error shown next to the tile. */ describedBy?: string; + disabled?: boolean; } /** 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 (