From 32c61c8e1482316a90d53a3b20b13f9cd2d202d8 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Wed, 14 Oct 2026 17:05:32 +0200 Subject: [PATCH] feat: T4 wave 6 - reminders and morning digest, next steps UI, warnings and notifications UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - In-app reminders: task due (skips rescheduled tasks, catch-up wording) and a per-user morning digest step in the daily job - Frontend: "Als Nächstes" on the colony page, "Heute" on the dashboard, inspection-due card, season chip, apiary overview fields, cleanup sheet for ended colonies - Frontend: plausibility warnings that stay until dismissed, withdrawal spans in the calendar, notification bell with categories and links, notification settings in the profile - ARCHITECTURE.md: T4a sections 11.1-11.10, assumptions 31-41, handler table and daily job step order Co-Authored-By: Claude Opus 5.5 (1M context) --- ARCHITECTURE.md | 232 ++++++++++-- ApiCore-Backend/src/app.module.ts | 2 + .../src/autopilot/next-steps.service.ts | 106 ++++-- .../src/jobs/daily-evaluation-steps.spec.ts | 8 +- .../src/jobs/daily-evaluation.job.spec.ts | 28 +- .../src/jobs/daily-evaluation.job.ts | 3 + ApiCore-Backend/src/jobs/jobs.module.ts | 2 + .../src/reminders/digest.service.spec.ts | 299 +++++++++++++++ .../src/reminders/digest.service.ts | 318 ++++++++++++++++ .../src/reminders/digest.step.spec.ts | 29 ++ ApiCore-Backend/src/reminders/digest.step.ts | 21 ++ .../src/reminders/reminders.module.ts | 21 ++ .../src/reminders/task-due.handlers.spec.ts | 216 +++++++++++ .../src/reminders/task-due.handlers.ts | 113 ++++++ ApiCore-Backend/src/worker/worker.module.ts | 2 + ApiCore-Backend/test/reminders.e2e-spec.ts | 295 +++++++++++++++ .../test/worker-runtime.e2e-spec.ts | 14 +- .../[id]/apiary-colonies-card.spec.tsx | 32 ++ .../apiaries/[id]/apiary-colonies-card.tsx | 10 + .../src/app/(app)/bottling/new/page.spec.tsx | 34 ++ .../src/app/(app)/bottling/new/page.tsx | 7 +- .../src/app/(app)/calendar/page.spec.tsx | 104 ++++++ .../src/app/(app)/calendar/page.tsx | 31 +- .../src/app/(app)/colonies/[id]/page.spec.tsx | 24 ++ .../src/app/(app)/colonies/[id]/page.tsx | 2 + .../src/app/(app)/dashboard/page.spec.tsx | 175 +++++++++ .../src/app/(app)/dashboard/page.tsx | 37 +- .../src/app/(app)/harvests/new/page.spec.tsx | 31 ++ .../src/app/(app)/harvests/new/page.tsx | 8 +- .../app/(app)/offline/colony/page.spec.tsx | 48 ++- .../src/app/(app)/offline/colony/page.tsx | 10 +- .../notification-settings-card.spec.tsx | 120 +++++++ .../profile/notification-settings-card.tsx | 285 +++++++++++++++ .../src/app/(app)/profile/page.tsx | 2 + .../src/app/(app)/suggestions/page.spec.tsx | 24 ++ .../src/app/(app)/suggestions/page.tsx | 19 + .../src/app/(app)/winter/page.spec.tsx | 77 ++++ .../src/app/(app)/winter/page.tsx | 55 ++- .../autopilot/next-steps-card.spec.tsx | 236 ++++++++++++ .../components/autopilot/next-steps-card.tsx | 223 ++++++++++++ .../src/components/autopilot/season-chip.tsx | 41 +++ .../components/autopilot/use-step-actions.ts | 153 ++++++++ .../src/components/form/choice-group.spec.tsx | 21 ++ .../src/components/form/choice-group.tsx | 12 +- .../src/components/notification-bell.spec.tsx | 164 +++++++++ .../src/components/notification-bell.tsx | 118 +++++- .../suggestions/cleanup-sheet.spec.tsx | 228 ++++++++++++ .../components/suggestions/cleanup-sheet.tsx | 339 ++++++++++++++++++ .../today/inspection-due-card.spec.tsx | 53 +++ .../components/today/inspection-due-card.tsx | 83 +++++ .../src/components/today/today-panel.tsx | 193 +++++++++- .../components/warnings/warning-list.spec.tsx | 56 +++ .../src/components/warnings/warning-list.tsx | 46 +++ .../src/lib/apiaries/field-types.ts | 21 +- .../src/lib/apiaries/offline-pack.fixtures.ts | 5 + ApiCore-Frontend/src/lib/autopilot/api.ts | 30 ++ .../src/lib/autopilot/format.spec.ts | 33 ++ ApiCore-Frontend/src/lib/autopilot/format.ts | 38 ++ .../src/lib/autopilot/step-fixtures.ts | 40 +++ ApiCore-Frontend/src/lib/autopilot/types.ts | 101 ++++++ ApiCore-Frontend/src/lib/bottling/types.ts | 4 +- ApiCore-Frontend/src/lib/calendar/types.ts | 5 +- .../src/lib/colonies/status-light.spec.ts | 21 +- .../src/lib/colonies/status-light.ts | 23 +- ApiCore-Frontend/src/lib/harvests/types.ts | 4 +- .../src/lib/notification-settings/api.ts | 18 + .../src/lib/notification-settings/types.ts | 14 + .../notification-settings/validation.spec.ts | 62 ++++ .../lib/notification-settings/validation.ts | 35 ++ ApiCore-Frontend/src/lib/notifications/api.ts | 5 + .../src/lib/notifications/types.ts | 11 + .../src/lib/suggestions/labels.spec.ts | 10 +- .../src/lib/suggestions/labels.ts | 22 +- ApiCore-Frontend/src/lib/tasks/api.ts | 4 + .../src/lib/warnings/toast-warnings.spec.ts | 77 ++++ .../src/lib/warnings/toast-warnings.ts | 44 +++ ApiCore-Frontend/src/lib/warnings/types.ts | 14 + 77 files changed, 5309 insertions(+), 142 deletions(-) create mode 100644 ApiCore-Backend/src/reminders/digest.service.spec.ts create mode 100644 ApiCore-Backend/src/reminders/digest.service.ts create mode 100644 ApiCore-Backend/src/reminders/digest.step.spec.ts create mode 100644 ApiCore-Backend/src/reminders/digest.step.ts create mode 100644 ApiCore-Backend/src/reminders/reminders.module.ts create mode 100644 ApiCore-Backend/src/reminders/task-due.handlers.spec.ts create mode 100644 ApiCore-Backend/src/reminders/task-due.handlers.ts create mode 100644 ApiCore-Backend/test/reminders.e2e-spec.ts create mode 100644 ApiCore-Frontend/src/app/(app)/calendar/page.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/dashboard/page.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/profile/notification-settings-card.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/profile/notification-settings-card.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/winter/page.spec.tsx create mode 100644 ApiCore-Frontend/src/components/autopilot/next-steps-card.spec.tsx create mode 100644 ApiCore-Frontend/src/components/autopilot/next-steps-card.tsx create mode 100644 ApiCore-Frontend/src/components/autopilot/season-chip.tsx create mode 100644 ApiCore-Frontend/src/components/autopilot/use-step-actions.ts create mode 100644 ApiCore-Frontend/src/components/notification-bell.spec.tsx create mode 100644 ApiCore-Frontend/src/components/suggestions/cleanup-sheet.spec.tsx create mode 100644 ApiCore-Frontend/src/components/suggestions/cleanup-sheet.tsx create mode 100644 ApiCore-Frontend/src/components/today/inspection-due-card.spec.tsx create mode 100644 ApiCore-Frontend/src/components/today/inspection-due-card.tsx create mode 100644 ApiCore-Frontend/src/components/warnings/warning-list.spec.tsx create mode 100644 ApiCore-Frontend/src/components/warnings/warning-list.tsx create mode 100644 ApiCore-Frontend/src/lib/autopilot/api.ts create mode 100644 ApiCore-Frontend/src/lib/autopilot/format.spec.ts create mode 100644 ApiCore-Frontend/src/lib/autopilot/format.ts create mode 100644 ApiCore-Frontend/src/lib/autopilot/step-fixtures.ts create mode 100644 ApiCore-Frontend/src/lib/autopilot/types.ts create mode 100644 ApiCore-Frontend/src/lib/notification-settings/api.ts create mode 100644 ApiCore-Frontend/src/lib/notification-settings/types.ts create mode 100644 ApiCore-Frontend/src/lib/notification-settings/validation.spec.ts create mode 100644 ApiCore-Frontend/src/lib/notification-settings/validation.ts create mode 100644 ApiCore-Frontend/src/lib/warnings/toast-warnings.spec.ts create mode 100644 ApiCore-Frontend/src/lib/warnings/toast-warnings.ts create mode 100644 ApiCore-Frontend/src/lib/warnings/types.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fa852bd..5e50b47 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -27,6 +27,11 @@ Ein NestJS-Modul pro fachlichem Bereich, Reihenfolge entspricht Baureihenfolge i | `SearchModule` | globale Suche, Filter-Bausteine | alle o.g. | | `ExportModule` | CSV/JSON/PDF-Export | alle o.g. | | `ErpExportModule` | Nur lesender Inventar-Spiegel für ERPNext (`/api/v1/export/inventar/...`, Recht `erp-inventory:read`), Vertrag in [`docs/erp-inventar-export.md`](docs/erp-inventar-export.md) | Products, Bottling, ProductionBatches, Stock, Harvests | +| `SuggestionsModule` | Vorschläge (Modell, API, Aufräumen) und Ketten-Handler, Abschnitt 11 | Tasks, Notifications | +| `AutopilotModule` | Saison-Autopilot (A1–A3), „Die nächsten Schritte“, „Heute“ | Season, Suggestions | +| `SeasonModule` | Saisonabschnitt, Fälligkeit von Durchsicht und Varroa-Messung, Alter der Königin | – | +| `WithdrawalModule` | Wartezeit-Prüfung bei Ernte, Abfüllung und Behandlung, Meldungen | Notifications | +| `RemindersModule` | Erinnerung je Aufgabe, Morgenübersicht | Notifications, Season, Autopilot | Jedes Modul: `*.controller.ts`, `*.service.ts`, `dto/`, `*.module.ts`, `*.spec.ts` (Unit), `test/*.e2e-spec.ts` (Integration). Rollenprüfung über `@Roles()`-Decorator + `RolesGuard`, angewendet pro Endpunkt – niemals nur UI-seitig. @@ -44,6 +49,7 @@ app/ │ ├── queens/[id]/ │ ├── inspections/new/ │ ├── tasks/ +│ ├── suggestions/ Vorschläge (Übernehmen, Verwerfen, Aufräumen) │ ├── varroa/new/ │ ├── treatments/new/ │ ├── feedings/new/ @@ -66,6 +72,8 @@ Zentrale Schnellaktion "+ Neu" (Abschnitt 22): permanent sichtbarer Floating-But **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. + Datenzugriff ausschließlich über TanStack Query gegen die Backend-API (kein direkter DB-Zugriff, kein Next.js-eigenes Datenbank-Layer) – erzwingt API-first auch praktisch. ## 3. Auth-Flow @@ -108,33 +116,44 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige 6. **Deviations vom Leitentscheidungs-Rahmen**: bisher keine. Wird ergänzt, falls während der Umsetzung ein konkreter Grund für eine Abweichung von NestJS/Prisma/Next.js/Tailwind/shadcn/TanStack Query auftritt. 7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder. 8. **Entra ID SSO ohne Auto-Provisioning**: `POST/GET /api/v1/auth/sso/*` implementiert Authorization Code + PKCE gegen Entra ID über `openid-client`. Der E-Mail-Claim aus dem ID-Token wird ausschließlich gegen einen bereits existierenden, aktiven `User`-Datensatz gematcht – es wird kein neuer Benutzer automatisch angelegt (Just-in-Time-Provisioning), passend zum bestehenden Modell, dass Admins Benutzer manuell anlegen. Eine erfolgreiche SSO-Anmeldung überspringt die App-eigene TOTP-MFA-Abfrage, da der Identity Provider bereits eine eigene (typischerweise stärkere) Authentifizierung durchgesetzt hat. Der PKCE-`code_verifier` wird kurzlebig (5 min) in einem httpOnly-`SameSite=Lax`-Cookie zwischengespeichert statt serverseitig, damit der Flow ohne Sticky-Sessions über mehrere Backend-Replicas hinweg funktioniert; der Rückweg vom Callback zum Frontend nutzt ein kurzlebiges (60 s), zweckgebundenes JWT statt des echten Access-Tokens in der Redirect-URL. `OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`/`OIDC_REDIRECT_URI` kommen ausschließlich aus Environment-Variablen; fehlen sie, meldet `GET /api/v1/auth/sso/status` `enabled: false` und die Login-Seite blendet den "Mit Microsoft anmelden"-Button aus, statt beim Start zu scheitern. -9. **M365-Kalender-Synchronisation als einseitiger, asynchroner Push**: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als `CalendarSyncModule` (`ApiCore-Backend/src/calendar-sync/`): App-only Graph-API-Zugriff per Client-Credentials-Flow (`GraphClientService`) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Nutzt bewusst dieselbe Entra-App-Registrierung wie das SSO-Login (`OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`, ausschließlich Env-Variablen) statt einer zweiten, separaten App-Registrierung – ein Enterprise-App-Objekt im Tenant genügt, sofern es zusätzlich zu den delegierten Login-Scopes auch die Application-Permission „Calendars.ReadWrite“ (Admin-Consent) erhält; die Tenant-ID wird dafür aus dem Pfad von `OIDC_ISSUER_URL` abgeleitet, statt eine weitere Konfigurationsvariable zu benötigen. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über `groupId`); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster, das die Worker-Schleife `calendar-outbox` abarbeitet (alle 60 s und auf `pg_notify('calendar_sync', '')`, siehe Abschnitt 10): Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag `FAILED` bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. Die Outbox ist sicher bei mehreren Replicas: Jeder Eintrag wird vor der Arbeit per bedingtem Update (`claimedAt`) reserviert, ein Claim älter als 5 min gilt als verwaist und wird übernommen, Graph-Requests haben ein 30-s-Timeout. `revision` sorgt dafür, dass eine Änderung während des Syncs nicht verloren geht (Eintrag bleibt PENDING, dasselbe Event wird beim nächsten Lauf aktualisiert); ein Abbruch während des Syncs löscht das gerade geschriebene Event. Abmelden eines Eintrags aus dem Kalender geschieht durch erneutes Einreihen: Wird eine Quelle (z. B. eine Aufgabe, die erledigt oder storniert wird oder ihr Fälligkeitsdatum verliert, oder ein gelöschter Datensatz) erneut in die Kalender-Outbox eingereiht, findet der Worker in `buildEventInput` nichts mehr anzuzeigen. Hat der Eintrag eine `graphEventId`, löscht der Worker das Graph-Event (404/410 gelten als bereits gelöscht, jeder andere Fehler zählt als fehlgeschlagener Versuch) und setzt den Eintrag auf `SKIPPED` mit `graphEventId = null`. Die API ruft Graph dafür nie auf; die Einreihung selbst erfolgt in der Handler-Transaktion der Domain-Events (`calendar-sync.*`-Handler über `CalendarSyncService.enqueueIn(tx, …)`). Nur die Königin-Zuordnung reiht noch nach dem Commit über `CalendarSyncService.enqueue` ein (Annahme 11). Ein neues Graph-Event entsteht beim Aktualisieren nur, wenn das alte nicht mehr existiert (404/410); andere Fehler gelten als fehlgeschlagener Versuch. Jedes Anlegen schickt eine `transactionId` (aus Eintrag, Revision und ersetztem Event), damit Graph eine Wiederholung nach einem Timeout nicht doppelt anlegt; „Jetzt synchronisieren“ startet den Lauf im Hintergrund (202, `running` im Status) und setzt FAILED-Einträge auf PENDING zurück. `groupId` muss eine Entra-Objekt-ID (GUID) sein; `""` oder `null` löscht sie; sie wird in Graph-URLs kodiert. Fehler aus Prisma werden nur mit Name, Code und Modell geloggt (`src/common/loggable-error.ts`), nie mit Abfragewerten. **Offene Entscheidung (2026-10-12):** Laut Microsoft-Doku (`group-post-events`) unterstützt `POST /groups/{id}/events` keine Application-Permission, nur delegiertes `Group.ReadWrite.All`. Mit dem App-only-Token oben kann das Anlegen im Gruppenkalender deshalb in Produktion scheitern (nicht gegen einen echten Tenant geprüft). Auswege: Zielkalender auf ein Ressourcen- oder Benutzerpostfach umstellen (`/users/{upn}/calendar/events`, App-only erlaubt, per Application Access Policy begrenzt) oder delegierter Zugriff über ein Dienstkonto. Pro Quelltyp (Aufgabe/Durchsicht/Behandlung/Fütterung/Ernte/Königinnen-Zuordnung) ist die Synchronisation einzeln über die GUI (Verwaltung > Automatisierung > Kalender-Sync) abschaltbar; Kalendereinträge werden als ganztägige Graph-Events (`isAllDay: true`) angelegt, um Zeitzonen-/Uhrzeit-Mehrdeutigkeiten zu vermeiden, da die zugrundeliegenden Fachdaten selbst keine Uhrzeit führen. -10. **Wartezeit manuell statt aus Wirkstoff-Datenbank abgeleitet**: `Treatment.withdrawalPeriodDays` wird pro Behandlung von Hand eingetragen (laut Packungsbeilage des verwendeten Mittels), statt automatisch aus Produktname/Wirkstoff hergeleitet zu werden. Eine hartkodierte Zuordnung Produkt→Wartezeit wäre eine faktische, rechtlich relevante Behauptung über Tierarzneimittel-Zulassungen, die im Code weder aktuell gehalten noch verlässlich verifiziert werden kann; die Verantwortung für den korrekten Wert bleibt beim Imker. Die App warnt in der Ernte-Erfassung nur, wenn ein Wert hinterlegt wurde und dessen Fenster (`endDate ?? date` + `withdrawalPeriodDays`) noch nicht abgelaufen ist – bezogen auf das Erntedatum (`GET /treatments/withdrawal-status?colonyIds=…&at=JJJJ-MM-TT`, ohne `at` gilt jetzt; Behandlungen, die erst nach diesem Datum beginnen, zählen nicht) – sie blockiert die Ernte nicht hart, da Ernte-Datensätze auch nachträglich für bereits erfolgte, reale Ernten erfasst werden. +9. **M365-Kalender-Synchronisation als einseitiger, asynchroner Push**: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als `CalendarSyncModule` (`ApiCore-Backend/src/calendar-sync/`): App-only Graph-API-Zugriff per Client-Credentials-Flow (`GraphClientService`) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Nutzt bewusst dieselbe Entra-App-Registrierung wie das SSO-Login (`OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`, ausschließlich Env-Variablen) statt einer zweiten, separaten App-Registrierung – ein Enterprise-App-Objekt im Tenant genügt, sofern es zusätzlich zu den delegierten Login-Scopes auch die Application-Permission „Calendars.ReadWrite“ (Admin-Consent) erhält; die Tenant-ID wird dafür aus dem Pfad von `OIDC_ISSUER_URL` abgeleitet, statt eine weitere Konfigurationsvariable zu benötigen. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über `groupId`); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster, das die Worker-Schleife `calendar-outbox` abarbeitet (alle 60 s und auf `pg_notify('calendar_sync', '')`, siehe Abschnitt 10): Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag `FAILED` bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. Die Outbox ist sicher bei mehreren Replicas: Jeder Eintrag wird vor der Arbeit per bedingtem Update (`claimedAt`) reserviert, ein Claim älter als 5 min gilt als verwaist und wird übernommen, Graph-Requests haben ein 30-s-Timeout. `revision` sorgt dafür, dass eine Änderung während des Syncs nicht verloren geht (Eintrag bleibt PENDING, dasselbe Event wird beim nächsten Lauf aktualisiert); ein Abbruch während des Syncs löscht das gerade geschriebene Event. Abmelden eines Eintrags aus dem Kalender geschieht durch erneutes Einreihen: Wird eine Quelle (z. B. eine Aufgabe, die erledigt oder storniert wird oder ihr Fälligkeitsdatum verliert, oder ein gelöschter Datensatz) erneut in die Kalender-Outbox eingereiht, findet der Worker in `buildEventInput` nichts mehr anzuzeigen. Hat der Eintrag eine `graphEventId`, löscht der Worker das Graph-Event (404/410 gelten als bereits gelöscht, jeder andere Fehler zählt als fehlgeschlagener Versuch) und setzt den Eintrag auf `SKIPPED` mit `graphEventId = null`. Die API ruft Graph dafür nie auf; die Einreihung selbst erfolgt in der Handler-Transaktion der Domain-Events (`calendar-sync.*`-Handler über `CalendarSyncService.enqueueIn(tx, …)`). Auch die Königin-Zuordnung (`QueenAssigned` → `calendar-sync.queen-assignment`) und die Wartezeit (`calendar-sync.withdrawal`, Abschnitt 11.8) laufen so; `CalendarSyncService.enqueue` und das Einreihen nach dem Commit gibt es nicht mehr, nur `enqueueIn`. Ein neues Graph-Event entsteht beim Aktualisieren nur, wenn das alte nicht mehr existiert (404/410); andere Fehler gelten als fehlgeschlagener Versuch. Jedes Anlegen schickt eine `transactionId` (aus Eintrag, Revision und ersetztem Event), damit Graph eine Wiederholung nach einem Timeout nicht doppelt anlegt; „Jetzt synchronisieren“ startet den Lauf im Hintergrund (202, `running` im Status) und setzt FAILED-Einträge auf PENDING zurück. `groupId` muss eine Entra-Objekt-ID (GUID) sein; `""` oder `null` löscht sie; sie wird in Graph-URLs kodiert. Fehler aus Prisma werden nur mit Name, Code und Modell geloggt (`src/common/loggable-error.ts`), nie mit Abfragewerten. **Offene Entscheidung (2026-10-12):** Laut Microsoft-Doku (`group-post-events`) unterstützt `POST /groups/{id}/events` keine Application-Permission, nur delegiertes `Group.ReadWrite.All`. Mit dem App-only-Token oben kann das Anlegen im Gruppenkalender deshalb in Produktion scheitern (nicht gegen einen echten Tenant geprüft). Auswege: Zielkalender auf ein Ressourcen- oder Benutzerpostfach umstellen (`/users/{upn}/calendar/events`, App-only erlaubt, per Application Access Policy begrenzt) oder delegierter Zugriff über ein Dienstkonto. Pro Quelltyp (Aufgabe/Durchsicht/Behandlung/Fütterung/Ernte/Königinnen-Zuordnung/Wartezeit) ist die Synchronisation einzeln über die GUI (Verwaltung > Automatisierung > Kalender-Sync) abschaltbar; Kalendereinträge werden als ganztägige Graph-Events (`isAllDay: true`) angelegt, um Zeitzonen-/Uhrzeit-Mehrdeutigkeiten zu vermeiden, da die zugrundeliegenden Fachdaten selbst keine Uhrzeit führen. +10. **Wartezeit manuell statt aus Wirkstoff-Datenbank abgeleitet**: `Treatment.withdrawalPeriodDays` wird pro Behandlung von Hand eingetragen (laut Packungsbeilage des verwendeten Mittels), statt automatisch aus Produktname/Wirkstoff hergeleitet zu werden. Eine hartkodierte Zuordnung Produkt→Wartezeit wäre eine faktische, rechtlich relevante Behauptung über Tierarzneimittel-Zulassungen, die im Code weder aktuell gehalten noch verlässlich verifiziert werden kann; die Verantwortung für den korrekten Wert bleibt beim Imker. Die App warnt in der Ernte-Erfassung nur, wenn ein Wert hinterlegt wurde und dessen Fenster (`endDate ?? date` + `withdrawalPeriodDays`) noch nicht abgelaufen ist – bezogen auf das Erntedatum (`GET /treatments/withdrawal-status?colonyIds=…&at=JJJJ-MM-TT`, ohne `at` gilt jetzt; Behandlungen, die erst nach diesem Datum beginnen, zählen nicht) – sie blockiert die Ernte nicht hart, da Ernte-Datensätze auch nachträglich für bereits erfolgte, reale Ernten erfasst werden. Warnungen sperren nie; einzige Ausnahme ist der Wassergehalt (Bestätigung, T4b). Ein einziger Fenster-Helfer (`src/treatments/withdrawal-window.ts`) bestimmt die Wartezeit; Warnung bei Ernte und Abfüllung, Meldung, Kalender und `getWithdrawalStatus` rechnen alle darüber, in Berliner Kalendertagen (Annahme 41). Die Kette beschreibt Abschnitt 11.8. -11. **Idempotenz per Opt-in**: Anlege-Endpunkte, die Formulare und die Offline-Warteschlange wiederholen können (Durchsicht, Behandlung, Fütterung, Varroa, Sammelerfassung von Behandlung, Fütterung und Varroa, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung, Herstellung, Rohstoffe, Überwinterung, Dokument-Upload), tragen `@Idempotent()`. Der Client sendet je Vorgang einen `Idempotency-Key`; eine Wiederholung liefert die gespeicherte erste Antwort (`IdempotencyRecord`, 90 Tage). Bewusst kein globaler Interceptor, da gespeicherte Antworten sonst Geheimnisse (MFA-Setup, Tokens) enthalten könnten. Die Tabelle ist flüchtig und nicht Teil des Backups. Idempotenz schützt nur, wenn nach dem Commit nichts mehr fehlschlägt. Darum ist das Ereignis-Rückgrat (Abschnitt 10) der Weg für Seiteneffekte (Webhooks, Kalender, Automationsregeln, Benachrichtigungen): Der Schreibpfad nimmt das Domänenereignis in derselben Transaktion auf, Handler im Worker setzen die Wirkung um. Aufgaben, Durchsichten, Varroamessungen, Behandlungen, Fütterungen, Ernten, Völker sowie Bestand und Abfüllung haben keine Post-Commit-Seiteneffekte mehr (Abschnitt 10, Absätze „Aufgaben“, „Durchsichten und Varroa“, „Behandlungen, Fütterungen und Ernten“, „Völker“, „Bestand und Abfüllung“, „Sammelerfassung am Stand“). Wiederkehrende Arbeit läuft als Kubernetes-CronJob über `dist/cli.js`, nie als Timer im Prozess: `@nestjs/schedule` und `ScheduleModule` gibt es nicht mehr, der Wächter `src/runtime/no-in-process-schedulers.spec.ts` hält das fest. `runAfterCommit` (`src/common/after-commit.ts`, best-effort: loggt auf Error-Level, wirft nie) bleibt nur für die Automatik `SWARM_MOOD_HIGH` und `STOCK_ITEM_LOW` (`AutomationService.evaluateSwarmControlCheck` und `evaluateStockItem`), den Webhook `colony.created` und den Kalendereintrag der Königin-Zuordnung (`CalendarSyncService.enqueue`). Nachzügler für eine spätere Stufe, die weiter über `WebhooksService.dispatch` laufen: Königinnen (`queen.created`, `queen.replaced`), Honigchargen (`batch.created`) und Lager (`product.stock_changed`). Der Wächter `src/common/after-commit-usage.spec.ts` hält die Aufrufer fest. Die früheren `AutomationService.evaluate*`-Wrapper für Durchsicht, Varroa, Behandlung, Fütterung, Volksgesundheit und Rohstoff sind entfernt. Was das Schreiben verhindern muss (z. B. `generateNumber`, Validierung), wirft weiter. Doppelte Automatik-Aufgaben verhindert eine Zeilensperre auf der Regel (`SELECT … FOR UPDATE`). Den Entwurf des Rückgrats beschreibt die Spec `docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md`. -12. **Automatische Aufgaben sind Vorschläge**: `Task.sourceAutomationTrigger` kennzeichnet von Regeln erzeugte Aufgaben. Je Regel und Objekt existiert höchstens eine offene Aufgabe. Geprüft wird unter einer Zeilensperre auf der Regel (`SELECT … FOR UPDATE`), die bis zum Commit gilt. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optional `config.dueInDays` je Regel. Die Varroa-Schwelle (Milben/Tag) wird nur mit Tagesraten des natürlichen Milbenfalls verglichen. Ihr Standardwert 10 und der Rückfall bei unbrauchbarer Einstellung kommen allein aus `varroaThresholdOf` (`automation/varroa-threshold.ts`). Die Tagesrate wird bei jeder Korrektur einer Eingangsgröße (Methode, Anzahl, Dauer) neu berechnet; ein mitgesendeter, vom gespeicherten abweichender Wert gilt als manuell gesetzt und gewinnt. Automatik-Aufgaben laufen wie jede andere Aufgabe über `TasksService.createInTx(tx, …, null)`: Audit-Eintrag ohne Benutzer und `TaskCreated`. Aus dem Ereignis folgen Kalendereintrag (`calendar-sync.task`) und Webhook `task.created` (`webhooks.forward`); die Regel ruft `WebhooksService` nicht selbst auf. Aufgabe, Warnung (`Notification`, Priorität `WARNUNG`) und der Audit-Eintrag der Regel (`AutomationRule CREATE`) entstehen in einer Transaktion: Scheitert ein Teil, entsteht nichts davon. Ausgewertet wird in den Handlern `automation.*` (`src/automation/automation.handlers.ts`) in der Zustell-Transaktion, bei Fehler mit Wiederholung; sie rufen `evaluate…In(tx, …)`. Nur `SWARM_MOOD_HIGH` und `STOCK_ITEM_LOW` laufen dauerhaft über `runAfterCommit` in eigener Transaktion (Annahme 11); sie rufen dieselben `evaluate…In`, werfen aber nie. +11. **Idempotenz per Opt-in**: Anlege-Endpunkte, die Formulare und die Offline-Warteschlange wiederholen können (Durchsicht, Behandlung, Fütterung, Varroa, Sammelerfassung von Behandlung, Fütterung und Varroa, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung, Herstellung, Rohstoffe, Überwinterung, Dokument-Upload), tragen `@Idempotent()`. Der Client sendet je Vorgang einen `Idempotency-Key`; eine Wiederholung liefert die gespeicherte erste Antwort (`IdempotencyRecord`, 90 Tage). Bewusst kein globaler Interceptor, da gespeicherte Antworten sonst Geheimnisse (MFA-Setup, Tokens) enthalten könnten. Die Tabelle ist flüchtig und nicht Teil des Backups. Idempotenz schützt nur, wenn nach dem Commit nichts mehr fehlschlägt. Darum ist das Ereignis-Rückgrat (Abschnitt 10) der Weg für Seiteneffekte (Webhooks, Kalender, Automationsregeln, Benachrichtigungen): Der Schreibpfad nimmt das Domänenereignis in derselben Transaktion auf, Handler im Worker setzen die Wirkung um. Aufgaben, Durchsichten, Varroamessungen, Behandlungen, Fütterungen, Ernten, Völker, Königin-Zuordnung sowie Bestand und Abfüllung haben keine Post-Commit-Seiteneffekte mehr (Abschnitt 10, Absätze „Aufgaben“, „Durchsichten und Varroa“, „Behandlungen, Fütterungen und Ernten“, „Völker“, „Bestand und Abfüllung“, „Sammelerfassung am Stand“). Wiederkehrende Arbeit läuft als Kubernetes-CronJob über `dist/cli.js`, nie als Timer im Prozess: `@nestjs/schedule` und `ScheduleModule` gibt es nicht mehr, der Wächter `src/runtime/no-in-process-schedulers.spec.ts` hält das fest. `runAfterCommit` (`src/common/after-commit.ts`, best-effort: loggt auf Error-Level, wirft nie) bleibt nur für die Automatik `SWARM_MOOD_HIGH` und `STOCK_ITEM_LOW` (`AutomationService.evaluateSwarmControlCheck` und `evaluateStockItem`) und den Webhook `colony.created`. Nachzügler für eine spätere Stufe, die weiter über `WebhooksService.dispatch` laufen: Königinnen (`queen.created`; `queen.replaced` kommt seit T4 aus `QueenAssigned`), Honigchargen (`batch.created`) und Lager (`product.stock_changed`). Der Wächter `src/common/after-commit-usage.spec.ts` hält die Aufrufer fest. Die früheren `AutomationService.evaluate*`-Wrapper für Durchsicht, Varroa, Behandlung, Fütterung, Volksgesundheit und Rohstoff sind entfernt. Was das Schreiben verhindern muss (z. B. `generateNumber`, Validierung), wirft weiter. Doppelte Automatik-Aufgaben verhindert eine Zeilensperre auf der Regel (`SELECT … FOR UPDATE`). Den Entwurf des Rückgrats beschreibt die Spec `docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md`. +12. **Automatische Hinweise sind Vorschläge, keine Aufgaben**: Automatisierungsregeln legen keine Aufgaben mehr an, sondern schreiben Vorschläge (`Suggestion`, Abschnitt 11.1). Erst „Übernehmen“ erzeugt die Aufgabe (Quelle „Aus Vorschlag“: `Task.sourceSuggestionId`, `sourceAutomationTrigger` = Regel); bestehende Automatik-Aufgaben bleiben Aufgaben. Je Regel und Objekt existiert höchstens eine offene Sache (Vorschlag oder Aufgabe). Geprüft wird unter der Volkssperre und der Zeilensperre der Regel (`SELECT … FOR UPDATE`, Reihenfolge Volk, dann Regel), die bis zum Commit gilt. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optional `config.dueInDays` je Regel, gerechnet ab dem Berliner Tag. Ohne sie ist die Aufgabe beim Übernehmen heute fällig. Anker je Regel (ein verworfener Anker kommt nie wieder): `QUEENLESS_NO_EGGS` und `FEED_STATUS_CRITICAL` die Durchsicht-ID; `VARROA_THRESHOLD` die Mess-ID (nur rot, nicht im Behandlungseffekt, Abschnitt 11.4); `SWARM_MOOD_HIGH` die Kontroll-ID; `COLONY_HEALTH_CRITICAL` die Ereignis-ID; `STOCK_ITEM_LOW` die jüngste IN-Bewegung (sonst `none`); `RAW_MATERIAL_LOW` die jüngste Charge der Sorte (sonst `none`); `TREATMENT_FOLLOW_UP` und `FEEDING_FOLLOW_UP` die Behandlungs- bzw. Fütterungs-ID. Die Tagesrate des natürlichen Milbenfalls wird bei jeder Korrektur einer Eingangsgröße (Methode, Anzahl, Dauer) neu berechnet; ein mitgesendeter, vom gespeicherten abweichender Wert gilt als manuell gesetzt und gewinnt. Vorschlag, Meldung und der Audit-Eintrag der Regel (`AutomationRule CREATE`, mit `suggestionId`) entstehen in einer Transaktion: Scheitert ein Teil, entsteht nichts davon. Eine Meldung (Kategorie `SUGGESTION`, WARNUNG, bei `URGENT` KRITISCH) schreibt ein Vorschlag nur, wenn die Regel `config.notify` hat. Empfänger sind Inhaber von `tasks:read` und des Leserechts des Bereichs; der Text lautet „: “ (bewusste Abweichung vom Spec-Wortlaut, bei dem das Präfix der Regelcode war; ohne Volk nur der Titel). Der Kalendereintrag (`calendar-sync.task`) und der Webhook `task.created` (`webhooks.forward`) folgen erst aus `TaskCreated` beim Übernehmen; die Regel ruft `WebhooksService` nicht selbst auf. Eine neuere Durchsicht führt die offenen Vorschläge von `QUEENLESS_NO_EGGS` und `FEED_STATUS_CRITICAL` nach: Zeigt die tatsächlich jüngste Durchsicht des Volkes den Befund nicht mehr, wird der Vorschlag `RESOLVED` („Bedingung entfällt“); besteht der Befund, bleibt es bei einem Vorschlag mit aktuellem Beleg (der Anker bleibt der alte); eine rückdatierte Durchsicht ändert nichts, eine Korrektur schlägt nie neu vor, führt aber Offenes nach. Dasselbe gilt für `VARROA_THRESHOLD` bei `VarroaCorrected`. Eine abgeschaltete Regel lässt offene Vorschläge unberührt. Ausgewertet wird in den Handlern `automation.*` (`src/automation/automation.handlers.ts`) in der Zustell-Transaktion, bei Fehler mit Wiederholung; sie rufen `evaluate…In(tx, …)`, überspringen beendete Völker und prüfen unter der Volkssperre erneut. Nur `SWARM_MOOD_HIGH` und `STOCK_ITEM_LOW` laufen dauerhaft über `runAfterCommit` in eigener Transaktion (Annahme 11); sie rufen dieselben `evaluate…In`, werfen aber nie. 14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop, ERPNext) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); die Suche läuft über den Hash, Geheimnisse werden nie verglichen; der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`, `shop-sync:read`, `shop-sync:write`, `erp-inventory:read`); je angebundenem System ein eigener Schlüssel. Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt. Lesen und Buchen sind getrennte API-Schlüssel (`shop-sync:read` bzw. `shop-sync:write`; `erp-inventory:read` zählt als Lesen, Buchungsrecht ist nur `shop-sync:write`, Liste `API_KEY_WRITE_SCOPES`): Ein neuer Schlüssel trägt genau ein Recht: einer mit mehreren Rechten wird beim Anlegen mit 400 abgelehnt (mit Lese- und Buchungsrecht eigens begründet); bereits ausgestellte Schlüssel mit mehreren Rechten bleiben gültig, die Oberfläche markiert sie und empfiehlt getrennte Schlüssel (neue anlegen, im angebundenen System – Wabenhain bzw. ERPNext – eintragen, alten widerrufen). Ein geleakter Lese-Schlüssel kann so nicht buchen, ein Buchungs-Schlüssel nicht lesen. Die Oberfläche bietet nur Rechte an, die der Anlegende selbst hat (alles andere lehnt der Server ohnehin ab). Ungültige Schlüssel werden je Client-Netz gezählt und nach 20 Fehlversuchen in 15 min gesperrt: Annahme 30. 13. **Lebende vs. beendete Völker**: Nur `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` sperren Umsetzen/Teilen/Vereinigen/Königinzuweisung (`colonies/colony-status.ts`). `WEAK`, `CONSPICUOUS`, `QUEENLESS` bleiben bearbeitbar. 15. **Bestandsbuch aus Behandlungen, nicht als eigenes Register**: Das Bestandsbuch nach Art. 108 VO (EU) 2019/6 wird aus `Treatment` erzeugt (`export/treatment-log.ts`); Lieferant, Erwerbsbeleg und verschreibender Tierarzt sind Freitextfelder der Behandlung, Name/Anschrift des Halters liegen in `SiteSettings`. Der Stand am Behandlungstag wird beim Anlegen als `Treatment.apiaryId` festgehalten (Umsetzen ändert ihn nicht nachträglich); für Altbestand wird er aus `ColonyApiaryHistory` abgeleitet und im PDF bei Unsicherheit markiert. Fehlende Pflichtangaben werden als „FEHLT“ gedruckt statt still weggelassen. Keine Wartezeit-Automatik (siehe 10). -16. **Überwinterung als eigene Einträge statt Volksstatus**: `WinterCheck` (eindeutig je Volk, `winterYear`, Saison) hält Ein- und Auswinterung; ab Juli gehört ein Datum zum Winter desselben Jahres. Verlustursachen sind bewusst nur die COLOSS-Kategorien, keine Diagnosen. Die Übersicht gruppiert nach dem aktuellen Stand des Volkes. Ein Verlust ändert den Volksstatus nur auf ausdrücklichen Wunsch (`markColonyDeceased`). Neue Rechte vergibt die Migration an Nicht-System-Rollen, weil der Katalog-Sync nur System-Rollen berechtigt. +16. **Überwinterung als eigene Einträge statt Volksstatus**: `WinterCheck` (eindeutig je Volk, `winterYear`, Saison) hält Ein- und Auswinterung; ab Juli gehört ein Datum zum Winter desselben Jahres. Verlustursachen sind bewusst nur die COLOSS-Kategorien, keine Diagnosen. Die Übersicht gruppiert nach dem aktuellen Stand des Volkes. Ein Verlust ändert den Volksstatus nur auf ausdrücklichen Wunsch (`markColonyDeceased`). Neue Rechte vergibt die Migration an Nicht-System-Rollen, weil der Katalog-Sync nur System-Rollen berechtigt. Ein Verlust mit `markColonyDeceased` beendet auch die Königin-Zuordnung und schreibt `ColonyEnded` mit `via: WINTER_CHECK` (Abschnitt 11.7). 17. **Schutz gegen Rechteausweitung in der Benutzerverwaltung**: Wer Benutzer pflegt, darf nur Konten verwalten (Passwort/MFA zurücksetzen, E-Mail ändern, (de)aktivieren) und Rollen vergeben, deren Rechte er selbst vollständig hat (`UsersService.assertWithinCallerPermissions`), analog zur Rollenbearbeitung. 18. **SSO-Bindung an die IdP-Identität**: Die erste Microsoft-Anmeldung bindet das Konto an `"|"` (`User.ssoSubject`); danach zählt nur noch diese Identität, nicht der unverifizierte E-Mail-Claim. Mandantenübergreifende Issuer (`common`/`organizations`/`consumers`) schalten SSO ab. E-Mails werden ohne Groß-/Kleinschreibung verglichen. -19. **Backups versiegelt**: AES-256-GCM über das gzip-Bundle, Schlüssel aus dem Schlüsselbund `BACKUP_ENCRYPTION_KEY` (+ `BACKUP_ENCRYPTION_KEY_PREVIOUS`, in Produktion Pflicht, siehe Annahme 22); Dateien von vor dem Schlüsselbund (Schlüssel per HKDF aus `JWT_ACCESS_SECRET`) bleiben lesbar, solange `JWT_ACCESS_SECRET` unverändert ist. Nur versiegelte Dateien stellen vollständig wieder her (und überschreiben). Eine unversiegelte (alte v2-)Datei darf nur die 27 Stamm- und Feldtabellen der Erlaubt-Liste `LEGACY_UNSEALED_TABLES` ergänzen – fehlende Zeilen anlegen, bestehende nie ändern. Alles andere (Benutzer, Rollen, Webhooks, Audit-Log, Kalender-Sync, Backup- und Website-Einstellungen, Nummernkreise, Bestands- und Chargenbewegungen, Shop-Anfragen, Rohstoffe/Herstellung, Dokumente, Benachrichtigungen und jede künftige Tabelle) kommt nur aus versiegelten Dateien; die Oberfläche listet übersprungene Tabellen auf; Import verlangt Passwort (+ TOTP). MFA- und Webhook-Secrets liegen mit eigenen Schlüsseln (`MFA_ENCRYPTION_KEY`, `WEBHOOK_ENCRYPTION_KEY`) verschlüsselt in der DB und damit auch im Backup – für eine Wiederherstellung in einem anderen Deployment dort dieselben Schlüssel setzen (oder als `*_PREVIOUS`), sonst müssen Webhook-Secrets und MFA neu eingerichtet werden. -20. **Bearbeiten-Regel für `null`** (Grundsatz „alles bearbeitbar“): In jeder Bearbeiten-Anfrage bleibt ein weggelassenes Feld unverändert, `null` leert ein Feld, das leer sein darf, und `null` auf einem Pflichtfeld wird mit 400 abgelehnt (statt still ignoriert oder als 500). Umgesetzt über `PartialType(..., { skipNullProperties: false })` in den Update-DTOs, `@IsOptionalNotNull()` (`src/common/validation/is-optional-not-null.ts`) für weglassbare, aber nicht leerbare Felder und `optionalDate()` (`src/common/patch-values.ts`) für leerbare Datumsfelder; `update-dto-null-handling.spec.ts` hält den Vertrag je DTO fest. Das Frontend sendet geleerte optionale Felder über `emptyToNull` (`ApiCore-Frontend/src/lib/forms/empty-to-null.ts`) als `null`. +19. **Backups versiegelt**: AES-256-GCM über das gzip-Bundle, Schlüssel aus dem Schlüsselbund `BACKUP_ENCRYPTION_KEY` (+ `BACKUP_ENCRYPTION_KEY_PREVIOUS`, in Produktion Pflicht, siehe Annahme 22); Dateien von vor dem Schlüsselbund (Schlüssel per HKDF aus `JWT_ACCESS_SECRET`) bleiben lesbar, solange `JWT_ACCESS_SECRET` unverändert ist. Nur versiegelte Dateien stellen vollständig wieder her (und überschreiben). Eine unversiegelte (alte v2-)Datei darf nur die 27 Stamm- und Feldtabellen der Erlaubt-Liste `LEGACY_UNSEALED_TABLES` ergänzen – fehlende Zeilen anlegen, bestehende nie ändern. Alles andere (Benutzer, Rollen, Webhooks, Audit-Log, Kalender-Sync, Backup- und Website-Einstellungen, Nummernkreise, Bestands- und Chargenbewegungen, Shop-Anfragen, Rohstoffe/Herstellung, Dokumente, Benachrichtigungen samt Einstellungen, Vorschläge und jede künftige Tabelle) kommt nur aus versiegelten Dateien; die Oberfläche listet übersprungene Tabellen auf; Import verlangt Passwort (+ TOTP). MFA- und Webhook-Secrets liegen mit eigenen Schlüsseln (`MFA_ENCRYPTION_KEY`, `WEBHOOK_ENCRYPTION_KEY`) verschlüsselt in der DB und damit auch im Backup – für eine Wiederherstellung in einem anderen Deployment dort dieselben Schlüssel setzen (oder als `*_PREVIOUS`), sonst müssen Webhook-Secrets und MFA neu eingerichtet werden. +20. **Bearbeiten-Regel für `null`** (Grundsatz „alles bearbeitbar“): In jeder Bearbeiten-Anfrage bleibt ein weggelassenes Feld unverändert, `null` leert ein Feld, das leer sein darf, und `null` auf einem Pflichtfeld wird mit 400 abgelehnt (statt still ignoriert oder als 500). Umgesetzt über `PartialType(..., { skipNullProperties: false })` in den Update-DTOs, `@IsOptionalNotNull()` (`src/common/validation/is-optional-not-null.ts`) für weglassbare, aber nicht leerbare Felder und `optionalDate()` (`src/common/patch-values.ts`) für leerbare Datumsfelder; `update-dto-null-handling.spec.ts` hält den Vertrag je DTO fest. Das Frontend sendet geleerte optionale Felder über `emptyToNull` (`ApiCore-Frontend/src/lib/forms/empty-to-null.ts`) als `null`. Für Regel-Configs gilt dasselbe Muster: `null` löscht einen optionalen Schlüssel (z. B. `dueInDays`, `windowDays`, `minGrams`, einzelne GTS-Schwellen), ein Pflichtschlüssel (z. B. `queenMaxAgeYears`, `notify`, die Gruppe `swarmSeason`) lehnt `null` mit 400 ab (Abschnitt 11.2). Neu leerbar sind `Colony.occupiedFrames`, `VarroaMeasurement.sampleBees` und `Inspection.feedStatus`. 21. **Lebende Völker**: „Lebend“ heißt jeder Status außer `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` – also auch `WEAK`, `CONSPICUOUS`, `QUEENLESS`. Backend-Abfragen filtern über `LIVING_COLONY_WHERE` (`colonies/colony-status.ts`), das Frontend über `isLivingColony` (`lib/colonies/status.ts`); `status = ACTIVE` steht nur dort, wo wirklich genau dieser Status gemeint ist. 22. **Schlüsselbund**: Jeder Zweck hat einen eigenen Schlüssel, nie ein JWT-Secret: `MFA_ENCRYPTION_KEY` (TOTP-Secrets), `WEBHOOK_ENCRYPTION_KEY` (Webhook-Signaturschlüssel), `BACKUP_ENCRYPTION_KEY` (Backup-Dateien). In Produktion Pflicht: je ≥ 32 Zeichen, paarweise verschieden und verschieden von `JWT_ACCESS_SECRET`/`JWT_REFRESH_SECRET` – sonst startet kein Prozess (`src/runtime/startup-checks.ts`). Außerhalb der Produktion fällt ein fehlender Schlüssel je Zweck auf eine Ableitung aus `JWT_ACCESS_SECRET` zurück. Format `k<8 hex>:..` (Base64-Teile, AES-256-GCM, 128-Bit-Tag Pflicht); AES-Schlüssel = HKDF-SHA256(Secret, Salt `apicore-key-ring`, Info `apicore--v1`), Schlüssel-ID = `k` + die ersten 8 Hex-Zeichen von sha256(Schlüssel). Backups behalten Dateiformat v3 und ihre bisherige Ableitung (`keyId` 12 Hex), folgen aber denselben Ring-Regeln. Code: `src/common/crypto/key-ring.ts`. In Produktion lehnt der Start außerdem Platzhalterwerte ab (`change-me…`, ohne Groß-/Kleinschreibung) – für beide JWT-Secrets, die drei Schlüssel und ihre `*_PREVIOUS`, ein gesetztes `OIDC_CLIENT_SECRET` und das Passwort in `DATABASE_URL`; auch `*_PREVIOUS` muss von den JWT-Secrets verschieden sein. Außerhalb der Produktion meldet jeder Prozess einmal je Variable, dass ein aus `JWT_ACCESS_SECRET` abgeleiteter Entwicklungsschlüssel einspringt. 23. **Öffentliche Herkunftsseite ohne Standort**: Sie nennt nur `Apiary.publicLocationLabel` (je Stand, z. B. „Nordhessen“) und `SiteSettings.originCountry`. Name, Ort, Adresse und Koordinaten eines Stands werden nie öffentlich ausgeliefert (Diebstahlschutz). Ohne Label erscheint nur das Land. Eine Label-Änderung kann am Edge bis zu 1 h verzögert sichtbar werden. **Rotation über `*_PREVIOUS`:** alten Wert nach `_PREVIOUS` (öffnet weiter, versiegelt nie), neuen `` setzen, api und worker neu starten (sonst versiegeln sie weiter mit dem alten Schlüssel), Job `rekey` laufen lassen; danach muss `rekey --dry-run` bei `rekeyed`, `plaintextSealed`, `changedMeanwhile` und `failed` 0 melden, erst dann `*_PREVIOUS` entfernen. Backup-Dateien werden nicht umgeschlüsselt: `BACKUP_ENCRYPTION_KEY_PREVIOUS` bleibt gesetzt, solange ältere Dateien einspielbar sein sollen. **Altchiffrate sind nur lesbar** (nie geschrieben), **und nur, solange `JWT_ACCESS_SECRET` unverändert ist**: MFA-Secrets ohne Präfix und Webhook-Secrets mit `enc:v1:` (beide AES-Schlüssel sha256(`JWT_ACCESS_SECRET`)), Webhook-Secrets im Klartext, Backup-Dateien mit JWT-abgeleiteter `keyId`. Liest ein Prozess ein solches MFA- oder Webhook-Secret, warnt er einmal je Zweck „legacy ciphertext read – run the rekey job“ (ohne Werte). Reihenfolge darum: Schlüssel setzen → `rekey` → neues Backup ziehen → erst dann die JWT-Secrets rotieren (alle Sitzungen enden einmal; JWT-versiegelte Backup-Dateien sind danach nicht mehr einspielbar). **Job `rekey`** (`src/jobs/rekey.job.ts`, nur von Hand: `kubectl create job --from=cronjob/apicore-job-rekey` bzw. `…-rekey-dry-run`): versiegelt jedes MFA-Secret (`User.mfaSecret`) und jedes Webhook-Secret (`WebhookSubscription.secret`), das nicht mit dem aktuellen Schlüssel seines Zwecks versiegelt ist, neu – Werte unter `*_PREVIOUS`, Altchiffrate und Webhook-Secrets im Klartext. Zeile für Zeile, ohne Transaktion um den Lauf: Jede Schreiboperation ist ein Compare-and-Swap auf den gelesenen Wert (`UPDATE … WHERE id = … AND "mfaSecret" = `, bei Webhooks `secret`), damit überschreibt der Job nie, was die App inzwischen geschrieben hat (z. B. eine neue MFA-Einrichtung). Solche Zeilen zählt er als `changedMeanwhile`; `changedMeanwhile > 0` ist ein harter Stopp, kein Erfolg: die Zeile kann noch mit dem alten Schlüssel versiegelt sein, darum Dry-Run (und bei Bedarf Lauf) wiederholen, bis alle Zähler 0 sind, erst dann rotieren. Vor dem Schreiben prüft der Job, dass der neue Wert zum selben Klartext öffnet. Ein abgebrochener Lauf behält, was er umgestellt hat; der nächste überspringt diese Zeilen ungeöffnet (`alreadyCurrent`). Ergebnis: `dryRun` und je Zweck `mfa.*`/`webhook.*` mit `total`, `rekeyed`, `alreadyCurrent`, `plaintextSealed`, `changedMeanwhile`, `failed`. Eine Zeile, die sich nicht öffnen oder schreiben lässt, behält ihren Wert; der Lauf macht weiter und endet bei `failed > 0` immer `FAILED` (`JobRun`, Zähler im Fehlertext, das Log nennt nur Zeilen-ID und Grund, z. B. `unknown key id`). `rekey --dry-run` schreibt nichts und öffnet jeden umzustellenden Wert, anders als der echte Lauf auch die Zeilen, die schon den aktuellen Schlüssel tragen (ein beschädigter Wert zählt als `failed`). Andere Argumente lehnen `dist/cli.js` (Exit 2) und der Job selbst ab. Werte, Chiffrate und Schlüssel-IDs erscheinen nie in Log oder `JobRun`. `updatedAt` umgestellter Zeilen ändert sich. Den Klartext-Rückfall in `openWebhookSecret` kann man entfernen, sobald der Dry-Run in Produktion `webhook.plaintextSealed: 0` zeigt. -24. **Offline-Standmodus**: „Stand laden“ legt ein Paket je Stand in IndexedDB ab (`GET /apiaries/:id/offline-pack`: lebende Völker, Königinnen, die letzten 3 Durchsichten je Volk, offene Aufgaben). Es enthält weder Koordinaten noch Adresse des Standes; Königinnen, Durchsichten und Aufgaben fehlen ohne das jeweilige Leserecht (Benutzer-ID der Durchsichten wird weggelassen). Ein Paket gilt nach 24 h als veraltet (Alter immer sichtbar), wird nach 1 h automatisch erneuert und nach 14 Tagen oder nach 403/404 gelöscht. Lehnt der Server einen Eintrag ab (bei der Sammelerfassung mit `invalidColonyIds`), erzwingt das ein Neuladen der betroffenen Pakete (frühestens nach 5 min). Der Service Worker (`public/sw.js`, `sw-routes.js`) speichert Seiten nur zusammen mit ihren Assets und Daten nur als `application/json`; nie gespeichert werden Auth-Antworten, Anfrage-Header (kein Bearer-Token im Cache), die Antworten, die ein Paket liefert (eine Offline-Datenquelle mit sichtbarem Alter), `/api/v1/apiaries` und `/apiaries/:id` (Standort), `/export/` und `/backup/`. Navigation hat routenabhängige Zeitlimits: App-Hülle 4 s, Offline-Seiten zuerst aus dem Cache, Deep Links etwa 10 s, übrige Seiten Netzfehler bzw. erst ab 15 s. Alle API-Anfragen haben eine Gesamtfrist inklusive Antwortkörper (Standard 4 s; Schreibzugriffe ohne Idempotenz-Schlüssel, darunter Login, MFA und Refresh, 15 s; Sammelerfassung 15 s; Upload 30 s; Download 60 s). +24. **Offline-Standmodus**: „Stand laden“ legt ein Paket je Stand in IndexedDB ab (`GET /apiaries/:id/offline-pack`: lebende Völker, Königinnen, die letzten 3 Durchsichten je Volk, offene Aufgaben, je Volk `occupiedFrames` und die nächsten Schritte `nextSteps`, optional, Version 1). Es enthält weder Koordinaten noch Adresse des Standes; Königinnen, Durchsichten und Aufgaben fehlen ohne das jeweilige Leserecht (Benutzer-ID der Durchsichten wird weggelassen). Ein Paket gilt nach 24 h als veraltet (Alter immer sichtbar), wird nach 1 h automatisch erneuert und nach 14 Tagen oder nach 403/404 gelöscht. Lehnt der Server einen Eintrag ab (bei der Sammelerfassung mit `invalidColonyIds`), erzwingt das ein Neuladen der betroffenen Pakete (frühestens nach 5 min). Der Service Worker (`public/sw.js`, `sw-routes.js`) speichert Seiten nur zusammen mit ihren Assets und Daten nur als `application/json`; nie gespeichert werden Auth-Antworten, Anfrage-Header (kein Bearer-Token im Cache), die Antworten, die ein Paket liefert (eine Offline-Datenquelle mit sichtbarem Alter), `/api/v1/apiaries` und `/apiaries/:id` (Standort), `/export/` und `/backup/`. Navigation hat routenabhängige Zeitlimits: App-Hülle 4 s, Offline-Seiten zuerst aus dem Cache, Deep Links etwa 10 s, übrige Seiten Netzfehler bzw. erst ab 15 s. Alle API-Anfragen haben eine Gesamtfrist inklusive Antwortkörper (Standard 4 s; Schreibzugriffe ohne Idempotenz-Schlüssel, darunter Login, MFA und Refresh, 15 s; Sammelerfassung 15 s; Upload 30 s; Download 60 s). 25. **Offline-Sitzung**: Ohne Netz bleibt der Benutzer mit der zwischengespeicherten Identität (Name, Rechte) 7 Tage angemeldet, nie mit einem Token; der Sync bleibt gesperrt, bis ein Refresh gelingt. Ein Refresh gilt nur für dieselbe Benutzer-ID; das Token eines anderen Kontos löscht das Gerät (Pakete, Warteschlange, Caches). Nur 401/403 beenden die Sitzung, 429/502/503 und Netzfehler zählen als Netzfehler. Ein Abmelden ohne Netz merkt den Widerruf vor und holt ihn beim nächsten Start vor allem anderen nach. Jeder Warteschlangen-Eintrag hat einen Besitzer (`ownerUserId`); fremde Einträge werden nie gesendet. Beim Abmelden und Benutzerwechsel werden Pakete, Rundenzustand, Query-Cache, Foto-Übergabemarker und die Cache-Storage-Einträge des Service Workers gelöscht (`WIPE_CACHES` mit Generationszähler, damit eine laufende Anfrage danach nichts mehr schreibt). Beim Sync beendet 401 den Lauf, ohne Einträge zu markieren; 408/429/502/503/504 lassen Einträge wartend; 409 „wird bereits verarbeitet“ heißt „später erneut“. 26. **Fotos**: Aufnahmen werden im Browser per Canvas neu kodiert (längste Kante 2560 px, JPEG, ohne EXIF/XMP/GPS), auch im bestehenden Dokument-Upload. Vor dem Upload prüft der Client Metadaten und höchstens 10 MB; der Upload ist idempotent. Ein Foto wartet in IndexedDB auf die Server-ID seiner Durchsicht und wird danach angehängt. Ein Übergabemarker in `localStorage` (mit `createdAt`, höchstens 3 Tage alt, je Benutzer) verhindert, dass ein bereits gespeicherter Entwurf samt Fotos ein zweites Mal gesendet wird. Offen: die Prüfung mit `exiftool` auf einem echten Telefon (auch älteres iOS Safari). 27. **Sammelerfassung am Stand**: `POST /treatments|feedings|varroa/bulk` schreibt alles oder nichts in einer Transaktion (`@Idempotent()`). Die Völker werden sortiert mit `lockRowsForShare` (`FOR SHARE`, `src/prisma/row-lock.ts`) gesperrt, der Ablauf steckt im Helfer `runBulk`; das Ereignis je Volk trägt `bulkId`. Entscheidung K1: Die Chargennummer des Arzneimittels (`batchNumber`) wird einmal eingegeben und nie erzeugt (rechtlich relevantes Bestandsbuch); gruppiert wird über `bulkId`. Entscheidung K2: Varroa nimmt je Volk einen eigenen Eintrag, ein gemeinsamer Milbenwert wäre erfundene Messung. Unbekannte oder beendete Völker meldet die 400-Antwort als `invalidColonyIds`. Der Idempotenz-Schlüssel bindet Benutzer, Schlüssel und Pfad, nicht den Inhalt: Der Client nimmt darum je abweichender Absendung einen neuen Schlüssel. -28. **Skalen und Vorbelegung**: Alle Bewertungsskalen der Durchsicht zeigen „5 = gut“ (Gespeichertes bleibt unverändert). Die Schwarmstimmung ist gespeichert umgekehrt (1 = ruhig, 5 = stark) und wird nur in der Anzeige gedreht (`RatingScale direction="lower-is-better"`; die Beschriftungen nennen die gespeicherten Enden); keine Datenmigration. Waben stehen per Plus/Minus in `colonyStrength` und neu `broodFrames`, das Altfeld `broodAmount` nur im Bearbeiten-Dialog. Werte der letzten Durchsicht erscheinen als sichtbarer Hinweis je Feld mit „Letztes Mal … übernehmen“ und „Alles wie letztes Mal“, nie stillschweigend vorbelegt. -29. **Ampeln**: Varroa wird gelb ab der halben Schwelle der aktiven Regel (`VARROA_WARN_FRACTION = 0.5`, in Milben/Tag), rot ab der Schwelle; der Trend zeigt Milben/Tag mit Schwellenlinie, Zeiträume 1/2/3/7 Tage oder „anders“. Die Ampel eines Volkes (`lib/colonies/status-light.ts`) nimmt den schlechtesten Befund: rot bei `QUEENLESS`, kritischem Gesundheits-, Futter- oder Brutzustand oder offener kritischer Meldung; gelb bei `WEAK`/`CONSPICUOUS`, schwachem Zustand, offener Warnung, noch keiner Durchsicht oder mehr als `INSPECTION_STALE_DAYS = 14` Tagen ohne Durchsicht (Letzteres nur mit Leserecht auf Durchsichten). Die Farbe steht nie allein (Symbol und Wort); die Kontraste der Farbtöne prüft ein Test. +28. **Skalen und Vorbelegung**: Alle Bewertungsskalen der Durchsicht zeigen „5 = gut“ (Gespeichertes bleibt unverändert). Die Schwarmstimmung ist gespeichert umgekehrt (1 = ruhig, 5 = stark) und wird nur in der Anzeige gedreht (`RatingScale direction="lower-is-better"`; die Beschriftungen nennen die gespeicherten Enden); keine Datenmigration. Waben stehen per Plus/Minus in `colonyStrength` und neu `broodFrames`, das Altfeld `broodAmount` nur im Bearbeiten-Dialog. `colonyStrength` zählt besetzte Waben, höchstens 40 (Annahme 34). Werte der letzten Durchsicht erscheinen als sichtbarer Hinweis je Feld mit „Letztes Mal … übernehmen“ und „Alles wie letztes Mal“, nie stillschweigend vorbelegt. +29. **Ampeln**: Die Varroa-Ampel kommt vom Server aus Bändern nach Methode, Monat und Volkstyp (Abschnitt 11.4); `VARROA_WARN_FRACTION` gibt es nicht mehr. Der Trend zeigt die Grenzen als Stufenlinien je Monat, Zeiträume 1/2/3/7 Tage oder „anders“. Die Ampel eines Volkes (`lib/colonies/status-light.ts`) nimmt den schlechtesten Befund: rot bei `QUEENLESS`, kritischem Gesundheits-, Futter- oder Brutzustand oder offener kritischer Meldung; gelb bei `WEAK`/`CONSPICUOUS`, schwachem Zustand, offener Warnung oder überfälliger Durchsicht. „Überfällig“ heißt `inspectionDueOn` überschritten („Durchsicht seit 3 Tagen fällig“, Abschnitt 11.3); das ersetzt die festen 14 Tage `INSPECTION_STALE_DAYS`. Ohne Fälligkeitstag (Winterruhe, alte Pakete) gibt es keinen Befund, und „Noch keine Durchsicht“ entfällt, weil die Fälligkeit auf die Anlage des Volkes zurückfällt. `openWarnings` und `openCritical` der Volksübersicht zählen offene Vorschläge der Priorität `HIGH` bzw. `URGENT`, die der Nutzer sehen darf (`tasks:read` und das Recht des Vorschlags), nicht mehr Benachrichtigungen. Die Farbe steht nie allein (Symbol und Wort); die Kontraste der Farbtöne prüft ein Test. 30. **Fehlversuchs-Sperre für API-Schlüssel, über alle Pods**: Ungültige Schlüssel (unbekannt, widerrufen, abgelaufen) zählen je Client-Netz – IPv4-Adresse bzw. IPv6-/64, wie der Throttler (`rateLimitSubject`). Nach 20 ungültigen Schlüsseln in einem Fenster von 15 min (ab dem ersten Fehlversuch) bekommt das Netz für weitere ungültige Schlüssel 15 min lang `429` (`src/api-keys/failed-attempt-limiter.ts`, Grenzen `API_KEY_FAILURE_LIMIT`). Ein gültiger Schlüssel wird davon nie abgewiesen: Erst kommt die Suche über den Hash, nur ein ungültiger Schlüssel wird gezählt oder gesperrt. Sonst könnte jeder, der eine gemeinsame Adresse (Cluster-Ausgang, Proxy) nutzt oder vortäuscht, ERPNext oder den Shop aussperren; bei 256 Zufallsbits je Schlüssel kostet das nichts. Falsch aufgebaute Tokens werden ohne Datenbankzugriff mit 401 abgewiesen und zählen nicht. Die Zähler liegen wie die des Throttlers in der UNLOGGED-Tabelle `ThrottlerHit` (über `PrismaThrottlerStorage`: eigener Throttler-Name `api-key-failures`, Schlüssel-Präfix `api-key-failures:`, eine Upsert-Anweisung, nur DB-Uhr `now()`) und gelten damit für alle api-Pods gemeinsam; kein zweiter Zähler im Prozessspeicher. Bei einem DB-Fehler schlägt die Anfrage fehl (fail-closed, wie der Throttler). Abgewiesene Schlüssel werden nur mit Präfix und IP protokolliert, das Erreichen der Grenze einmal je Fenster. +31. **Vorschläge, Anker und Dubletten**: Regel-, Ketten- und Autopilot-Vorschläge sind ein Modell mit den Arten `PLAN` und `DO` (Abschnitt 11.1). `dedupeKey = ::` ist eindeutig. Offene, übernommene, verworfene und veraltete (`OBSOLETE`) Vorschläge behalten den Schlüssel, ein verworfener oder übernommener Anker kommt also nie wieder; erledigte (`RESOLVED`) und abgelaufene (`EXPIRED`) geben ihn im selben Statement frei, derselbe Anker darf danach neu vorgeschlagen werden. Je Absicht und Objekt gibt es höchstens eine offene Sache: ein offener Vorschlag oder eine offene Aufgabe (`OPEN`, `IN_PROGRESS`); die Absicht einer Aufgabe ist `sourceSuggestion.intent`, sonst die der Altregel, sonst `FOLLOW_UP`. Bei Vorschlägen ohne Volk und Objekt gilt die Prüfung je Regel, damit sich unabhängige nicht blockieren. Verworfene lassen sich 30 Tage nach `decidedAt` wieder öffnen. Systemübergänge (Auffrischen, Auflösen, Veralten, Ablaufen) schreiben keinen Audit-Eintrag, weil Status und `decidedAt` sichtbar sind; der Audit-Eintrag gilt Entscheidungen von Nutzern. Eine Lücke bleibt bewusst: Beim Wiederöffnen gibt es keine Volkssperre, ein gleichzeitiger Vorschlag derselben Absicht kann ausnahmsweise doppelt offen sein. +32. **Vorschläge verbergen ihr Dasein**: Liste, Zähler und nächste Schritte zeigen nur Vorschläge, deren `requiredPermission` leer ist oder vom Nutzer gehalten wird. Übernehmen, Verwerfen und Wiederöffnen eines unsichtbaren Vorschlags antworten 404 statt 403, damit weder Titel noch Dasein durchsickern. +33. **Futter-Vokabular**: `Inspection.feedStatus` ist `ColonyCondition` wie auf der Volkskarte (`EXCELLENT` Reichlich, `GOOD` Ausreichend, `AVERAGE` Mittel, `WEAK` Niedrig, `CRITICAL` Kritisch). Die Migration bildet den alten Freitext (getrimmt auf Leerzeichen, Tab, CR, LF; ohne Groß-/Kleinschreibung) ab: „ausreichend“ → `GOOD`, „niedrig“ → `WEAK`, „kritisch“ → `CRITICAL`; leer → `NULL`; alles andere → `NULL` und `Futter (alt): ` wird nach einem Zeilenumbruch an `notes` gehängt (der Lauf meldet die Zahl der verschobenen Texte per `RAISE NOTICE`). Dieselbe Abbildung gilt für den CSV-Völkerimport (unbekannter Text landet in den Notizen, die Zeile trägt die Meldung „Futterangabe … nicht erkannt, in die Notizen übernommen.“) und für alte Backup-Bundles (ein Wert, der kein Text ist, wird `NULL` mit Notiz `Futter (alt): `). Die API akzeptiert bei `POST/PATCH /inspections` die Enum-Namen in jeder Schreibweise und die drei alten Wörter (so bleiben Einträge der Offline-Warteschlange alter Clients sendbar); leerer Text wird `NULL`, anderer Freitext 400. Die Regel `FEED_STATUS_CRITICAL` feuert bei `feedStatus = CRITICAL`; der Webhook `inspection.created` trägt `feedStatus` als Enum-Wert (der Shop nutzt das Ereignis nicht). +34. **Volksstärke in besetzten Waben**: `Colony.occupiedFrames` und `Inspection.colonyStrength` zählen besetzte Waben, 0 bis 40 (CHECK in der Datenbank; 400 „Höchstens 40 besetzte Waben.“). Das alte `Colony.strength` (1–10) bleibt unverändert, wird in Swagger als veraltet markiert und nie umgerechnet; die Oberfläche zeigt es nur, wenn es gespeichert ist, als „Volksstärke (alt)“. Vor dem Ausrollen listet der Kopf der Migration `20261014100700_t4_occupied_frames` die Abfrage nach `Inspection.colonyStrength > 40`. +35. **Übernahme Durchsicht → Volkskarte**: Siehe Abschnitt 11.10. Annahme: Das Brutbild wird zum Brutzustand der Volkskarte (5 `EXCELLENT`, 4 `GOOD`, 3 `AVERAGE`, 2 `WEAK`, 1 `CRITICAL`); diese Zuordnung ist nicht aus dem Lastenheft abgeleitet. +36. **Saison aus Pflanzenentwicklung**: Die Saison (Abschnitte `WINTERRUHE`, `FRUEHJAHR`, `SCHWARMZEIT`, `SOMMER`, `SPAETSOMMER`, `HERBST`) leitet sich aus der Pflanzenentwicklung ab (Abschnitt 11.3). T4a kennt nur den Kalender-Rückfall (`PHENOLOGY.calendarFallback`, Standard `SALWEIDE` 20.03., `KIRSCHE` 15.04., `APFEL` 28.04., `HOLUNDER` 30.05., `LINDE` 20.06., `TRACHTENDE` 01.08., `HERBST` 15.09., `WINTER` 15.11.). Die Tage sind eine Annahme für Schwaben und per Regel anpassbar. Vor `SALWEIDE` gilt noch `WINTER` des Vorjahres. Gemeldete Phasen und Wetter (Gradtagszahl) folgen später über `SeasonService.statesFor`; die Aufrufer bleiben gleich. +37. **Alter der Königin**: Eine Königin gilt als gealtert, wenn aktuelles Jahr minus Schlupfjahr ≥ `queenMaxAgeYears` (Regel `SEASON_AUTOPILOT`, Standard 2). Die frühere getrennte Schwelle (3 im Dashboard, 2 im Ereignis) ist aufgehoben; `isQueenAged` (`src/season/queen-age.ts`) ist die einzige Stelle, ein Wächter-Test (`queen-age-threshold.spec.ts`) hält das fest. Das Dashboard-Fenster „alte Königinnen“ beginnt daher bei 2 Jahren und folgt der Regel. Der Wert 2 ist eine Annahme (Imkerpraxis). +38. **Autopilot: Annahmen zu Ablauf und Alter**: Ein Vorschlag läuft `max(Fälligkeit, heute) + suggestionTtlDays` (Standard 30 Tage) ab, nicht schlicht Fälligkeit + 30: Sonst würde eine schon über 30 Tage überfällige Bedingung beim Anlegen abgelaufen, ihr Schlüssel freigegeben und sie täglich neu vorgeschlagen, ohne je sichtbar zu sein. Das Auffrischen behält `expiresAt`; besteht die Bedingung weiter, läuft der Vorschlag nach etwa 30 Tagen ab und kommt mit frischer Frist wieder. A3 beobachtet nur Behandlungen, die vor höchstens 60 Tagen endeten (`RECHECK_MAX_AGE_DAYS`); ältere Historie ohne spätere Messung erzeugt keinen Vorschlag. Beim Abgleich werden entfallene Vorschläge vor neuen Kandidaten aufgelöst (gleicher Endzustand, aber einen Abgleich früher, weil `proposeIn` Kandidaten bei offenem Vorschlag derselben Absicht verwirft). +39. **Benachrichtigungen je Nutzer**: Jede Meldung gehört genau einem Empfänger (`Notification.userId` Pflicht). Rundmeldungen gibt es nicht mehr: `NotificationsService.recordForPermission` und `recordForUsers` schreiben beim Anlegen je einen Eintrag für jeden aktiven Nutzer mit aktiver Rolle, der alle nötigen Rechte hält (Fan-out mit Rechteprüfung beim Schreiben, bewusste Abweichung von der Rückgrat-Spec §4). Lesen und Gelesen-Markieren ist so pro Person, und wer das Recht nicht hat, sieht den Inhalt nie. Dasselbe Recht gilt beim Lesen: `requiredPermissions` (Vereinigung aus Empfänger- und Inhaltsrechten) muss der Nutzer zum Lesezeitpunkt vollständig halten, sonst ist die Zeile für ihn unsichtbar (`GET /notifications`, `POST /notifications/:id/read` → 404, `read-all` markiert nur sichtbare). `dedupeKey = ":"` verhindert Doppelungen (`createManyAndReturn` mit `skipDuplicates`); `link` ist ein relativer App-Pfad (`assertAppLink`). Die Migration hat vorhandene Rundmeldungen für alle aktiven Nutzer kopiert (gleiche Sichtbarkeit wie zuvor; ohne aktiven Nutzer entfallen sie). `Notification.userId` und `NotificationPreference.userId` hängen mit `ON DELETE CASCADE` am Benutzer (Benutzer werden produktiv nur deaktiviert, `RESTRICT` schützte nichts und blockierte Aufräumen). Wächter: `no-broadcast-notifications.spec.ts`. +40. **Erinnerungen in der App und Morgenübersicht**: Siehe Abschnitt 11.9. Die Morgenzeit (05:00–12:00), die Ruhezeit und die Umstellung Sommer-/Winterzeit steuern in dieser Lieferung nichts in der App; die Übersicht entsteht immer um 04:30 mit dem Stand dieser Stunde. Sie wirken erst auf die Telegram-Zustellung (T4c). +41. **Berliner Kalendertage**: Tagesrechnung steckt in `src/common/dates.ts` (`berlinToday`, `berlinDayOf`, `dayToDate`, `addDays`, `daysBetween`; unmögliche Tage wie 30.02. werfen). Fachliche Tage sind Berliner Kalendertage, gespeichert als Mitternacht UTC dieses Tages: Varroa-Ampel, Behandlungs- und Wartezeitfenster, Erntetag, Fälligkeiten der Vorschläge, „heute“ im Job und im Autopilot. Für Werte um Mitternacht UTC ändert das nichts; ein Behandlungszeitpunkt am Abend (UTC) kann so auf den nächsten Berliner Tag fallen. ## 7. Kubernetes @@ -210,7 +229,7 @@ Scheitert die Migration, scheitert der initContainer und der neue Pod startet ni **Datensicherung:** Seit der Entscheidung, PostgreSQL selbst im Cluster zu betreiben (siehe CLAUDE.md), liegt die Backup-Verantwortung bei diesem Projekt, nicht mehr bei einem externen Cluster-/DBA-Betrieb. Umgesetzt als anwendungsseitiges `BackupModule` (`ApiCore-Backend/src/backup/`) statt der ursprünglich hier vorgesehenen reinen `pg_dump`/PITR-Strategie: -- **Export/Import:** `BackupService.exportAll()`/`.importAll()` sichert alle fachlichen und Konfigurationsmodelle (Betriebsdaten inkl. Herstellung, Rohstoffe, Bewegungsbuch und Shop-Anfragen, Nummernkreise, Benutzer/Rollen/Rechte, Audit-Log, Website-/Backup-/Kalender-Einstellungen samt Kalender-Outbox, hochgeladene Dokumente inkl. Dateiinhalt als Base64) als eine versionierte JSON-Datei (Bundle-Version 3; ältere Bundles bleiben einspielbar). Bewusst ausgenommen sind nur Permission (Katalog aus dem Code), RefreshToken, ConsumedToken, ApiKey (Annahme 14), IdempotencyRecord (Annahme 11) sowie die Betriebsmodelle des Plattform-Rückgrats DomainEvent und DomainEventDelivery (Outbox – Seiteneffekte dürfen nach einem Restore nicht erneut ausgelöst werden), WebhookDelivery (Zustellprotokoll und Wiederholungs-Warteschlange – sonst gingen alte Ereignisse erneut an Empfänger), JobRun (Laufhistorie der CronJobs dieses Deployments) und ThrottlerHit (UNLOGGED-Zähler des Rate-Limits); die Liste mit Begründung steht in `MODELS_EXCLUDED_FROM_BACKUP`, ein Test erzwingt sie für jedes neue Prisma-Modell. Import ist upsert-basiert (Rollen/Berechtigungen über `key`/`resource+action` statt roher `id`, damit ein Restore auch in ein anderes Deployment funktioniert) und läuft in einer einzigen `$transaction`. +- **Export/Import:** `BackupService.exportAll()`/`.importAll()` sichert alle fachlichen und Konfigurationsmodelle (Betriebsdaten inkl. Herstellung, Rohstoffe, Bewegungsbuch und Shop-Anfragen, Nummernkreise, Benutzer/Rollen/Rechte, Audit-Log, Website-/Backup-/Kalender-Einstellungen samt Kalender-Outbox, hochgeladene Dokumente inkl. Dateiinhalt als Base64) als eine versionierte JSON-Datei (Bundle-Version 3; ältere Bundles bleiben einspielbar). Neu im Bundle: `Suggestion` (Import nach `colony`, vor `task`, weil `Task.sourceSuggestionId` darauf zeigt; `decidedByUserId` wie andere Benutzer-IDs, `null` bleibt `null`; nur aus versiegelten Dateien, sonst kämen verworfene oder übernommene Vorschläge nach einem Restore als neue zurück) und `NotificationPreference`. Meldungen: Ein Eintrag eines im Zielsystem unbekannten Nutzers oder mit einem `dedupeKey`, der dort unter anderer ID existiert, wird übersprungen und in `imported.notificationSkipped` gezählt (nie umgehängt); Einträge alter Bundles ohne `userId` werden je aktivem Nutzer kopiert (abgeleitete ID `:`, wiederholbar), ohne `category` nach der Regel von Migration `100800` (`relatedType 'JobRun'` oder leer → `SYSTEM`, sonst `SUGGESTION`). Ein alter Bundle-Wert für `Inspection.feedStatus` wird auf das Futter-Vokabular abgebildet (Annahme 33). Bewusst ausgenommen sind nur Permission (Katalog aus dem Code), RefreshToken, ConsumedToken, ApiKey (Annahme 14), IdempotencyRecord (Annahme 11) sowie die Betriebsmodelle des Plattform-Rückgrats DomainEvent und DomainEventDelivery (Outbox – Seiteneffekte dürfen nach einem Restore nicht erneut ausgelöst werden), WebhookDelivery (Zustellprotokoll und Wiederholungs-Warteschlange – sonst gingen alte Ereignisse erneut an Empfänger), JobRun (Laufhistorie der CronJobs dieses Deployments) und ThrottlerHit (UNLOGGED-Zähler des Rate-Limits); die Liste mit Begründung steht in `MODELS_EXCLUDED_FROM_BACKUP`, ein Test erzwingt sie für jedes neue Prisma-Modell. Import ist upsert-basiert (Rollen/Berechtigungen über `key`/`resource+action` statt roher `id`, damit ein Restore auch in ein anderes Deployment funktioniert) und läuft in einer einzigen `$transaction`. - **Automatischer Zeitplan:** Kein Timer im API-Prozess. Der CronJob `apicore-job-app-backup` (`0 * * * *`, Europe/Berlin, `concurrencyPolicy: Forbid`) startet stündlich `node dist/cli.js app-backup` (`BackupSchedulerService.runIfDue`). Der Job liest Pfad (`BACKUP_STORAGE_PATH`), Cron-Ausdruck und Aufbewahrung (`BACKUP_RETENTION_COUNT`) frisch – Env-Variable vor GUI-Wert (Verwaltung > Backup) –, vergleicht den nächsten Termin nach der mtime der neuesten Backup-Datei (`isBackupDue`, Zeitzone Europe/Berlin, „jetzt“ = DB-Uhr) und schreibt nur, wenn er erreicht ist; alte Stände rotiert er nach der Aufbewahrung. Prüfung und Schreiben laufen unter einem DB-Advisory-Lock, zwei Job-Pods schreiben nie doppelt. Ohne Pfad endet der Lauf erfolgreich mit `{ written: false, reason: 'no-path' }`; ein Fehler lässt den `JobRun` fehlschlagen (Benachrichtigung an die Inhaber von `backup:configure`). GUI-Änderungen und Importe gelten ab der nächsten stündlichen Prüfung. „Jetzt sichern“ bleibt in der API und schreibt immer. `GET /backup/schedule` liefert zusätzlich `lastJobRun` (Status, Start, Ende, Fehler); die Seite zeigt „Wird stündlich geprüft · letzter Lauf …“. Die Lock-Transaktion der Fälligkeitsprüfung hält eine Verbindung bis zum Ende des Schreibens (Timeout 1 h = `activeDeadlineSeconds`; der Export nutzt andere Verbindungen, der Pool braucht mindestens zwei). Sie braucht weder `idle_in_transaction_session_timeout` noch einen Transaction-Mode-PgBouncer: Postgres wird direkt angesprochen, und der Lock hängt an der Transaktion. Scheitert der Commit, nachdem die Datei schon geschrieben wurde, bleibt die Datei bestehen, der Lauf steht aber kosmetisch auf `FAILED`; der nächste Lauf sieht die Datei an ihrer mtime. Das Aufräumen von Idempotenz-Einträgen (90 Tage) und verbrauchten Einmal-Tokens läuft einmal täglich im CronJob `apicore-job-cleanup` (Abschnitt 10, „Jobs“). - **Restore:** Über `POST /backup/import` (bzw. den Import-Dialog unter Verwaltung > Backup) eine zuvor exportierte Bundle-Datei einspielen. Import-Limit `BACKUP_MAX_IMPORT_BYTES` (Standard 50 MB, höchstens 256 MB, weil der Upload vollständig im RAM des 512-Mi-Pods verarbeitet wird); Export und Zeitplan prüfen jede Sicherung dagegen und warnen in Log und UI (Verwaltung > Backup), bevor eine Datei entsteht, die sich im Notfall nicht mehr einspielen ließe. @@ -243,7 +262,7 @@ Grundlage: OWASP API Security Top 10 (2023) und OWASP ASVS Level 2. Umsetzung un **Outbox.** Fachliche Folgen werden nicht direkt nach dem Commit ausgeführt. Sie werden als `DomainEvent` geschrieben, in derselben Prisma-Transaktion wie die fachliche Änderung und der Audit-Eintrag (`DomainEventsService.record/recordAll`, nur mit Transaktions-Client). Kein Commit ohne Ereignis, kein Ereignis ohne Commit. Nach dem Commit weckt `pg_notify('domain_event', '')` den Worker. Ein verlorenes Wecken verzögert nur bis zum nächsten Takt (5 s). -**Claim.** `DomainEventDispatcher.runOnce()` übernimmt höchstens 50 fällige Ereignisse (`status = 'PENDING'`, `availableAt <= now()`), älteste zuerst (`occurredAt`). Das geschieht per `UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP LOCKED) RETURNING *` → `PROCESSING`, `claimedAt = now()`. Mehrere Worker übernehmen so nebeneinander disjunkte Mengen, ohne aufeinander zu warten. `drain()` wiederholt `runOnce()`, bis eine Runde nichts übernimmt, höchstens 20 Runden je Takt. Folgeereignisse der Handler kommen so in der nächsten Runde dran. Claim, Abschluss und Freigabe liegen für alle Outboxen in `src/common/outbox/outbox-claims.ts`, die Wiederholregel in `src/common/retry-policy.ts`. Alle Zeitvergleiche laufen in SQL gegen `now()` (DB-Uhr). +**Claim.** `DomainEventDispatcher.runOnce()` übernimmt höchstens 50 fällige Ereignisse (`status = 'PENDING'`, `availableAt <= now()`), älteste zuerst (`occurredAt`). Das geschieht per `UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP LOCKED) RETURNING *` → `PROCESSING`, `claimedAt = now()`. Mehrere Worker übernehmen so nebeneinander disjunkte Mengen, ohne aufeinander zu warten. `drain()` wiederholt `runOnce()`, bis eine Runde nichts übernimmt, höchstens 20 Runden je Takt. Folgeereignisse der Handler kommen so in der nächsten Runde dran. Claim, Abschluss und Freigabe liegen für alle Outboxen in `src/common/outbox/outbox-claims.ts`, die Wiederholregel in `src/common/retry-policy.ts`. Alle Zeitvergleiche laufen in SQL gegen `now()` (DB-Uhr). `availableAt` ist `timestamp(3)`: Ein per SQL geschriebenes `now()` wird auf die Millisekunde gerundet und kann bis 0,5 ms hinter dem `now()` der nächsten Anweisung liegen. Darum vergleicht der Claim gegen `CAST(now() AS timestamp(3))`, sonst fände ein sofortiger Claim frisch fällig gesetzte Zeilen gelegentlich nicht (Regressionstest `test/outbox-claims.e2e-spec.ts`). **Delivery-Sperre.** Je Ereignis laufen die Handler nacheinander in fester Reihenfolge (Handler-ID nach Codepunkt), jeder in einer eigenen Transaktion mit 10 s Timeout: 1. `DomainEventDelivery` (eventId, handler) anlegen, falls es sie noch nicht gibt (`skipDuplicates`). @@ -259,13 +278,13 @@ Die Wirkung des Handlers und `DONE` werden gemeinsam committet. Die Exklusivitä **Handler-Vertrag.** `@OnDomainEvent(types, '.')` steht nur auf Singleton-Providern. Ein Handler schreibt ausschließlich über den übergebenen `tx`; lesen darf er auch über `PrismaService`. Er macht keine HTTP- oder Graph-Aufrufe: Netzwerkarbeit kommt in eine eigene Outbox (`CalendarSyncOutboxEntry`, `WebhookDelivery`), die eine Worker-Schleife abarbeitet. Der Wächter `src/domain-events/handler-contract.spec.ts` prüft, dass `*.handlers.ts` keine Netzwerk-Clients importieren. Fehlertexte gehen nur über `loggableError()` in `lastError` und Logs, nie Payload oder Secrets. -**Aufgaben.** Jede Aufgabe, die die App anlegt oder bearbeitet, läuft über `TasksService` (auch die Aufgaben der Automatik-Regeln, siehe Annahme 12; nur die Backup-Wiederherstellung ist ausgenommen). `createInTx(tx, input, actingUserId)` schreibt Aufgabe, Audit-Eintrag und `TaskCreated` (`relatedType`, `relatedId`, `dueDate` als UTC-Tag, `utcDay()`) in der Transaktion des Aufrufers – Aufgabenseite, Folgeaufgabe aus der Durchsicht, Vorlage. `update` schreibt `TaskUpdated` (`status`, `dueDate`). `TaskCompleted` entsteht nur beim Übergang nach `DONE`, durch „Erledigt“ (`complete`) ebenso wie durch eine Bearbeitung mit `status: DONE`: beide setzen Status und `completedAt` über dasselbe bedingte Update (`status <> 'DONE'`), sodass die Erledigung auch bei zwei gleichzeitigen Anfragen genau einmal gemeldet wird. Kalender (`calendar-sync.task`) und Webhooks (`webhooks.forward`: `task.created`, `task.completed`) folgen ausschließlich aus diesen Ereignissen; direkte Aufrufe gibt es für Aufgaben nicht mehr. Wiederkehrende Vorlagen erzeugt `TasksService.generateFromTemplates(now)` (lazy vor `GET /tasks`): je Vorlage ein Compare-and-Swap auf `nextDueDate` und `createInTx` in einer Transaktion, sodass von mehreren Replicas genau eine das Vorkommen anlegt und ein gescheiterter Schreibversuch es nicht verliert. Solche Aufgaben haben keinen Ersteller, ihr Audit-Eintrag keinen Benutzer, ihr Ereignis `actorUserId = null`. +**Aufgaben.** Jede Aufgabe, die die App anlegt oder bearbeitet, läuft über `TasksService` (auch die Aufgaben der Automatik-Regeln, siehe Annahme 12; nur die Backup-Wiederherstellung ist ausgenommen). `createInTx(tx, input, actingUserId)` schreibt Aufgabe, Audit-Eintrag und `TaskCreated` (`relatedType`, `relatedId`, `dueDate` als UTC-Tag, `utcDay()`) in der Transaktion des Aufrufers – Aufgabenseite, Folgeaufgabe aus der Durchsicht, Vorlage. `update` schreibt `TaskUpdated` (`status`, `dueDate`). `TaskCompleted` entsteht nur beim Übergang nach `DONE`, durch „Erledigt“ (`complete`) ebenso wie durch eine Bearbeitung mit `status: DONE`: beide setzen Status und `completedAt` über dasselbe bedingte Update (`status <> 'DONE'`), sodass die Erledigung auch bei zwei gleichzeitigen Anfragen genau einmal gemeldet wird. Kalender (`calendar-sync.task`) und Webhooks (`webhooks.forward`: `task.created`, `task.completed`) folgen ausschließlich aus diesen Ereignissen; direkte Aufrufe gibt es für Aufgaben nicht mehr. Wiederkehrende Vorlagen erzeugt `TasksService.generateFromTemplates(now)` (lazy vor `GET /tasks`): je Vorlage ein Compare-and-Swap auf `nextDueDate` und `createInTx` in einer Transaktion, sodass von mehreren Replicas genau eine das Vorkommen anlegt und ein gescheiterter Schreibversuch es nicht verliert. Solche Aufgaben haben keinen Ersteller, ihr Audit-Eintrag keinen Benutzer, ihr Ereignis `actorUserId = null`. `createInTx` nimmt auch `sourceSuggestionId` (Aufgabe aus einem Vorschlag, Abschnitt 11.1). `completeIn` und `cancelIn` laufen in der Transaktion des Aufrufers (Zeilensperre, Neulesen, bedingter Übergang, Audit-Eintrag und `TaskCompleted` bzw. `TaskUpdated` nur beim Übergang); `complete()` nutzt `completeIn`, es gibt keinen zweiten Pfad. Das Aufräumen eines beendeten Volkes bricht Aufgaben darüber ab oder erledigt sie (Abschnitt 11.7); `TaskDue` löst die Erinnerung aus (Abschnitt 11.9). -**Durchsichten und Varroa.** `InspectionsService` und `VarroaService` schreiben Datensatz, Audit-Eintrag und Ereignis in einer Transaktion: `create` → `InspectionRecorded` bzw. `VarroaMeasured`, `update` → `InspectionCorrected` bzw. `VarroaCorrected`, jeweils mit `{ colonyId }` (das Volk nach der Änderung, auch nach einer Umbuchung). `update` sperrt die Zeile zuerst (`lockRowForUpdate`) und liest „vorher“ danach über `tx`; auch die Tagesrate der Varroamessung wird aus dieser Zeile abgeleitet. Alle Folgen kommen aus Handlern: Regeln über `automation.inspection-rules` (nur `InspectionRecorded`; eine korrigierte Durchsicht wertet keine Regel aus) und `automation.varroa-threshold` (`VarroaMeasured` und `VarroaCorrected`, eine korrigierte Zählung kann die Schwelle überschreiten), Kalender über `calendar-sync.inspection` (beide Durchsicht-Ereignisse), Webhooks `inspection.created` und `varroa.measured` über `webhooks.forward`. Ein Handler wertet den Datensatz so aus, wie er bei der Zustellung in der Datenbank steht. +**Durchsichten und Varroa.** `InspectionsService` und `VarroaService` schreiben Datensatz, Audit-Eintrag und Ereignis in einer Transaktion: `create` → `InspectionRecorded` bzw. `VarroaMeasured`, `update` → `InspectionCorrected` bzw. `VarroaCorrected`, jeweils mit `{ colonyId }` (das Volk nach der Änderung, auch nach einer Umbuchung). `update` sperrt die Zeile zuerst (`lockRowForUpdate`) und liest „vorher“ danach über `tx`; auch die Tagesrate der Varroamessung wird aus dieser Zeile abgeleitet. Alle Folgen kommen aus Handlern: Regeln über `automation.inspection-rules` (`InspectionRecorded`: auswerten und offene Vorschläge nachführen; `InspectionCorrected`: nur nachführen, nie neu vorschlagen) und `automation.varroa-threshold` (`VarroaMeasured` und `VarroaCorrected`; eine korrigierte Zählung kann die Schwelle überschreiten, und der offene Vorschlag derselben Messung wird aufgefrischt oder aufgelöst, Abschnitt 11.4), den Autopilot über `autopilot.colony-sync` (Abschnitt 11.5), Kalender über `calendar-sync.inspection` (beide Durchsicht-Ereignisse), Webhooks `inspection.created` und `varroa.measured` über `webhooks.forward`. Ein Handler wertet den Datensatz so aus, wie er bei der Zustellung in der Datenbank steht. **Behandlungen, Fütterungen und Ernten.** Anlegen schreibt Datensatz, Audit-Eintrag und `TreatmentRecorded`, `FeedingRecorded` bzw. `HarvestRecorded` in einer Transaktion, Ändern `TreatmentCorrected`, `FeedingCorrected` bzw. `HarvestCorrected`. Kalendereintrag (`calendar-sync.*`), Webhook (`treatment.created`, `feeding.created`, `harvest.created`) und Folgekontrolle (`automation.treatment-follow-up`, `automation.feeding-follow-up`) entstehen ausschließlich in den Handlern; Korrekturen lösen nur den Kalender-Handler aus, keine Folgekontrolle und keinen Webhook. Die Fälligkeit der Folgekontrolle gilt ab dem Datum der Maßnahme. `update` sperrt die Zeile und liest „vorher“ danach über `tx`; die Behandlung sperrt beim Anlegen das Volk und liest es über `tx`, bei Ernten läuft die Gewichtsprüfung unter der Sperre. -**Völker.** `ColoniesService` und `WinterChecksService` schreiben Audit, Datenänderung und Ereignis in einer Transaktion. Ändert sich `status` oder `healthStatus` (update, merge je vereinigtem Volk, dissolve, reactivate, Auswinterungsverlust mit `markColonyDeceased`), entsteht `ColonyStatusChanged { from, to }`; beim Übergang von lebend nach beendet (`DISSOLVED`, `SOLD`, `DECEASED`, `MERGED`) zusätzlich `ColonyEnded { status }`. `automation.colony-health` hört auf `ColonyStatusChanged`. Die Operationen sperren das Volk (`lockColony`: Sperre, dann Neulesen über `tx`) und prüfen darunter, ob es noch lebt; `merge` sperrt alle vereinigten Völker mit einer Anweisung (`lockRowsForUpdate`). `colony.created` bleibt im Altpfad `WebhooksService.dispatch` (Annahme 11). +**Völker.** `ColoniesService` und `WinterChecksService` schreiben Audit, Datenänderung und Ereignis in einer Transaktion. Ändert sich `status` oder `healthStatus` (update, merge je vereinigtem Volk, dissolve, reactivate, Auswinterungsverlust mit `markColonyDeceased`), entsteht `ColonyStatusChanged { from, to }`; beim Übergang von lebend nach beendet (`DISSOLVED`, `SOLD`, `DECEASED`, `MERGED`) zusätzlich `ColonyEnded { status, endedOn, releasedQueenId, via }` (Abschnitt 11.7). `automation.colony-health` hört auf `ColonyStatusChanged`. Die Operationen sperren das Volk (`lockColony`: Sperre, dann Neulesen über `tx`) und prüfen darunter, ob es noch lebt; `merge` sperrt alle vereinigten Völker mit einer Anweisung (`lockRowsForUpdate`). `colony.created` bleibt im Altpfad `WebhooksService.dispatch` (Annahme 11). Das Teilen schreibt `ColonySplit`, die Königin-Zuordnung `QueenAssigned` in ihrer Transaktion (Handler-Übersicht unten); Kalender, Webhook und Ketten folgen aus diesen Ereignissen. **Bestand und Abfüllung.** Bestandsbuchungen kündigen sich über `StockChanged { bottlingBatchIds, productIds, rawMaterialKinds }` an. Das Ereignis entsteht in derselben Transaktion wie Bewegung, Audit-Eintrag und Datenänderung, über `StockService.recordChange(tx, change, actingUserId, aggregate)`. Eine leere Änderung erzeugt kein Ereignis, jede ID und jede Sorte steht nur einmal darin. - Entnahme und Inventur hängen das Ereignis an die Bewegung (`BatchMovement`). Bei einer Rohstoff-Charge steht deren Sorte in `rawMaterialKinds`. @@ -281,11 +300,35 @@ Daraus folgen die Webhooks `batch.updated` und `stock.changed` (`webhooks.forwar **Tests.** e2e-Tests haben keinen Worker. Sie rufen nach der Anfrage `drainDomainEvents(app, [aggregateId])` aus `test/utils/domain-events.ts` auf. Das arbeitet nur die Ereignisse dieser Korrelationen ab, sodass parallele e2e-Dateien sich nicht stören. +**Neue und geänderte Ereignisse.** `ColonySplit { parentColonyId, lineageId, queenSource | null, splitOn }` (Aggregat: das neue Volk) schreibt `ColoniesService.split` in der Teil-Transaktion. `QueenAssigned { colonyId, historyId, assignedOn, previousColonyId | null }` (Aggregat: die Königin) schreibt `QueensService.assignColony` nach dem Sperren beider Völker (eine Anweisung, aufsteigend nach id) und der Königin; ein geänderter vorheriger Stand zwischen Vorab-Lesung und Sperre ergibt 409. `ColonyEnded` trägt jetzt `{ status, endedOn, releasedQueenId, via }` (Abschnitt 11.7). `InspectionOverdue`, `VarroaMeasurementDue` und `QueenAged` haben zusätzliche, optionale Payload-Felder (Abschnitt „Jobs“, `daily-evaluation`). + +**Handler-Übersicht (T4).** Alle Handler laufen nach dem Vertrag oben (eigene Transaktion, nur über `tx` schreiben, kein Netzwerk). Die Handler der Ketten, des Autopilots und der Wartezeit prüfen zuerst das Volk: Zeile sperren (`lockRowForUpdate`; fehlt es, ist nichts zu tun), Status über `tx` neu lesen (beendet: nichts), Regel über `readRule(tx, …)` lesen (abgeschaltet: nichts vorschlagen). + +| Handler-ID | Ereignis | Wirkung | +|---|---|---| +| `automation.inspection-rules` | `InspectionRecorded`, `InspectionCorrected` | Regeln `QUEENLESS_NO_EGGS`, `FEED_STATUS_CRITICAL`: bei `InspectionRecorded` Vorschlag oder Nachführen offener Vorschläge, bei `InspectionCorrected` nur Nachführen (Annahme 12). | +| `automation.varroa-threshold` | `VarroaMeasured`, `VarroaCorrected` | `VARROA_THRESHOLD`: nur rot (außerhalb des Behandlungseffekts) schlägt `VARROA_RED` vor; bei `VarroaCorrected` wird der offene Vorschlag derselben Messung aufgefrischt (noch rot) oder aufgelöst. | +| `automation.colony-health` | `ColonyStatusChanged` | `COLONY_HEALTH_CRITICAL`, Anker = Ereignis-ID. | +| `automation.treatment-follow-up`, `automation.feeding-follow-up`, `automation.raw-material-low` | `TreatmentRecorded`, `FeedingRecorded`, `StockChanged` | Unverändert ausgelöst, schreiben jetzt Vorschläge (Annahme 12). | +| `suggestions.colony-ended` | `ColonyEnded` | Setzt offene Vorschläge des Volkes auf `OBSOLETE`; bei offenen Aufgaben oder einer freigegebenen Königin mit Status `ACTIVE` legt er den Aufräum-Vorschlag an (Abschnitt 11.7). | +| `chains.queen-cells` | `InspectionRecorded` | Erledigt zuerst offene Ketten-Vorschläge (Notiz „Mit Durchsicht erledigt“): `SWARM_CHECK` bei Weiselzellen alle, sonst die bis zum Durchsichtstag fälligen; `QUEEN_ACCEPTANCE` (fällig bis Durchsichtstag) nur bei `queenSeen` oder `eggsPresent`. Bei `queenCellsPresent = true` und aktiver Regel `QUEEN_CELLS_SWARM_CHECK`: PLAN `SWARM_CHECK` (`inspections:read`), Anker Durchsicht-ID, fällig Durchsichtstag + `dueInDays` (7). | +| `chains.queen-acceptance` | `QueenAssigned` | Regel `QUEEN_ACCEPTANCE_CHECK` aktiv: PLAN `QUEEN_ACCEPTANCE` (`queens:read`), Anker `historyId`, fällig `assignedOn` + `dueInDays` (7). | +| `chains.split-schedule` | `ColonySplit` | Regel `SPLIT_SCHEDULE` aktiv und `queenSource` gesetzt: `RAISE_OWN` und `QUEEN_CELL` → PLAN `SPLIT_PLAN` (`colonies:read`), `QUEEN` → PLAN `QUEEN_ACCEPTANCE` (`queens:read`); Anker `lineageId`, Plan = Schritte der Variante ab `splitOn` (Abschnitt 11.6). | +| `autopilot.colony-sync` | 19 Typen: `InspectionRecorded/Corrected`, `VarroaMeasured/Corrected`, `TreatmentRecorded/Corrected`, `FeedingRecorded/Corrected`, `HarvestRecorded/Corrected`, `ColonyStatusChanged`, `ColonySplit`, `QueenAssigned`, `TaskCompleted`, `TaskUpdated`, `WithdrawalEnded`, `QueenAged`, `InspectionOverdue`, `VarroaMeasurementDue` | Ruft `AutopilotService.syncColonyIn(tx, colonyId, berlinToday())` je betroffenem Volk in aufsteigender ID-Reihenfolge: bei Ernten jedes Volk, bei `ColonySplit` neues und Muttervolk, bei `QueenAssigned` Volk und vorheriges Volk; Aufgaben-Ereignisse nur mit `relatedType = COLONY` (Abschnitt 11.5). | +| `withdrawal.harvest-check` | `HarvestRecorded`, `HarvestCorrected` | Prüft Wartezeit-Konflikte der Ernte und schreibt die Meldung (Abschnitt 11.8). | +| `withdrawal.bottling-check` | `BatchBottled`, `BottlingCorrected` | Wie oben für die Honigcharge der Abfüllung; bei `removed: true` nichts. | +| `withdrawal.treatment-check` | `TreatmentRecorded`, `TreatmentCorrected` | Prüft Ernten des Volkes, die das (nachträglich erfasste oder verlängerte) Wartezeitfenster treffen. | +| `notifications.withdrawal-ended` | `WithdrawalEnded` | INFO „ ist wieder erntefähig“ (Abschnitt 11.8). | +| `notifications.task-due` | `TaskDue` | Erinnerung je fälliger Aufgabe (Abschnitt 11.9). | +| `calendar-sync.queen-assignment` | `QueenAssigned` | `enqueueIn(tx, 'QUEEN_ASSIGNMENT', historyId)`. | +| `calendar-sync.withdrawal` | `TreatmentRecorded`, `TreatmentCorrected` | `enqueueIn(tx, 'WITHDRAWAL', treatmentId)` (Abschnitt 11.8). | +| `webhooks.forward` | erweitert um `QueenAssigned` | `queen.replaced` (siehe „Webhook-Zustellung“). | + ### Webhook-Zustellung **Einreihen.** `WebhooksService.enqueueDeliveries(db, event, payload)` legt je aktivem Abonnement des Webhook-Ereignisses eine `WebhookDelivery` an und weckt den Worker mit `pg_notify('webhook_delivery', '')`. Gesendet wird dabei nichts. Der Body `JSON.stringify({ event, data })` wird beim Einreihen festgehalten, damit jede Wiederholung dieselben Bytes sendet. Mit einem Transaktions-Client committen Zeilen und Wecken zusammen mit dem Aufrufer. Zwei Wege führen dorthin: -- Der Handler `webhooks.forward` (`src/webhooks/webhook.handlers.ts`) übersetzt Ereignisse nach `DOMAIN_EVENT_WEBHOOKS`: `InspectionRecorded` → `inspection.created`, `VarroaMeasured` → `varroa.measured`, `TreatmentRecorded` → `treatment.created`, `FeedingRecorded` → `feeding.created`, `HarvestRecorded` → `harvest.created`, `BatchBottled` und `BottlingCorrected` → `batch.updated`, `StockChanged` → `stock.changed`, `TaskCreated` → `task.created`, `TaskCompleted` → `task.completed`. `data` ist wie bisher der Datensatz, gelesen über `tx`: die Ernte mit ihren Völkern, bei `batch.updated` die Honigcharge, bei `stock.changed` nur `{ bottlingBatchIds, productIds }` und nur, wenn eine der Listen nicht leer ist. Fehlt der Datensatz, wird nichts eingereiht. -- Der Altpfad `WebhooksService.dispatch(event, payload)` für Erzeuger, die noch kein Ereignis schreiben (`colony.created`, `batch.created`, `queen.created`, `queen.replaced`, `product.stock_changed`), reiht nach dem Commit über `runAfterCommit` ein und wirft nie. +- Der Handler `webhooks.forward` (`src/webhooks/webhook.handlers.ts`) übersetzt Ereignisse nach `DOMAIN_EVENT_WEBHOOKS`: `InspectionRecorded` → `inspection.created`, `VarroaMeasured` → `varroa.measured`, `TreatmentRecorded` → `treatment.created`, `FeedingRecorded` → `feeding.created`, `HarvestRecorded` → `harvest.created`, `BatchBottled` und `BottlingCorrected` → `batch.updated`, `StockChanged` → `stock.changed`, `TaskCreated` → `task.created`, `TaskCompleted` → `task.completed`, `QueenAssigned` → `queen.replaced`. `data` ist wie bisher der Datensatz, gelesen über `tx`: die Ernte mit ihren Völkern, bei `batch.updated` die Honigcharge, bei `queen.replaced` die Königin, bei `stock.changed` nur `{ bottlingBatchIds, productIds }` und nur, wenn eine der Listen nicht leer ist. Fehlt der Datensatz, wird nichts eingereiht. +- Der Altpfad `WebhooksService.dispatch(event, payload)` für Erzeuger, die noch kein Ereignis schreiben (`colony.created`, `batch.created`, `queen.created`, `product.stock_changed`), reiht nach dem Commit über `runAfterCommit` ein und wirft nie. **Zustellung.** Die Worker-Schleife `webhook-deliveries` (alle 5 s und auf `webhook_delivery`) ruft `WebhookDeliveryWorker.releaseStaleClaims()` und dann `drain()` auf. Claim, Abschluss und Freigabe laufen über `outbox-claims.ts`: höchstens 50 fällige Zustellungen je Runde, älteste zuerst (`createdAt`), höchstens 20 Runden je Takt. Höchstens 5 Zustellungen laufen gleichzeitig. Jede wird mit Zeitstempel und ihrer ID signiert (`X-ApiCore-Signature: sha256=HMAC(secret, "..")`) und über `postWebhook` gesendet: keine Weiterleitungen, Ziel-IP beim Verbindungsaufbau geprüft, 5 s Leerlauf-Timeout und eine Frist von 10 s je Versuch. Bei Ablauf der Frist wird die Anfrage samt Verbindung abgebrochen, auch wenn der Empfänger seine Antwort nur tröpfchenweise sendet; so hält er weder einen der 5 Plätze noch einen Socket. `X-ApiCore-Delivery` ist die `WebhookDelivery.id` und bleibt über alle Wiederholungen gleich. Zugestellt wird mindestens einmal; Empfänger verwerfen Duplikate anhand dieser ID. @@ -312,20 +355,159 @@ Das Ergebnis zählt jede Kategorie (`idempotencyRecords`, `consumedTokens`, `thr **`task-templates`** (täglich 05:00) ruft `TasksService.generateFromTemplates()` auf und meldet `{ created }`. Wegen des Compare-and-Swap je Vorlage entsteht jedes Vorkommen genau einmal, auch bei zwei Läufen oder parallel zur Lazy-Erzeugung vor `GET /tasks`. -**`daily-evaluation`** (täglich 04:30) erzeugt die zeitbasierten Ereignisse. Regeln und Werte stehen in `src/jobs/daily-evaluation.rules.ts`, eine Zeile je Typ in `DAILY_EVALUATION_RULES`; jede Regel nennt je Vorkommen das Ereignis und den UTC-Tag des fachlichen Ereignisses: -- `WithdrawalEnded` (Aggregat `Treatment`, Payload `colonyId`, `endedOn`): `(endDate ?? date) + withdrawalPeriodDays`, nur mit Wartezeit. -- `TaskDue` (`Task`, `dueOn`): `dueDate` offener Aufgaben (`OPEN`, `IN_PROGRESS`). -- `QueenAged` (`Queen`, `hatchYear`, `colonyId`): Königin `ACTIVE` mit `Jahr − hatchYear ≥ 2` (`QUEEN_AGED_MIN_YEARS`), Tag `-01-01`, also höchstens einmal im Jahr und nur im Fenster 1.–8. Januar; wer später im Jahr `ACTIVE` wird oder sein `hatchYear` erhält, bekommt in diesem Jahr kein Ereignis. -- `VarroaMeasurementDue` (`Colony`, `dueOn`, `lastMeasuredOn`) und `InspectionOverdue` (`Colony`, `dueOn`, `lastInspectedOn`): lebende Völker (`LIVING_COLONY_WHERE`), letzte Messung bzw. Durchsicht plus k × 28 bzw. 21 Tage (k ≥ 1; ohne Eintrag ab `createdAt`), jeweils das späteste solche Datum bis heute. Nur wenn sein Monat in März–Oktober liegt (`ACTIVE_SEASON_MONTHS`); ein Vorkommen in der Winterpause entfällt, es wird nicht auf den 1. März verschoben. - -„Heute“ ist der Tag der DB-Uhr (`SELECT now()`). Aufgezeichnet wird jedes Vorkommen, dessen Tag im Nachholfenster `[heute − 7, heute]` liegt (`CATCH_UP_DAYS`), mit `dedupeKey = "::"` (`dailyDedupeKey`). Jeder Lauf im Fenster wiederholt denselben Schlüssel, der eindeutige `dedupeKey` (`recordAll`, `skipDuplicates`) hält ein Ereignis je Vorkommen: ausgefallene Läufe bis 7 Tage werden nachgeholt, wiederholte erzeugen nichts. Je Typ eine Transaktion; das Ergebnis zählt die neuen Ereignisse je Typ. Scheitert eine Regel, laufen die übrigen weiter und ihre Ereignisse bleiben; der Lauf scheitert dann mit `Daily evaluation failed for: `. Handler für diese fünf Ereignisse gibt es noch nicht (spätere Stufe); bis dahin werden sie ohne Wirkung `DONE`, und `cleanup` löscht sie nach 30 Tagen. +**`daily-evaluation`** (täglich 04:30) führt feste Schritte in dieser Reihenfolge aus (`DAILY_EVALUATION_STEP_ORDER`, `runDailyEvaluationSteps` in `src/jobs/daily-evaluation-steps.ts`): `phenology`, `time-events`, `autopilot`, `suggestions-expiry`, `digest`. „Heute“ ist der Berliner Tag (`berlinToday()`, Annahme 41). Der Schritt `phenology` (Wetter, gemeldete Phasen) gehört zur Wetter-Stufe; sein Platz in der Reihenfolge steht schon fest. Ein scheiternder Schritt hält die übrigen nicht auf: Der Lauf endet `FAILED` mit allen Zählern (`JobPartialFailure`, `JobRun.result` bleibt erhalten), `error` nennt Schritt und Ursache (`Daily evaluation failed for: time-events: TaskDue`, bei Völkern `autopilot: 2 colonies`). +- **`time-events`** (`src/jobs/steps/time-events.step.ts`) erzeugt die zeitbasierten Ereignisse. Regeln und Werte stehen in `src/jobs/daily-evaluation.rules.ts`, eine Zeile je Typ in `DAILY_EVALUATION_RULES`; jede Regel nennt je Vorkommen das Ereignis und den Tag des fachlichen Ereignisses. Je Typ eine Transaktion; scheitert eine Regel, laufen die übrigen weiter, und der Schritt wirft `JobPartialFailure` mit den Zählern der anderen. Das Ergebnis zählt die neuen Ereignisse je Typ. + - `WithdrawalEnded` (Aggregat `Treatment`, Payload `colonyId`, `endedOn`): Ende des Wartezeitfensters über den Fenster-Helfer (Berliner Tag, Abschnitt 11.8), nur mit Wartezeit. Die SQL-Kandidatenabfrage nimmt einen Tag Rand auf jeder Seite, der Helfer entscheidet. + - `TaskDue` (`Task`, `dueOn`): `dueDate` offener Aufgaben (`OPEN`, `IN_PROGRESS`). + - `QueenAged` (`Queen`, `hatchYear`, `colonyId`): jeden Jobtag für lebende Völker, deren aktuelle Königin `ACTIVE` und gealtert ist (`isQueenAged`, Annahme 37); Schlüssel `QueenAged::-01-01` (`anchorDay`), also höchstens einmal je Königin und Jahr. Beim ersten Lauf nach dem Rollout bekommt jedes lebende Volk mit schon gealterter Königin einmal ein Ereignis. + - `VarroaMeasurementDue` (`Colony`, `dueOn`, `lastMeasuredOn`, optional `kind`, `anchor`): am Fälligkeitstag aus `varroaMeasurementDue` (Abschnitt 11.3). + - `InspectionOverdue` (`Colony`, `dueOn`, `lastInspectedOn`, optional `firstOfYear`): am Tag nach `inspectionDueOn`; `dueOn` im Payload ist der echte Fälligkeitstag, nicht der Ereignistag (Abschnitt 11.3). Eine feste Märzmeldung entfällt. + - Die Ereignisse sind Tatsachen: `active` einer Regel (`INSPECTION_INTERVAL`, `SEASON_AUTOPILOT`) unterdrückt nur Vorschläge, nicht das Ereignis. Es entsteht ein Ereignis je Fälligkeitstag; ein Wechsel des Abschnitts-Intervalls ergibt einen neuen Fälligkeitstag und damit ein zweites Ereignis. + - Aufgezeichnet wird jedes Vorkommen, dessen Tag im Nachholfenster `[heute − 7, heute]` liegt (`CATCH_UP_DAYS`), mit `dedupeKey = "::"` (`dailyDedupeKey`). Jeder Lauf im Fenster wiederholt denselben Schlüssel, der eindeutige `dedupeKey` (`recordAll`, `skipDuplicates`) hält ein Ereignis je Vorkommen: Ausfälle des Jobs bis 7 Tage werden nachgeholt, wiederholte Läufe erzeugen nichts. Wer länger überfällig ist, bekommt kein neues Ereignis; die Erinnerung tragen dann die Autopilot-Regeln A1 und A2. + - Handler: `TaskDue` → `notifications.task-due`; `WithdrawalEnded` → `notifications.withdrawal-ended` und `autopilot.colony-sync`; `QueenAged`, `InspectionOverdue`, `VarroaMeasurementDue` → `autopilot.colony-sync`. +- **`autopilot`** (`AutopilotStep`): lädt Regeln und Saisonzustände einmal vorab (`preloadFor`) und gleicht dann jedes lebende Volk in einer eigenen Transaktion ab (`syncColonyIn`, Abschnitt 11.5). Zähler `autopilotColonies`, `suggestionsOpened`, `suggestionsResolved`. Scheitert ein Volk, läuft der Schritt weiter und endet mit `JobPartialFailure`. +- **`suggestions-expiry`**: `OPEN` mit `expiresAt` vor heute → `EXPIRED`, gibt den `dedupeKey` frei. Zähler `suggestionsExpired`; zweimal am selben Tag ändert nichts. +- **`digest`**: schreibt die Morgenübersicht (Abschnitt 11.9). Zähler `digestsWritten`; ein Wiederholungslauf am selben Tag schreibt 0. **`app-backup`** (stündlich) prüft, ob ein Backup fällig ist, und schreibt es dann: siehe Abschnitt 8, „Automatischer Zeitplan“. **`rekey`** (nur von Hand, CronJobs `apicore-job-rekey` und `apicore-job-rekey-dry-run` mit `suspend: true`): siehe Annahme 22. -**Tests.** `test/jobs.e2e-spec.ts` ruft `runCli([...])` mit dem echten `JobsModule` auf, wie `node dist/cli.js`. `test/daily-evaluation.e2e-spec.ts` ruft `DailyEvaluationJob.evaluate(Tag)` mit Tagen im Jahr 2001 auf: Der Job wertet die ganze geteilte Test-DB aus, so bleiben die Ereignisse paralleler e2e-Dateien unberührt (und es entstehen keine `JobRun`-Zeilen, die `jobs.e2e-spec.ts` zählt). `test/rekey.e2e-spec.ts` setzt nur `*_PREVIOUS`, weil der Job jede Zeile der geteilten Test-DB besucht und die anderen e2e-Prozesse mit denselben aktuellen Schlüsseln weiterlesen müssen. +**Tests.** `test/jobs.e2e-spec.ts` ruft `runCli([...])` mit dem echten `JobsModule` auf, wie `node dist/cli.js`. `test/daily-evaluation.e2e-spec.ts` ruft `TimeEventsStep.run(Tag)` mit Tagen in den Jahren 2001 und 2002 auf: Der Schritt wertet die ganze geteilte Test-DB aus, so bleiben die Ereignisse paralleler e2e-Dateien unberührt (und es entstehen keine `JobRun`-Zeilen, die `jobs.e2e-spec.ts` zählt). `test/rekey.e2e-spec.ts` setzt nur `*_PREVIOUS`, weil der Job jede Zeile der geteilten Test-DB besucht und die anderen e2e-Prozesse mit denselben aktuellen Schlüsseln weiterlesen müssen. ### System-Seite Die Seite „Verwaltung > System“ (Rechte `system:read` zum Ansehen, `system:manage` zum Wiederholen; nur Systemrollen) zeigt je Job den letzten Lauf (`JobRun`), die Warteschlange der Domänenereignisse (offen, in Arbeit, fehlgeschlagen, Alter des ältesten offenen Eintrags nach DB-Uhr) und die offenen und fehlgeschlagenen Webhook-Zustellungen. Routen: `GET /system/overview`, `GET /system/job-runs?job=&limit=1..100` (Standard 50), `GET /system/domain-events/failed`, `POST /system/domain-events/:id/retry` (204), `GET /system/webhook-deliveries/failed`, `POST /system/webhook-deliveries/:id/retry` (204). Wiederholen ist nur für `FAILED` möglich (409 sonst, 404 unbekannt): Versuche und `availableAt` werden in SQL (`now()`) zurückgesetzt, fehlgeschlagene Handler-Zustellungen eines Ereignisses wieder `PENDING`, ein Audit-Eintrag (UPDATE) geschrieben und der Worker per `pg_notify` geweckt. Die Ansichten enthalten nie Payload, Webhook-Body, Secrets oder vollständige URLs (nur den Host); `lastError` steht wie gespeichert (`loggableError()`). API-Schlüssel erreichen `/system` nie (Scope-Allowlist). + +## 11. Logik-Ketten und Saison-Autopilot (T4a) + +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`. Nicht Teil dieser Lieferung sind die Bestätigung beim Wassergehalt (Regel `HONEY_WATER_CONTENT`, T4b), Wetter und gemeldete Pflanzenphasen, die Autopilot-Regeln A4–A10 und die Telegram-Zustellung (T4c). + +### 11.1 Vorschläge + +**Modell.** `Suggestion` hat u. a. Quelle (`RULE`, `CHAIN`, `AUTOPILOT`), `ruleKey`, Absicht, Art (`PLAN`, `DO`), Priorität, Status (`OPEN`, `ACCEPTED`, `DISMISSED`, `RESOLVED`, `EXPIRED`, `OBSOLETE`), `dedupeKey`, `reasonCode`, `evidence`, `dueDate`, `expiresAt`, `requiredPermission`, `decidedAt` und `decidedByUserId`. Absichten: `INSPECT`, `SWARM_CHECK`, `QUEEN_CHECK`, `QUEEN_ACCEPTANCE`, `QUEEN_REPLACE`, `SPLIT_PLAN`, `VARROA_MEASURE`, `VARROA_RECHECK`, `VARROA_TREAT`, `SUMMER_TREATMENT`, `FEED`, `HONEY_SUPER`, `WINTER_CHECK`, `HEALTH_CHECK`, `FOLLOW_UP`, `STOCK_REORDER`, `CLEANUP`. `evidence` trägt nur IDs, Zahlen und Tage. Anker, Dubletten und die Regel „eine offene Sache je Absicht“: Annahme 31. + +**PLAN und DO.** `PLAN` hat mehrere Termine oder ist heute fällig; Übernehmen legt je Schritt eine Aufgabe an (Basisdatum aus dem Beleg plus Versatz, Titel aus `PLAN_STEP_TITLES`, ein Fensterschritt trägt „Zeitfenster bis TT.MM.“). Ein gewähltes Datum verschiebt den ganzen Plan, sodass Schritt 1 darauf liegt; ohne Plan und Datum ist die Aufgabe heute (Berliner Tag) fällig. `DO` ist genau eine Aufgabe und braucht ein Datum (eigenes oder `dueDate` des Vorschlags), sonst 400 „Bitte ein Datum wählen.“. „Als Aufgabe planen“ ist Übernehmen mit Datum. Titel und Beschreibung kommen aus `renderSuggestion` mit dem `actionTemplate` der Regel. Die Aufgabe entsteht über `TasksService.createInTx` (Audit, `TaskCreated`, danach Kalender und Webhook). `sourceInspectionId` wird nur gesetzt, wenn die Durchsicht aus dem Beleg noch existiert (unter `FOR SHARE` geprüft). Übernehmen für ein beendetes oder unbekanntes Volk: 409 „Das Volk ist nicht mehr aktiv.“; ein `assignedUserId`, der nicht existiert oder inaktiv ist: 400 mit `issues`. + +**Übergänge.** Jede Entscheidung sperrt die Zeile `Suggestion` (Sammelrouten in id-Reihenfolge), liest „vorher“ danach, prüft den Status (409 `{ message: 'Der Vorschlag ist nicht mehr offen.', status }`) und setzt ihn per Compare-and-Set in SQL gegen `now()`; der Audit-Eintrag (`Suggestion UPDATE`) steht in derselben Transaktion. Entscheidungen sperren nie das Volk; `proposeIn` sperrt zuerst die Volkszeile, dann die Regelzeile (`LOCK_ORDER` in `row-lock.ts`: Colony, Suggestion, Task, Queen, AutomationRule, …, NotificationPreference). + +| von | nach | Auslöser | +|---|---|---| +| – | `OPEN` | Handler, Job oder Abgleich (`proposeIn`) | +| `OPEN` | `ACCEPTED` | „Übernehmen“ (legt Aufgaben an) | +| `OPEN` | `DISMISSED` | „Verwerfen“ | +| `DISMISSED` | `OPEN` | „Rückgängig“ oder „Wieder öffnen“, bis 30 Tage nach `decidedAt`; sonst 409 | +| `OPEN` | `RESOLVED` | Bedingung entfällt (Nachführen, Abgleich, Kette), Aufräumen ausgeführt; gibt den Schlüssel frei | +| `OPEN` | `EXPIRED` | Schritt `suggestions-expiry`; gibt den Schlüssel frei | +| `OPEN` | `OBSOLETE` | Volk beendet (Abschnitt 11.7) | + +**Texte.** Titel und Gründe werden beim Lesen aus `reasonCode` und `evidence` gerendert (`renderSuggestion`, `src/suggestions/suggestion-texts.ts` mit den Registern `texts/rule-texts.ts`, `chain-texts.ts`, `autopilot-texts.ts`, `cleanup-texts.ts`): höchstens 2 Gründe zu je 90 Zeichen, deutsch formatiert; ein doppelter Code im Register wirft, ein Test prüft jede Vorlage. Der `actionTemplate` einer Regel überschreibt nur den Titel. Eine Liste rendert alle Zeilen mit einer Abfrage je Modell (`buildEvidenceLookup`). + +**API** (alle `@Idempotent()`; derselbe `Idempotency-Key` gibt die erste Antwort wieder, ein zweites Übernehmen mit anderem Schlüssel antwortet 409): + +| Route | Recht | Zweck | +|---|---|---| +| `GET /suggestions` | `tasks:read` | Liste; Filter `status` (Standard `OPEN`; `DISMISSED` nur 30 Tage nach `decidedAt`, Datenbankuhr), `colonyId`, `apiaryId`, `limit` 1–500 (200), `offset`; Reihenfolge `dueDate` (ohne Datum zuletzt), Priorität, `createdAt`, `id` | +| `GET /suggestions/summary` | `tasks:read` | Zähler (offen, je Priorität); Liste und Zähler teilen den Sichtbarkeitsfilter in der Abfrage (Annahme 32) | +| `POST /suggestions/:id/accept`, `/bulk-accept` | `tasks:create` | Übernehmen mit optional `dueDate` (`JJJJ-MM-TT`) und `assignedUserId`; Antwort `{ suggestion, tasks }`; Sammelroute: 1–50 verschiedene IDs, alles oder nichts, 400 mit den DO-IDs ohne Datum (`issues[{ field: 'ids', ids }]`) | +| `POST /suggestions/:id/dismiss`, `/bulk-dismiss`, `/:id/reopen` | `tasks:update` | Verwerfen (Sammelroute `{ dismissed }`), Wieder öffnen (409, wenn für die Absicht schon etwas offen ist oder das Volk nicht mehr lebt) | +| `POST /suggestions/:id/apply-cleanup` | `tasks:update` | Aufräumen (Abschnitt 11.7) | + +Die Antworten sind die gerenderte `SuggestionView`; `evidence`, `dedupeKey`, `requiredPermission`, `decidedByUserId`, `reasonCode` und `sourceEventId` verlassen den Server nicht. Backup: `Suggestion` gehört zum versiegelten Bundle (Abschnitt 8). + +**Oberfläche.** Seite `/suggestions` (Navigation „Arbeit“, Recht `tasks:read`, Zähler aus `summary.open`, 60 s Aktualisierung in Seitenleiste und „Mehr“). Gruppen je Stand (nach Name), dann „Betrieb“; gleiche Absicht je Stand ist eine Zeile („X · n Völker“). „Runde starten“ (nur `INSPECT`) beginnt eine Standrunde mit den fälligen Völkern in Code-Reihenfolge, „Alle übernehmen“ gibt es nur, wenn jede Zeile der Gruppe `PLAN` ist (ein `bulk-accept`, bei mehr als 50 IDs in Teilen; bei Teilerfolg meldet die Seite „n von m angelegt“). Die Seite arbeitet nur online: Offline zeigt sie „Keine Verbindung – nichts gespeichert.“. Übernehmen und Menü brauchen `tasks:create`, Verwerfen und Wieder öffnen `tasks:update`. Verwerfen lässt sich 8 s per Toast und dauerhaft über „Verworfene (30 Tage)“ rückgängig machen. Jede Aktion sendet einen eigenen `Idempotency-Key`; nach jeder Entscheidung, auch im Fehlerfall, werden `suggestions`, `tasks` und `dashboard` neu geladen; die Fehlertexte des Servers erscheinen deutsch (404: „Der Vorschlag ist nicht mehr da.“). Nach einer Entscheidung wandert der Fokus zur nächsten Karte. „Erfassen“ öffnet das Formular zur Absicht (`captureHref`: Varroa → `/varroa/new`, `FEED` → `/feedings/new`, `WINTER_CHECK` → `/winter?colonyId=…&season=AUTUMN|SPRING` ab Juli Herbst, sonst Frühjahr, alles andere `/inspections/new`). Das Abzeichen an Aufgaben heißt „Aus Vorschlag“. + +### 11.2 Regel-Werte + +Zu den 13 bisherigen Regeln kommen neun Trigger: `INSPECTION_INTERVAL`, `QUEEN_CELLS_SWARM_CHECK`, `QUEEN_ACCEPTANCE_CHECK`, `SPLIT_SCHEDULE`, `COLONY_ENDED_CLEANUP`, `WITHDRAWAL_ENDED`, `HONEY_WATER_CONTENT`, `SEASON_AUTOPILOT`, `PHENOLOGY`; `VARROA_THRESHOLD` behält den Namen, die Config wird zum Bänder-Format. Jede Regel hat einen Standard (`default-rules.ts`) und einen Parser (`src/automation/rule-config/`, Leser `readRule`). Ein gespeicherter ungültiger Wert fällt auf den Standard zurück. Eine alte Varroa-Config `{ thresholdMitesPerDay }` wird als `naturalFall.redAbove` gelesen (liegt der Wert unter 5, sinkt das Gelb auf den Rotwert). `PATCH /automation-rules/:id` läuft in einer Transaktion unter der Zeilensperre der Regel, prüft die Config und antwortet bei ungültigen bekannten Schlüsseln mit 400 `{ message: 'Die Einstellung ist ungültig.', issues: [{ field, message }] }` (Pfad-Notation `a.b[1].c`). Unbekannte Schlüssel bleiben erhalten. Für `null` gilt Annahme 20. Verletzt eine Gruppe wie `PHENOLOGY.calendarFallback` oder `swarmSeason` ihre Grenzen, fällt die ganze Gruppe auf den Standard; ein fehlerhafter Eintrag in einer `SPLIT_SCHEDULE`-Liste setzt diese Liste zurück. + +Verwaltung > Automatisierung hat für die zehn Regeln mit eigenen Werten je ein Formular. Jede Zahl zeigt ihre Quelle als Text („LWG Veitshöchheim“, „Bieneninstitute (LWG, LLH Kirchhain)“, „Erfahrungswert“, „Annahme“). Da der Server beim PATCH die ganze Config ersetzt, sendet das Formular die vollständige Config (gespeicherte Werte über den Standards, nicht bearbeitete Teile unverändert); 400-`issues` erscheinen unter dem Feld, Meldungen ohne Feld als Hinweis. Alle Regeln mit Meldung haben den Schalter „Zusätzlich melden“ (`notify`). + +### 11.3 Saison und Pflanzenentwicklung + +`SeasonService.statesFor` liefert je Stand den Zustand aus der Regel `PHENOLOGY` (`SeasonModule`). Heute entsteht der Abschnitt allein aus dem Kalender-Rückfall (Annahme 36; ein nicht aufsteigender `calendarFallback` fällt auf den Standard). `springSince` ist der Tag, an dem `SALWEIDE` dieses Jahres erreicht ist, sonst `null`. + +- **Durchsicht fällig** (`inspectionDueOn`): letzte Durchsicht (ohne Eintrag die Anlage des Volkes) plus das Intervall des heutigen Abschnitts aus `INSPECTION_INTERVAL` (Schwarmzeit 7 Tage; die Oberfläche prüft 7–9 Tage für die Schwarmzeit, sonst 7–35, der Server entscheidet). Wer vor Beginn von `FRUEHJAHR` zuletzt durchgesehen wurde, ist ab Abschnittsbeginn fällig („Erste Durchsicht im Jahr“). In `WINTERRUHE` ist nie etwas fällig. Fällt der Termin in den Winter, rückt er auf den Frühjahrsbeginn. +- **Varroa-Messung fällig** (`varroaMeasurementDue`): letzte Messung (sonst Anlage) plus `varroaMeasureIntervalDays` (28); in `WINTERRUHE` einmal im Fenster 15.11.–20.12., wenn seit 01.11. nicht gemessen wurde (Winterfenster = Annahme nach LWG-Konzept, Restentmilbung im brutfreien Volk). Ein Fälligkeitstag, der in den Winter gefallen ist, rückt auf `springSince`. +- Die Tagesauswertung und die Autopilot-Regeln A1/A2 nutzen dieselben Funktionen (Abschnitt „Jobs“, 11.5). + +### 11.4 Varroa-Ampel nach Jahreszeit + +Quelle der Grenzen ist die LWG Veitshöchheim; die Zahlen stehen als Bänder in der Regel `VARROA_THRESHOLD` und sind änderbar (`src/automation/varroa-bands.ts`). + +- Natürlicher Milbenfall, März–Oktober: gelb ab 5, rot über 10 Milben/Tag; die Grenze selbst ist gelb (10,0 gelb, rot erst darüber). November–Februar: rot über 0,5, kein Gelb. Die Gültigkeit ab März ist eine Annahme. +- Puderzucker und Waschmethode: Mai–Juli gelb ab 1 %, August–Oktober gelb ab 2 %, rot über 5 %; November–April keine Ampel. Drohnenbrutentnahme: nie eine Ampel. +- Jungvölker (`youngColonyTypes`): die Werte des natürlichen Milbenfalls mal 0,5, im Winter also rot über 0,25; die Prozent-Bänder bleiben unverändert. +- Behandlungseffekt: Von Behandlungsbeginn bis `(endDate ?? date) + recheckAfterDays` (14) hat eine Messung keine Ampel. Der Text lautet „Behandlungseffekt – Nachkontrolle ab TT.MM.“. Alle Fenster (Wartezeit, Behandlungseffekt, Tagesregeln) rechnen über `src/treatments/withdrawal-window.ts` und `treatment-windows.ts` in Berliner Tagen (Annahme 41). +- Probe: `VarroaMeasurement.sampleBees` (1–2000) leitet bei Puderzucker und Waschmethode `calculatedValue = Milben / Probe × 100` ab, auf zwei Stellen gerundet (7203 Milben in 720 Tagen = 10 = gelb). Beim Ändern wird ein abgeleiteter Prozentwert gelöscht, wenn die Probe entfällt; ein von Hand gesetzter bleibt. Die Sammelerfassung erbt das Feld. +- API: `GET /varroa-measurements/thresholds?colonyId=&date=` liefert die Bänder des Tages samt `postTreatmentUntil` und `active` (Regel aus: `bands {}`, `active false`); mit `from` und `to` (höchstens 731 Tage, nicht zusammen mit `date`) liefert sie zusammenhängende Segmente mit gleichen Bändern. Recht `varroa:read` oder `varroa:create`; der Behandlungshinweis ist damit bewusst für die Messung sichtbar (keine Standrechte im System). Unbekanntes Volk 404, ungültiger Tag 400. Die Route `/varroa-measurements/threshold` und der alte Schwellen-Dienst entfallen. `GET /colonies/:id/varroa-measurements` trägt je Messung ein `rating`. +- Regel: Nur rot (außerhalb des Behandlungseffekts) schlägt `VARROA_RED` vor, jetzt auch für die Prozent-Methoden. `VARROA_TREAT` entsteht nur aus dieser Regel, nicht aus dem Autopilot. +- Oberfläche: Die Erfassung zeigt Wert, Wort (Gut, Achtung, Kritisch) und Grenze mit Quelle in einem durchgehenden Statusbereich; das Probenfeld (Chip „500 Bienen (≈ 50 g)“, nie vorbelegt, Zeitraum entfällt bei Probe-Methoden) prüft 1–2000 vor dem Senden und der Warteschlange. Der Trend zeichnet die Bänder als Stufenlinien (eine Bereichsabfrage für bis zu 24 Monate; Messungen liegen am Berliner Kalendertag). Die Bänder-Anfragen (`["varroa","thresholds"]`) werden nach Behandlungsänderungen und dem Speichern der Regel neu geladen. Offline gibt es keine Bänder, nur den Wert. + +### 11.5 Saison-Autopilot und „Die nächsten Schritte“ + +**Abgleich.** `AutopilotService.syncColonyIn(tx, colonyId, today)` (`AutopilotModule`) sperrt die Volkszeile, liest die Eingaben in fester Abfragefolge (`loadAutopilotInputIn`) und die Regeln (`SEASON_AUTOPILOT`, `INSPECTION_INTERVAL`, `VARROA_THRESHOLD` nur `recheckAfterDays`, `PHENOLOGY`). Ein beendetes Volk wird übersprungen (das Aufräumen ist `suggestions.colony-ended`). Ist `SEASON_AUTOPILOT` aus, werden alle offenen Autopilot-Vorschläge `RESOLVED` („Autopilot aus“). Sonst: Offene Autopilot-Vorschläge, deren `dedupeKey` nicht mehr vorkommt, werden `RESOLVED` („Bedingung entfällt“); bei gleichem Schlüssel frischt `refreshIn` Belege, Fälligkeit, Priorität und `reasonCode` auf; ein neuer Kandidat geht über `proposeIn` (verworfen, wenn für das Volk schon etwas derselben Absicht offen ist). Regel- und Kettenvorschläge fasst der Abgleich nicht an. Kandidaten kommen immer in der Reihenfolge A1 bis A10; `rules.A = false` schaltet eine Regel ab, `INSPECTION_INTERVAL.active = false` nur A1. Der Handler `autopilot.colony-sync` und der Job-Schritt `autopilot` rufen ihn auf (Abschnitte 10 und „Jobs“). + +**Regeln A1–A3** (`src/autopilot/autopilot-rules.ts`, reine Funktion `evaluateAutopilot`; A4–A10 folgen später): + +| Nr | Absicht | Wann | Anker | Fälligkeit, Priorität | +|---|---|---|---|---| +| A1 | `INSPECT` | Abschnitt ≠ `WINTERRUHE`, `inspectionDueOn` ≤ heute + 2 (`INSPECTION_DUE`, bzw. `INSPECTION_FIRST_OF_YEAR`) | ID der letzten Durchsicht oder `none` | `inspectionDueOn`; überfällig = `HIGH`, sonst `NORMAL` | +| A2 | `VARROA_MEASURE` | `varroaMeasurementDue` ≤ heute (`VARROA_MEASURE_DUE`, im Winterfenster `VARROA_WINTER_MEASURE`) | ID der letzten Messung oder `winter-` | Fälligkeitstag; wird von A3 unterdrückt, solange A3 gilt | +| A3 | `VARROA_RECHECK` | Behandlungsende + `recheckAfterDays` ≤ heute, keine Messung nach dem Ende, Ende höchstens 60 Tage her (Annahme 38); `requiredPermission` `varroa:read` | Behandlungs-ID | Tag, ab dem die Bedingung gilt | + +Als „letzte Behandlung“ zählt das späteste Ende (`endDate ?? date`); eine Messung am Endetag zählt nicht, am Tag danach schon. Ablauf `expiresAt` siehe Annahme 38. + +**Nächste Schritte.** `NextStepsService` liefert `GET /colonies/:id/next-steps` (`ColonyNextSteps`, höchstens `maxStepsPerColony` = 3) und `GET /autopilot/today` (`AutopilotToday`: dringlichster Schritt über alle lebenden Völker mit demselben Vergleich, fällige Durchsichten je Stand, Zahl offener Vorschläge, Abschnitt je Stand); beide `colonies:read`, 404 bei unbekanntem Volk. Die Zahl der Abfragen ist unabhängig von der Zahl der Völker. +- Schritte = offene Aufgaben (`OPEN`, `IN_PROGRESS`) plus sichtbare offene Vorschläge, nur mit `tasks:read` (ohne: keine Schritte). Ein offener Vorschlag entfällt, wenn für das Volk eine offene Aufgabe derselben Absicht besteht. +- Rang (deterministisch): Dringlichkeit (`IN_PROGRESS`, überfällig oder heute = 0; in ≤ 3 Tagen = 1; ≤ 14 = 2; später = 3; ohne Datum = 4; ohne Datum und `URGENT` = 0), Priorität, feste Absichtsreihenfolge (`INTENT_ORDER`: `VARROA_TREAT`, `QUEEN_CHECK`, `QUEEN_ACCEPTANCE`, `SWARM_CHECK`, `SPLIT_PLAN`, `FEED`, `INSPECT`, `VARROA_RECHECK`, `HEALTH_CHECK`, `SUMMER_TREATMENT`, `VARROA_MEASURE`, `WINTER_CHECK`, `HONEY_SUPER`, `QUEEN_REPLACE`, `FOLLOW_UP`, `CLEANUP`, `STOCK_REORDER`; eine Aufgabe ohne Vorschlag zählt wie `FOLLOW_UP`), Fälligkeit, `createdAt`, `id`. Dringlichkeitsstufen im Schritt: `OVERDUE`, `TODAY` (auch eine laufende Aufgabe), `SOON`, `LATER`, `NONE`. Zeitstempel zählen als Berliner Tage. +- **Zusammenlegen** (Tabelle nach Absicht, nicht nach Formular): Die Durchsicht-Familie (`INSPECT`, `SWARM_CHECK`, `QUEEN_CHECK`, `QUEEN_ACCEPTANCE`) und die Varroa-Familie (`VARROA_MEASURE`, `VARROA_RECHECK`) werden je Volk zu einem Schritt, wenn mindestens ein Mitglied `DO` ist. Alle anderen Absichten (z. B. `HONEY_SUPER`, `FEED`, `VARROA_TREAT`, `WINTER_CHECK`) bleiben eigene Schritte, auch wenn sie dasselbe Formular öffnen; Aufgaben legen nie zusammen. Der Schritt sitzt beim bestplatzierten Mitglied, trägt die früheste Fälligkeit, zeigt höchstens zwei Gründe (das spezifische Mitglied zuerst), Titel „ mit “ (z. B. „Durchsicht mit Schwarmkontrolle“) und `suggestionIds` aller Mitglieder; `dismissible` ist wahr, wenn ein Mitglied ein Vorschlag ist (Verwerfen sendet alle IDs). +- Aktion: `CAPTURE` (Formular-Link; auch Ersatz, wenn `tasks:create` fehlt und die Absicht ein Formular hat), `ACCEPT` (nur mit `tasks:create`), `CLEANUP`, `OPEN_TASK` (`/tasks`). Ohne mögliche Aktion entfällt der Schritt. `withdrawalUntil` (nur mit `treatments:read`) und `inspectionDueOn` (nur mit `inspections:read`, `null` bei abgeschalteter Regel oder in der Winterruhe) sind Hinweise, keine Schritte. Das Erfassen einer Durchsicht erledigt Ketten-Vorschläge (`chains.queen-cells`) und führt Regel-Vorschläge nach (Annahme 12). +- `GET /apiaries/:id/colony-overview` trägt je Volk zusätzlich `occupiedFrames`, `inspectionDueOn`, `segment` (oder `null`) und `nextStepTitle`; der Offline-Pack enthält `nextSteps` je Volk. + +**Oberfläche.** Volksseite: „Als Nächstes“ (bis 3 Schritte mit Gründen, Fälligkeitschip, Aktion, Verwerfen; „Wartezeit bis TT.MM.“ als Hinweis; Fehlerzustand mit Wiederholen). Startseite „Heute“: der dringlichste Schritt, Karten „Durchsicht fällig – “ mit Saison-Chip und „Runde starten“, Chips „Vorschläge “ (nur mit `tasks:read`) und „: “; bei Fehler, offline oder ohne Recht greift die bisherige Aufgabenlogik. Stand-Seite: je Volk „Durchsicht fällig in 2 Tagen“ und „Als Nächstes: …“. Offline zeigt die Volksseite die Schritte aus dem Paket nur lesend (ohne Übernehmen, Verwerfen, Aufräumen). Aufräumen öffnet das Sheet aus Abschnitt 11.7; Übernehmen aus Schritten zeigt „Aufgabe angelegt“. + +### 11.6 Biotechnik-Ketten + +Drei Ketten schlagen die nächsten Prüfschritte vor (Handler `chains.*`, Tabelle in Abschnitt 10; Vorschlagsquelle `CHAIN`, Art `PLAN`, Priorität `NORMAL`, Meldung nur bei `notify` der Regel). Titel der Schritte (`PLAN_STEP_TITLES`): „Nachschaffungszellen prüfen“, „Begattung prüfen“, „Stifte prüfen“, „Annahme der Königin prüfen“. Gründe: `QUEEN_CELLS_SWARM_CHECK`, `QUEEN_ASSIGNED_ACCEPTANCE`, `SPLIT_SCHEDULE_RAISE_OWN` („Ableger-Plan übernehmen“), `SPLIT_SCHEDULE_QUEEN_CELL` („Stifte prüfen“), `SPLIT_SCHEDULE_QUEEN`. +- Das Teilen eines Volkes (`ColoniesService.split`) nimmt `date` (`JJJJ-MM-TT`, nicht in der Zukunft: 400 „Das Datum liegt in der Zukunft.“; Standard heute) und `queenSource` (`RAISE_OWN`, `QUEEN_CELL`, `QUEEN`). Die Herkunft der Königin ist eine Beobachtung und wird nie vorbelegt; ohne sie entsteht keine Kette („Weiß ich noch nicht“). Die Oberfläche fragt Datum und Herkunft im Teilen-Dialog und nennt die Folge („Es entsteht ein Plan: Zellen prüfen, Begattung, Stifte.“). +- Ein Ableger mit `queenSource = QUEEN` und eine spätere Zuordnung (`QueenAssigned`) haben dieselbe Absicht `QUEEN_ACCEPTANCE`: Wer zuerst behandelt wird, gewinnt, der zweite schlägt nichts vor. +- `chains.queen-cells` räumt bei jeder Durchsicht eines lebenden Volkes erledigte Ketten-Vorschläge ab, auch wenn die Regel abgeschaltet ist (er schließt nur); eine rückdatierte Durchsicht löst nichts auf (ein offener `SWARM_CHECK` löst sich nur, wenn seine Basis-Durchsicht älter ist als die aktuelle). +- Der Plan der Kette `SPLIT_SCHEDULE` kommt aus den Schritten der Variante; das früheste Datum ist `splitOn` + kleinster Versatz. + +### 11.7 Volk beendet und Aufräumen + +Jeder Weg, auf dem ein Volk endet (Auflösen, Vereinigen, Bearbeiten auf `SOLD`/`DECEASED`, Auswinterungsverlust mit `markColonyDeceased`), beendet in derselben Transaktion die Königin-Zuordnung über `releaseQueenIn` (`src/colonies/colony-lifecycle.ts`): Die offene `QueenColonyHistory` bekommt `endDate`, `Colony.currentQueenId` wird `null`, der Status der Königin ändert sich nicht. Das Enddatum ist der Berliner Tag der Änderung (bei der Auswinterung das Datum der Prüfung). Beim Bearbeiten greift das nur beim Übergang von lebend nach beendet. Ein Wächter (`colony-end-releases-queen.spec.ts`, TypeScript-Compiler-API) fordert, dass jede Stelle, die einen beendeten Status schreibt, vorher `releaseQueenIn(` aufruft. `ColonyEnded` trägt `{ status, endedOn, releasedQueenId, via }` (`via`: `DISSOLVE`, `MERGE`, `EDIT`, `WINTER_CHECK`); ältere Ereignisse ohne diese Felder fallen auf den Berliner Tag des Ereignisses und `EDIT` zurück. + +Der Handler `suggestions.colony-ended` setzt offene Vorschläge des Volkes auf `OBSOLETE` und legt den Aufräum-Vorschlag an (Regel `COLONY_ENDED_CLEANUP`; Quelle `CHAIN`, Absicht `CLEANUP`, Art `DO`, Priorität `NORMAL`, `colonies:read`). Anker `ended--`, Beleg `{ colonyId, status, endedOn, via, openTaskIds, queenId }`; die Königin zählt nur, solange sie `ACTIVE` ist. Wird ein Volk reaktiviert (`reactivate`), werden seine offenen Aufräum-Vorschläge `OBSOLETE`; endet es am selben Tag erneut, entsteht ein neuer. + +`POST /suggestions/:id/apply-cleanup` (`CleanupController`/`CleanupModule`, nur API, weil `QueensService` den Webhook-Dienst braucht und `JobsModule` ihn nicht hat) prüft zuerst die Rechte (Aufgaben „Erledigt“ brauchen `tasks:complete`, die Königin `queens:update`) und läuft dann in einer Transaktion: Vorschlag sperren und lesen (404 unbekannt oder unsichtbar, 409 nicht offen oder Volk wieder aktiv „Das Volk ist wieder aktiv.“, 400 falsche Absicht), IDs prüfen (nur Aufgaben und Königin aus dem Beleg, sonst 400 „Nur Aufgaben und Königin aus dem Vorschlag.“), Aufgaben gesperrt je `CANCEL` (`TasksService.cancelIn`), `DONE` (`completeIn`, wie eine normale Erledigung) oder `KEEP` (eine nicht mehr offene Aufgabe zählt als behalten; fremde `relatedType` ebenso), Königinnenstatus über `QueensService.updateStatusIn` (409, solange ein Volk sie noch hält und der neue Status sie aus dem Betrieb nimmt), dann `resolveIn(…, 'Aufgeräumt')`. Kalender und Webhooks folgen aus den Aufgaben-Ereignissen. + +Oberfläche: Das Aufräum-Sheet (von Vorschlagsliste, Schritten und „Heute“) listet die offenen Aufgaben (Standard „Abbrechen“, umschaltbar auf „Erledigt“ oder „Behalten“) und den Königinnenstatus („verstorben“ bei `DECEASED`, „verkauft“ bei `SOLD`, sonst unverändert); ein Knopf „Aufräumen“. Ohne `tasks:complete` ist „Erledigt“ gesperrt, ohne `queens:update` entfällt die Königin, jeweils mit Hinweis. Offline gibt es das Aufräumen nicht. + +### 11.8 Wartezeit-Kette + +Ein einziger Fenster-Helfer (`src/treatments/withdrawal-window.ts`, Annahme 10) bestimmt die Wartezeit. Die Prüfung läuft bei Ernte, Abfüllung und nachträglich erfasster oder geänderter Behandlung (Schleuderung folgt mit T4b). +- **Warnungen.** `POST/PATCH /harvests` und `/bottling-batches` antworten mit `warnings` (Format `PlausibilityWarning`: Code `WITHDRAWAL_ACTIVE`, Stufe `WARNUNG`; Stufen `HINWEIS`, `WARNUNG`, `BESTAETIGEN`) und nie mit 4xx wegen der Wartezeit. Text: „B3: Wartezeit nach Oxuvar endet am 20.08.2026.“. Überdecken mehrere Fenster den Erntetag, nennt die Warnung das längste. Die Prüfung läuft in der Transaktion nach dem Schreiben. +- **Meldung.** Pro Ernte und Volk höchstens eine Meldung (WARNUNG, Kategorie `WITHDRAWAL`, an Inhaber von `harvests:read` und `treatments:read`, Link `/harvests/`, Schlüssel `withdrawal-conflict::`), auch wenn Abfüllung oder Behandlung erneut warnen; die erste Meldung gewinnt. Wurde die Ernte schon abgefüllt, hängt sie „Bereits abgefüllt: Los L-1, L-2.“ an (Rückruf-Relevanz). Korrekturen, die aus dem Fenster herausführen, nehmen eine frühere Meldung nicht zurück. +- **Ende.** `WithdrawalEnded` (Job `time-events`) löst `notifications.withdrawal-ended` aus: INFO „ ist wieder erntefähig“ (Kategorie `WITHDRAWAL`, `harvests:create` + `treatments:read`, Link `/colonies/`, Schlüssel `withdrawal-ended::`). Der Handler schweigt, wenn `WITHDRAWAL_ENDED.notify` aus ist, das Volk nicht lebt, die Behandlung fehlt, ihr Ende nicht mehr `endedOn` ist (veraltetes Ereignis) oder ein weiteres Fenster des Volkes an diesem Tag läuft. +- **Kalender.** Die Ansicht bekommt den Typ `WITHDRAWAL` als Spanne (`date` = erster Tag, `endDate` = letzter Tag einschließlich, id `withdrawal:`), und zwar wie die Behandlungstermine nur mit `treatments:read`; eine ungültige `from`/`to`-Angabe antwortet 400. Der M365-Kalender bekommt einen ganztägigen Mehrtagestermin „Wartezeit · “ (`date` = erster Tag, `endDate` exklusiv = erster erntefähiger Tag); ohne Wartezeittage oder bei einem Fenster von null Tagen (`withdrawalSpanOf`) entfällt der Termin (Eintrag `SKIPPED`, ein vorhandenes Graph-Event wird gelöscht). Abschaltbar über `syncWithdrawal` (Standard an). +- **Oberfläche.** Die Formulare zeigen Warnungen nach dem Speichern als Toast „Ernte gespeichert · n Warnung(en)“ bzw. „Abfüllung angelegt · n Warnung(en)“ mit einer Zeile je Warnung; Warnungen bleiben stehen, bis sie bestätigt werden („Verstanden“), Hinweise laufen aus. Offline gespeicherte Ernten haben keine Warnungen; die Abfüllung arbeitet nur online. Die Bestätigungsliste (`WarningList`) ist für die Wassergehalt-Bestätigung vorbereitet. Der Kalender zeigt „Wartezeit“ an jedem Tag der Spanne. + +### 11.9 Meldungen je Nutzer, Erinnerungen und Morgenübersicht + +**Meldungen.** Annahme 39. `GET /notifications` (eigene, nach aktuellen Rechten gefiltert, höchstens 100), `POST /notifications/read-all` (vor `:id/read` deklariert), `POST /notifications/:id/read` (404 für fremde, unbekannte oder verborgene). Kategorien (`NotificationCategory`) u. a. `SUGGESTION`, `SYSTEM`, `WITHDRAWAL`, `TASK_DUE`, `DIGEST`; die Glocke zeigt das Kategoriewort, „Alle gelesen“ und öffnet interne `link`-Pfade (nur mit führendem `/`, nie `//` oder `/\`). Einstellungen je Nutzer in `NotificationPreference`: `GET/PATCH /profile/notification-settings` (`@AnyAuthenticatedUser`, API-Schlüssel erhalten 403): `taskDueEnabled`, `digestEnabled`, `digestTime` (05:00–12:00 einschließlich), Ruhezeit nur als Paar (beide leer oder beide gesetzt); `telegramEnabled` wird nie geschrieben. Profil > Benachrichtigungen bietet „Erinnerung je Aufgabe“, „Morgenübersicht“ (Uhrzeit, Standard 06:30) und „Ruhezeit von/bis“; gesendet wird nur Geändertes. + +**Erinnerung je Aufgabe.** Handler `notifications.task-due` (`RemindersModule`, von `AppModule`, `WorkerModule` und `JobsModule` importiert) schreibt zu `TaskDue` je Empfänger eine Meldung (HINWEIS, `TASK_DUE`, Link `/tasks`, Schlüssel `task-due:::`): „Heute fällig: “, bei einem Nachhol-Ereignis „Fällig seit TT.MM.: “. Empfänger ist der Zuständige, sonst alle aktiven Nutzer mit `tasks:complete`; immer nur mit `tasks:read` und eingeschaltetem `taskDueEnabled` (fehlende Einstellung = an). Nur offene Aufgaben (`OPEN`, `IN_PROGRESS`); eine inzwischen verschobene oder fällig-lose Aufgabe wird übersprungen. + +**Morgenübersicht.** Der Schritt `digest` (`DigestService.runFor`) schreibt je aktivem Nutzer mit aktiver Rolle und `digestEnabled` höchstens eine Meldung je Tag (INFO, `DIGEST`, Link `/dashboard`, Schlüssel `digest::`). Zeilen nur mit dem jeweiligen Recht: Aufgaben „n Aufgabe(n) fällig, k davon überfällig.“ (`tasks:read`), „Durchsicht fällig: n Völker am Stand …“ (`colonies:read` und `inspections:read`), „n Vorschläge warten.“ (`tasks:read`, gezählt werden die zum Schreibzeitpunkt sichtbaren), „Stand …: Schwarmzeit beginnt. Durchsicht alle n Tage.“ (`apiaries:read`, nur am Tag des Abschnittsbeginns). Ohne Zeile keine Übersicht. `requiredPermissions` der Übersicht enthalten nur die Grundrechte der Zeilen. Der Inhalt hat den Stand 04:30; später angelegte Aufgaben kommen erst am nächsten Tag hinein, die Erinnerung je Aufgabe kommt trotzdem. Scheitert ein Nutzer, schreibt der Schritt die übrigen und der Lauf wird `FAILED`. + +**Job-Ausfall.** Ein gescheiterter Lauf benachrichtigt die Inhaber des Job-Rechts (Abschnitt 10, Jobs) einmal je Job und Berliner Tag (Kategorie `SYSTEM`). + +### 11.10 Futter, besetzte Waben und Übernahme Durchsicht → Volkskarte + +**Futter und Waben.** Die Annahmen 33 und 34 beschreiben Vokabular und Wertebereich. `CreateInspectionDto.feedStatus` wandelt vor `@IsEnum` über `feedConditionFromInput` (`src/common/feed-vocabulary.ts`; Enum-Namen in jeder Schreibweise, drei alte Wörter, leerer oder blanker Text → `null`); `UpdateInspectionDto` erbt das. Die Stockkarte (PDF) druckt `FEED_CONDITION_LABELS_DE`. In der Oberfläche bietet die Durchsicht „Ausreichend“, „Niedrig“, „Kritisch“ und „Nicht geprüft“ (ein gespeichertes „Mittel“ oder „Reichlich“ bleibt wählbar), Volkskarte und Bearbeiten-Dialog das volle Vokabular; alte Wörter in Entwürfen, Paketen und Warteschlangen mappt `feedConditionFromInput`, ein nicht abbildbarer alter Text bleibt als Hinweis stehen und überschreibt den Wert beim Bearbeiten nicht. „Besetzte Waben“ (0–40) steht auf Volk (anlegen, bearbeiten, leeren mit `null`), Volkskarte, CSV-Export (letzte Spalte), CSV-Import und im Offline-Pack; die Volksseite zeigt „n besetzte Waben“, sonst „Volksstärke (alt): 7 von 10“. Das Feld „Volksstärke“ ist auf 40 begrenzt (Hinweis „Höchstens 40“); eine Warteschlangenmeldung 400 zeigt „Besetzte Waben: höchstens 40 – bitte im Eintrag korrigieren.“. Die Migration `20261014100200_t4_feed_vocabulary` wird mit dem Test `test/utils/migration-harness.ts` geprüft, der in einem Scratch-Schema `mig_` bis zu einer Migration migriert. + +**Übernahme.** `POST /inspections` nimmt `applyToColony` (höchstens 3 verschiedene Werte aus `occupiedFrames`, `broodStatus`, `feedStatus`). Durchsicht und Übernahme laufen in einer Transaktion: Der Server sperrt zuerst das Volk, speichert die Durchsicht, prüft dann, ob es eine neuere gibt (Datum, `createdAt`, `id`), und plant die Übernahme (`planColonyTakeover`) allein aus der gespeicherten Zeile, nie aus der Anfrage; `ColoniesService.updateIn(tx, …)` schreibt (inklusive Historie, Ereignissen und Audit-Eintrag mit Vorher/Nachher). Die Antwort trägt `colonyTakeover { applied, skipped[{ field, reason }] }` (leer, wenn nichts verlangt wurde). Gründe in der Prüfreihenfolge: `NO_PERMISSION` (braucht `colonies:update`, die Anfrage antwortet trotzdem 201), `COLONY_ENDED` (die Durchsicht bleibt gespeichert), `NEWER_INSPECTION` (blockiert jedes Feld), dann je Feld `NO_VALUE`, `OUT_OF_RANGE`. Eine Korrektur (PATCH) übernimmt nie etwas (`applyToColony` dort: 400). + +**Oberfläche.** Der Block „Volkskarte“ steht über „Durchsicht speichern“, nur mit `colonies:update` und nur, wenn Felder gefüllt sind: je Feld ein Kontrollkästchen mit „Futter: Ausreichend → Niedrig“, „Besetzte Waben: 6 → 9“, „Brut: Mittel → Gut“. Angehakt wird nur, was in dieser Durchsicht selbst eingegeben wurde (ein Wert aus „Letztes Mal“ hakt nie von allein an; Tippen auf das Feld hakt an, Leeren nimmt zurück, ein manuelles Abhaken bleibt). Gesendet wird `applyToColony` nur mit Recht und nur für gefüllte Felder. Online meldet ein Toast die Gründe („Volkskarte nicht übernommen: Es gibt eine neuere Durchsicht.“, „…: Das Volk ist beendet.“); gelungene Felder laden die Volks-Abfragen neu. Offline reiht die Warteschlange `applyToColony` mit ein und der Server entscheidet beim Sync still (NEWER_INSPECTION, COLONY_ENDED, NO_PERMISSION); „bisher“-Werte kommen aus der zwischengespeicherten Volksliste, ohne sie steht „bisher unbekannt“. diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index ed5cdc5..ec74e40 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -28,6 +28,7 @@ import { DashboardModule } from './dashboard/dashboard.module'; import { SearchModule } from './search/search.module'; import { ExportModule } from './export/export.module'; import { QrModule } from './qr/qr.module'; +import { RemindersModule } from './reminders/reminders.module'; import { ProductsModule } from './products/products.module'; import { BottlingModule } from './bottling/bottling.module'; import { ProductionBatchesModule } from './production-batches/production-batches.module'; @@ -114,6 +115,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce CleanupModule, AutopilotModule, WithdrawalModule, + RemindersModule, LabelTemplatesModule, ImportModule, PublicModule, diff --git a/ApiCore-Backend/src/autopilot/next-steps.service.ts b/ApiCore-Backend/src/autopilot/next-steps.service.ts index 2da9b23..39071af 100644 --- a/ApiCore-Backend/src/autopilot/next-steps.service.ts +++ b/ApiCore-Backend/src/autopilot/next-steps.service.ts @@ -233,36 +233,13 @@ export class NextStepsService { ); return { nextStep: this.mostUrgent(colonies, today), - inspectionDue: sortedApiaries.flatMap((season) => { - const due = [...colonies] - .filter( - ([, c]) => - c.apiaryId === season.apiaryId && - c.view.inspectionDueOn !== null && - c.view.inspectionDueOn <= today, - ) - .map(([id, c]) => ({ - id, - code: c.code, - dueOn: c.view.inspectionDueOn ?? today, - overdueDays: daysBetween(c.view.inspectionDueOn ?? today, today), - })) - .sort((a, b) => a.dueOn.localeCompare(b.dueOn) || byCode(a, b)); - return due.length === 0 - ? [] - : [ - { - apiaryId: season.apiaryId, - apiaryName: nameOf(season.apiaryId), - segment: season.segment, - intervalDays: - season.segment === 'WINTERRUHE' - ? null - : intervalDays[season.segment], - colonies: due, - }, - ]; - }), + inspectionDue: this.dueInspections( + colonies, + sortedApiaries, + intervalDays, + nameOf, + today, + ), suggestionsOpen, seasons: sortedApiaries.map((season) => ({ apiaryId: season.apiaryId, @@ -275,6 +252,75 @@ export class NextStepsService { }; } + /** + * Only the due inspections of all living colonies, whatever the reader + * may see (the caller filters by right): the digest's input. + */ + async inspectionDueAll( + today: string = berlinToday(), + ): Promise { + const living = await this.prisma.colony.findMany({ + where: LIVING_COLONY_WHERE, + select: { id: true }, + }); + const { colonies, seasons, intervalDays } = await this.compute( + living.map((colony) => colony.id), + new Set(['inspections:read']), + today, + ); + const apiaries = await this.prisma.apiary.findMany({ + where: { id: { in: [...seasons.keys()] } }, + select: { id: true, name: true }, + }); + const names = new Map(apiaries.map((a) => [a.id, a.name] as const)); + const nameOf = (apiaryId: string): string => names.get(apiaryId) ?? ''; + const sorted = [...seasons.values()].sort((a, b) => + nameOf(a.apiaryId).localeCompare(nameOf(b.apiaryId), 'de', { + numeric: true, + }), + ); + return this.dueInspections(colonies, sorted, intervalDays, nameOf, today); + } + + private dueInspections( + colonies: ReadonlyMap, + sortedSeasons: readonly SeasonState[], + intervalDays: Computation['intervalDays'], + nameOf: (apiaryId: string) => string, + today: string, + ): AutopilotToday['inspectionDue'] { + return sortedSeasons.flatMap((season) => { + const due = [...colonies] + .filter( + ([, c]) => + c.apiaryId === season.apiaryId && + c.view.inspectionDueOn !== null && + c.view.inspectionDueOn <= today, + ) + .map(([id, c]) => ({ + id, + code: c.code, + dueOn: c.view.inspectionDueOn ?? today, + overdueDays: daysBetween(c.view.inspectionDueOn ?? today, today), + })) + .sort((a, b) => a.dueOn.localeCompare(b.dueOn) || byCode(a, b)); + return due.length === 0 + ? [] + : [ + { + apiaryId: season.apiaryId, + apiaryName: nameOf(season.apiaryId), + segment: season.segment, + intervalDays: + season.segment === 'WINTERRUHE' + ? null + : intervalDays[season.segment], + colonies: due, + }, + ]; + }); + } + private mostUrgent( colonies: ReadonlyMap, today: string, diff --git a/ApiCore-Backend/src/jobs/daily-evaluation-steps.spec.ts b/ApiCore-Backend/src/jobs/daily-evaluation-steps.spec.ts index c2483c6..dd2c99c 100644 --- a/ApiCore-Backend/src/jobs/daily-evaluation-steps.spec.ts +++ b/ApiCore-Backend/src/jobs/daily-evaluation-steps.spec.ts @@ -37,7 +37,7 @@ describe('runDailyEvaluationSteps', () => { const order: string[] = []; const digest = step('digest', () => { order.push('digest'); - return Promise.resolve({ digests: 3 }); + return Promise.resolve({ digestsWritten: 3 }); }); const timeEvents = step('time-events', () => { order.push('time-events'); @@ -52,7 +52,7 @@ describe('runDailyEvaluationSteps', () => { expect(order).toEqual(['time-events', 'digest']); expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10'); - expect(result).toEqual({ TaskDue: 1, digests: 3 }); + expect(result).toEqual({ TaskDue: 1, digestsWritten: 3 }); expect(failed).toEqual([]); }); @@ -68,7 +68,7 @@ describe('runDailyEvaluationSteps', () => { const autopilot = step('autopilot', () => Promise.reject(new Error('connection reset')), ); - const digest = step('digest', () => Promise.resolve({ digests: 4 })); + const digest = step('digest', () => Promise.resolve({ digestsWritten: 4 })); const { result, failed, failures } = await runDailyEvaluationSteps( [phenology, timeEvents, autopilot, digest], @@ -83,7 +83,7 @@ describe('runDailyEvaluationSteps', () => { phenologyApiaries: 2, WithdrawalEnded: 1, TaskDue: 0, - digests: 4, + digestsWritten: 4, }); expect(errorSpy).toHaveBeenCalledWith( 'Step "autopilot" failed: connection reset', diff --git a/ApiCore-Backend/src/jobs/daily-evaluation.job.spec.ts b/ApiCore-Backend/src/jobs/daily-evaluation.job.spec.ts index 584e3a5..76e4cec 100644 --- a/ApiCore-Backend/src/jobs/daily-evaluation.job.spec.ts +++ b/ApiCore-Backend/src/jobs/daily-evaluation.job.spec.ts @@ -3,6 +3,7 @@ import { JobPartialFailure } from '../system/job-run.service'; import { DailyEvaluationJob } from './daily-evaluation.job'; import type { AutopilotStep } from '../autopilot/autopilot.step'; import type { SuggestionsExpiryStep } from '../autopilot/suggestions-expiry.step'; +import type { DigestStep } from '../reminders/digest.step'; import type { TimeEventsStep } from './steps/time-events.step'; describe('DailyEvaluationJob', () => { @@ -21,6 +22,7 @@ describe('DailyEvaluationJob', () => { let timeEvents: { name: 'time-events'; run: jest.Mock }; let autopilot: { name: 'autopilot'; run: jest.Mock }; let expiry: { name: 'suggestions-expiry'; run: jest.Mock }; + let digest: { name: 'digest'; run: jest.Mock }; let job: DailyEvaluationJob; beforeEach(() => { @@ -31,10 +33,13 @@ describe('DailyEvaluationJob', () => { autopilot.run.mockResolvedValue(AUTOPILOT_COUNTS); expiry = { name: 'suggestions-expiry', run: jest.fn() }; expiry.run.mockResolvedValue({ suggestionsExpired: 3 }); + digest = { name: 'digest', run: jest.fn() }; + digest.run.mockResolvedValue({ digestsWritten: 5 }); job = new DailyEvaluationJob( timeEvents as unknown as TimeEventsStep, autopilot as unknown as AutopilotStep, expiry as unknown as SuggestionsExpiryStep, + digest as unknown as DigestStep, ); }); @@ -52,13 +57,15 @@ describe('DailyEvaluationJob', () => { ...COUNTS, ...AUTOPILOT_COUNTS, suggestionsExpired: 3, + digestsWritten: 5, }); expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10'); expect(autopilot.run).toHaveBeenCalledWith('2026-06-10'); expect(expiry.run).toHaveBeenCalledWith('2026-06-10'); + expect(digest.run).toHaveBeenCalledWith('2026-06-10'); }); - it('runs time-events, autopilot and suggestions-expiry in that order', async () => { + it('runs time-events, autopilot, suggestions-expiry and digest in that order', async () => { await job.run(); expect(timeEvents.run.mock.invocationCallOrder[0]).toBeLessThan( @@ -67,6 +74,25 @@ describe('DailyEvaluationJob', () => { expect(autopilot.run.mock.invocationCallOrder[0]).toBeLessThan( expiry.run.mock.invocationCallOrder[0], ); + expect(expiry.run.mock.invocationCallOrder[0]).toBeLessThan( + digest.run.mock.invocationCallOrder[0], + ); + }); + + it('a failing time-events step does not stop the digest', async () => { + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + timeEvents.run.mockRejectedValue(new Error('boom')); + + const failure = await job.run().then( + () => null, + (error: unknown) => error, + ); + + expect(digest.run).toHaveBeenCalled(); + expect(failure).toMatchObject({ + failedSteps: ['time-events'], + result: { digestsWritten: 5 }, + }); }); it('a failing autopilot step does not stop the expiry step', async () => { diff --git a/ApiCore-Backend/src/jobs/daily-evaluation.job.ts b/ApiCore-Backend/src/jobs/daily-evaluation.job.ts index 1877c8c..494747f 100644 --- a/ApiCore-Backend/src/jobs/daily-evaluation.job.ts +++ b/ApiCore-Backend/src/jobs/daily-evaluation.job.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { AutopilotStep } from '../autopilot/autopilot.step'; import { SuggestionsExpiryStep } from '../autopilot/suggestions-expiry.step'; import { berlinToday } from '../common/dates'; +import { DigestStep } from '../reminders/digest.step'; import { JobPartialFailure } from '../system/job-run.service'; import { DailyEvaluationStep, @@ -27,6 +28,7 @@ export class DailyEvaluationJob implements Job { private readonly timeEvents: TimeEventsStep, private readonly autopilot: AutopilotStep, private readonly suggestionsExpiry: SuggestionsExpiryStep, + private readonly digest: DigestStep, ) {} /** Evaluates the current Berlin day. */ @@ -35,6 +37,7 @@ export class DailyEvaluationJob implements Job { this.timeEvents, this.autopilot, this.suggestionsExpiry, + this.digest, ]; const { result, failed, failures } = await runDailyEvaluationSteps( steps, diff --git a/ApiCore-Backend/src/jobs/jobs.module.ts b/ApiCore-Backend/src/jobs/jobs.module.ts index 654d191..884b50f 100644 --- a/ApiCore-Backend/src/jobs/jobs.module.ts +++ b/ApiCore-Backend/src/jobs/jobs.module.ts @@ -8,6 +8,7 @@ import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cle import { DomainEventsModule } from '../domain-events/domain-events.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { PrismaModule } from '../prisma/prisma.module'; +import { RemindersModule } from '../reminders/reminders.module'; import { SeasonModule } from '../season/season.module'; import { SystemModule } from '../system/system.module'; import { TasksModule } from '../tasks/tasks.module'; @@ -49,6 +50,7 @@ const JOB_CLASSES = [ SystemModule, SeasonModule, AutopilotModule, + RemindersModule, ], providers: [ ...JOB_CLASSES, diff --git a/ApiCore-Backend/src/reminders/digest.service.spec.ts b/ApiCore-Backend/src/reminders/digest.service.spec.ts new file mode 100644 index 0000000..cb0da7b --- /dev/null +++ b/ApiCore-Backend/src/reminders/digest.service.spec.ts @@ -0,0 +1,299 @@ +import { Logger } from '@nestjs/common'; +import type { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; +import type { NextStepsService } from '../autopilot/next-steps.service'; +import type { NotificationsService } from '../notifications/notifications.service'; +import type { PrismaService } from '../prisma/prisma.service'; +import { JobPartialFailure } from '../system/job-run.service'; +import { buildDigest, DigestService, type DigestData } from './digest.service'; + +const EMPTY: DigestData = { + today: '2026-06-10', + tasks: [], + inspectionDue: [], + suggestions: [], + seasonChanges: [], +}; + +const DATA: DigestData = { + today: '2026-06-10', + tasks: [ + { assignedUserId: 'u1', overdue: false }, + { assignedUserId: 'u1', overdue: true }, + { assignedUserId: null, overdue: true }, + { assignedUserId: 'u2', overdue: true }, + ], + inspectionDue: [ + { + apiaryId: 'a1', + apiaryName: 'Wald', + segment: 'SCHWARMZEIT', + intervalDays: 7, + colonies: [ + { id: 'c1', code: 'V1', dueOn: '2026-06-08', overdueDays: 2 }, + { id: 'c2', code: 'V2', dueOn: '2026-06-10', overdueDays: 0 }, + ], + }, + ], + suggestions: [ + { requiredPermission: null }, + { requiredPermission: 'varroa:read' }, + { requiredPermission: 'inspections:read' }, + ], + seasonChanges: [ + { apiaryName: 'Wald', segment: 'SCHWARMZEIT', intervalDays: 7 }, + ], +}; + +const ALL = new Set([ + 'tasks:read', + 'tasks:complete', + 'colonies:read', + 'inspections:read', + 'apiaries:read', + 'varroa:read', +]); + +function lines(message: string | undefined): string[] { + return (message ?? '').split('\n'); +} + +describe('buildDigest', () => { + it('includes a line only with its permission and lists every permission used', () => { + const full = buildDigest('u1', ALL, DATA); + expect(lines(full?.message)).toEqual([ + 'Guten Morgen. Heute für dich:', + '3 Aufgaben fällig, 2 davon überfällig.', + 'Durchsicht fällig: 2 Völker am Stand Wald.', + '3 Vorschläge warten.', + 'Stand Wald: Schwarmzeit beginnt. Durchsicht alle 7 Tage.', + ]); + expect(full?.requiredPermissions).toEqual([ + 'apiaries:read', + 'colonies:read', + 'inspections:read', + 'tasks:read', + ]); + + const tasksOnly = buildDigest('u1', new Set(['tasks:read']), DATA); + expect(lines(tasksOnly?.message)).toEqual([ + 'Guten Morgen. Heute für dich:', + '2 Aufgaben fällig, 1 davon überfällig.', + '1 Vorschlag wartet.', + ]); + expect(tasksOnly?.requiredPermissions).toEqual(['tasks:read']); + }); + + it('counts suggestions by their requiredPermission', () => { + const digest = buildDigest( + 'u1', + new Set(['tasks:read', 'varroa:read']), + DATA, + ); + + expect(lines(digest?.message)).toContain('2 Vorschläge warten.'); + }); + + it('needs colonies:read and inspections:read for the inspection line', () => { + const noInspections = new Set(ALL); + noInspections.delete('inspections:read'); + const noColonies = new Set(ALL); + noColonies.delete('colonies:read'); + + expect(buildDigest('u1', noInspections, DATA)?.message).not.toContain( + 'Durchsicht fällig', + ); + expect(buildDigest('u1', noColonies, DATA)?.message).not.toContain( + 'Durchsicht fällig', + ); + }); + + it('counts unassigned tasks only with tasks:complete', () => { + const withoutComplete = new Set(ALL); + withoutComplete.delete('tasks:complete'); + + expect(lines(buildDigest('u1', withoutComplete, DATA)?.message)[1]).toBe( + '2 Aufgaben fällig, 1 davon überfällig.', + ); + expect(lines(buildDigest('u1', ALL, DATA)?.message)[1]).toBe( + '3 Aufgaben fällig, 2 davon überfällig.', + ); + }); + + it('uses the singular and leaves out "davon überfällig" when nothing is overdue', () => { + const digest = buildDigest('u1', new Set(['tasks:read']), { + ...EMPTY, + tasks: [{ assignedUserId: 'u1', overdue: false }], + }); + + expect(lines(digest?.message)[1]).toBe('1 Aufgabe fällig.'); + }); + + it('names one colony in the singular', () => { + const digest = buildDigest('u1', ALL, { + ...EMPTY, + inspectionDue: [ + { + apiaryId: 'a1', + apiaryName: 'Wald', + segment: 'SOMMER', + intervalDays: 14, + colonies: [ + { id: 'c1', code: 'V1', dueOn: '2026-06-10', overdueDays: 0 }, + ], + }, + ], + }); + + expect(lines(digest?.message)[1]).toBe( + 'Durchsicht fällig: 1 Volk am Stand Wald.', + ); + }); + + it('leaves out the interval of the winter rest', () => { + const digest = buildDigest('u1', new Set(['apiaries:read']), { + ...EMPTY, + seasonChanges: [ + { apiaryName: 'Wald', segment: 'WINTERRUHE', intervalDays: null }, + ], + }); + + expect(lines(digest?.message)[1]).toBe('Stand Wald: Winterruhe beginnt.'); + }); + + it('builds no digest without content', () => { + expect(buildDigest('u1', ALL, EMPTY)).toBeNull(); + expect(buildDigest('u1', new Set(), DATA)).toBeNull(); + expect( + buildDigest('u3', new Set(['tasks:read']), { + ...EMPTY, + tasks: [{ assignedUserId: 'u1', overdue: true }], + }), + ).toBeNull(); + }); +}); + +describe('DigestService', () => { + let users: { id: string }[]; + let transaction: jest.Mock; + let recordForUsers: jest.Mock; + let getPermissions: jest.Mock; + let userFindMany: jest.Mock; + let service: DigestService; + const tx = { tx: true }; + + beforeEach(() => { + users = [{ id: 'u1' }, { id: 'u2' }]; + userFindMany = jest.fn(() => Promise.resolve(users)); + transaction = jest.fn((work: (client: unknown) => unknown) => work(tx)); + recordForUsers = jest.fn().mockResolvedValue([{ id: 'n' }]); + getPermissions = jest.fn().mockResolvedValue(ALL); + const prisma = { + user: { findMany: userFindMany }, + task: { + findMany: jest.fn().mockResolvedValue([ + { assignedUserId: null, dueDate: new Date('2026-06-10T00:00:00Z') }, + { assignedUserId: null, dueDate: new Date('2026-06-09T00:00:00Z') }, + ]), + }, + suggestion: { + findMany: jest + .fn() + .mockResolvedValue([{ requiredPermission: 'tasks:read' }]), + }, + colony: { + findMany: jest.fn().mockResolvedValue([{ apiaryId: 'a1' }]), + }, + apiary: { + findMany: jest.fn().mockResolvedValue([{ id: 'a1', name: 'Wald' }]), + }, + $transaction: transaction, + } as unknown as PrismaService; + service = new DigestService( + prisma, + { recordForUsers } as unknown as NotificationsService, + { getPermissions } as unknown as PermissionResolverService, + { + inspectionDueAll: jest.fn().mockResolvedValue([]), + } as unknown as NextStepsService, + { + statesFor: jest.fn().mockResolvedValue(new Map()), + }, + ); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('writes at most one digest per user and day', async () => { + const written = await service.runFor('2026-06-10'); + + expect(written).toBe(2); + expect(recordForUsers).toHaveBeenCalledTimes(2); + const [userIds, input, client] = recordForUsers.mock.calls[0] as [ + string[], + Record, + unknown, + ]; + expect(userIds).toEqual(['u1']); + expect(client).toBe(tx); + expect(input).toMatchObject({ + priority: 'INFO', + category: 'DIGEST', + link: '/dashboard', + dedupeKeyPrefix: 'digest:2026-06-10', + }); + }); + + it('counts only digests that were written', async () => { + recordForUsers.mockResolvedValueOnce([]); + + expect(await service.runFor('2026-06-10')).toBe(1); + }); + + it('skips users who have no content', async () => { + getPermissions.mockResolvedValueOnce(new Set(['colonies:read'])); + + expect(await service.runFor('2026-06-10')).toBe(1); + expect(recordForUsers).toHaveBeenCalledTimes(1); + }); + + it('selects active users with an active role who did not switch the digest off', async () => { + await service.runFor('2026-06-10'); + + expect(userFindMany).toHaveBeenCalledWith({ + where: { + isActive: true, + role: { status: 'ACTIVE' }, + NOT: { notificationPreference: { digestEnabled: false } }, + }, + select: { id: true }, + orderBy: { id: 'asc' }, + }); + }); + + it('limits the run to the given users', async () => { + await service.runFor('2026-06-10', ['u2']); + + expect(userFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ id: { in: ['u2'] } }) as unknown, + }), + ); + }); + + it('a failing user does not stop the others and fails the step with the count', async () => { + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + recordForUsers.mockRejectedValueOnce(new Error('boom')); + + const failure = await service.runFor('2026-06-10').then( + () => null, + (error: unknown) => error, + ); + + expect(recordForUsers).toHaveBeenCalledTimes(2); + expect(failure).toBeInstanceOf(JobPartialFailure); + expect(failure).toMatchObject({ + result: { digestsWritten: 1 }, + failedSteps: ['1 user'], + }); + }); +}); diff --git a/ApiCore-Backend/src/reminders/digest.service.ts b/ApiCore-Backend/src/reminders/digest.service.ts new file mode 100644 index 0000000..d562586 --- /dev/null +++ b/ApiCore-Backend/src/reminders/digest.service.ts @@ -0,0 +1,318 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PermissionKey } from '../auth/permissions/permission-catalog'; +import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; +import { + AutopilotToday, + NextStepsService, +} from '../autopilot/next-steps.service'; +import { readRule } from '../automation/rule-config'; +import { LIVING_COLONY_WHERE } from '../colonies/colony-status'; +import { addDays, dayToDate } from '../common/dates'; +import { loggableError } from '../common/loggable-error'; +import { utcDay } from '../domain-events/domain-event-types'; +import { NotificationsService } from '../notifications/notifications.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { SeasonSegmentKey } from '../season/season-types'; +import { SeasonService } from '../season/season.service'; +import { JobPartialFailure } from '../system/job-run.service'; + +/** One line of the digest and the permissions that entitled the reader to it. */ +export interface DigestLine { + text: string; + permissions: PermissionKey[]; +} + +/** Everything the digest of all users is built from, read once per run. */ +export interface DigestData { + /** The Berlin day of the run, YYYY-MM-DD. */ + today: string; + /** Open tasks due today or earlier. */ + tasks: { assignedUserId: string | null; overdue: boolean }[]; + inspectionDue: AutopilotToday['inspectionDue']; + /** Open suggestions. */ + suggestions: { requiredPermission: string | null }[]; + /** Apiaries whose season segment begins today. */ + seasonChanges: { + apiaryName: string; + segment: SeasonSegmentKey; + intervalDays: number | null; + }[]; +} + +export interface Digest { + message: string; + requiredPermissions: PermissionKey[]; +} + +const GREETING = 'Guten Morgen. Heute für dich:'; + +const SEGMENT_NAMES: Readonly> = { + WINTERRUHE: 'Winterruhe', + FRUEHJAHR: 'Frühjahr', + SCHWARMZEIT: 'Schwarmzeit', + SOMMER: 'Sommer', + SPAETSOMMER: 'Spätsommer', + HERBST: 'Herbst', +}; + +const OPEN_TASK_STATUSES = ['OPEN', 'IN_PROGRESS'] as const; + +function plural(count: number, one: string, many: string): string { + return `${count} ${count === 1 ? one : many}`; +} + +function taskLine( + userId: string, + permissions: ReadonlySet, + data: DigestData, +): DigestLine | null { + if (!permissions.has('tasks:read')) { + return null; + } + const canComplete = permissions.has('tasks:complete'); + const mine = data.tasks.filter( + (task) => + task.assignedUserId === userId || + (task.assignedUserId === null && canComplete), + ); + if (mine.length === 0) { + return null; + } + const overdue = mine.filter((task) => task.overdue).length; + const due = `${plural(mine.length, 'Aufgabe', 'Aufgaben')} fällig`; + return { + text: overdue > 0 ? `${due}, ${overdue} davon überfällig.` : `${due}.`, + permissions: ['tasks:read'], + }; +} + +function inspectionLines( + permissions: ReadonlySet, + data: DigestData, +): DigestLine[] { + if ( + !permissions.has('colonies:read') || + !permissions.has('inspections:read') + ) { + return []; + } + return data.inspectionDue + .filter((apiary) => apiary.colonies.length > 0) + .map((apiary) => ({ + text: `Durchsicht fällig: ${plural(apiary.colonies.length, 'Volk', 'Völker')} am Stand ${apiary.apiaryName}.`, + permissions: ['colonies:read', 'inspections:read'], + })); +} + +function suggestionLine( + permissions: ReadonlySet, + data: DigestData, +): DigestLine | null { + if (!permissions.has('tasks:read')) { + return null; + } + const visible = data.suggestions.filter( + (suggestion) => + suggestion.requiredPermission === null || + permissions.has(suggestion.requiredPermission), + ); + if (visible.length === 0) { + return null; + } + return { + text: `${plural(visible.length, 'Vorschlag wartet', 'Vorschläge warten')}.`, + permissions: ['tasks:read'], + }; +} + +function seasonLines( + permissions: ReadonlySet, + data: DigestData, +): DigestLine[] { + if (!permissions.has('apiaries:read')) { + return []; + } + return data.seasonChanges.map((change) => ({ + text: + `Stand ${change.apiaryName}: ${SEGMENT_NAMES[change.segment]} beginnt.` + + (change.intervalDays === null + ? '' + : ` Durchsicht alle ${change.intervalDays} Tage.`), + permissions: ['apiaries:read'], + })); +} + +/** + * The morning digest of one user (Spec 9a.3): each line only if the user + * holds the permission for it. The stored permissions are those of the + * lines used. null if no line is left - no empty digest. + */ +export function buildDigest( + userId: string, + permissions: ReadonlySet, + data: DigestData, +): Digest | null { + const lines: DigestLine[] = [ + taskLine(userId, permissions, data), + ...inspectionLines(permissions, data), + suggestionLine(permissions, data), + ...seasonLines(permissions, data), + ].filter((line): line is DigestLine => line !== null); + if (lines.length === 0) { + return null; + } + const used = new Set(lines.flatMap((line) => line.permissions)); + return { + message: [GREETING, ...lines.map((line) => line.text)].join('\n'), + requiredPermissions: [...used].sort(), + }; +} + +/** + * Writes the in-app morning digest. The data is read once per run, at the + * time of the daily job (04:30): tasks created later appear in the next + * digest. One transaction and one row (key digest::) per user, + * so a rerun for the same day writes nothing new. + */ +@Injectable() +export class DigestService { + private readonly logger = new Logger(DigestService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly notifications: NotificationsService, + private readonly permissionResolver: PermissionResolverService, + private readonly nextSteps: NextStepsService, + private readonly seasons: SeasonService, + ) {} + + /** + * The digests of the Berlin day `today` for every active user who did not + * switch them off (`userIds` narrows that, for tests). Returns how many + * were written; a rerun for the same day writes 0 (the keys exist). A failing user is logged and does not stop the others; + * the run then fails with the count of the digests that went through. + */ + async runFor(today: string, userIds?: readonly string[]): Promise { + const users = await this.prisma.user.findMany({ + where: { + isActive: true, + role: { status: 'ACTIVE' }, + NOT: { notificationPreference: { digestEnabled: false } }, + ...(userIds ? { id: { in: [...userIds] } } : {}), + }, + select: { id: true }, + orderBy: { id: 'asc' }, + }); + if (users.length === 0) { + return 0; + } + const data = await this.loadData(today); + let written = 0; + let failed = 0; + for (const { id } of users) { + try { + written += await this.writeFor(id, data); + } catch (error: unknown) { + const { message, stack } = loggableError(error); + this.logger.error(`Digest of user ${id} failed: ${message}`, stack); + failed += 1; + } + } + if (failed > 0) { + throw new JobPartialFailure({ digestsWritten: written }, [ + `${failed} ${failed === 1 ? 'user' : 'users'}`, + ]); + } + return written; + } + + private async writeFor(userId: string, data: DigestData): Promise { + const permissions = await this.permissionResolver.getPermissions(userId); + const digest = buildDigest(userId, permissions, data); + if (digest === null) { + return 0; + } + const rows = await this.prisma.$transaction((tx) => + this.notifications.recordForUsers( + [userId], + { + priority: 'INFO', + category: 'DIGEST', + message: digest.message, + link: '/dashboard', + requiredPermissions: digest.requiredPermissions, + dedupeKeyPrefix: `digest:${data.today}`, + }, + tx, + ), + ); + return rows.length; + } + + private async loadData(today: string): Promise { + const [tasks, suggestions, inspectionDue, seasonChanges] = + await Promise.all([ + this.loadTasks(today), + this.prisma.suggestion.findMany({ + where: { status: 'OPEN' }, + select: { requiredPermission: true }, + }), + // Every colony, whatever the reader may see: the lines are filtered per user. + this.nextSteps.inspectionDueAll(today), + this.loadSeasonChanges(today), + ]); + return { today, tasks, inspectionDue, suggestions, seasonChanges }; + } + + private async loadTasks(today: string): Promise { + const rows = await this.prisma.task.findMany({ + where: { + status: { in: [...OPEN_TASK_STATUSES] }, + dueDate: { lt: dayToDate(addDays(today, 1)) }, + }, + select: { assignedUserId: true, dueDate: true }, + }); + return rows.flatMap(({ assignedUserId, dueDate }) => + dueDate === null + ? [] + : [{ assignedUserId, overdue: utcDay(dueDate) < today }], + ); + } + + /** Apiaries with living colonies whose season segment begins today. */ + private async loadSeasonChanges( + today: string, + ): Promise { + const colonies = await this.prisma.colony.findMany({ + where: LIVING_COLONY_WHERE, + select: { apiaryId: true }, + distinct: ['apiaryId'], + }); + const apiaryIds = colonies.map((colony) => colony.apiaryId); + const states = await this.seasons.statesFor(this.prisma, apiaryIds, today); + const changed = [...states.values()].filter( + (state) => state.segmentSince === today, + ); + if (changed.length === 0) { + return []; + } + const [apiaries, interval] = await Promise.all([ + this.prisma.apiary.findMany({ + where: { id: { in: changed.map((state) => state.apiaryId) } }, + select: { id: true, name: true }, + }), + readRule(this.prisma, 'INSPECTION_INTERVAL'), + ]); + const names = new Map(apiaries.map((a) => [a.id, a.name] as const)); + return changed + .map((state) => ({ + apiaryName: names.get(state.apiaryId) ?? '', + segment: state.segment, + intervalDays: + state.segment === 'WINTERRUHE' || !interval.active + ? null + : interval.config.intervalDays[state.segment], + })) + .sort((a, b) => a.apiaryName.localeCompare(b.apiaryName, 'de')); + } +} diff --git a/ApiCore-Backend/src/reminders/digest.step.spec.ts b/ApiCore-Backend/src/reminders/digest.step.spec.ts new file mode 100644 index 0000000..bb0e3b8 --- /dev/null +++ b/ApiCore-Backend/src/reminders/digest.step.spec.ts @@ -0,0 +1,29 @@ +import { JobPartialFailure } from '../system/job-run.service'; +import type { DigestService } from './digest.service'; +import { DigestStep } from './digest.step'; + +describe('DigestStep', () => { + it('is the digest step', () => { + expect(new DigestStep({} as DigestService).name).toBe('digest'); + }); + + it('writes the digests of the day and reports their number', async () => { + const runFor = jest.fn().mockResolvedValue(7); + + const result = await new DigestStep({ + runFor, + } as unknown as DigestService).run('2026-06-10'); + + expect(runFor).toHaveBeenCalledWith('2026-06-10'); + expect(result).toEqual({ digestsWritten: 7 }); + }); + + it('lets a partial failure through with its counter', async () => { + const failure = new JobPartialFailure({ digestsWritten: 3 }, ['1 user']); + const runFor = jest.fn().mockRejectedValue(failure); + + await expect( + new DigestStep({ runFor } as unknown as DigestService).run('2026-06-10'), + ).rejects.toBe(failure); + }); +}); diff --git a/ApiCore-Backend/src/reminders/digest.step.ts b/ApiCore-Backend/src/reminders/digest.step.ts new file mode 100644 index 0000000..2f1fe6d --- /dev/null +++ b/ApiCore-Backend/src/reminders/digest.step.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import type { DailyEvaluationStep } from '../jobs/daily-evaluation-steps'; +import type { JobResult } from '../jobs/job'; +import { DigestService } from './digest.service'; + +/** + * Step "digest", the last of the daily evaluation: the in-app morning + * digest of every user. Running it twice for one day writes nothing the + * second time (the key holds the day and the user): `digestsWritten` is 0 + * on a same-day rerun. + */ +@Injectable() +export class DigestStep implements DailyEvaluationStep { + readonly name = 'digest'; + + constructor(private readonly digests: DigestService) {} + + async run(today: string): Promise { + return { digestsWritten: await this.digests.runFor(today) }; + } +} diff --git a/ApiCore-Backend/src/reminders/reminders.module.ts b/ApiCore-Backend/src/reminders/reminders.module.ts new file mode 100644 index 0000000..a1f30b3 --- /dev/null +++ b/ApiCore-Backend/src/reminders/reminders.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { AutopilotModule } from '../autopilot/autopilot.module'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { SeasonModule } from '../season/season.module'; +import { DigestService } from './digest.service'; +import { DigestStep } from './digest.step'; +import { TaskDueHandlers } from './task-due.handlers'; + +/** + * The in-app reminders: the notification per due task (event handler + * `notifications.task-due`) and the morning digest (daily step `digest`). + * Imported by AppModule and WorkerModule (the handler) and JobsModule (the + * step). The PermissionResolverService comes from the global + * PermissionsModule (API) or JobPermissionsModule (worker, jobs). + */ +@Module({ + imports: [NotificationsModule, SeasonModule, AutopilotModule], + providers: [TaskDueHandlers, DigestService, DigestStep], + exports: [DigestService, DigestStep], +}) +export class RemindersModule {} diff --git a/ApiCore-Backend/src/reminders/task-due.handlers.spec.ts b/ApiCore-Backend/src/reminders/task-due.handlers.spec.ts new file mode 100644 index 0000000..94d2ccc --- /dev/null +++ b/ApiCore-Backend/src/reminders/task-due.handlers.spec.ts @@ -0,0 +1,216 @@ +import type { Prisma } from '../../generated/prisma'; +import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types'; +import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator'; +import type { NotificationsService } from '../notifications/notifications.service'; +import { TaskDueHandlers } from './task-due.handlers'; + +function taskDue( + taskId = 'task-1', + dueOn = '2026-06-10', +): AnyDomainEventEnvelope<'TaskDue'> { + return { + id: 'evt-1', + type: 'TaskDue', + aggregateType: 'Task', + aggregateId: taskId, + payload: { dueOn }, + actorUserId: null, + correlationId: 'cor-1', + occurredAt: new Date(), + attempts: 0, + }; +} + +type RecordCall = [string[], Record, unknown]; + +describe('TaskDueHandlers', () => { + let task: Record | null; + let completers: { id: string }[]; + let disabled: { userId: string }[]; + let taskFindUnique: jest.Mock; + let userFindMany: jest.Mock; + let preferenceFindMany: jest.Mock; + let recordForUsers: jest.Mock; + let handlers: TaskDueHandlers; + const tx = {} as Prisma.TransactionClient; + + beforeEach(() => { + jest.useFakeTimers({ now: new Date('2026-06-10T08:00:00.000Z') }); + task = { + id: 'task-1', + title: 'Waben pruefen', + status: 'OPEN', + assignedUserId: 'u-assignee', + dueDate: new Date('2026-06-10T00:00:00.000Z'), + }; + completers = [{ id: 'u-a' }, { id: 'u-b' }]; + disabled = []; + taskFindUnique = jest.fn(() => Promise.resolve(task)); + userFindMany = jest.fn(() => Promise.resolve(completers)); + preferenceFindMany = jest.fn(() => Promise.resolve(disabled)); + recordForUsers = jest.fn().mockResolvedValue([]); + const client = { + task: { findUnique: taskFindUnique }, + user: { findMany: userFindMany }, + notificationPreference: { findMany: preferenceFindMany }, + } as unknown as Prisma.TransactionClient; + handlers = new TaskDueHandlers({ + recordForUsers, + } as unknown as NotificationsService); + Object.assign(tx, client); + }); + + const firstCall = (): RecordCall => + recordForUsers.mock.calls[0] as RecordCall; + + afterEach(() => jest.useRealTimers()); + + it('is registered as notifications.task-due for TaskDue', () => { + const entries = domainEventHandlerMetadata( + // eslint-disable-next-line @typescript-eslint/unbound-method + TaskDueHandlers.prototype.taskDue, + ); + + expect(entries).toEqual([ + { types: ['TaskDue'], handlerId: 'notifications.task-due' }, + ]); + }); + + it('notifies the assignee only', async () => { + await handlers.taskDue(taskDue(), tx); + + expect(userFindMany).not.toHaveBeenCalled(); + expect(recordForUsers).toHaveBeenCalledTimes(1); + expect(firstCall()[0]).toEqual(['u-assignee']); + }); + + it('without an assignee notifies holders of tasks:complete', async () => { + task = { ...task, assignedUserId: null }; + + await handlers.taskDue(taskDue(), tx); + + expect(userFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + isActive: true, + role: { + status: 'ACTIVE', + permissions: { some: { permission: { key: 'tasks:complete' } } }, + }, + }, + }), + ); + expect(firstCall()[0]).toEqual(['u-a', 'u-b']); + }); + + it('writes nothing for a user with taskDueEnabled false', async () => { + task = { ...task, assignedUserId: null }; + disabled = [{ userId: 'u-a' }]; + + await handlers.taskDue(taskDue(), tx); + + expect(preferenceFindMany).toHaveBeenCalledWith({ + where: { userId: { in: ['u-a', 'u-b'] }, taskDueEnabled: false }, + select: { userId: true }, + }); + expect(firstCall()[0]).toEqual(['u-b']); + }); + + it('writes nothing when the only recipient switched it off', async () => { + disabled = [{ userId: 'u-assignee' }]; + + await handlers.taskDue(taskDue(), tx); + + expect(recordForUsers).not.toHaveBeenCalled(); + }); + + it('uses the key task-due:: as prefix, so rows end in :', async () => { + task = { ...task, dueDate: new Date('2026-06-09T00:00:00.000Z') }; + + await handlers.taskDue(taskDue('task-1', '2026-06-09'), tx); + + expect(firstCall()[1]).toEqual({ + priority: 'HINWEIS', + category: 'TASK_DUE', + message: 'Fällig seit 09.06.: Waben pruefen', + relatedType: 'Task', + relatedId: 'task-1', + link: '/tasks', + requiredPermissions: ['tasks:read'], + dedupeKeyPrefix: 'task-due:task-1:2026-06-09', + }); + expect(firstCall()[2]).toBe(tx); + }); + + it.each(['DONE', 'CANCELLED'])('skips a %s task', async (status) => { + task = { ...task, status }; + + await handlers.taskDue(taskDue(), tx); + + expect(recordForUsers).not.toHaveBeenCalled(); + }); + + it('says "Heute fällig" for an event of today', async () => { + await handlers.taskDue(taskDue(), tx); + + expect(firstCall()[1].message).toBe('Heute fällig: Waben pruefen'); + }); + + it('says "Fällig seit {TT.MM.}" for a catch-up event', async () => { + task = { ...task, dueDate: new Date('2026-06-07T00:00:00.000Z') }; + + await handlers.taskDue(taskDue('task-1', '2026-06-07'), tx); + + expect(firstCall()[1].message).toBe('Fällig seit 07.06.: Waben pruefen'); + }); + + it('skips a task whose due day was moved after the event', async () => { + task = { ...task, dueDate: new Date('2026-06-20T00:00:00.000Z') }; + + await handlers.taskDue(taskDue('task-1', '2026-06-10'), tx); + + expect(recordForUsers).not.toHaveBeenCalled(); + }); + + it('skips a task whose due date was removed', async () => { + task = { ...task, dueDate: null }; + + await handlers.taskDue(taskDue(), tx); + + expect(recordForUsers).not.toHaveBeenCalled(); + }); + + it('accepts an IN_PROGRESS task', async () => { + task = { ...task, status: 'IN_PROGRESS' }; + + await handlers.taskDue(taskDue(), tx); + + expect(recordForUsers).toHaveBeenCalledTimes(1); + }); + + it('skips a task that no longer exists', async () => { + task = null; + + await handlers.taskDue(taskDue(), tx); + + expect(recordForUsers).not.toHaveBeenCalled(); + }); + + it('cuts the title to 200 characters', async () => { + task = { ...task, title: 'x'.repeat(300) }; + + await handlers.taskDue(taskDue(), tx); + + const input = firstCall()[1]; + expect(input.message).toBe(`Heute fällig: ${'x'.repeat(200)}`); + }); + + it('writes nothing when no user holds tasks:complete', async () => { + task = { ...task, assignedUserId: null }; + completers = []; + + await handlers.taskDue(taskDue(), tx); + + expect(recordForUsers).not.toHaveBeenCalled(); + }); +}); diff --git a/ApiCore-Backend/src/reminders/task-due.handlers.ts b/ApiCore-Backend/src/reminders/task-due.handlers.ts new file mode 100644 index 0000000..9036f20 --- /dev/null +++ b/ApiCore-Backend/src/reminders/task-due.handlers.ts @@ -0,0 +1,113 @@ +import { Injectable } from '@nestjs/common'; +import type { Prisma } from '../../generated/prisma'; +import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types'; +import { berlinToday } from '../common/dates'; +import { utcDay } from '../domain-events/domain-event-types'; +import { OnDomainEvent } from '../domain-events/on-domain-event.decorator'; +import { NotificationsService } from '../notifications/notifications.service'; +import { formatDayDe } from '../suggestions/suggestion-texts'; + +const OPEN_TASK_STATUSES: readonly string[] = ['OPEN', 'IN_PROGRESS']; +const MAX_TITLE_LENGTH = 200; + +/** "Heute fällig" on the due day, "Fällig seit 07.06." for a catch-up event. */ +function dueLabel(dueOn: string): string { + return dueOn < berlinToday() + ? `Fällig seit ${formatDayDe(dueOn)}` + : 'Heute fällig'; +} + +/** + * The in-app reminder per due task (Spec 9a.2): "Heute fällig: {title}" for + * the assignee, or - without an assignee - for everyone who may complete + * tasks. Only users who hold tasks:read and did not switch the reminder off + * get a row; the key task-due::: makes a redelivery + * (and the catch-up of the daily job) write nothing twice. + */ +@Injectable() +export class TaskDueHandlers { + constructor(private readonly notifications: NotificationsService) {} + + @OnDomainEvent('TaskDue', 'notifications.task-due') + async taskDue( + event: AnyDomainEventEnvelope<'TaskDue'>, + tx: Prisma.TransactionClient, + ): Promise { + const task = await tx.task.findUnique({ + where: { id: event.aggregateId }, + select: { + id: true, + title: true, + status: true, + assignedUserId: true, + dueDate: true, + }, + }); + if (!task || !OPEN_TASK_STATUSES.includes(task.status)) { + return; + } + // The task was rescheduled (or lost its date) after the event: the + // reminder belongs to the new day, which has its own event. + const { dueOn } = event.payload; + if (task.dueDate === null || utcDay(task.dueDate) !== dueOn) { + return; + } + const candidates = await this.candidates(tx, task.assignedUserId); + const recipients = await this.withReminderOn(tx, candidates); + if (recipients.length === 0) { + return; + } + await this.notifications.recordForUsers( + recipients, + { + priority: 'HINWEIS', + category: 'TASK_DUE', + message: `${dueLabel(dueOn)}: ${[...task.title].slice(0, MAX_TITLE_LENGTH).join('')}`, + relatedType: 'Task', + relatedId: task.id, + link: '/tasks', + requiredPermissions: ['tasks:read'], + dedupeKeyPrefix: `task-due:${task.id}:${dueOn}`, + }, + tx, + ); + } + + /** The assignee, else every active user with tasks:complete (tasks:read is checked on write). */ + private async candidates( + tx: Prisma.TransactionClient, + assignedUserId: string | null, + ): Promise { + if (assignedUserId !== null) { + return [assignedUserId]; + } + const users = await tx.user.findMany({ + where: { + isActive: true, + role: { + status: 'ACTIVE', + permissions: { some: { permission: { key: 'tasks:complete' } } }, + }, + }, + select: { id: true }, + orderBy: { id: 'asc' }, + }); + return users.map((user) => user.id); + } + + /** A missing preference row means the default: on. */ + private async withReminderOn( + tx: Prisma.TransactionClient, + userIds: readonly string[], + ): Promise { + if (userIds.length === 0) { + return []; + } + const off = await tx.notificationPreference.findMany({ + where: { userId: { in: [...userIds] }, taskDueEnabled: false }, + select: { userId: true }, + }); + const switchedOff = new Set(off.map((row) => row.userId)); + return userIds.filter((id) => !switchedOff.has(id)); + } +} diff --git a/ApiCore-Backend/src/worker/worker.module.ts b/ApiCore-Backend/src/worker/worker.module.ts index 5d28c89..baa40ba 100644 --- a/ApiCore-Backend/src/worker/worker.module.ts +++ b/ApiCore-Backend/src/worker/worker.module.ts @@ -8,6 +8,7 @@ import { CalendarSyncModule } from '../calendar-sync/calendar-sync.module'; import { DomainEventsModule } from '../domain-events/domain-events.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { PrismaModule } from '../prisma/prisma.module'; +import { RemindersModule } from '../reminders/reminders.module'; import { SuggestionsModule } from '../suggestions/suggestions.module'; import { TasksModule } from '../tasks/tasks.module'; import { WebhooksModule } from '../webhooks/webhooks.module'; @@ -37,6 +38,7 @@ import { WorkerRuntimeModule } from './worker-runtime.module'; SuggestionsModule, AutopilotModule, WithdrawalModule, + RemindersModule, WorkerRuntimeModule, ], providers: [WorkerHealthServer], diff --git a/ApiCore-Backend/test/reminders.e2e-spec.ts b/ApiCore-Backend/test/reminders.e2e-spec.ts new file mode 100644 index 0000000..3012998 --- /dev/null +++ b/ApiCore-Backend/test/reminders.e2e-spec.ts @@ -0,0 +1,295 @@ +import { INestApplication } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import { App } from 'supertest/types'; +import { DomainEventsService } from '../src/domain-events/domain-events.service'; +import type { AnyDomainEventEnvelope } from '../src/domain-events/domain-event-types'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { DigestService } from '../src/reminders/digest.service'; +import { TaskDueHandlers } from '../src/reminders/task-due.handlers'; +import { createTestApp } from './utils/create-test-app'; +import { drainDomainEvents } from './utils/domain-events'; + +const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`); + +/** + * Fixed day in 2001 on which the swarm season begins by calendar + * (KIRSCHE 15 April), so an apiary's segment starts "today". Other files + * share the database: assertions only look at the rows and lines of this + * file's own users, apiary and tasks. + */ +const TODAY = '2001-04-15'; + +describe('Reminders (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + let digests: DigestService; + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; + const roleIds: string[] = []; + const userIds: string[] = []; + const taskIds: string[] = []; + const colonyIds: string[] = []; + let apiaryId: string; + let apiaryName: string; + const users = {} as Record< + 'full' | 'noInspection' | 'completer' | 'off' | 'nothing', + string + >; + + async function createUser( + label: string, + permissions: string[], + ): Promise { + const role = await prisma.role.create({ + data: { + key: `REMINDERS_${label.toUpperCase()}_${suffix}`, + name: `Reminders ${label}`, + permissions: { + create: permissions.map((key) => ({ + permission: { connect: { key } }, + })), + }, + }, + }); + roleIds.push(role.id); + const user = await prisma.user.create({ + data: { + email: `reminders-e2e-${label}-${suffix}@example.com`, + passwordHash: await bcrypt.hash('SuperSecret123!', 4), + name: `Reminders ${label}`, + role: { connect: { id: role.id } }, + }, + }); + userIds.push(user.id); + return user.id; + } + + async function createTask( + title: string, + assignedUserId: string | null, + dueDate: string, + ): Promise { + const task = await prisma.task.create({ + data: { title, assignedUserId, dueDate: d(dueDate) }, + }); + taskIds.push(task.id); + return task.id; + } + + async function recordTaskDue(taskId: string, dueOn: string): Promise { + await prisma.$transaction((tx) => + app.get(DomainEventsService).record(tx, { + type: 'TaskDue', + aggregateType: 'Task', + aggregateId: taskId, + payload: { dueOn }, + dedupeKey: `TaskDue:${taskId}:${dueOn}`, + }), + ); + } + + const notificationsOf = (userId: string, relatedId: string) => + prisma.notification.findMany({ where: { userId, relatedId } }); + + const digestOf = (userId: string) => + prisma.notification.findMany({ + where: { userId, dedupeKey: `digest:${TODAY}:${userId}` }, + }); + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + digests = app.get(DigestService); + users.full = await createUser('full', [ + 'tasks:read', + 'colonies:read', + 'inspections:read', + 'apiaries:read', + ]); + users.noInspection = await createUser('noinspection', [ + 'tasks:read', + 'apiaries:read', + ]); + users.completer = await createUser('completer', [ + 'tasks:read', + 'tasks:complete', + ]); + users.off = await createUser('off', ['tasks:read', 'apiaries:read']); + users.nothing = await createUser('nothing', ['colonies:read']); + await prisma.notificationPreference.create({ + data: { userId: users.off, taskDueEnabled: false, digestEnabled: false }, + }); + apiaryName = `Reminders e2e ${suffix}`; + apiaryId = (await prisma.apiary.create({ data: { name: apiaryName } })).id; + for (const code of ['A', 'B']) { + const colony = await prisma.colony.create({ + data: { + code: `REM-E2E-${suffix}-${code}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + createdAt: d('2001-03-01'), + }, + }); + colonyIds.push(colony.id); + await prisma.inspection.create({ + data: { + colonyId: colony.id, + userId: users.full, + date: d('2001-03-10'), + }, + }); + } + }); + + afterAll(async () => { + await prisma.notification.deleteMany({ + where: { + OR: [{ userId: { in: userIds } }, { relatedId: { in: taskIds } }], + }, + }); + await prisma.domainEvent.deleteMany({ + where: { aggregateId: { in: taskIds } }, + }); + await prisma.inspection.deleteMany({ + where: { colonyId: { in: colonyIds } }, + }); + await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } }); + await prisma.apiary.delete({ where: { id: apiaryId } }); + await prisma.task.deleteMany({ where: { id: { in: taskIds } } }); + await prisma.refreshToken.deleteMany({ + where: { userId: { in: userIds } }, + }); + await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } }); + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + await prisma.role.deleteMany({ where: { id: { in: roleIds } } }); + await app.close(); + }); + + it('TaskDue after draining yields exactly one notification per recipient, also after redelivery', async () => { + const taskId = await createTask('Reminders e2e Waben', users.full, TODAY); + await recordTaskDue(taskId, TODAY); + + await drainDomainEvents(app, [taskId]); + + const rows = await notificationsOf(users.full, taskId); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + priority: 'HINWEIS', + category: 'TASK_DUE', + message: 'Fällig seit 15.04.: Reminders e2e Waben', + link: '/tasks', + relatedType: 'Task', + dedupeKey: `task-due:${taskId}:${TODAY}:${users.full}`, + requiredPermissions: ['tasks:read'], + }); + // A redelivery runs the handler a second time. + const envelope: AnyDomainEventEnvelope<'TaskDue'> = { + id: 'redelivery', + type: 'TaskDue', + aggregateType: 'Task', + aggregateId: taskId, + payload: { dueOn: TODAY }, + actorUserId: null, + correlationId: 'redelivery', + occurredAt: new Date(), + attempts: 1, + }; + await prisma.$transaction((tx) => + app.get(TaskDueHandlers).taskDue(envelope, tx), + ); + expect(await notificationsOf(users.full, taskId)).toHaveLength(1); + for (const other of [users.noInspection, users.completer, users.nothing]) { + expect(await notificationsOf(other, taskId)).toHaveLength(0); + } + }); + + it('a task without an assignee reaches holders of tasks:complete and not users who switched it off', async () => { + const taskId = await createTask('Reminders e2e offen', null, TODAY); + await recordTaskDue(taskId, TODAY); + + await drainDomainEvents(app, [taskId]); + + expect(await notificationsOf(users.completer, taskId)).toHaveLength(1); + for (const other of [users.full, users.noInspection, users.off]) { + expect(await notificationsOf(other, taskId)).toHaveLength(0); + } + }); + + it('writes nothing for a task that was completed before delivery', async () => { + const taskId = await createTask( + 'Reminders e2e erledigt', + users.full, + TODAY, + ); + await prisma.task.update({ + where: { id: taskId }, + data: { status: 'DONE', completedAt: new Date() }, + }); + await recordTaskDue(taskId, TODAY); + + await drainDomainEvents(app, [taskId]); + + expect(await notificationsOf(users.full, taskId)).toHaveLength(0); + }); + + it('running the digest step twice yields one digest per user', async () => { + // Plus the open task of the first test: three due, one overdue. + await createTask('Reminders e2e heute', users.full, TODAY); + await createTask('Reminders e2e ueberfaellig', users.full, '2001-04-10'); + const own = [ + users.full, + users.noInspection, + users.completer, + users.off, + users.nothing, + ]; + + const first = await digests.runFor(TODAY, own); + const second = await digests.runFor(TODAY, own); + + expect(first).toBeGreaterThanOrEqual(2); + expect(second).toBe(0); + for (const userId of own) { + expect((await digestOf(userId)).length).toBeLessThanOrEqual(1); + } + const [full] = await digestOf(users.full); + expect(full).toMatchObject({ + priority: 'INFO', + category: 'DIGEST', + link: '/dashboard', + }); + const lines = full.message.split('\n'); + expect(lines[0]).toBe('Guten Morgen. Heute für dich:'); + expect(lines).toContain('3 Aufgaben fällig, 1 davon überfällig.'); + expect(lines).toContain( + `Durchsicht fällig: 2 Völker am Stand ${apiaryName}.`, + ); + expect(lines).toContain( + `Stand ${apiaryName}: Schwarmzeit beginnt. Durchsicht alle 7 Tage.`, + ); + expect(full.requiredPermissions).toEqual( + expect.arrayContaining([ + 'tasks:read', + 'colonies:read', + 'inspections:read', + 'apiaries:read', + ]), + ); + }); + + it('the digest of a user without varroa or inspection rights has no such line', async () => { + await digests.runFor(TODAY, [users.noInspection]); + + const [digest] = await digestOf(users.noInspection); + expect(digest.message).toContain(`Stand ${apiaryName}: Schwarmzeit`); + expect(digest.message).not.toContain('Durchsicht fällig'); + expect(digest.requiredPermissions).not.toContain('inspections:read'); + expect(digest.requiredPermissions).not.toContain('colonies:read'); + }); + + it('builds no digest for a user who can see nothing in it, and none for a user who switched it off', async () => { + await digests.runFor(TODAY, [users.nothing, users.off]); + + expect(await digestOf(users.nothing)).toHaveLength(0); + expect(await digestOf(users.off)).toHaveLength(0); + }); +}); diff --git a/ApiCore-Backend/test/worker-runtime.e2e-spec.ts b/ApiCore-Backend/test/worker-runtime.e2e-spec.ts index e206e01..54cde04 100644 --- a/ApiCore-Backend/test/worker-runtime.e2e-spec.ts +++ b/ApiCore-Backend/test/worker-runtime.e2e-spec.ts @@ -200,10 +200,16 @@ describe('Worker runtime (e2e)', () => { where: { aggregateId: `${suffix}-0` }, include: { deliveries: true }, }); - expect(event).toMatchObject({ - status: 'DONE', - deliveries: [{ handler: 'test.worker-counter', status: 'DONE' }], - }); + // TaskDue also has the real reminder handler (the task does not exist: a no-op). + expect(event.status).toBe('DONE'); + expect( + event.deliveries.map(({ handler, status }) => ({ handler, status })), + ).toEqual( + expect.arrayContaining([ + { handler: 'test.worker-counter', status: 'DONE' }, + { handler: 'notifications.task-due', status: 'DONE' }, + ]), + ); }); it('wakes the calendar outbox loop on a calendar_sync notification', async () => { diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.spec.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.spec.tsx index 262bfa6..e85c5e1 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.spec.tsx @@ -6,6 +6,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ApiaryColoniesCard, StartRoundButton } from "./apiary-colonies-card"; import { fetchColonyOverview } from "@/lib/apiaries/field-api"; import { readRound } from "@/lib/rounds/round-state"; +import { toLocalIsoDate } from "@/lib/dates"; import type { ColonyOverview, ColonyOverviewRow } from "@/lib/apiaries/field-types"; vi.mock("@/lib/apiaries/field-api", async (importOriginal) => ({ @@ -16,16 +17,27 @@ vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" })) const apiary = { id: "a-1", name: "Obstwiese" }; +/** YYYY-MM-DD, `offset` days from today (local day, like the component). */ +function dayFromToday(offset: number): string { + const date = new Date(); + date.setDate(date.getDate() + offset); + return toLocalIsoDate(date); +} + function row(overrides: Partial): ColonyOverviewRow { return { id: "c-1", code: "A1", status: "ACTIVE", strength: 6, + occupiedFrames: 9, broodStatus: "GOOD", feedStatus: "GOOD", healthStatus: "GOOD", lastInspectionDate: new Date().toISOString(), + inspectionDueOn: dayFromToday(5), + segment: "SOMMER", + nextStepTitle: null, openWarnings: 0, openCritical: 0, ...overrides, @@ -71,6 +83,26 @@ describe("ApiaryColoniesCard", () => { expect(within(first).getByText("Aktiv")).toBeInTheDocument(); }); + it("apiary rows say „Durchsicht fällig in 2 Tagen“ and „seit 3 Tagen“ and show the next step", async () => { + vi.mocked(fetchColonyOverview).mockResolvedValue( + overview([ + row({ id: "c-1", code: "A1", inspectionDueOn: dayFromToday(2), nextStepTitle: "Varroa messen" }), + row({ id: "c-2", code: "A2", inspectionDueOn: dayFromToday(-3) }), + row({ id: "c-3", code: "A3", inspectionDueOn: null }), + ]), + ); + renderCard(); + + await screen.findByRole("heading", { name: "Völker (3)" }); + const first = screen.getByRole("link", { name: /A1/ }); + expect(within(first).getByText("Durchsicht fällig in 2 Tagen")).toBeInTheDocument(); + expect(within(first).getByText("Als Nächstes: Varroa messen")).toBeInTheDocument(); + const second = screen.getByRole("link", { name: /A2/ }); + expect(within(second).getByText(/Durchsicht seit 3 Tagen fällig/)).toBeInTheDocument(); + const third = screen.getByRole("link", { name: /A3/ }); + expect(within(third).queryByText(/Durchsicht/)).not.toBeInTheDocument(); + }); + it("„Runde starten\" opens the first colony's inspection with the round param", async () => { vi.mocked(fetchColonyOverview).mockResolvedValue( overview([row({ id: "c-1", code: "A1" }), row({ id: "c-2", code: "A2" })]), diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.tsx index 541da56..0102552 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.tsx +++ b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.tsx @@ -7,6 +7,7 @@ import type { ColonyOverview } from "@/lib/apiaries/field-types"; import { useCurrentUserId } from "@/lib/auth/auth-context"; import { COLONY_STATUS_LABELS } from "@/lib/colonies/labels"; import { colonyStatusLight } from "@/lib/colonies/status-light"; +import { daysBetween, inspectionDueText } from "@/lib/autopilot/format"; import { todayLocalIsoDate } from "@/lib/dates"; import { startRound } from "@/lib/rounds/round-state"; import { StatusLightBadge } from "@/components/colonies/status-light-badge"; @@ -68,6 +69,11 @@ export function ColonyOverviewLinks({ overview, hrefFor }: ColonyOverviewLinksPr today, inspectionsVisible: overview.inspectionsVisible, }); + // An overdue inspection is already a reason of the traffic light. + const dueText = + overview.inspectionsVisible && row.inspectionDueOn && daysBetween(today, row.inspectionDueOn) >= 0 + ? inspectionDueText(row.inspectionDueOn, today) + : null; return ( 0 ? ( {reasons.join(" · ")} ) : null} + {dueText ? {dueText} : null} + {row.nextStepTitle ? ( + Als Nächstes: {row.nextStepTitle} + ) : null} 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 68f0c97..ea9dc46 100644 --- a/ApiCore-Frontend/src/app/(app)/bottling/new/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/bottling/new/page.spec.tsx @@ -6,6 +6,7 @@ import NewBottlingBatchPage from "./page"; import { createBottlingBatch } from "@/lib/bottling/api"; import { fetchHoneyBatches } from "@/lib/honey-batches/api"; import { fetchProducts } from "@/lib/products/api"; +import { toast } from "sonner"; const push = vi.fn(); vi.mock("next/navigation", () => ({ @@ -15,6 +16,9 @@ vi.mock("next/navigation", () => ({ vi.mock("@/lib/honey-batches/api", () => ({ fetchHoneyBatches: vi.fn() })); vi.mock("@/lib/products/api", () => ({ fetchProducts: vi.fn() })); vi.mock("@/lib/bottling/api", () => ({ createBottlingBatch: vi.fn() })); +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), warning: vi.fn(), info: vi.fn(), error: vi.fn() }, +})); function renderPage() { const client = new QueryClient({ @@ -36,6 +40,8 @@ describe("NewBottlingBatchPage", () => { ] as never); vi.mocked(fetchProducts).mockResolvedValue([] as never); vi.mocked(createBottlingBatch).mockReset(); + vi.mocked(toast.success).mockReset(); + vi.mocked(toast.warning).mockReset(); }); it("marks the five required fields and explains the units", () => { @@ -86,4 +92,32 @@ describe("NewBottlingBatchPage", () => { expect(screen.getByLabelText(/Anzahl Gläser/)).toHaveFocus(); expect(createBottlingBatch).not.toHaveBeenCalled(); }); + + it("after a bottling with WITHDRAWAL_ACTIVE the toast names the colony and the end day", async () => { + vi.mocked(createBottlingBatch).mockResolvedValue({ + id: "b-1", + warnings: [ + { + code: "WITHDRAWAL_ACTIVE", + severity: "WARNUNG", + message: "B3: Wartezeit nach Oxuvar endet am 20.08.2026.", + refs: [{ type: "Colony", id: "c-3" }], + }, + ], + } as never); + 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(toast.warning).toHaveBeenCalledWith( + "Abfüllung angelegt · 1 Warnung", + expect.objectContaining({ + description: expect.stringMatching(/B3.*20\.08\.2026/), + }), + ); + expect(toast.success).not.toHaveBeenCalled(); + expect(push).toHaveBeenCalledWith("/bottling/b-1"); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/bottling/new/page.tsx b/ApiCore-Frontend/src/app/(app)/bottling/new/page.tsx index 49938a3..c5bf00a 100644 --- a/ApiCore-Frontend/src/app/(app)/bottling/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/bottling/new/page.tsx @@ -10,6 +10,7 @@ import { fetchProducts } from "@/lib/products/api"; import { createBottlingBatch } from "@/lib/bottling/api"; import { useFormDraft } from "@/lib/forms/use-form-draft"; import { errorMessage } from "@/lib/forms/error-message"; +import { toastWarnings } from "@/lib/warnings/toast-warnings"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { RecordSelect } from "@/components/form/record-select"; @@ -104,7 +105,11 @@ export default function NewBottlingBatchPage() { draft.clear(); void queryClient.invalidateQueries({ queryKey: ["bottling-batches"] }); void queryClient.invalidateQueries({ queryKey: ["honey-batches"] }); - toast.success("Abfüllung angelegt"); + if (bottlingBatch.warnings?.length) { + toastWarnings(bottlingBatch.warnings, "Abfüllung angelegt"); + } else { + toast.success("Abfüllung angelegt"); + } router.push(`/bottling/${bottlingBatch.id}`); }, onError: (mutationError: Error) => { diff --git a/ApiCore-Frontend/src/app/(app)/calendar/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/calendar/page.spec.tsx new file mode 100644 index 0000000..f828482 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/calendar/page.spec.tsx @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import CalendarPage from "./page"; +import { fetchCalendarEvents } from "@/lib/calendar/api"; +import { fetchApiaries } from "@/lib/apiaries/api"; + +vi.mock("@/lib/calendar/api", () => ({ fetchCalendarEvents: vi.fn() })); +vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() })); + +function renderPage() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +describe("CalendarPage withdrawal spans", () => { + beforeEach(() => { + // Noon of 15 August 2026: the month grid shows August 2026. + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-08-15T12:00:00.000Z")); + vi.mocked(fetchApiaries).mockResolvedValue([] as never); + vi.mocked(fetchCalendarEvents).mockResolvedValue([ + { + id: "withdrawal:t-1", + type: "WITHDRAWAL", + date: "2026-08-10T00:00:00.000Z", + endDate: "2026-08-12T00:00:00.000Z", + title: "Wartezeit B3 · Oxuvar", + relatedType: "Colony", + relatedId: "c-3", + }, + ]); + }); + + afterEach(() => vi.useRealTimers()); + + it("the calendar renders a withdrawal span across its days", async () => { + renderPage(); + // The phone list repeats the span on every day from start to end, inclusive. + const rows = await screen.findAllByText("Wartezeit B3 · Oxuvar", { selector: "span.truncate" }); + const phoneList = rows[0].closest("div.sm\\:hidden") as HTMLElement; + const sections = within(phoneList).getAllByRole("heading", { level: 2 }); + expect(sections.map((heading) => heading.textContent)).toEqual([ + expect.stringContaining("10. August"), + expect.stringContaining("11. August"), + expect.stringContaining("12. August"), + ]); + expect(within(phoneList).getAllByText("Wartezeit", { selector: "span" })).toHaveLength(3); + }); + + it("links the span to the colony and marks its days in the grid", async () => { + renderPage(); + await screen.findAllByText("Wartezeit B3 · Oxuvar", { selector: "span.truncate" }); + const link = screen.getAllByRole("link", { name: /Wartezeit B3/ })[0]; + expect(link).toHaveAttribute("href", "/colonies/c-3"); + + const day = screen.getByRole("button", { name: /Dienstag, 11\. August.*1 Einträge/ }); + await userEvent.click(day); + expect(within(day).getByText("Wartezeit B3 · Oxuvar")).toBeInTheDocument(); + }); + + it("shows only the August days of a span that starts in July", async () => { + vi.mocked(fetchCalendarEvents).mockResolvedValue([ + { + id: "withdrawal:t-2", + type: "WITHDRAWAL", + date: "2026-07-30T00:00:00.000Z", + endDate: "2026-08-02T00:00:00.000Z", + title: "Wartezeit B4 · Oxuvar", + relatedType: "Colony", + relatedId: "c-4", + }, + ]); + renderPage(); + const rows = await screen.findAllByText("Wartezeit B4 · Oxuvar", { selector: "span.truncate" }); + const phoneList = rows[0].closest("div.sm\\:hidden") as HTMLElement; + const headings = within(phoneList).getAllByRole("heading", { level: 2 }); + expect(headings.map((heading) => heading.textContent)).toEqual([ + expect.stringContaining("1. August"), + expect.stringContaining("2. August"), + ]); + }); + + it("renders a response without WITHDRAWAL or TREATMENT entries", async () => { + vi.mocked(fetchCalendarEvents).mockResolvedValue([ + { + id: "task:1", + type: "TASK", + date: "2026-08-12T00:00:00.000Z", + title: "Beute prüfen", + relatedType: "Task", + relatedId: "t-1", + }, + ]); + renderPage(); + expect((await screen.findAllByText("Beute prüfen", { selector: "span.truncate" })).length).toBeGreaterThan(0); + expect(screen.queryByText(/Wartezeit/)).not.toBeInTheDocument(); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/calendar/page.tsx b/ApiCore-Frontend/src/app/(app)/calendar/page.tsx index 0b05bfe..1a7f137 100644 --- a/ApiCore-Frontend/src/app/(app)/calendar/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/calendar/page.tsx @@ -13,6 +13,7 @@ import { ListTodo, type LucideIcon, Package, + ShieldAlert, Syringe, } from "lucide-react"; import { fetchCalendarEvents } from "@/lib/calendar/api"; @@ -35,8 +36,29 @@ const EVENT_META: Record + new Date(start + offset * DAY_MS).toISOString().slice(0, 10), + ); +} + function eventHref(event: CalendarEvent): string | null { if (event.relatedType === "Task") return "/tasks"; if (event.relatedType === "Colony") return `/colonies/${event.relatedId}`; @@ -145,10 +167,11 @@ export default function CalendarPage() { const eventsByDay = useMemo(() => { const map = new Map(); for (const event of eventsQuery.data ?? []) { - const key = toLocalIsoDate(new Date(event.date)); - const bucket = map.get(key) ?? []; - bucket.push(event); - map.set(key, bucket); + for (const key of eventDayKeys(event)) { + const bucket = map.get(key) ?? []; + bucket.push(event); + map.set(key, bucket); + } } return map; }, [eventsQuery.data]); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.spec.tsx index 1a0d122..bb82901 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.spec.tsx @@ -24,6 +24,9 @@ vi.mock("./colony-swarm-control", () => ({ ColonySwarmControlCard: () => null }) vi.mock("./colony-treatments", () => ({ ColonyTreatmentsCard: () => null })); vi.mock("./colony-feedings", () => ({ ColonyFeedingsCard: () => null })); vi.mock("@/components/document-upload-card", () => ({ DocumentUploadCard: () => null })); +vi.mock("@/components/autopilot/next-steps-card", () => ({ + NextStepsCard: ({ colonyId }: { colonyId: string }) =>

Als Nächstes für {colonyId}

, +})); vi.mock("@/components/qr-code-dialog", () => ({ QrCodeDialog: () => null })); function renderPage() { @@ -97,3 +100,24 @@ describe("ColonyDetailPage – strength", () => { expect(screen.queryByText(/besetzte Waben|Volksstärke/)).not.toBeInTheDocument(); }); }); + +describe("ColonyDetailPage – Als Nächstes", () => { + it("puts the next steps of the colony on top of the page", async () => { + vi.mocked(fetchColony).mockResolvedValue({ + id: "colony-1", + code: "V-01", + colonyType: "PRODUCTION_COLONY", + status: "ACTIVE", + strength: null, + occupiedFrames: null, + } as Colony); + renderPage(); + + const steps = await screen.findByText("Als Nächstes für colony-1"); + // Above the cards: the page's first thing after the header. + const firstCard = screen.getByRole("heading", { level: 1 }).closest("header")!; + expect( + firstCard.compareDocumentPosition(steps) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.tsx index 9ad82cd..93c667f 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.tsx @@ -8,6 +8,7 @@ import { toast } from "sonner"; import { fetchColony } from "@/lib/colonies/api"; import { COLONY_STATUS_LABELS, COLONY_TYPE_LABELS, colonyStrengthText } from "@/lib/colonies/labels"; import { exportColonyAnnualReportPdf, exportColonyRecordCardPdf } from "@/lib/export/api"; +import { NextStepsCard } from "@/components/autopilot/next-steps-card"; import { QrCodeDialog } from "@/components/qr-code-dialog"; import { DocumentUploadCard } from "@/components/document-upload-card"; import { DetailHeader, DetailQueryFallback, DetailStatus } from "@/components/detail-header"; @@ -84,6 +85,7 @@ export default function ColonyDetailPage() { {...BACK} /> {strengthText ?

{strengthText}

: null} +
diff --git a/ApiCore-Frontend/src/app/(app)/dashboard/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/dashboard/page.spec.tsx new file mode 100644 index 0000000..451c481 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/dashboard/page.spec.tsx @@ -0,0 +1,175 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import DashboardPage from "./page"; +import { fetchAutopilotToday } from "@/lib/autopilot/api"; +import { stepFixture, todayFixture } from "@/lib/autopilot/step-fixtures"; +import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api"; +import { fetchTasks } from "@/lib/tasks/api"; +import { ApiError } from "@/lib/auth/api-client"; +import type { Task } from "@/lib/tasks/types"; + +vi.mock("@/lib/auth/auth-context", () => ({ + useAuth: () => ({ user: { name: "Nick Test" }, hasPermission: () => true }), + useCurrentUserId: () => "user-1", +})); +vi.mock("@/lib/dashboard/api", () => ({ + fetchDashboardSummary: vi.fn(), + fetchDashboardAlerts: vi.fn(), +})); +vi.mock("@/lib/tasks/api", () => ({ + fetchTasks: vi.fn(), + completeTask: vi.fn(), + updateTask: vi.fn(), +})); +vi.mock("@/lib/autopilot/api", async (importOriginal) => ({ + ...(await importOriginal()), + fetchAutopilotToday: vi.fn(), +})); +vi.mock("sonner", () => ({ + toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }), +})); + +function overdueTask(): Task { + const yesterday = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(); + return { + id: "t1", + title: "Stock prüfen", + description: null, + priority: "NORMAL", + dueDate: yesterday, + status: "OPEN", + assignedUserId: null, + createdByUserId: null, + relatedType: "GENERAL", + relatedId: null, + sourceInspectionId: null, + sourceTemplateId: null, + sourceAutomationTrigger: null, + createdAt: yesterday, + completedAt: null, + }; +} + +function renderPage() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +describe("DashboardPage – Heute", () => { + beforeEach(() => { + vi.mocked(fetchDashboardSummary).mockResolvedValue({ + apiariesActive: 1, + coloniesActive: 2, + queensActive: 2, + openTasks: 1, + honeyBatchesTotal: 0, + stockItemsLowCount: 0, + } as never); + vi.mocked(fetchDashboardAlerts).mockResolvedValue([]); + vi.mocked(fetchTasks).mockResolvedValue([overdueTask()]); + vi.mocked(fetchAutopilotToday).mockReset(); + }); + + it("the dashboard shows the next step from /autopilot/today and falls back to tasks offline", async () => { + vi.mocked(fetchAutopilotToday).mockResolvedValue( + todayFixture({ + nextStep: { + ...stepFixture({ title: "Durchsicht A1" }), + colonyId: "c1", + colonyCode: "A1", + }, + }), + ); + const { unmount } = renderPage(); + + const section = (await screen.findByRole("heading", { name: "Als Nächstes" })).closest("section")!; + expect(await within(section).findByText("Durchsicht A1")).toBeInTheDocument(); + expect(within(section).getByText("A1")).toBeInTheDocument(); + expect(within(section).getByRole("link", { name: "Erfassen" })).toHaveAttribute( + "href", + "/inspections/new?colonyId=c1", + ); + unmount(); + + vi.mocked(fetchAutopilotToday).mockRejectedValue(new TypeError("Failed to fetch")); + renderPage(); + + const fallback = (await screen.findByRole("heading", { name: "Als Nächstes" })).closest("section")!; + expect(await within(fallback).findByText("Stock prüfen")).toBeInTheDocument(); + }); + + it("shows a „Durchsicht fällig“ card per apiary, the suggestion count and the season per apiary", async () => { + vi.mocked(fetchTasks).mockResolvedValue([]); + vi.mocked(fetchAutopilotToday).mockResolvedValue( + todayFixture({ + suggestionsOpen: 4, + inspectionDue: [ + { + apiaryId: "a-1", + apiaryName: "Obstwiese", + segment: "SCHWARMZEIT", + intervalDays: 7, + colonies: [{ id: "c-1", code: "A1", dueOn: "2026-05-12", overdueDays: 0 }], + }, + ], + seasons: [{ apiaryId: "a-1", apiaryName: "Obstwiese", segment: "SCHWARMZEIT" }], + }), + ); + renderPage(); + + expect( + await screen.findByRole("heading", { name: /Durchsicht fällig · Obstwiese/ }), + ).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Vorschläge 4/ })).toHaveAttribute("href", "/suggestions"); + expect(screen.getByText("Obstwiese: Schwarmzeit")).toBeInTheDocument(); + }); + + it("falls back silently for a network error and a 403, but says so for other errors and allows a retry", async () => { + vi.mocked(fetchAutopilotToday).mockRejectedValue(new ApiError(403, "Forbidden", {})); + const first = renderPage(); + await screen.findByText("Stock prüfen"); + expect(screen.queryByText(/Hinweise des Autopiloten/)).not.toBeInTheDocument(); + first.unmount(); + + vi.mocked(fetchAutopilotToday).mockRejectedValue(new ApiError(500, "boom", {})); + renderPage(); + expect(await screen.findByText(/Hinweise des Autopiloten/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Erneut versuchen" })).toBeInTheDocument(); + expect(await screen.findByText("Stock prüfen")).toBeInTheDocument(); + }); + + it("says the tasks failed to load instead of „nichts fällig“ when only the autopilot answered", async () => { + vi.mocked(fetchTasks).mockRejectedValue(new ApiError(500, "boom", {})); + vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture()); + renderPage(); + + expect(await screen.findByText("Die Aufgaben konnten gerade nicht geladen werden.")).toBeInTheDocument(); + expect(screen.queryByText(/Heute ist nichts fällig/)).not.toBeInTheDocument(); + }); + + it("keeps one primary button: „Runde starten“ is an outline button below a step card", async () => { + vi.mocked(fetchAutopilotToday).mockResolvedValue( + todayFixture({ + nextStep: { ...stepFixture({ title: "Durchsicht A1" }), colonyId: "c1", colonyCode: "A1" }, + inspectionDue: [ + { + apiaryId: "a-1", + apiaryName: "Obstwiese", + segment: "SCHWARMZEIT", + intervalDays: 7, + colonies: [{ id: "c-1", code: "A1", dueOn: "2026-05-12", overdueDays: 0 }], + }, + ], + }), + ); + renderPage(); + + expect(await screen.findByRole("link", { name: "Erfassen" })).toHaveClass("bg-primary"); + expect(screen.getByRole("link", { name: "Runde starten" })).not.toHaveClass("bg-primary"); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx b/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx index ce5dcb6..5a2badc 100644 --- a/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx @@ -15,11 +15,15 @@ import { ShieldAlert, Sprout, } from "lucide-react"; +import { ApiError } from "@/lib/auth/api-client"; import { useAuth } from "@/lib/auth/auth-context"; +import { isNetworkError } from "@/lib/http/fetch-with-timeout"; +import { autopilotTodayKey, fetchAutopilotToday } from "@/lib/autopilot/api"; import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api"; import { DashboardAlert, DashboardAlertType } from "@/lib/dashboard/types"; import { fetchTasks } from "@/lib/tasks/api"; import { tasksForToday } from "@/lib/tasks/today"; +import { Button } from "@/components/ui/button"; import { HoneycombPattern } from "@/components/honeycomb-pattern"; import { TodayPanel } from "@/components/today/today-panel"; @@ -97,9 +101,22 @@ export default function DashboardPage() { queryKey: ["tasks", "dashboard"], queryFn: () => fetchTasks({}), }); + // Without the autopilot (offline, missing right) "Heute" works from the tasks alone. + const autopilotQuery = useQuery({ + queryKey: autopilotTodayKey, + queryFn: fetchAutopilotToday, + retry: false, + }); // Overdue tasks already appear in "Heute"; listing them again as alerts // would announce the same thing twice. + // Offline and a missing right are normal: "Heute" then works from the tasks. + // Any other failure is said out loud. + const autopilotError = autopilotQuery.isError ? autopilotQuery.error : null; + const isAutopilotSilent = + autopilotError !== null && + (isNetworkError(autopilotError) || + (autopilotError instanceof ApiError && autopilotError.status === 403)); const otherAlerts = (alerts ?? []).filter((alert) => alert.type !== "OVERDUE_TASK"); const todayCount = tasksQuery.data ? tasksForToday(tasksQuery.data, now).length : null; const firstName = user?.name.trim().split(/\s+/)[0]; @@ -145,12 +162,30 @@ export default function DashboardPage() {
+ {autopilotError && !isAutopilotSilent ? ( +
+

Die Hinweise des Autopiloten konnten nicht geladen werden.

+ +
+ ) : null} + void tasksQuery.refetch()} now={now} + autopilot={autopilotQuery.data ?? null} /> {alertsLoading || otherAlerts.length > 0 ? ( diff --git a/ApiCore-Frontend/src/app/(app)/harvests/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/harvests/new/page.spec.tsx index 4f0c497..363bafd 100644 --- a/ApiCore-Frontend/src/app/(app)/harvests/new/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/harvests/new/page.spec.tsx @@ -7,6 +7,7 @@ import { createHarvest } from "@/lib/harvests/api"; import { fetchColonies } from "@/lib/colonies/api"; import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchWithdrawalStatus } from "@/lib/treatments/api"; +import { toast } from "sonner"; const push = vi.fn(); vi.mock("next/navigation", () => ({ @@ -18,6 +19,9 @@ vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() })); vi.mock("@/lib/treatments/api", () => ({ fetchWithdrawalStatus: vi.fn() })); vi.mock("@/lib/harvests/api", () => ({ createHarvest: vi.fn() })); vi.mock("@/lib/offline/db", () => ({ queueHarvest: vi.fn() })); +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), warning: vi.fn(), info: vi.fn(), error: vi.fn() }, +})); function renderPage() { const client = new QueryClient({ @@ -43,6 +47,8 @@ describe("NewHarvestPage", () => { ] as never); vi.mocked(fetchWithdrawalStatus).mockResolvedValue([]); vi.mocked(createHarvest).mockReset(); + vi.mocked(toast.success).mockReset(); + vi.mocked(toast.warning).mockReset(); }); it("marks apiary, date and colonies as required and explains gross vs net", () => { @@ -139,4 +145,29 @@ describe("NewHarvestPage", () => { ).toHaveFocus(); expect(createHarvest).not.toHaveBeenCalled(); }); + + it("toasts the warnings of an online save and plain success without any", async () => { + vi.mocked(createHarvest).mockResolvedValue({ + id: "h-1", + warnings: [ + { + code: "WITHDRAWAL_ACTIVE", + severity: "WARNUNG", + message: "V-01: Wartezeit nach Oxuvar endet am 20.08.2026.", + refs: [], + }, + ], + } as never); + renderPage(); + + await userEvent.click(await screen.findByRole("checkbox", { name: "V-01" })); + await userEvent.click(screen.getByRole("button", { name: "Ernte speichern" })); + + expect(toast.warning).toHaveBeenCalledWith( + "Ernte gespeichert · 1 Warnung", + expect.objectContaining({ description: expect.stringContaining("20.08.2026") }), + ); + expect(toast.success).not.toHaveBeenCalled(); + expect(push).toHaveBeenCalledWith("/harvests/h-1"); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/harvests/new/page.tsx b/ApiCore-Frontend/src/app/(app)/harvests/new/page.tsx index 27fd88a..0604a3b 100644 --- a/ApiCore-Frontend/src/app/(app)/harvests/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/harvests/new/page.tsx @@ -14,6 +14,7 @@ import { fetchWithdrawalStatus } from "@/lib/treatments/api"; import { todayLocalIsoDate } from "@/lib/dates"; import { queueHarvest } from "@/lib/offline/db"; import { useCaptureMutation } from "@/lib/offline/use-capture-mutation"; +import { toastWarnings } from "@/lib/warnings/toast-warnings"; import { useFormDraft } from "@/lib/forms/use-form-draft"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -129,7 +130,12 @@ export default function NewHarvestPage() { return; } void queryClient.invalidateQueries({ queryKey: ["harvests"] }); - toast.success("Ernte gespeichert"); + // Warnings come only from an online answer; a queued entry has none. + if (outcome.result.warnings?.length) { + toastWarnings(outcome.result.warnings, "Ernte gespeichert"); + } else { + toast.success("Ernte gespeichert"); + } router.push(`/harvests/${outcome.result.id}`); }, }); diff --git a/ApiCore-Frontend/src/app/(app)/offline/colony/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/offline/colony/page.spec.tsx index efddbd8..9910cbb 100644 --- a/ApiCore-Frontend/src/app/(app)/offline/colony/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/offline/colony/page.spec.tsx @@ -14,6 +14,7 @@ import { packTask, storedPack, } from "@/lib/apiaries/offline-pack.fixtures"; +import { stepFixture } from "@/lib/autopilot/step-fixtures"; import OfflineColonyPage from "./page"; let searchString = "id=colony-1"; @@ -79,6 +80,51 @@ describe("OfflineColonyPage", () => { await deleteAllPacks(); }); + it("shows the next steps of the pack read-only, with links to the capture forms", async () => { + await putPacks( + storedPack({ + ...fullPack(), + nextSteps: { "colony-1": [stepFixture({ title: "Durchsicht fällig" })] }, + }), + ); + renderPage(); + + const section = (await screen.findByRole("heading", { name: "Als Nächstes" })).closest( + "section", + ) as HTMLElement; + expect(within(section).getByText("Durchsicht fällig")).toBeInTheDocument(); + expect(within(section).getByRole("link", { name: "Erfassen" })).toHaveAttribute( + "href", + "/inspections/new?colonyId=c1", + ); + expect(within(section).queryByRole("button")).not.toBeInTheDocument(); + }); + + it("hides the link to the task list offline but keeps the capture links", async () => { + await putPacks( + storedPack({ + ...fullPack(), + nextSteps: { + "colony-1": [ + stepFixture({ + key: "task:t1", + kind: "TASK", + title: "Aufgabe X", + suggestionIds: [], + action: { type: "OPEN_TASK", taskId: "t1", href: "/tasks" }, + }), + stepFixture({ key: "suggestion:s2", title: "Durchsicht" }), + ], + }, + }), + ); + renderPage(); + + await screen.findByText("Aufgabe X"); + expect(screen.queryByRole("link", { name: "Aufgabe öffnen" })).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Erfassen" })).toBeInTheDocument(); + }); + it("renders colony, queen, last three inspections and open tasks from the pack", async () => { await putPacks(storedPack(fullPack())); renderPage(); @@ -90,7 +136,7 @@ describe("OfflineColonyPage", () => { expect(within(header).getByText("Gut")).toBeInTheDocument(); expect(within(header).getByText("Aktiv")).toBeInTheDocument(); const colony = screen.getByRole("region", { name: "Volk" }); - expect(within(colony).getByText("9 Waben")).toBeInTheDocument(); + expect(within(colony).getByText("9 besetzte Waben")).toBeInTheDocument(); const queen = screen.getByRole("region", { name: "Königin" }); expect(within(queen).getByText("K-12")).toBeInTheDocument(); diff --git a/ApiCore-Frontend/src/app/(app)/offline/colony/page.tsx b/ApiCore-Frontend/src/app/(app)/offline/colony/page.tsx index f8df522..14c7148 100644 --- a/ApiCore-Frontend/src/app/(app)/offline/colony/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/offline/colony/page.tsx @@ -17,12 +17,14 @@ import { COLONY_CONDITION_LABELS, COLONY_STATUS_LABELS, COLONY_TYPE_LABELS, + colonyStrengthText, } from "@/lib/colonies/labels"; import { colonyStatusLight } from "@/lib/colonies/status-light"; import type { ColonyCondition } from "@/lib/colonies/types"; import { todayLocalIsoDate } from "@/lib/dates"; import type { StoredPack } from "@/lib/offline/db"; import { QUEEN_STATUS_LABELS } from "@/lib/queens/labels"; +import { NextStepsView } from "@/components/autopilot/next-steps-card"; import { StatusLightBadge } from "@/components/colonies/status-light-badge"; import { DetailHeader, DetailStatus } from "@/components/detail-header"; import { PackAgeNote } from "@/components/offline/load-pack-button"; @@ -152,6 +154,12 @@ function OfflineColonyView({ stored, colony, readAt, returnHref }: OfflineColony {light && light.reasons.length > 0 ? (

{light.reasons.join(" · ")}

) : null} + {/* Read-only here: only the links to the capture forms work without network. */} +
{pack.queens !== null ? : null} @@ -186,7 +194,7 @@ function ColonyFacts({ colony }: { colony: PackColony }) { return (
- + diff --git a/ApiCore-Frontend/src/app/(app)/profile/notification-settings-card.spec.tsx b/ApiCore-Frontend/src/app/(app)/profile/notification-settings-card.spec.tsx new file mode 100644 index 0000000..35c8f18 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/profile/notification-settings-card.spec.tsx @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { NotificationSettingsCard } from "./notification-settings-card"; +import { + fetchNotificationSettings, + updateNotificationSettings, +} from "@/lib/notification-settings/api"; +import type { NotificationSettings } from "@/lib/notification-settings/types"; + +vi.mock("@/lib/notification-settings/api", () => ({ + fetchNotificationSettings: vi.fn(), + updateNotificationSettings: vi.fn(), +})); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +const settings: NotificationSettings = { + taskDueEnabled: true, + digestEnabled: true, + digestTime: "06:30", + quietFrom: "21:00", + quietUntil: "06:30", + telegramCategories: ["TASK_DUE", "DIGEST"], + telegramDetail: true, +}; + +let client: QueryClient; + +function renderCard() { + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +describe("NotificationSettingsCard", () => { + beforeEach(() => { + vi.mocked(fetchNotificationSettings).mockReset().mockResolvedValue(settings); + vi.mocked(updateNotificationSettings).mockReset().mockResolvedValue(settings); + }); + + it("saving sends only changed fields", async () => { + renderCard(); + await userEvent.click(await screen.findByRole("switch", { name: "Erinnerung je Aufgabe" })); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + await waitFor(() => expect(updateNotificationSettings).toHaveBeenCalled()); + expect(updateNotificationSettings).toHaveBeenCalledWith({ taskDueEnabled: false }); + }); + + it("rejects 04:30 with the German message and does not save", async () => { + renderCard(); + const time = await screen.findByLabelText("Uhrzeit der Morgenübersicht"); + await userEvent.clear(time); + await userEvent.type(time, "04:30"); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + expect( + await screen.findByText("Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen."), + ).toBeInTheDocument(); + expect(updateNotificationSettings).not.toHaveBeenCalled(); + }); + + it("clears quiet hours only as a pair", async () => { + renderCard(); + await userEvent.clear(await screen.findByLabelText("Ruhezeit von")); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + expect( + await screen.findByText("Bitte Beginn und Ende der Ruhezeit angeben oder beide leeren."), + ).toBeInTheDocument(); + expect(updateNotificationSettings).not.toHaveBeenCalled(); + + await userEvent.clear(screen.getByLabelText("Ruhezeit bis")); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + await waitFor(() => + expect(updateNotificationSettings).toHaveBeenCalledWith({ + quietFrom: null, + quietUntil: null, + }), + ); + }); + + it("shows the defaults 06:30 and 21:00 to 06:30", async () => { + renderCard(); + expect(await screen.findByLabelText("Uhrzeit der Morgenübersicht")).toHaveValue("06:30"); + expect(screen.getByLabelText("Ruhezeit von")).toHaveValue("21:00"); + expect(screen.getByLabelText("Ruhezeit bis")).toHaveValue("06:30"); + }); + + it("diffs against the values from when editing started, not a later refetch", async () => { + renderCard(); + const time = await screen.findByLabelText("Uhrzeit der Morgenübersicht"); + await userEvent.clear(time); + await userEvent.type(time, "07:00"); + // Another device switched the task reminder off meanwhile. + act(() => client.setQueryData(["notification-settings"], { ...settings, taskDueEnabled: false })); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + await waitFor(() => expect(updateNotificationSettings).toHaveBeenCalled()); + expect(updateNotificationSettings).toHaveBeenCalledWith({ digestTime: "07:00" }); + }); + + it("links the error texts to their inputs", async () => { + renderCard(); + const time = await screen.findByLabelText("Uhrzeit der Morgenübersicht"); + await userEvent.clear(time); + await userEvent.type(time, "04:30"); + await userEvent.clear(screen.getByLabelText("Ruhezeit von")); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + const digestError = await screen.findByText("Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen."); + expect(time.getAttribute("aria-describedby")).toContain(digestError.id); + const quietError = screen.getByText("Bitte Beginn und Ende der Ruhezeit angeben oder beide leeren."); + expect(screen.getByLabelText("Ruhezeit von").getAttribute("aria-describedby")).toContain(quietError.id); + expect(screen.getByLabelText("Ruhezeit bis").getAttribute("aria-describedby")).toContain(quietError.id); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/profile/notification-settings-card.tsx b/ApiCore-Frontend/src/app/(app)/profile/notification-settings-card.tsx new file mode 100644 index 0000000..61a2901 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/profile/notification-settings-card.tsx @@ -0,0 +1,285 @@ +"use client"; + +import { FormEvent, useId, useRef, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { + fetchNotificationSettings, + updateNotificationSettings, +} from "@/lib/notification-settings/api"; +import type { NotificationSettings } from "@/lib/notification-settings/types"; +import { + settingsPatch, + validateDigestTime, + validateQuietHours, +} from "@/lib/notification-settings/validation"; +import { errorMessage } from "@/lib/forms/error-message"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { ErrorState, LoadingState } from "@/components/query-state"; + +const QUERY_KEY = ["notification-settings"] as const; + +interface SwitchRowProps { + label: string; + description?: string; + checked: boolean; + onChange: (checked: boolean) => void; +} + +function SwitchRow({ label, description, checked, onChange }: SwitchRowProps) { + const id = useId(); + return ( + + ); +} + +interface FormErrors { + digestTime?: string; + quiet?: string; +} + +function NotificationSettingsForm({ + settings, +}: { + settings: NotificationSettings; +}) { + const queryClient = useQueryClient(); + const [draft, setDraft] = useState(settings); + // The values editing started from. A refetch meanwhile must not change what + // counts as "changed": only the person's own edits go into the patch. + const baseline = useRef(settings); + const [errors, setErrors] = useState({}); + const [submitError, setSubmitError] = useState(null); + + const mutation = useMutation({ + mutationFn: (patch: Partial) => + updateNotificationSettings(patch), + onSuccess: (saved) => { + queryClient.setQueryData(QUERY_KEY, saved); + baseline.current = saved; + setDraft(saved); + toast.success("Benachrichtigungen gespeichert"); + }, + onError: (error: Error) => + setSubmitError( + errorMessage(error, "Einstellungen konnten nicht gespeichert werden."), + ), + }); + + function change( + key: K, + value: NotificationSettings[K], + ) { + setDraft((current) => ({ ...current, [key]: value })); + setErrors((current) => ({ + ...current, + ...(key === "digestTime" ? { digestTime: undefined } : {}), + })); + } + + function changeQuiet(key: "quietFrom" | "quietUntil", value: string) { + setDraft((current) => ({ ...current, [key]: value === "" ? null : value })); + setErrors((current) => ({ ...current, quiet: undefined })); + } + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + setSubmitError(null); + const next: FormErrors = { + digestTime: validateDigestTime(draft.digestTime) ?? undefined, + quiet: + validateQuietHours(draft.quietFrom ?? "", draft.quietUntil ?? "") ?? + undefined, + }; + setErrors(next); + if (next.digestTime || next.quiet) return; + + const patch = settingsPatch(baseline.current, draft); + if (Object.keys(patch).length === 0) { + toast.success("Nichts geändert"); + return; + } + mutation.mutate(patch); + } + + const hasQuietHours = draft.quietFrom !== null || draft.quietUntil !== null; + + return ( +
+ change("taskDueEnabled", checked)} + /> + change("digestEnabled", checked)} + /> +
+ + change("digestTime", event.target.value)} + /> +

+ Zwischen 05:00 und 12:00 Uhr. +

+ {errors.digestTime ? ( + + ) : null} +
+
+ Ruhezeit +

+ In dieser Zeit kommen keine Nachrichten außerhalb der App. +

+
+
+ + changeQuiet("quietFrom", event.target.value)} + /> +
+
+ + + changeQuiet("quietUntil", event.target.value) + } + /> +
+ {hasQuietHours ? ( + + ) : null} +
+ {errors.quiet ? ( + + ) : null} +
+ {submitError ? ( +

+ {submitError} +

+ ) : null} + + + ); +} + +export function NotificationSettingsCard() { + const query = useQuery({ + queryKey: QUERY_KEY, + queryFn: fetchNotificationSettings, + }); + + return ( + + + Benachrichtigungen + + Welche Erinnerungen du bekommst und wann. + + + + {query.isLoading ? : null} + {query.isError ? ( + void query.refetch()} + isRetrying={query.isFetching} + /> + ) : null} + {query.data ? : null} + + + ); +} diff --git a/ApiCore-Frontend/src/app/(app)/profile/page.tsx b/ApiCore-Frontend/src/app/(app)/profile/page.tsx index 2857496..3ab53de 100644 --- a/ApiCore-Frontend/src/app/(app)/profile/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/profile/page.tsx @@ -27,6 +27,7 @@ import { import { MfaSection } from "./mfa-section"; import { DangerZoneSection } from "./danger-zone-section"; import { MotionSection } from "./motion-section"; +import { NotificationSettingsCard } from "./notification-settings-card"; function splitName(fullName: string): { firstName: string; lastName: string } { const [firstName, ...rest] = fullName.trim().split(/\s+/); @@ -302,6 +303,7 @@ export default function ProfilePage() { void refreshUser()} /> +
diff --git a/ApiCore-Frontend/src/app/(app)/suggestions/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/suggestions/page.spec.tsx index d879cd3..6fdf0a0 100644 --- a/ApiCore-Frontend/src/app/(app)/suggestions/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/suggestions/page.spec.tsx @@ -43,6 +43,11 @@ vi.mock("@/lib/suggestions/api", async (importOriginal) => ({ reopenSuggestion: vi.fn(), })); +vi.mock("@/components/suggestions/cleanup-sheet", () => ({ + CleanupSheet: ({ suggestionId, colonyId }: { suggestionId: string | null; colonyId: string | null }) => + suggestionId ?

Aufräumen-Sheet {suggestionId} für {colonyId}

: null, +})); + const OPEN: Suggestion[] = [ suggestionFixture({ id: "s1", colonyId: "c1", colonyCode: "A1", intent: "HONEY_SUPER" }), suggestionFixture({ id: "s2", colonyId: "c2", colonyCode: "A2", intent: "HONEY_SUPER" }), @@ -454,4 +459,23 @@ describe("SuggestionsPage", () => { expect(screen.queryByRole("button", { name: "Alle übernehmen" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Verwerfen" })).not.toBeInTheDocument(); }); + + it("offers „Aufräumen“ for a CLEANUP suggestion and opens the cleanup sheet", async () => { + mockLists([ + suggestionFixture({ + id: "k1", + intent: "CLEANUP", + mode: "DO", + colonyId: "c9", + colonyCode: "B3", + title: "B3 aufräumen", + cleanup: { taskIds: ["t1"], queenId: null }, + }), + ]); + renderPage(); + + await userEvent.click(await screen.findByRole("button", { name: "Aufräumen" })); + + expect(screen.getByText("Aufräumen-Sheet k1 für c9")).toBeInTheDocument(); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/suggestions/page.tsx b/ApiCore-Frontend/src/app/(app)/suggestions/page.tsx index 970aa77..e54c5bd 100644 --- a/ApiCore-Frontend/src/app/(app)/suggestions/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/suggestions/page.tsx @@ -8,6 +8,7 @@ import { toast } from "sonner"; import { EmptyState } from "@/components/empty-state"; import { PageHeader } from "@/components/page-header"; import { QueryState } from "@/components/query-state"; +import { CleanupSheet } from "@/components/suggestions/cleanup-sheet"; import { SuggestionGroup } from "@/components/suggestions/suggestion-group"; import { Button } from "@/components/ui/button"; import { useAuth } from "@/lib/auth/auth-context"; @@ -99,6 +100,10 @@ export default function SuggestionsPage() { const countRef = useRef(null); const pendingFocus = useRef(null); const [pendingIds, setPendingIds] = useState>(new Set()); + const [cleanupTarget, setCleanupTarget] = useState<{ + id: string; + colonyId: string | null; + } | null>(null); const openQuery = useInfiniteQuery({ queryKey: [...suggestionsKey({ status: "OPEN" }), "pages"], @@ -314,6 +319,9 @@ export default function SuggestionsPage() { onDismiss={(suggestion) => dismissMutation.mutate(suggestion)} onAcceptAll={(ids) => acceptAllMutation.mutate(ids)} onStartRound={handleStartRound} + onCleanup={(suggestion) => + setCleanupTarget({ id: suggestion.id, colonyId: suggestion.colonyId }) + } /> ))} {total > loaded && openQuery.hasNextPage ? ( @@ -369,6 +377,17 @@ export default function SuggestionsPage() { ) : null}
+ setCleanupTarget(null)} + onDone={() => { + if (cleanupTarget) { + requestFocus({ target: neighbourOf(cleanupTarget.id), gone: cleanupTarget.id }); + } + refresh(); + }} + /> ); } diff --git a/ApiCore-Frontend/src/app/(app)/winter/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/winter/page.spec.tsx new file mode 100644 index 0000000..092b137 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/winter/page.spec.tsx @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import WinterPage from "./page"; +import { fetchApiaries } from "@/lib/apiaries/api"; +import { fetchColonies } from "@/lib/colonies/api"; +import { fetchWinterChecks, fetchWinterSummary } from "@/lib/winter-checks/api"; +import type { Apiary } from "@/lib/apiaries/types"; +import type { Colony } from "@/lib/colonies/types"; + +let search = ""; +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(search), +})); +vi.mock("@/lib/auth/auth-context", () => ({ + useAuth: () => ({ hasPermission: () => true }), +})); +vi.mock("@/lib/winter-checks/api", () => ({ + fetchWinterChecks: vi.fn(), + fetchWinterSummary: vi.fn(), +})); +vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() })); +vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() })); +vi.mock("./winter-check-dialog", () => ({ + WinterCheckDialog: ({ target }: { target: { code: string; season: string } }) => ( +

+ Dialog {target.code} {target.season} +

+ ), +})); + +function colony(id: string, code: string): Colony { + return { id, code, apiaryId: "a-1", status: "ACTIVE" } as Colony; +} + +function renderPage() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +describe("WinterPage", () => { + beforeEach(() => { + search = ""; + vi.mocked(fetchWinterChecks).mockResolvedValue([]); + vi.mocked(fetchWinterSummary).mockResolvedValue({ + total: { wintered: 0, survived: 0, weak: 0, lost: 0, lostByCause: {}, lossRatePercent: null, pendingSpring: 0, isProvisional: false }, + } as never); + vi.mocked(fetchColonies).mockResolvedValue([colony("c1", "A1"), colony("c2", "A2")]); + vi.mocked(fetchApiaries).mockResolvedValue([{ id: "a-1", name: "Obstwiese" } as Apiary]); + }); + + it("/winter?colonyId=c1&season=SPRING opens the dialog for that colony", async () => { + search = "colonyId=c1&season=SPRING"; + renderPage(); + + expect(await screen.findByRole("dialog")).toHaveTextContent("Dialog A1 SPRING"); + }); + + it("opens the autumn dialog for season=AUTUMN", async () => { + search = "colonyId=c2&season=AUTUMN"; + renderPage(); + + expect(await screen.findByRole("dialog")).toHaveTextContent("Dialog A2 AUTUMN"); + }); + + it("opens nothing without the parameters or for an unknown colony", async () => { + search = "colonyId=nope&season=SPRING"; + renderPage(); + + await screen.findByText("A1"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/winter/page.tsx b/ApiCore-Frontend/src/app/(app)/winter/page.tsx index 44e5b8b..63079b9 100644 --- a/ApiCore-Frontend/src/app/(app)/winter/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/winter/page.tsx @@ -1,8 +1,9 @@ "use client"; -import { useMemo, useState } from "react"; +import { Suspense, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; +import { useSearchParams } from "next/navigation"; import { ChevronLeft, ChevronRight, @@ -155,8 +156,19 @@ function RowActions({ ); } +/** `?colonyId=&season=` (a link from a next step) opens the dialog for that colony. */ export default function WinterPage() { + return ( + }> + + + ); +} + +function WinterContent() { const { hasPermission } = useAuth(); + const searchParams = useSearchParams(); + const [isDeepLinkClosed, setIsDeepLinkClosed] = useState(false); const [winterYear, setWinterYear] = useState(() => winterYearFor(new Date())); const [target, setTarget] = useState(null); const canCreate = hasPermission("winter-checks:create"); @@ -190,16 +202,41 @@ export default function WinterPage() { 0, ); - function open(row: WinterRow, season: "AUTUMN" | "SPRING", existing: WinterCheckWithColony | null) { - setTarget({ + function targetFor( + row: WinterRow, + season: "AUTUMN" | "SPRING", + existing: WinterCheckWithColony | null, + ): WinterCheckTarget { + return { colonyId: row.colonyId, code: row.code, isLiving: row.isLiving, season, winterYear, existing, - }); + }; } + function open(row: WinterRow, season: "AUTUMN" | "SPRING", existing: WinterCheckWithColony | null) { + setTarget(targetFor(row, season, existing)); + } + + // The deep link counts once the lists are loaded; closing the dialog ends it. + const deepColonyId = searchParams.get("colonyId"); + const deepSeason = searchParams.get("season"); + const isDeepSeason = deepSeason === "AUTUMN" || deepSeason === "SPRING"; + const isLoaded = [checksQuery, coloniesQuery, apiariesQuery].every((query) => query.isSuccess); + const deepRow = + !isDeepLinkClosed && isLoaded && deepColonyId !== null && isDeepSeason + ? groups.flatMap((group) => group.rows).find((row) => row.colonyId === deepColonyId) + : undefined; + const deepExisting = + deepRow && deepSeason === "AUTUMN" ? deepRow.autumn : (deepRow?.spring ?? null); + const canOpenDeepLink = deepExisting ? canUpdate : canCreate; + const deepTarget = + deepRow && isDeepSeason && canOpenDeepLink + ? targetFor(deepRow, deepSeason, deepExisting) + : null; + const activeTarget = target ?? deepTarget; const listQueries = [checksQuery, coloniesQuery, apiariesQuery]; const isLoading = listQueries.some((query) => query.isLoading); @@ -308,7 +345,15 @@ export default function WinterPage() { )) )} - {target ? setTarget(null)} /> : null} + {activeTarget ? ( + { + setTarget(null); + setIsDeepLinkClosed(true); + }} + /> + ) : null} ); } diff --git a/ApiCore-Frontend/src/components/autopilot/next-steps-card.spec.tsx b/ApiCore-Frontend/src/components/autopilot/next-steps-card.spec.tsx new file mode 100644 index 0000000..f9b2dae --- /dev/null +++ b/ApiCore-Frontend/src/components/autopilot/next-steps-card.spec.tsx @@ -0,0 +1,236 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { NextStepsCard } from "./next-steps-card"; +import { fetchColonyNextSteps } from "@/lib/autopilot/api"; +import { colonyStepsFixture, stepFixture } from "@/lib/autopilot/step-fixtures"; +import { acceptSuggestion, bulkDismissSuggestions, reopenSuggestion } from "@/lib/suggestions/api"; +import { ApiError } from "@/lib/auth/api-client"; +import { toast } from "sonner"; + +vi.mock("@/lib/autopilot/api", async (importOriginal) => ({ + ...(await importOriginal()), + fetchColonyNextSteps: vi.fn(), +})); +vi.mock("@/lib/suggestions/api", async (importOriginal) => ({ + ...(await importOriginal()), + acceptSuggestion: vi.fn(), + bulkDismissSuggestions: vi.fn(), + reopenSuggestion: vi.fn(), +})); +vi.mock("@/lib/auth/auth-context", () => ({ + useAuth: () => ({ hasPermission: () => true }), +})); +vi.mock("@/components/suggestions/cleanup-sheet", () => ({ + CleanupSheet: ({ suggestionId }: { suggestionId: string | null }) => + suggestionId ?

Aufräumen-Sheet {suggestionId}

: null, +})); +vi.mock("sonner", () => ({ + toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }), +})); + +function renderCard() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +const TASK_STEP = stepFixture({ + key: "task:t2", + kind: "TASK", + title: "Zwei", + reasons: [], + suggestionIds: [], + action: { type: "OPEN_TASK", taskId: "t2", href: "/tasks" }, +}); + +describe("NextStepsCard", () => { + beforeEach(() => { + vi.mocked(fetchColonyNextSteps).mockReset(); + vi.mocked(acceptSuggestion).mockReset(); + vi.mocked(bulkDismissSuggestions).mockReset(); + }); + + it("NextStepsCard shows at most 3 steps with reasons and links CAPTURE to the form with colonyId", async () => { + vi.mocked(fetchColonyNextSteps).mockResolvedValue( + colonyStepsFixture({ + steps: [ + stepFixture({ key: "suggestion:s1", title: "Durchsicht" }), + TASK_STEP, + stepFixture({ key: "suggestion:s3", title: "Drei", suggestionIds: ["s3"] }), + stepFixture({ key: "suggestion:s4", title: "Vier", suggestionIds: ["s4"] }), + ], + }), + ); + renderCard(); + + expect(await screen.findByText("Durchsicht")).toBeInTheDocument(); + expect(screen.getAllByRole("heading", { level: 3 })).toHaveLength(3); + expect(screen.queryByText("Vier")).not.toBeInTheDocument(); + expect(screen.getAllByText("Letzte Durchsicht vor 8 Tagen.").length).toBeGreaterThan(0); + expect(screen.getAllByText("In der Schwarmzeit alle 7 Tage.").length).toBeGreaterThan(0); + const first = screen.getAllByRole("heading", { level: 3 })[0].closest("li") as HTMLElement; + expect(within(first).getByRole("link", { name: "Erfassen" })).toHaveAttribute( + "href", + "/inspections/new?colonyId=c1", + ); + expect(screen.getByRole("link", { name: "Aufgabe öffnen" })).toHaveAttribute("href", "/tasks"); + }); + + it("NextStepsCard shows the withdrawal hint, not a step", async () => { + vi.mocked(fetchColonyNextSteps).mockResolvedValue( + colonyStepsFixture({ steps: [], withdrawalUntil: "2026-05-05" }), + ); + renderCard(); + + expect(await screen.findByText("Wartezeit bis 05.05.")).toBeInTheDocument(); + expect(screen.queryAllByRole("listitem")).toHaveLength(0); + }); + + it("accepts a step with one tap and an idempotency key", async () => { + vi.mocked(fetchColonyNextSteps).mockResolvedValue( + colonyStepsFixture({ + steps: [ + stepFixture({ + intent: "HONEY_SUPER", + title: "Honigraum", + action: { type: "ACCEPT", suggestionId: "s1" }, + }), + ], + }), + ); + vi.mocked(acceptSuggestion).mockResolvedValue({} as never); + renderCard(); + + await userEvent.click(await screen.findByRole("button", { name: "Übernehmen" })); + + expect(acceptSuggestion).toHaveBeenCalledWith("s1", {}, { idempotencyKey: expect.any(String) }); + }); + + it("dismisses a merged step with all its suggestion ids", async () => { + vi.mocked(fetchColonyNextSteps).mockResolvedValue( + colonyStepsFixture({ steps: [stepFixture({ suggestionIds: ["s1", "s2"] })] }), + ); + vi.mocked(bulkDismissSuggestions).mockResolvedValue({ dismissed: 2 }); + renderCard(); + + await userEvent.click(await screen.findByRole("button", { name: "Verwerfen" })); + + expect(bulkDismissSuggestions).toHaveBeenCalledWith(["s1", "s2"], { + idempotencyKey: expect.any(String), + }); + }); + + it("opens the cleanup sheet for a CLEANUP step", async () => { + vi.mocked(fetchColonyNextSteps).mockResolvedValue( + colonyStepsFixture({ + steps: [ + stepFixture({ + intent: "CLEANUP", + title: "Volk aufräumen", + suggestionIds: ["s9"], + action: { type: "CLEANUP", suggestionId: "s9" }, + }), + ], + }), + ); + renderCard(); + + await userEvent.click(await screen.findByRole("button", { name: "Aufräumen" })); + + expect(screen.getByText("Aufräumen-Sheet s9")).toBeInTheDocument(); + }); + + it("renders nothing while there is nothing to say", async () => { + vi.mocked(fetchColonyNextSteps).mockResolvedValue(colonyStepsFixture({ steps: [] })); + const { container } = renderCard(); + + await vi.waitFor(() => expect(fetchColonyNextSteps).toHaveBeenCalled()); + expect(container).toBeEmptyDOMElement(); + }); + + it("makes only the first step's action primary and the rest outline", async () => { + vi.mocked(fetchColonyNextSteps).mockResolvedValue( + colonyStepsFixture({ + steps: [stepFixture({ key: "suggestion:s1" }), stepFixture({ key: "suggestion:s2", title: "Zweite", suggestionIds: ["s2"] })], + }), + ); + renderCard(); + + const links = await screen.findAllByRole("link", { name: "Erfassen" }); + expect(links[0]).toHaveClass("bg-primary"); + expect(links[1]).not.toHaveClass("bg-primary"); + }); + + it("announces only a short status, not the whole list", async () => { + vi.mocked(fetchColonyNextSteps).mockResolvedValue(colonyStepsFixture()); + const { container } = renderCard(); + + await screen.findByText("Durchsicht"); + expect(screen.getByRole("status")).toHaveTextContent("1 nächster Schritt"); + expect(container.querySelector("[aria-live]")).toBeNull(); + }); + + it("moves focus to the heading after a decision, or to the page h1 when the section is gone", async () => { + vi.mocked(bulkDismissSuggestions).mockResolvedValue({ dismissed: 1 }); + vi.mocked(fetchColonyNextSteps) + .mockResolvedValueOnce(colonyStepsFixture()) + .mockResolvedValueOnce(colonyStepsFixture()); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + +

V-01

+ +
, + ); + + await userEvent.click(await screen.findByRole("button", { name: "Verwerfen" })); + await vi.waitFor(() => + expect(screen.getByRole("heading", { name: "Als Nächstes" })).toHaveFocus(), + ); + + vi.mocked(fetchColonyNextSteps).mockResolvedValue(colonyStepsFixture({ steps: [] })); + await userEvent.click(screen.getByRole("button", { name: "Verwerfen" })); + await vi.waitFor(() => expect(screen.getByRole("heading", { name: "V-01" })).toHaveFocus()); + }); + + it("reports a partly failed undo as „n von m wieder geöffnet“", async () => { + vi.mocked(fetchColonyNextSteps).mockResolvedValue( + colonyStepsFixture({ steps: [stepFixture({ suggestionIds: ["s1", "s2"] })] }), + ); + vi.mocked(bulkDismissSuggestions).mockResolvedValue({ dismissed: 2 }); + vi.mocked(reopenSuggestion) + .mockResolvedValueOnce({} as never) + .mockRejectedValueOnce(new ApiError(409, "Der Vorschlag ist nicht mehr offen.", {})); + renderCard(); + + await userEvent.click(await screen.findByRole("button", { name: "Verwerfen" })); + await vi.waitFor(() => expect(toast).toHaveBeenCalledWith("Verworfen", expect.anything())); + const options = vi.mocked(toast).mock.calls.find(([text]) => text === "Verworfen")![1] as unknown as { + action: { onClick: () => void }; + }; + options.action.onClick(); + + await vi.waitFor(() => expect(toast.error).toHaveBeenCalledWith("1 von 2 wieder geöffnet")); + }); + + it("shows a compact hint with retry for a server error and nothing when offline", async () => { + vi.mocked(fetchColonyNextSteps).mockRejectedValue(new ApiError(500, "boom", {})); + const first = renderCard(); + + expect(await screen.findByRole("status")).toHaveTextContent( + "Die nächsten Schritte konnten nicht geladen werden.", + ); + expect(screen.getByRole("button", { name: "Erneut versuchen" })).toBeInTheDocument(); + first.unmount(); + + vi.mocked(fetchColonyNextSteps).mockRejectedValue(new TypeError("Failed to fetch")); + const second = renderCard(); + await vi.waitFor(() => expect(fetchColonyNextSteps).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(second.container).toBeEmptyDOMElement()); + }); +}); diff --git a/ApiCore-Frontend/src/components/autopilot/next-steps-card.tsx b/ApiCore-Frontend/src/components/autopilot/next-steps-card.tsx new file mode 100644 index 0000000..78926cc --- /dev/null +++ b/ApiCore-Frontend/src/components/autopilot/next-steps-card.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; +import { Check, Eraser, ExternalLink, Hourglass, ListTodo, X } from "lucide-react"; +import { CleanupSheet } from "@/components/suggestions/cleanup-sheet"; +import { DueChip } from "@/components/task-status"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { isNetworkError } from "@/lib/http/fetch-with-timeout"; +import { colonyNextStepsKey, fetchColonyNextSteps } from "@/lib/autopilot/api"; +import { dueChipProps, formatDayMonth } from "@/lib/autopilot/format"; +import type { NextStep } from "@/lib/autopilot/types"; +import { useAuth } from "@/lib/auth/auth-context"; +import { todayLocalIsoDate } from "@/lib/dates"; +import { INTENT_ICONS } from "@/lib/suggestions/labels"; +import { useStepActions } from "./use-step-actions"; + +/** The colony page shows at most this many steps. */ +const MAX_STEPS = 3; + +const NEXT_STEPS_HEADING_ID = "colony-next-steps-heading"; + +interface StepActionsProps { + step: NextStep; + /** The one main button of the view; every other action is an outline button. */ + primary?: boolean; + /** Offline the task list is not available: no link to it. */ + hideTaskLink?: boolean; + busy?: boolean; + /** tasks:create - needed for „Übernehmen“. */ + canAccept?: boolean; + /** tasks:update - needed for „Verwerfen“ and „Aufräumen“. */ + canDismiss?: boolean; + onAccept?: (step: NextStep) => void; + onDismiss?: (step: NextStep) => void; + onCleanup?: (step: NextStep) => void; +} + +/** The main action of a step (a real link where it navigates) plus „Verwerfen“. */ +export function StepActions({ + step, + primary = true, + hideTaskLink = false, + busy = false, + canAccept = true, + canDismiss = true, + onAccept, + onDismiss, + onCleanup, +}: StepActionsProps) { + const { action } = step; + const variant = primary ? "default" : "outline"; + return ( +
+ {action.type === "CAPTURE" ? ( + + + Erfassen + + ) : null} + {action.type === "OPEN_TASK" && !hideTaskLink ? ( + + + Aufgabe öffnen + + ) : null} + {action.type === "ACCEPT" && onAccept && canAccept ? ( + + ) : null} + {action.type === "CLEANUP" && onCleanup && canDismiss ? ( + + ) : null} + {step.dismissible && onDismiss && canDismiss ? ( + + ) : null} +
+ ); +} + +interface NextStepsViewProps extends Omit { + steps: readonly NextStep[]; + /** YYYY-MM-DD; a hint („Wartezeit bis 05.05.“), never a step. */ + withdrawalUntil: string | null; + busyKeys?: ReadonlySet; +} + +/** The steps as a list; without steps and hint nothing is rendered. */ +export function NextStepsView({ + steps, + withdrawalUntil, + busyKeys, + ...actions +}: NextStepsViewProps) { + const [today] = useState(() => todayLocalIsoDate()); + const shown = steps.slice(0, MAX_STEPS); + if (shown.length === 0 && !withdrawalUntil) return null; + + return ( +
+

+ Als Nächstes +

+ {/* Only this short text is announced, not the whole list. */} +

+ {shown.length === 1 ? "1 nächster Schritt" : `${shown.length} nächste Schritte`} +

+
+ {shown.length > 0 ? ( +
    + {shown.map((step, index) => { + const Icon = step.intent ? INTENT_ICONS[step.intent] : ListTodo; + return ( +
  1. +
    + + + +
    +

    {step.title}

    + {step.reasons.length > 0 ? ( +
      + {step.reasons.slice(0, 2).map((reason) => ( +
    • {reason}
    • + ))} +
    + ) : null} +
    + +
    +
    +
    + +
  2. + ); + })} +
+ ) : null} + {withdrawalUntil ? ( +

+ + Wartezeit bis {formatDayMonth(withdrawalUntil)} +

+ ) : null} +
+
+ ); +} + +/** „Als Nächstes“ on top of the colony page: the colony's next steps from the autopilot. */ +export function NextStepsCard({ colonyId }: { colonyId: string }) { + const { hasPermission } = useAuth(); + const query = useQuery({ + queryKey: colonyNextStepsKey(colonyId), + queryFn: () => fetchColonyNextSteps(colonyId), + }); + const stepActions = useStepActions(NEXT_STEPS_HEADING_ID); + + if (query.isError) { + // Offline the colony page still works; a missing hint is no news. + if (isNetworkError(query.error)) return null; + return ( +

+ Die nächsten Schritte konnten nicht geladen werden. + +

+ ); + } + if (!query.data) return null; + + return ( + <> + stepActions.openCleanup(step, colonyId)} + /> + void stepActions.afterCleanup()} + /> + + ); +} diff --git a/ApiCore-Frontend/src/components/autopilot/season-chip.tsx b/ApiCore-Frontend/src/components/autopilot/season-chip.tsx new file mode 100644 index 0000000..a8fae91 --- /dev/null +++ b/ApiCore-Frontend/src/components/autopilot/season-chip.tsx @@ -0,0 +1,41 @@ +import { CalendarRange } from "lucide-react"; +import type { SeasonSegment } from "@/lib/autopilot/types"; + +export const SEGMENT_LABELS: Record = { + WINTERRUHE: "Winterruhe", + FRUEHJAHR: "Frühjahr", + SCHWARMZEIT: "Schwarmzeit", + SOMMER: "Sommer", + SPAETSOMMER: "Spätsommer", + HERBST: "Herbst", +}; + +/** „Schwarmzeit · alle 7 Tage“; without an interval just the segment. */ +export function seasonChipText(season: { + segment: SeasonSegment; + intervalDays: number | null; +}): string { + const label = SEGMENT_LABELS[season.segment]; + if (season.intervalDays === null) return label; + return season.intervalDays === 1 + ? `${label} · täglich` + : `${label} · alle ${season.intervalDays} Tage`; +} + +interface SeasonChipProps { + segment: SeasonSegment; + intervalDays?: number | null; + /** Names the apiary: „Obstwiese: Schwarmzeit“. */ + apiaryName?: string; +} + +/** The season of an apiary as icon plus words. */ +export function SeasonChip({ segment, intervalDays = null, apiaryName }: SeasonChipProps) { + const text = seasonChipText({ segment, intervalDays }); + return ( + + + {apiaryName ? `${apiaryName}: ${text}` : text} + + ); +} diff --git a/ApiCore-Frontend/src/components/autopilot/use-step-actions.ts b/ApiCore-Frontend/src/components/autopilot/use-step-actions.ts new file mode 100644 index 0000000..90d731d --- /dev/null +++ b/ApiCore-Frontend/src/components/autopilot/use-step-actions.ts @@ -0,0 +1,153 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { newIdempotencyKey } from "@/lib/offline/idempotency"; +import type { NextStep } from "@/lib/autopilot/types"; +import { + acceptSuggestion, + bulkDismissSuggestions, + reopenSuggestion, +} from "@/lib/suggestions/api"; +import { suggestionErrorText } from "@/lib/suggestions/errors"; + +/** Sonner's own buttons are 24 px high; actions in a toast need 44 px. */ +const TOAST_ACTION_CLASS = "h-11! min-h-11! px-4!"; +const UNDO_TOAST_MS = 8000; +/** Everything a decision can change on screen. */ +const REFRESHED_KEYS = [ + ["colonies"], + ["apiaries"], + ["suggestions"], + ["tasks"], + ["dashboard"], + ["autopilot"], +] as const; + +/** + * After a decision focus goes to the section heading, or to the page's h1 + * when the section is gone - never onto the removed button. + */ +function focusHeadingOrPage(headingId: string) { + requestAnimationFrame(() => { + const target = document.getElementById(headingId) ?? document.querySelector("h1"); + if (!(target instanceof HTMLElement)) return; + if (!target.hasAttribute("tabindex")) target.setAttribute("tabindex", "-1"); + target.focus(); + }); +} + +class PartialReopenError extends Error { + constructor( + readonly done: number, + readonly total: number, + readonly reason: unknown, + ) { + super("Not all suggestions were reopened"); + } +} + +export interface CleanupTarget { + suggestionId: string; + colonyId: string | null; +} + +/** + * Accept, dismiss and clean up the steps of the autopilot - shared by the + * colony page and the start page. Every decision (also a failed one: a 409 + * means the list is stale) reloads what it may have changed. + */ +export function useStepActions(headingId: string) { + const queryClient = useQueryClient(); + const [busyKeys, setBusyKeys] = useState>(new Set()); + const [cleanupTarget, setCleanupTarget] = useState(null); + + function setBusy(key: string, busy: boolean) { + setBusyKeys((current) => { + const next = new Set(current); + if (busy) next.add(key); + else next.delete(key); + return next; + }); + } + async function refresh() { + await Promise.all( + REFRESHED_KEYS.map((queryKey) => queryClient.invalidateQueries({ queryKey })), + ); + } + async function settle() { + await refresh(); + focusHeadingOrPage(headingId); + } + + const reopen = useMutation({ + mutationFn: async (ids: string[]) => { + const results = await Promise.allSettled( + ids.map((id) => reopenSuggestion(id, { idempotencyKey: newIdempotencyKey() })), + ); + const failed = results.find((result) => result.status === "rejected"); + if (failed) { + throw new PartialReopenError( + results.filter((result) => result.status === "fulfilled").length, + results.length, + failed.reason, + ); + } + }, + onSuccess: () => toast("Wieder geöffnet"), + onError: (error) => { + if (error instanceof PartialReopenError && error.done > 0) { + toast.error(`${error.done} von ${error.total} wieder geöffnet`); + } else { + toast.error(suggestionErrorText(error instanceof PartialReopenError ? error.reason : error)); + } + }, + onSettled: settle, + }); + + const accept = useMutation({ + mutationFn: (step: NextStep) => { + if (step.action.type !== "ACCEPT") throw new Error("Not an ACCEPT step"); + return acceptSuggestion(step.action.suggestionId, {}, { idempotencyKey: newIdempotencyKey() }); + }, + onMutate: (step) => setBusy(step.key, true), + onSuccess: () => toast.success("Aufgabe angelegt"), + onError: (error) => toast.error(suggestionErrorText(error)), + onSettled: async (_result, _error, step) => { + await settle(); + setBusy(step.key, false); + }, + }); + + const dismiss = useMutation({ + mutationFn: (step: NextStep) => + bulkDismissSuggestions(step.suggestionIds, { idempotencyKey: newIdempotencyKey() }), + onMutate: (step) => setBusy(step.key, true), + onSuccess: (_result, step) => + toast("Verworfen", { + duration: UNDO_TOAST_MS, + action: { label: "Rückgängig", onClick: () => reopen.mutate(step.suggestionIds) }, + classNames: { actionButton: TOAST_ACTION_CLASS }, + }), + onError: (error) => toast.error(suggestionErrorText(error)), + onSettled: async (_result, _error, step) => { + await settle(); + setBusy(step.key, false); + }, + }); + + return { + busyKeys, + accept: (step: NextStep) => accept.mutate(step), + dismiss: (step: NextStep) => dismiss.mutate(step), + openCleanup: (step: NextStep, colonyId: string | null) => { + if (step.action.type === "CLEANUP") { + setCleanupTarget({ suggestionId: step.action.suggestionId, colonyId }); + } + }, + closeCleanup: () => setCleanupTarget(null), + cleanupTarget, + afterCleanup: settle, + }; +} diff --git a/ApiCore-Frontend/src/components/form/choice-group.spec.tsx b/ApiCore-Frontend/src/components/form/choice-group.spec.tsx index 9600162..4b6c46f 100644 --- a/ApiCore-Frontend/src/components/form/choice-group.spec.tsx +++ b/ApiCore-Frontend/src/components/form/choice-group.spec.tsx @@ -181,6 +181,27 @@ describe("ChoiceGroup", () => { }); }); +describe("ChoiceGroup – disabled options", () => { + const OPTIONS: ChoiceOption[] = [ + { value: "a", label: "A" }, + { value: "b", label: "B", disabled: true }, + { value: "c", label: "C" }, + ]; + + it("cannot be chosen by tap and is skipped by the arrow keys", async () => { + const onChange = vi.fn(); + render(); + + expect(screen.getByRole("radio", { name: "B" })).toBeDisabled(); + await userEvent.click(screen.getByRole("radio", { name: "B" })); + expect(onChange).not.toHaveBeenCalled(); + + screen.getByRole("radio", { name: "A" }).focus(); + await userEvent.keyboard("{ArrowRight}"); + expect(onChange).toHaveBeenCalledWith("c"); + }); +}); + describe("choiceOptions", () => { const MOOD_LABELS = { NONE: "Keine", LOW: "Leicht", HIGH: "Stark" } as const; diff --git a/ApiCore-Frontend/src/components/form/choice-group.tsx b/ApiCore-Frontend/src/components/form/choice-group.tsx index 969709c..5c061d1 100644 --- a/ApiCore-Frontend/src/components/form/choice-group.tsx +++ b/ApiCore-Frontend/src/components/form/choice-group.tsx @@ -17,6 +17,8 @@ export interface ChoiceOption { srSuffix?: string; /** Replaces the default selected look, e.g. a tone colour of a rating step. */ selectedClassName?: string; + /** Shown but not choosable; arrow keys skip it. */ + disabled?: boolean; } /** Distinct per value and type: "null" (text) and null must not collide. */ @@ -92,9 +94,15 @@ export function ChoiceGroup({ const tabStop = checkedIndex === -1 ? 0 : checkedIndex; function handleKeyDown(event: KeyboardEvent, index: number) { - const next = targetIndex(event.key, index, options.length); + let next = targetIndex(event.key, index, options.length); if (next === null) return; event.preventDefault(); + // Skip disabled options in the direction of travel (at most one lap). + const step = event.key === "Home" ? 1 : event.key === "End" ? -1 : (KEY_STEPS[event.key] ?? 1); + for (let tries = 0; options[next].disabled && tries < options.length; tries++) { + next = (next + step + options.length) % options.length; + } + if (options[next].disabled) return; radioRefs.current[next]?.focus(); onChange(options[next].value); } @@ -120,6 +128,7 @@ export function ChoiceGroup({ type="button" role="radio" aria-checked={selected} + disabled={option.disabled} tabIndex={index === tabStop ? 0 : -1} onClick={() => onChange(selected && clearValue !== undefined ? clearValue : option.value) @@ -129,6 +138,7 @@ export function ChoiceGroup({ "min-h-11 rounded-lg border px-2 text-sm outline-none motion-safe:transition-colors", "focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50", !selected && "border-border bg-background hover:bg-muted", + option.disabled && "cursor-not-allowed opacity-50 hover:bg-background", selected && isUnanswered && "border-foreground/30 bg-muted font-medium text-foreground", selected && !isUnanswered && diff --git a/ApiCore-Frontend/src/components/notification-bell.spec.tsx b/ApiCore-Frontend/src/components/notification-bell.spec.tsx new file mode 100644 index 0000000..3756bac --- /dev/null +++ b/ApiCore-Frontend/src/components/notification-bell.spec.tsx @@ -0,0 +1,164 @@ +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 { NotificationBell } from "./notification-bell"; +import { + fetchNotifications, + markAllNotificationsRead, + markNotificationRead, +} from "@/lib/notifications/api"; +import type { Notification } from "@/lib/notifications/types"; +import { ApiError } from "@/lib/auth/api-client"; +import { toast } from "sonner"; + +vi.mock("@/lib/notifications/api", () => ({ + fetchNotifications: vi.fn(), + markNotificationRead: vi.fn(), + markAllNotificationsRead: vi.fn(), +})); +vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } })); + +function notification(overrides: Partial): Notification { + return { + id: "n-1", + priority: "WARNUNG", + category: "WITHDRAWAL", + message: "Wartezeit-Konflikt bei B3", + relatedType: null, + relatedId: null, + link: null, + read: false, + createdAt: "2026-08-01T10:00:00.000Z", + ...overrides, + }; +} + +function renderBell() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + invalidate = vi.spyOn(client, "invalidateQueries"); + return render( + + + , + ); +} + +let invalidate: ReturnType; + +describe("NotificationBell", () => { + beforeEach(() => { + vi.mocked(markAllNotificationsRead).mockReset().mockResolvedValue({ updated: 2 }); + vi.mocked(markNotificationRead).mockReset().mockResolvedValue({} as never); + vi.mocked(toast.error).mockReset(); + vi.mocked(toast.success).mockReset(); + }); + + it("„Alle gelesen“ calls read-all and a notification with link navigates there", async () => { + vi.mocked(fetchNotifications).mockResolvedValue([ + notification({ id: "n-1", link: "/harvests/h-1" }), + notification({ id: "n-2", message: "Ohne Ziel", relatedType: "Colony", relatedId: "c-9" }), + ]); + renderBell(); + await userEvent.click(await screen.findByRole("button", { name: "Benachrichtigungen" })); + + const linked = await screen.findByText("Wartezeit-Konflikt bei B3"); + expect(linked.closest("a")).toHaveAttribute("href", "/harvests/h-1"); + expect(screen.getByText("Ohne Ziel").closest("a")).toHaveAttribute("href", "/colonies/c-9"); + + await userEvent.click(screen.getByRole("menuitem", { name: /Alle gelesen/ })); + await waitFor(() => expect(markAllNotificationsRead).toHaveBeenCalledTimes(1)); + }); + + it("ignores a link that is not an internal path", async () => { + vi.mocked(fetchNotifications).mockResolvedValue([ + notification({ link: "//evil.example/x" }), + notification({ id: "n-2", message: "Schema", link: "https://evil.example" }), + ]); + renderBell(); + await userEvent.click(await screen.findByRole("button", { name: "Benachrichtigungen" })); + expect((await screen.findByText("Wartezeit-Konflikt bei B3")).closest("a")).toBeNull(); + expect(screen.getByText("Schema").closest("a")).toBeNull(); + }); + + it("hides „Alle gelesen“ when nothing is unread and names the category in words", async () => { + vi.mocked(fetchNotifications).mockResolvedValue([notification({ read: true })]); + renderBell(); + await userEvent.click(await screen.findByRole("button", { name: "Benachrichtigungen" })); + expect(await screen.findByText("Wartezeit")).toBeInTheDocument(); + expect(screen.queryByRole("menuitem", { name: /Alle gelesen/ })).not.toBeInTheDocument(); + }); + + it("clicking an unread notification marks it read and its link navigates", async () => { + vi.mocked(fetchNotifications).mockResolvedValue([notification({ id: "n-7", link: "/harvests/h-7" })]); + renderBell(); + await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ })); + const text = await screen.findByText("Wartezeit-Konflikt bei B3"); + expect(text.closest("a")).toHaveAttribute("href", "/harvests/h-7"); + await userEvent.click(text); + await waitFor(() => expect(markNotificationRead).toHaveBeenCalled()); + expect(vi.mocked(markNotificationRead).mock.calls[0][0]).toBe("n-7"); + }); + + it("a 404 on mark-read refreshes the list without an error toast", async () => { + vi.mocked(fetchNotifications).mockResolvedValue([notification({})]); + vi.mocked(markNotificationRead).mockRejectedValue(new ApiError(404, "Not found")); + renderBell(); + await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ })); + await userEvent.click(await screen.findByText("Wartezeit-Konflikt bei B3")); + await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ["notifications"] })); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it("another mark-read error shows one toast with a fixed id", async () => { + vi.mocked(fetchNotifications).mockResolvedValue([notification({})]); + vi.mocked(markNotificationRead).mockRejectedValue(new ApiError(500, "boom")); + renderBell(); + await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ })); + await userEvent.click(await screen.findByText("Wartezeit-Konflikt bei B3")); + await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1)); + expect(vi.mocked(toast.error).mock.calls[0][1]).toEqual({ id: "notification-read-error" }); + }); + + it("the badge is announced with the unread count, the digit is hidden from readers", async () => { + vi.mocked(fetchNotifications).mockResolvedValue([notification({}), notification({ id: "n-2" })]); + renderBell(); + expect(await screen.findByText("2 ungelesene Benachrichtigungen")).toHaveClass("sr-only"); + expect(screen.getByText("2", { selector: "[aria-hidden='true']" })).toBeInTheDocument(); + }); + + it("read-all confirms with a toast and refreshes", async () => { + vi.mocked(fetchNotifications).mockResolvedValue([notification({})]); + renderBell(); + await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ })); + await userEvent.click(await screen.findByRole("menuitem", { name: /Alle gelesen/ })); + await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Alle als gelesen markiert.")); + expect(invalidate).toHaveBeenCalledWith({ queryKey: ["notifications"] }); + }); + + it("read-all refreshes the list on failure too", async () => { + vi.mocked(fetchNotifications).mockResolvedValue([notification({})]); + vi.mocked(markAllNotificationsRead).mockRejectedValue(new ApiError(500, "x")); + renderBell(); + await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ })); + await userEvent.click(await screen.findByRole("menuitem", { name: /Alle gelesen/ })); + await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ["notifications"] })); + }); + + it.each([ + ["/harvests/h-1?tab=a&x=1", true], + ["/\t/x", false], + ["//evil", false], + ["/\\evil", false], + ["https://evil.example", false], + ["/a b", false], + [`/${"a".repeat(300)}`, false], + ])("link %j is internal: %s", async (link, internal) => { + vi.mocked(fetchNotifications).mockResolvedValue([notification({ link })]); + renderBell(); + await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ })); + const anchor = (await screen.findByText("Wartezeit-Konflikt bei B3")).closest("a"); + if (internal) expect(anchor).toHaveAttribute("href", link); + else expect(anchor).toBeNull(); + }); +}); diff --git a/ApiCore-Frontend/src/components/notification-bell.tsx b/ApiCore-Frontend/src/components/notification-bell.tsx index 7339931..abb8265 100644 --- a/ApiCore-Frontend/src/components/notification-bell.tsx +++ b/ApiCore-Frontend/src/components/notification-bell.tsx @@ -2,10 +2,19 @@ import Link from "next/link"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { Bell } from "lucide-react"; +import { Bell, CheckCheck } from "lucide-react"; import { toast } from "sonner"; -import { fetchNotifications, markNotificationRead } from "@/lib/notifications/api"; -import { Notification, NotificationPriority } from "@/lib/notifications/types"; +import { ApiError } from "@/lib/auth/api-client"; +import { + fetchNotifications, + markAllNotificationsRead, + markNotificationRead, +} from "@/lib/notifications/api"; +import { + Notification, + NotificationCategory, + NotificationPriority, +} from "@/lib/notifications/types"; import { Badge, badgeVariants } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -15,16 +24,39 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -const PRIORITY_VARIANT: Record[0]>["variant"]> = { +const PRIORITY_VARIANT: Record< + NotificationPriority, + NonNullable[0]>["variant"] +> = { INFO: "secondary", HINWEIS: "outline", WARNUNG: "warning", KRITISCH: "destructive", }; +const CATEGORY_LABEL: Record = { + TASK_DUE: "Aufgabe", + DIGEST: "Übersicht", + SUGGESTION: "Vorschlag", + WITHDRAWAL: "Wartezeit", + SEASON: "Saison", + SYSTEM: "System", +}; + +/** Same rule as the backend (`assertAppLink`): an internal path, never "//host", "/\host" or a scheme. */ +const INTERNAL_PATH = /^\/(?![/\\])[A-Za-z0-9\-._~/?=&%]{0,299}$/; + +function isInternalPath(link: string): boolean { + return INTERNAL_PATH.test(link); +} + function notificationHref(notification: Notification): string | null { - if (notification.relatedType === "Colony") return `/colonies/${notification.relatedId}`; - if (notification.relatedType === "Apiary") return `/apiaries/${notification.relatedId}`; + if (notification.link) + return isInternalPath(notification.link) ? notification.link : null; + if (notification.relatedType === "Colony") + return `/colonies/${notification.relatedId}`; + if (notification.relatedType === "Apiary") + return `/apiaries/${notification.relatedId}`; if (notification.relatedType === "Task") return "/tasks"; return null; } @@ -37,33 +69,76 @@ export function NotificationBell() { refetchInterval: 60_000, }); + const refresh = () => + void queryClient.invalidateQueries({ queryKey: ["notifications"] }); + const readMutation = useMutation({ - mutationFn: markNotificationRead, - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["notifications"] }); + mutationFn: (id: string) => markNotificationRead(id), + onSettled: refresh, + onError: (error) => { + // 404: the entry is gone or no longer visible; the refresh drops it. + if (error instanceof ApiError && error.status === 404) return; + toast.error("Benachrichtigung konnte nicht als gelesen markiert werden", { + id: "notification-read-error", + }); }, - onError: () => toast.error("Benachrichtigung konnte nicht als gelesen markiert werden"), }); - const unreadCount = notifications?.filter((notification) => !notification.read).length ?? 0; + const readAllMutation = useMutation({ + mutationFn: () => markAllNotificationsRead(), + onSuccess: () => toast.success("Alle als gelesen markiert."), + onSettled: refresh, + onError: () => + toast.error( + "Benachrichtigungen konnten nicht als gelesen markiert werden", + { + id: "notification-read-error", + }, + ), + }); + + const unreadCount = + notifications?.filter((notification) => !notification.read).length ?? 0; return ( + + + ); +} + +interface CleanupSheetProps { + /** The CLEANUP suggestion; the sheet is open while this is set. */ + suggestionId: string | null; + colonyId: string | null; + onClose: () => void; + /** Called after a successful clean-up (the sheet closes itself). */ + onDone?: () => void; +} + +/** „Aufräumen“ of an ended colony: what happens with its open tasks and its queen. */ +export function CleanupSheet({ suggestionId, colonyId, onClose, onDone }: CleanupSheetProps) { + return ( + (open ? undefined : onClose())}> + + {/* Remounted per opening, so the choices always start at the defaults. */} + {suggestionId !== null ? ( + + ) : null} + + + ); +} + +interface CleanupFormProps extends Omit { + suggestionId: string; +} + +function CleanupForm({ suggestionId, colonyId, onClose, onDone }: CleanupFormProps) { + const queryClient = useQueryClient(); + const { hasPermission } = useAuth(); + const canComplete = hasPermission("tasks:complete"); + const canChangeQueen = hasPermission("queens:update"); + const [outcomes, setOutcomes] = useState>>({}); + const [queenOverride, setQueenOverride] = useState(null); + // One key per set of choices: a retry after a lost answer is not applied twice. + const idempotencyKey = useRef(newIdempotencyKey()); + + const suggestions = useQuery({ + queryKey: suggestionsKey({ status: "OPEN", ...(colonyId ? { colonyId } : {}) }), + queryFn: () => fetchSuggestions({ status: "OPEN", ...(colonyId ? { colonyId } : {}) }), + }); + const suggestion = suggestions.data?.find((candidate) => candidate.id === suggestionId) ?? null; + const taskIds = suggestion?.cleanup?.taskIds ?? []; + const queenId = canChangeQueen ? (suggestion?.cleanup?.queenId ?? null) : null; + + const tasks = useQuery({ + queryKey: ["tasks", "cleanup", ...taskIds], + queryFn: () => loadEvidenceTasks(taskIds), + enabled: taskIds.length > 0, + }); + const colony = useQuery({ + queryKey: ["colonies", colonyId], + queryFn: () => fetchColony(colonyId ?? ""), + enabled: queenId !== null && colonyId !== null, + }); + const queen = useQuery({ + queryKey: ["queens", queenId], + queryFn: () => fetchQueen(queenId ?? ""), + enabled: queenId !== null, + }); + + const evidence = tasks.data ?? []; + const taskRows = evidence.flatMap(({ id, task }) => (task && isOpenTask(task) ? [{ id, title: task.title }] : [])); + const closedTasks = evidence.flatMap(({ id, task }) => + task && !isOpenTask(task) ? [{ id, title: task.title }] : [], + ); + const goneCount = evidence.filter(({ task }) => task === null).length; + const loadFailed = tasks.isError || (queenId !== null && (queen.isError || colony.isError)); + const queenChoice = queenOverride ?? defaultQueenChoice(colony.data?.status); + const isReady = + suggestion !== null && + (taskIds.length === 0 || tasks.isSuccess) && + (queenId === null || (queen.isSuccess && (colonyId === null || colony.isSuccess))); + + const mutation = useMutation({ + mutationFn: (input: ApplyCleanupInput) => + applyCleanup(suggestionId, input, { idempotencyKey: idempotencyKey.current }), + onSuccess: async (result) => { + toast.success(resultText(result)); + await Promise.all(REFRESHED_KEYS.map((queryKey) => queryClient.invalidateQueries({ queryKey }))); + onDone?.(); + onClose(); + }, + onError: (error) => { + toast.error(cleanupErrorText(error)); + // A 409 or 404 means the list is stale. + void queryClient.invalidateQueries({ queryKey: ["suggestions"] }); + }, + }); + + function changeOutcome(id: string, outcome: CleanupOutcome) { + idempotencyKey.current = newIdempotencyKey(); + setOutcomes((current) => ({ ...current, [id]: outcome })); + } + function changeQueen(choice: QueenChoice) { + idempotencyKey.current = newIdempotencyKey(); + setQueenOverride(choice); + } + function submit() { + mutation.mutate({ + tasks: taskRows.map(({ id }) => ({ id, outcome: outcomes[id] ?? "CANCEL" })), + // „unverändert“ sends nothing: the queen keeps her status. + ...(queenId !== null && queen.data && queenChoice !== "KEEP" + ? { queen: { id: queenId, status: queenChoice } } + : {}), + }); + } + + const taskOptions = TASK_OPTIONS.map((option) => ({ + ...option, + disabled: option.value === "DONE" && !canComplete, + })); + + return ( +
{ + event.preventDefault(); + if (isReady) submit(); + }} + className="flex flex-col gap-4 px-4 pb-4" + > + + + Volk aufräumen{suggestion?.colonyCode ? ` · ${suggestion.colonyCode}` : ""} + + + Was soll mit den offenen Aufgaben und der Königin dieses beendeten Volks geschehen? + + + + {suggestions.isSuccess && suggestion === null ? ( +

+ Der Vorschlag ist nicht mehr offen. +

+ ) : null} + {suggestions.isError ? ( +

+ Der Vorschlag konnte nicht geladen werden. +

+ ) : null} + + {tasks.isError ? void tasks.refetch()} /> : null} + {queenId !== null && queen.isError ? ( + void queen.refetch()} /> + ) : null} + {queenId !== null && colony.isError ? ( + void colony.refetch()} /> + ) : null} + {closedTasks.length > 0 || goneCount > 0 ? ( +
    + {closedTasks.map(({ id, title }) => ( +
  • „{title}“ – nicht mehr offen
  • + ))} + {goneCount > 0 ? ( +
  • + {goneCount === 1 ? "Eine Aufgabe gibt es" : `${goneCount} Aufgaben gibt es`} nicht + mehr. +
  • + ) : null} +
+ ) : null} + + {taskRows.length > 0 ? ( +
+ {taskRows.map(({ id, title }) => ( +
+

+ Aufgabe „{title}“ +

+ changeOutcome(id, outcome)} + labelledBy={`cleanup-task-${id}`} + className="grid-cols-3" + /> +
+ ))} + {!canComplete ? ( +

+ „Erledigt“ braucht das Recht „Aufgaben abschließen“. +

+ ) : null} +
+ ) : null} + + {queenId !== null && queen.data ? ( +
+

+ Königin {queen.data.code} +

+ +
+ ) : null} + {!canChangeQueen && suggestion?.cleanup?.queenId ? ( +

+ Die Königin bleibt unverändert – dafür fehlt das Recht „Königinnen bearbeiten“. +

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

+ Aufräumen ist erst möglich, wenn alles geladen ist. +

+ ) : null} +
+ + }> + Schließen + +
+ + ); +} diff --git a/ApiCore-Frontend/src/components/today/inspection-due-card.spec.tsx b/ApiCore-Frontend/src/components/today/inspection-due-card.spec.tsx new file mode 100644 index 0000000..37a563d --- /dev/null +++ b/ApiCore-Frontend/src/components/today/inspection-due-card.spec.tsx @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { InspectionDueCard } from "./inspection-due-card"; +import { readRound } from "@/lib/rounds/round-state"; +import type { InspectionDueApiary } from "@/lib/autopilot/types"; + +vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" })); + +const entry: InspectionDueApiary = { + apiaryId: "a-1", + apiaryName: "Obstwiese", + segment: "SCHWARMZEIT", + intervalDays: 7, + colonies: [ + { id: "c-2", code: "B2", dueOn: "2026-05-10", overdueDays: 2 }, + { id: "c-1", code: "A1", dueOn: "2026-05-12", overdueDays: 0 }, + ], +}; + +describe("InspectionDueCard", () => { + beforeEach(() => window.sessionStorage.clear()); + + it("„Durchsicht fällig“ starts a round only with the due colonies of that apiary", async () => { + render(); + + const start = screen.getByRole("link", { name: "Runde starten" }); + expect(start).toHaveAttribute("href", "/inspections/new?colonyId=c-1&round=a-1"); + await userEvent.click(start); + + expect(readRound("user-1")).toMatchObject({ + apiaryId: "a-1", + apiaryName: "Obstwiese", + colonies: [ + { id: "c-1", code: "A1" }, + { id: "c-2", code: "B2" }, + ], + }); + }); + + it("names the apiary, the season with its interval and every due colony", () => { + render(); + + expect( + screen.getByRole("heading", { name: /Durchsicht fällig · Obstwiese/ }), + ).toBeInTheDocument(); + expect(screen.getByText("Schwarmzeit · alle 7 Tage")).toBeInTheDocument(); + const list = screen.getByRole("list"); + expect(within(list).getByText("A1")).toBeInTheDocument(); + expect(within(list).getByText("seit 2 Tagen")).toBeInTheDocument(); + expect(within(list).getByText("heute")).toBeInTheDocument(); + }); +}); diff --git a/ApiCore-Frontend/src/components/today/inspection-due-card.tsx b/ApiCore-Frontend/src/components/today/inspection-due-card.tsx new file mode 100644 index 0000000..7c18b12 --- /dev/null +++ b/ApiCore-Frontend/src/components/today/inspection-due-card.tsx @@ -0,0 +1,83 @@ +"use client"; + +import Link from "next/link"; +import { ClipboardCheck, Play } from "lucide-react"; +import { SeasonChip } from "@/components/autopilot/season-chip"; +import { buttonVariants } from "@/components/ui/button"; +import type { InspectionDueApiary } from "@/lib/autopilot/types"; +import { useCurrentUserId } from "@/lib/auth/auth-context"; +import { startRound } from "@/lib/rounds/round-state"; + +function overdueText(overdueDays: number): string { + if (overdueDays <= 0) return "heute"; + return `seit ${overdueDays} ${overdueDays === 1 ? "Tag" : "Tagen"}`; +} + +interface InspectionDueCardProps { + entry: InspectionDueApiary; + /** False when a step card above already has the main button. */ + primary?: boolean; +} + +/** + * „Durchsicht fällig“ of one apiary: the colonies that are due today or + * overdue. „Runde starten“ walks through exactly these, in code order. + */ +export function InspectionDueCard({ entry, primary = true }: InspectionDueCardProps) { + const userId = useCurrentUserId(); + const colonies = [...entry.colonies].sort((a, b) => + a.code.localeCompare(b.code, "de", { numeric: true }), + ); + const first = colonies[0]; + const headingId = `inspection-due-${entry.apiaryId}`; + const roundHref = first + ? `/inspections/new?colonyId=${encodeURIComponent(first.id)}&round=${encodeURIComponent(entry.apiaryId)}` + : null; + + return ( +
+
+ + + +

+ Durchsicht fällig · {entry.apiaryName} +

+ +
+
    + {colonies.map((colony) => ( +
  • + {colony.code} + {overdueText(colony.overdueDays)} +
  • + ))} +
+ {roundHref ? ( + { + if (userId) { + startRound( + userId, + { id: entry.apiaryId, name: entry.apiaryName }, + colonies.map(({ id, code }) => ({ id, code })), + ); + } + }} + className={buttonVariants({ + size: "touch", + variant: primary ? "default" : "outline", + className: "self-start", + })} + > + + Runde starten + + ) : null} +
+ ); +} diff --git a/ApiCore-Frontend/src/components/today/today-panel.tsx b/ApiCore-Frontend/src/components/today/today-panel.tsx index 643feb8..873f154 100644 --- a/ApiCore-Frontend/src/components/today/today-panel.tsx +++ b/ApiCore-Frontend/src/components/today/today-panel.tsx @@ -1,11 +1,21 @@ "use client"; import Link from "next/link"; -import { ArrowRight, Check, ClipboardPlus, ListPlus, Play, Sun } from "lucide-react"; +import { ArrowRight, Check, ClipboardPlus, Lightbulb, ListPlus, Play, Sun } from "lucide-react"; import { Task, TaskRelatedType } from "@/lib/tasks/types"; import { dueState, pickNextStep, relatedHref, tasksForToday } from "@/lib/tasks/today"; import { Button } from "@/components/ui/button"; import { DueChip, TaskSourceBadge, TaskStatusPill } from "@/components/task-status"; +import { SeasonChip } from "@/components/autopilot/season-chip"; +import { StepActions } from "@/components/autopilot/next-steps-card"; +import { useStepActions } from "@/components/autopilot/use-step-actions"; +import { CleanupSheet } from "@/components/suggestions/cleanup-sheet"; +import { dueChipProps } from "@/lib/autopilot/format"; +import type { AutopilotToday, TodayNextStep } from "@/lib/autopilot/types"; +import { useAuth } from "@/lib/auth/auth-context"; +import { todayLocalIsoDate } from "@/lib/dates"; +import { INTENT_ICONS } from "@/lib/suggestions/labels"; +import { InspectionDueCard } from "./inspection-due-card"; import { useTaskActions } from "./use-task-actions"; const TODAY_LIST_LIMIT = 5; @@ -25,10 +35,23 @@ interface TodayPanelProps { isError: boolean; onRetry: () => void; now: Date; + /** The autopilot's view of today; without it (offline, no right) the tasks alone decide. */ + autopilot?: AutopilotToday | null; } -export function TodayPanel({ tasks, isLoading, isError, onRetry, now }: TodayPanelProps) { +export function TodayPanel({ + tasks: loadedTasks, + isLoading, + isError, + onRetry, + now, + autopilot = null, +}: TodayPanelProps) { const actions = useTaskActions(); + const stepActions = useStepActions("next-step-heading"); + const { hasPermission } = useAuth(); + // The autopilot is enough to work with when the task list is missing. + const tasks = loadedTasks ?? (autopilot ? [] : undefined); if (isLoading) { return ( @@ -40,7 +63,7 @@ export function TodayPanel({ tasks, isLoading, isError, onRetry, now }: TodayPan ); } - if (isError || !tasks) { + if (!tasks || (isError && !autopilot)) { return (
; } - const today = tasksForToday(tasks, now).filter((task) => task.id !== nextStep.id); + const headTaskId = + autopilotStep?.action.type === "OPEN_TASK" ? autopilotStep.action.taskId : nextTask?.id; + const today = tasksForToday(tasks, now).filter((task) => task.id !== headTaskId); const visible = today.slice(0, TODAY_LIST_LIMIT); const hidden = today.length - visible.length; return (
- actions.complete(nextStep)} - onStart={() => actions.start(nextStep)} - isStarting={actions.isStarting} + {autopilotStep ? ( + stepActions.openCleanup(step, autopilotStep.colonyId)} + /> + ) : nextTask ? ( + actions.complete(nextTask)} + onStart={() => actions.start(nextTask)} + isStarting={actions.isStarting} + /> + ) : null} + void stepActions.afterCleanup()} /> + {inspectionDue.map((entry, index) => ( + + ))} + {autopilot ? : null} +

@@ -88,7 +144,17 @@ export function TodayPanel({ tasks, isLoading, isError, onRetry, now }: TodayPan Alle Aufgaben

- {visible.length === 0 ? ( + {tasksFailed ? ( +
+

Die Aufgaben konnten gerade nicht geladen werden.

+ +
+ ) : visible.length === 0 ? (

Sonst ist für heute nichts fällig. Ein Schritt reicht.

@@ -183,6 +249,105 @@ function NextStepCard({ task, now, leaving, onComplete, onStart, isStarting }: N ); } +interface AutopilotStepCardProps { + step: TodayNextStep; + busy: boolean; + canAccept: boolean; + canDismiss: boolean; + onAccept: (step: TodayNextStep) => void; + onDismiss: (step: TodayNextStep) => void; + onCleanup: (step: TodayNextStep) => void; +} + +/** „Als Nächstes“ from the autopilot: one step across all colonies, with its reasons and action. */ +function AutopilotStepCard({ step, busy, ...handlers }: AutopilotStepCardProps) { + const Icon = step.intent ? INTENT_ICONS[step.intent] : ArrowRight; + return ( +
+

+ Als Nächstes +

+

+ Nächster Schritt: {step.title} +

+
+
+

+ + {step.title} +

+
+ + {step.colonyCode} + + +
+ {step.reasons.length > 0 ? ( +
    + {step.reasons.slice(0, 2).map((reason) => ( +
  • {reason}
  • + ))} +
+ ) : null} +
+
+ handlers.onAccept(step)} + onDismiss={() => handlers.onDismiss(step)} + onCleanup={() => handlers.onCleanup(step)} + /> + +
+
+
+ ); +} + +/** Open suggestions and the season of each apiary - small, but readable as words. */ +function AutopilotChips({ autopilot }: { autopilot: AutopilotToday }) { + const showSuggestions = autopilot.tasksVisible && autopilot.suggestionsOpen > 0; + if (!showSuggestions && autopilot.seasons.length === 0) return null; + return ( +
    + {showSuggestions ? ( +
  • + + + Vorschläge {autopilot.suggestionsOpen} + +
  • + ) : null} + {autopilot.seasons.map((season) => ( +
  • + +
  • + ))} +
+ ); +} + interface TodayRowProps { task: Task; now: Date; diff --git a/ApiCore-Frontend/src/components/warnings/warning-list.spec.tsx b/ApiCore-Frontend/src/components/warnings/warning-list.spec.tsx new file mode 100644 index 0000000..338bca1 --- /dev/null +++ b/ApiCore-Frontend/src/components/warnings/warning-list.spec.tsx @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import { WarningList } from "./warning-list"; +import type { PlausibilityWarning } from "@/lib/warnings/types"; + +const warnings: PlausibilityWarning[] = [ + { + code: "WITHDRAWAL_ACTIVE", + severity: "WARNUNG", + message: "B3: Wartezeit nach Oxuvar endet am 20.08.2026.", + refs: [{ type: "Colony", id: "c-1" }], + }, + { + code: "JAR_SIZE_DIFFERS", + severity: "HINWEIS", + message: "Die Glasgröße weicht ab.", + refs: [], + }, +]; + +describe("WarningList", () => { + it("announces warnings with icon and word in a status region", () => { + render(); + + const region = screen.getByRole("status"); + expect(region).toHaveAttribute("aria-live", "polite"); + expect(within(region).getByText("Bitte prüfen")).toBeInTheDocument(); + expect(within(region).getByText("Warnung")).toBeInTheDocument(); + expect(within(region).getByText("Hinweis")).toBeInTheDocument(); + expect( + within(region).getByText("B3: Wartezeit nach Oxuvar endet am 20.08.2026."), + ).toBeInTheDocument(); + expect(region.querySelectorAll("svg[aria-hidden='true']")).toHaveLength(2); + }); + + it("shows the word for a confirmation request", () => { + render( + , + ); + expect(screen.getByText("Bestätigen")).toBeInTheDocument(); + }); + + it("keeps the live region mounted but empty without warnings", () => { + render(); + expect(screen.getByRole("status")).toBeEmptyDOMElement(); + }); +}); diff --git a/ApiCore-Frontend/src/components/warnings/warning-list.tsx b/ApiCore-Frontend/src/components/warnings/warning-list.tsx new file mode 100644 index 0000000..5392fc0 --- /dev/null +++ b/ApiCore-Frontend/src/components/warnings/warning-list.tsx @@ -0,0 +1,46 @@ +import { CircleAlert, Info, type LucideIcon, TriangleAlert } from "lucide-react"; +import type { PlausibilityWarning, WarningSeverity } from "@/lib/warnings/types"; +import { cn } from "@/lib/utils"; + +const SEVERITY: Record = { + HINWEIS: { icon: Info, word: "Hinweis", tone: "bg-muted" }, + WARNUNG: { icon: TriangleAlert, word: "Warnung", tone: "bg-warning/20" }, + BESTAETIGEN: { icon: CircleAlert, word: "Bestätigen", tone: "bg-destructive/15" }, +}; + +export interface WarningListProps { + warnings: readonly PlausibilityWarning[]; + title?: string; +} + +/** + * Plausibility findings in a polite live region: icon and word per level, so + * the state never depends on colour. The region stays mounted when empty so + * a later warning is announced. + */ +export function WarningList({ warnings, title }: WarningListProps) { + return ( +
0 && "flex flex-col gap-2")}> + {warnings.length > 0 ? ( + <> + {title ?

{title}

: null} +
    + {warnings.map((warning, index) => { + const { icon: Icon, word, tone } = SEVERITY[warning.severity]; + return ( +
  • +
  • + ); + })} +
+ + ) : null} +
+ ); +} diff --git a/ApiCore-Frontend/src/lib/apiaries/field-types.ts b/ApiCore-Frontend/src/lib/apiaries/field-types.ts index 74e67c2..b167e83 100644 --- a/ApiCore-Frontend/src/lib/apiaries/field-types.ts +++ b/ApiCore-Frontend/src/lib/apiaries/field-types.ts @@ -1,4 +1,5 @@ import type { ApiaryStatus } from "./types"; +import type { NextStep, SeasonSegment } from "@/lib/autopilot/types"; import type { Colony, ColonyCondition, ColonyStatus } from "@/lib/colonies/types"; import type { Inspection } from "@/lib/inspections/types"; import type { Queen } from "@/lib/queens/types"; @@ -12,14 +13,24 @@ export interface ColonyOverviewRow { code: string; status: ColonyStatus; strength: number | null; + occupiedFrames: number | null; broodStatus: ColonyCondition | null; feedStatus: ColonyCondition | null; healthStatus: ColonyCondition | null; /** ISO timestamp; always null when the caller may not read inspections. */ lastInspectionDate: string | null; - /** Unread WARNUNG notifications visible to the caller. */ + /** + * The day the next inspection is due (YYYY-MM-DD); null in the winter + * rest, when the interval rule is off and without `inspections:read`. + */ + inspectionDueOn: string | null; + /** The season segment of the apiary today; null when it is unknown. */ + segment: SeasonSegment | null; + /** Title of the first next step; null without one or without `tasks:read`. */ + nextStepTitle: string | null; + /** Open suggestions of priority HIGH the caller may see. */ openWarnings: number; - /** Unread KRITISCH notifications visible to the caller. */ + /** Open suggestions of priority URGENT the caller may see. */ openCritical: number; } @@ -54,6 +65,7 @@ export interface OfflinePackV1 { | "colonyType" | "hiveType" | "strength" + | "occupiedFrames" | "broodStatus" | "feedStatus" | "healthStatus" @@ -80,4 +92,9 @@ export interface OfflinePackV1 { | "relatedId" >[] | null; + /** + * The next steps per colony id (what `GET /colonies/:id/next-steps` + * returns as `steps`). Absent in packs cached before T4. + */ + nextSteps?: Record; } diff --git a/ApiCore-Frontend/src/lib/apiaries/offline-pack.fixtures.ts b/ApiCore-Frontend/src/lib/apiaries/offline-pack.fixtures.ts index 5b07113..d5b1b0d 100644 --- a/ApiCore-Frontend/src/lib/apiaries/offline-pack.fixtures.ts +++ b/ApiCore-Frontend/src/lib/apiaries/offline-pack.fixtures.ts @@ -20,6 +20,7 @@ export function packColony(id: string, code: string, overrides: Partial { + return apiFetch(`/colonies/${encodeURIComponent(colonyId)}/next-steps`); +} + +export function fetchAutopilotToday(): Promise { + return apiFetch("/autopilot/today"); +} + +/** Ends the leftovers of an ended colony; only ids from the suggestion's evidence. */ +export function applyCleanup( + suggestionId: string, + input: ApplyCleanupInput, + options: CreateOptions = {}, +): Promise { + return apiFetch(`/suggestions/${encodeURIComponent(suggestionId)}/apply-cleanup`, { + method: "POST", + headers: idempotencyHeaders(options), + body: JSON.stringify(input), + }); +} diff --git a/ApiCore-Frontend/src/lib/autopilot/format.spec.ts b/ApiCore-Frontend/src/lib/autopilot/format.spec.ts new file mode 100644 index 0000000..62f9ea6 --- /dev/null +++ b/ApiCore-Frontend/src/lib/autopilot/format.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { daysBetween, dueChipProps, formatDayMonth, inspectionDueText } from "./format"; + +describe("autopilot format helpers", () => { + it("formats a day as TT.MM.", () => { + expect(formatDayMonth("2026-05-05")).toBe("05.05."); + }); + + it("counts whole days across a month and a DST change", () => { + expect(daysBetween("2026-03-28", "2026-04-02")).toBe(5); + expect(daysBetween("2026-10-14", "2026-10-12")).toBe(-2); + }); + + it.each([ + ["2026-10-16", "Durchsicht fällig in 2 Tagen"], + ["2026-10-15", "Durchsicht fällig in 1 Tag"], + ["2026-10-14", "Durchsicht heute fällig"], + ["2026-10-13", "Durchsicht seit 1 Tag fällig"], + ["2026-10-11", "Durchsicht seit 3 Tagen fällig"], + ])("says when the inspection due on %s is due", (dueOn, text) => { + expect(inspectionDueText(dueOn, "2026-10-14")).toBe(text); + }); + + it("maps a due day to the state the DueChip knows", () => { + expect(dueChipProps(null, "2026-10-14")).toEqual({ state: "none", dueDate: null }); + expect(dueChipProps("2026-10-13", "2026-10-14").state).toBe("overdue"); + expect(dueChipProps("2026-10-14", "2026-10-14").state).toBe("today"); + expect(dueChipProps("2026-10-15", "2026-10-14")).toEqual({ + state: "upcoming", + dueDate: "2026-10-15T00:00:00.000Z", + }); + }); +}); diff --git a/ApiCore-Frontend/src/lib/autopilot/format.ts b/ApiCore-Frontend/src/lib/autopilot/format.ts new file mode 100644 index 0000000..63c637d --- /dev/null +++ b/ApiCore-Frontend/src/lib/autopilot/format.ts @@ -0,0 +1,38 @@ +import type { DueState } from "@/lib/tasks/today"; + +const DAY_MS = 24 * 60 * 60 * 1000; +const CALENDAR_DAY = /^(\d{4})-(\d{2})-(\d{2})$/; + +/** "2026-05-05" as „05.05.“ */ +export function formatDayMonth(day: string): string { + const match = CALENDAR_DAY.exec(day); + return match ? `${match[3]}.${match[2]}.` : day; +} + +/** Whole days from `from` to `to` (both YYYY-MM-DD); negative when `to` is earlier. */ +export function daysBetween(from: string, to: string): number { + return Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / DAY_MS); +} + +function plural(count: number, one: string, many: string): string { + return `${count} ${count === 1 ? one : many}`; +} + +/** „Durchsicht fällig in 2 Tagen“, „Durchsicht heute fällig“, „Durchsicht seit 3 Tagen fällig“. */ +export function inspectionDueText(dueOn: string, today: string): string { + const days = daysBetween(today, dueOn); + if (days === 0) return "Durchsicht heute fällig"; + if (days > 0) return `Durchsicht fällig in ${plural(days, "Tag", "Tagen")}`; + return `Durchsicht seit ${plural(-days, "Tag", "Tagen")} fällig`; +} + +/** What `DueChip` takes for a plain day (YYYY-MM-DD) and today (YYYY-MM-DD). */ +export function dueChipProps( + dueOn: string | null, + today: string, +): { state: DueState; dueDate: string | null } { + if (!dueOn) return { state: "none", dueDate: null }; + const days = daysBetween(today, dueOn); + const state: DueState = days < 0 ? "overdue" : days === 0 ? "today" : "upcoming"; + return { state, dueDate: `${dueOn}T00:00:00.000Z` }; +} diff --git a/ApiCore-Frontend/src/lib/autopilot/step-fixtures.ts b/ApiCore-Frontend/src/lib/autopilot/step-fixtures.ts new file mode 100644 index 0000000..e1426d6 --- /dev/null +++ b/ApiCore-Frontend/src/lib/autopilot/step-fixtures.ts @@ -0,0 +1,40 @@ +// Test data for next steps - shared by the specs, never imported by app code. +import type { AutopilotToday, ColonyNextSteps, NextStep } from "./types"; + +export function stepFixture(overrides: Partial = {}): NextStep { + return { + key: "suggestion:s1", + kind: "SUGGESTION", + intent: "INSPECT", + title: "Durchsicht", + reasons: ["Letzte Durchsicht vor 8 Tagen.", "In der Schwarmzeit alle 7 Tage."], + dueOn: "2026-05-12", + urgency: "TODAY", + suggestionIds: ["s1"], + action: { type: "CAPTURE", form: "inspection", href: "/inspections/new?colonyId=c1" }, + dismissible: true, + ...overrides, + }; +} + +export function colonyStepsFixture(overrides: Partial = {}): ColonyNextSteps { + return { + colonyId: "c1", + segment: "SCHWARMZEIT", + inspectionDueOn: "2026-05-12", + withdrawalUntil: null, + steps: [stepFixture()], + ...overrides, + }; +} + +export function todayFixture(overrides: Partial = {}): AutopilotToday { + return { + nextStep: null, + inspectionDue: [], + suggestionsOpen: 0, + seasons: [], + tasksVisible: true, + ...overrides, + }; +} diff --git a/ApiCore-Frontend/src/lib/autopilot/types.ts b/ApiCore-Frontend/src/lib/autopilot/types.ts new file mode 100644 index 0000000..1cf3d68 --- /dev/null +++ b/ApiCore-Frontend/src/lib/autopilot/types.ts @@ -0,0 +1,101 @@ +import type { QueenStatus } from "@/lib/queens/types"; +import type { SuggestionIntent } from "@/lib/suggestions/types"; + +// Mirrors ApiCore-Backend/src/autopilot (next-step-ranking.ts, next-steps.service.ts, +// dto/next-steps-response.dto.ts) - keep both in step. + +export const SEASON_SEGMENTS = [ + "WINTERRUHE", + "FRUEHJAHR", + "SCHWARMZEIT", + "SOMMER", + "SPAETSOMMER", + "HERBST", +] as const; +export type SeasonSegment = (typeof SEASON_SEGMENTS)[number]; + +export type NextStepForm = "inspection" | "varroa" | "feeding" | "treatment" | "winter-check"; + +export type NextStepAction = + | { type: "CAPTURE"; form: NextStepForm; href: string } + | { type: "ACCEPT"; suggestionId: string } + | { type: "CLEANUP"; suggestionId: string } + | { type: "OPEN_TASK"; taskId: string; href: string }; + +export type NextStepUrgency = "OVERDUE" | "TODAY" | "SOON" | "LATER" | "NONE"; + +export interface NextStep { + /** 'task:' | 'suggestion:' */ + key: string; + kind: "TASK" | "SUGGESTION"; + intent: SuggestionIntent | null; + title: string; + /** At most 2 short sentences. */ + reasons: string[]; + /** YYYY-MM-DD */ + dueOn: string | null; + urgency: NextStepUrgency; + /** The suggestions behind the step (all members of a merged step); [] for a task. */ + suggestionIds: string[]; + action: NextStepAction; + /** True if any member suggestion may be dismissed. */ + dismissible: boolean; +} + +export interface ColonyNextSteps { + colonyId: string; + segment: SeasonSegment; + inspectionDueOn: string | null; + /** YYYY-MM-DD; the last day of a withdrawal period (a hint, not a step). */ + withdrawalUntil: string | null; + /** At most 3. */ + steps: NextStep[]; +} + +export interface TodayNextStep extends NextStep { + colonyId: string; + colonyCode: string; +} + +export interface DueColony { + id: string; + code: string; + dueOn: string; + overdueDays: number; +} + +export interface InspectionDueApiary { + apiaryId: string; + apiaryName: string; + segment: SeasonSegment; + intervalDays: number | null; + colonies: DueColony[]; +} + +export interface SeasonOfApiary { + apiaryId: string; + apiaryName: string; + segment: SeasonSegment; +} + +export interface AutopilotToday { + nextStep: TodayNextStep | null; + inspectionDue: InspectionDueApiary[]; + suggestionsOpen: number; + seasons: SeasonOfApiary[]; + tasksVisible: boolean; +} + +export type CleanupOutcome = "CANCEL" | "DONE" | "KEEP"; + +export interface ApplyCleanupInput { + tasks: { id: string; outcome: CleanupOutcome }[]; + queen?: { id: string; status: QueenStatus } | null; +} + +export interface CleanupResult { + cancelled: number; + completed: number; + kept: number; + queenStatus: QueenStatus | null; +} diff --git a/ApiCore-Frontend/src/lib/bottling/types.ts b/ApiCore-Frontend/src/lib/bottling/types.ts index 869c2cf..891635a 100644 --- a/ApiCore-Frontend/src/lib/bottling/types.ts +++ b/ApiCore-Frontend/src/lib/bottling/types.ts @@ -1,4 +1,6 @@ -export interface BottlingBatch { +import type { WithWarnings } from "@/lib/warnings/types"; + +export interface BottlingBatch extends WithWarnings { id: string; honeyBatchId: string; productId: string | null; diff --git a/ApiCore-Frontend/src/lib/calendar/types.ts b/ApiCore-Frontend/src/lib/calendar/types.ts index f340912..053f1f5 100644 --- a/ApiCore-Frontend/src/lib/calendar/types.ts +++ b/ApiCore-Frontend/src/lib/calendar/types.ts @@ -4,12 +4,15 @@ export type CalendarEventType = | "TREATMENT" | "FEEDING" | "HARVEST" - | "QUEEN_ASSIGNMENT"; + | "QUEEN_ASSIGNMENT" + | "WITHDRAWAL"; export interface CalendarEvent { id: string; type: CalendarEventType; date: string; + /** WITHDRAWAL only: the last day of the span, inclusive. */ + endDate?: string; title: string; relatedType: string; relatedId: string; diff --git a/ApiCore-Frontend/src/lib/colonies/status-light.spec.ts b/ApiCore-Frontend/src/lib/colonies/status-light.spec.ts index c5b2ad2..7c56fb8 100644 --- a/ApiCore-Frontend/src/lib/colonies/status-light.spec.ts +++ b/ApiCore-Frontend/src/lib/colonies/status-light.spec.ts @@ -10,10 +10,14 @@ function row(overrides: Partial = {}): ColonyOverviewRow { code: "V-1", status: "ACTIVE", strength: 6, + occupiedFrames: 9, broodStatus: "GOOD", feedStatus: "GOOD", healthStatus: "GOOD", lastInspectionDate: "2026-10-10T00:00:00.000Z", + inspectionDueOn: "2026-10-17", + segment: "HERBST", + nextStepTitle: null, openWarnings: 0, openCritical: 0, ...overrides, @@ -32,17 +36,24 @@ describe("colonyStatusLight", () => { ["a weak colony", row({ status: "WEAK" }), "yellow", ["Volk schwach"]], ["a conspicuous colony", row({ status: "CONSPICUOUS" }), "yellow", ["Volk auffällig"]], ["weak feed", row({ feedStatus: "WEAK" }), "yellow", ["Futter schwach"]], - ["15 days without inspection", row({ lastInspectionDate: "2026-09-28T00:00:00.000Z" }), "yellow", ["15 Tage ohne Durchsicht"]], - ["14 days without inspection", row({ lastInspectionDate: "2026-09-29T00:00:00.000Z" }), "green", []], - ["never inspected", row({ lastInspectionDate: null }), "yellow", ["Noch keine Durchsicht"]], + ["an inspection due today", row({ inspectionDueOn: TODAY }), "green", []], + ["an inspection due tomorrow", row({ inspectionDueOn: "2026-10-14" }), "green", []], + ["an inspection one day overdue", row({ inspectionDueOn: "2026-10-12" }), "yellow", ["Durchsicht seit 1 Tag fällig"]], + ["an inspection three days overdue", row({ inspectionDueOn: "2026-10-10" }), "yellow", ["Durchsicht seit 3 Tagen fällig"]], + ["no due day (winter rest)", row({ inspectionDueOn: null, lastInspectionDate: null }), "green", []], ["one warning", row({ openWarnings: 1 }), "yellow", ["1 Warnung"]], ["two warnings", row({ openWarnings: 2 }), "yellow", ["2 Warnungen"]], ] as const)("%s", (_name, input, light, reasons) => { expect(colonyStatusLight(input, visible)).toEqual({ light, reasons }); }); - it("ignores the inspection age when the caller may not read inspections", () => { - const result = colonyStatusLight(row({ lastInspectionDate: null }), { + it("status light turns yellow once inspectionDueOn has passed", () => { + expect(colonyStatusLight(row({ inspectionDueOn: "2026-10-12" }), visible).light).toBe("yellow"); + expect(colonyStatusLight(row({ inspectionDueOn: TODAY }), visible).light).toBe("green"); + }); + + it("ignores the due day when the caller may not read inspections", () => { + const result = colonyStatusLight(row({ inspectionDueOn: "2026-10-01" }), { today: TODAY, inspectionsVisible: false, }); diff --git a/ApiCore-Frontend/src/lib/colonies/status-light.ts b/ApiCore-Frontend/src/lib/colonies/status-light.ts index 787234d..aec8701 100644 --- a/ApiCore-Frontend/src/lib/colonies/status-light.ts +++ b/ApiCore-Frontend/src/lib/colonies/status-light.ts @@ -1,10 +1,8 @@ import type { ColonyOverviewRow } from "@/lib/apiaries/field-types"; -import { daysSince } from "@/lib/inspections/takeover"; +import { daysBetween, inspectionDueText } from "@/lib/autopilot/format"; export type StatusLight = "green" | "yellow" | "red"; -export const INSPECTION_STALE_DAYS = 14; - export interface StatusLightResult { light: StatusLight; reasons: string[]; @@ -38,22 +36,21 @@ function conditionFindings(row: ColonyOverviewRow): Finding[] { return findings; } +/** + * Yellow from the day after `inspectionDueOn`. A pack cached before T4 has no + * due day - then nothing is said instead of a false alarm. + */ function inspectionFinding(row: ColonyOverviewRow, today: string): Finding | null { - if (row.lastInspectionDate === null) { - return { light: "yellow", reason: "Noch keine Durchsicht" }; - } - const days = daysSince(row.lastInspectionDate, today); - if (days > INSPECTION_STALE_DAYS) { - return { light: "yellow", reason: plural(days, "Tag ohne Durchsicht", "Tage ohne Durchsicht") }; - } - return null; + const dueOn = row.inspectionDueOn; + if (!dueOn || daysBetween(dueOn, today) <= 0) return null; + return { light: "yellow", reason: inspectionDueText(dueOn, today) }; } /** * The traffic light of one living colony: the worst finding wins. Reasons are * plain text (the badge also shows icon and word, never colour alone), critical - * ones first. The inspection age counts only when the caller may read - * inspections - otherwise "never inspected" would be a false alarm. + * ones first. The due inspection counts only when the caller may read + * inspections (the due day is derived from the last inspection). */ export function colonyStatusLight( row: ColonyOverviewRow, diff --git a/ApiCore-Frontend/src/lib/harvests/types.ts b/ApiCore-Frontend/src/lib/harvests/types.ts index d0f3b2d..5c65a8b 100644 --- a/ApiCore-Frontend/src/lib/harvests/types.ts +++ b/ApiCore-Frontend/src/lib/harvests/types.ts @@ -1,3 +1,5 @@ +import type { WithWarnings } from "@/lib/warnings/types"; + export interface HarvestColonyEntry { id: string; colonyId: string; @@ -7,7 +9,7 @@ export interface HarvestColonyEntry { }; } -export interface Harvest { +export interface Harvest extends WithWarnings { id: string; apiaryId: string; userId: string; diff --git a/ApiCore-Frontend/src/lib/notification-settings/api.ts b/ApiCore-Frontend/src/lib/notification-settings/api.ts new file mode 100644 index 0000000..e906e08 --- /dev/null +++ b/ApiCore-Frontend/src/lib/notification-settings/api.ts @@ -0,0 +1,18 @@ +import { apiFetch } from "@/lib/auth/api-client"; +import type { NotificationSettings } from "./types"; + +const PATH = "/profile/notification-settings"; + +export function fetchNotificationSettings(): Promise { + return apiFetch(PATH); +} + +/** Send only what changed; an omitted field stays as it is. */ +export function updateNotificationSettings( + patch: Partial, +): Promise { + return apiFetch(PATH, { + method: "PATCH", + body: JSON.stringify(patch), + }); +} diff --git a/ApiCore-Frontend/src/lib/notification-settings/types.ts b/ApiCore-Frontend/src/lib/notification-settings/types.ts new file mode 100644 index 0000000..fd3e7a2 --- /dev/null +++ b/ApiCore-Frontend/src/lib/notification-settings/types.ts @@ -0,0 +1,14 @@ +import type { NotificationCategory } from "@/lib/notifications/types"; + +/** The signed-in user's own settings (GET/PATCH /profile/notification-settings). */ +export interface NotificationSettings { + taskDueEnabled: boolean; + digestEnabled: boolean; + /** HH:MM, 05:00 to 12:00 (Europe/Berlin). */ + digestTime: string; + /** HH:MM or null; quiet hours are set or cleared only as a pair. */ + quietFrom: string | null; + quietUntil: string | null; + telegramCategories: NotificationCategory[]; + telegramDetail: boolean; +} diff --git a/ApiCore-Frontend/src/lib/notification-settings/validation.spec.ts b/ApiCore-Frontend/src/lib/notification-settings/validation.spec.ts new file mode 100644 index 0000000..03f1f02 --- /dev/null +++ b/ApiCore-Frontend/src/lib/notification-settings/validation.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { settingsPatch, validateDigestTime, validateQuietHours } from "./validation"; +import type { NotificationSettings } from "./types"; + +const base: NotificationSettings = { + taskDueEnabled: true, + digestEnabled: true, + digestTime: "06:30", + quietFrom: "21:00", + quietUntil: "06:30", + telegramCategories: ["TASK_DUE", "DIGEST"], + telegramDetail: true, +}; + +describe("validateDigestTime", () => { + it("rejects 04:30 with the German message", () => { + expect(validateDigestTime("04:30")).toBe( + "Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen.", + ); + }); + + it("rejects 12:01, empty and malformed values", () => { + expect(validateDigestTime("12:01")).not.toBeNull(); + expect(validateDigestTime("")).not.toBeNull(); + expect(validateDigestTime("abc")).not.toBeNull(); + }); + + it("accepts the bounds", () => { + expect(validateDigestTime("05:00")).toBeNull(); + expect(validateDigestTime("12:00")).toBeNull(); + }); +}); + +describe("validateQuietHours", () => { + it("clears only as a pair", () => { + expect(validateQuietHours("", "")).toBeNull(); + expect(validateQuietHours("21:00", "06:30")).toBeNull(); + expect(validateQuietHours("21:00", "")).toBe( + "Bitte Beginn und Ende der Ruhezeit angeben oder beide leeren.", + ); + expect(validateQuietHours("", "06:30")).not.toBeNull(); + }); +}); + +describe("settingsPatch", () => { + it("contains only changed fields", () => { + expect(settingsPatch(base, { ...base, digestTime: "07:00" })).toEqual({ + digestTime: "07:00", + }); + }); + + it("is empty when nothing changed", () => { + expect(settingsPatch(base, { ...base })).toEqual({}); + }); + + it("sends null for both quiet fields when cleared", () => { + expect(settingsPatch(base, { ...base, quietFrom: null, quietUntil: null })).toEqual({ + quietFrom: null, + quietUntil: null, + }); + }); +}); diff --git a/ApiCore-Frontend/src/lib/notification-settings/validation.ts b/ApiCore-Frontend/src/lib/notification-settings/validation.ts new file mode 100644 index 0000000..f035331 --- /dev/null +++ b/ApiCore-Frontend/src/lib/notification-settings/validation.ts @@ -0,0 +1,35 @@ +import type { NotificationSettings } from "./types"; + +export const DIGEST_TIME_MIN = "05:00"; +export const DIGEST_TIME_MAX = "12:00"; +export const DIGEST_TIME_MESSAGE = "Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen."; +export const QUIET_PAIR_MESSAGE = "Bitte Beginn und Ende der Ruhezeit angeben oder beide leeren."; + +const TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/; + +/** HH:MM strings of one day compare correctly as text. */ +export function validateDigestTime(value: string): string | null { + if (!TIME_PATTERN.test(value) || value < DIGEST_TIME_MIN || value > DIGEST_TIME_MAX) { + return DIGEST_TIME_MESSAGE; + } + return null; +} + +/** Quiet hours are both set or both empty. */ +export function validateQuietHours(from: string, until: string): string | null { + return Boolean(from) === Boolean(until) ? null : QUIET_PAIR_MESSAGE; +} + +/** Only the fields that differ from what the server last sent. */ +export function settingsPatch( + original: NotificationSettings, + draft: NotificationSettings, +): Partial { + const patch: Partial = {}; + if (draft.taskDueEnabled !== original.taskDueEnabled) patch.taskDueEnabled = draft.taskDueEnabled; + if (draft.digestEnabled !== original.digestEnabled) patch.digestEnabled = draft.digestEnabled; + if (draft.digestTime !== original.digestTime) patch.digestTime = draft.digestTime; + if (draft.quietFrom !== original.quietFrom) patch.quietFrom = draft.quietFrom; + if (draft.quietUntil !== original.quietUntil) patch.quietUntil = draft.quietUntil; + return patch; +} diff --git a/ApiCore-Frontend/src/lib/notifications/api.ts b/ApiCore-Frontend/src/lib/notifications/api.ts index 8705bef..4213c43 100644 --- a/ApiCore-Frontend/src/lib/notifications/api.ts +++ b/ApiCore-Frontend/src/lib/notifications/api.ts @@ -8,3 +8,8 @@ export function fetchNotifications(unreadOnly = false): Promise export function markNotificationRead(id: string): Promise { return apiFetch(`/notifications/${encodeURIComponent(id)}/read`, { method: "POST" }); } + +/** Marks all of the user's visible unread notifications as read. */ +export function markAllNotificationsRead(): Promise<{ updated: number }> { + return apiFetch<{ updated: number }>("/notifications/read-all", { method: "POST" }); +} diff --git a/ApiCore-Frontend/src/lib/notifications/types.ts b/ApiCore-Frontend/src/lib/notifications/types.ts index ccd7a6c..7ff50c0 100644 --- a/ApiCore-Frontend/src/lib/notifications/types.ts +++ b/ApiCore-Frontend/src/lib/notifications/types.ts @@ -1,11 +1,22 @@ export type NotificationPriority = "INFO" | "HINWEIS" | "WARNUNG" | "KRITISCH"; +export type NotificationCategory = + | "TASK_DUE" + | "DIGEST" + | "SUGGESTION" + | "WITHDRAWAL" + | "SEASON" + | "SYSTEM"; + export interface Notification { id: string; priority: NotificationPriority; + category: NotificationCategory; message: string; relatedType: string | null; relatedId: string | null; + /** Relative app path (starts with "/") or null; opened as an internal link. */ + link: string | null; read: boolean; createdAt: string; } diff --git a/ApiCore-Frontend/src/lib/suggestions/labels.spec.ts b/ApiCore-Frontend/src/lib/suggestions/labels.spec.ts index af7c864..adb5ebb 100644 --- a/ApiCore-Frontend/src/lib/suggestions/labels.spec.ts +++ b/ApiCore-Frontend/src/lib/suggestions/labels.spec.ts @@ -18,8 +18,14 @@ describe("suggestion labels", () => { expect(captureHref("HONEY_SUPER", "c1")).toBe("/inspections/new?colonyId=c1"); }); - it("opens the winter page for a winter check and omits a missing colony", () => { - expect(captureHref("WINTER_CHECK", "c1")).toBe("/winter"); + it("opens the winter page of that colony with the season, and omits a missing colony", () => { + expect(captureHref("WINTER_CHECK", "c1", new Date(2026, 9, 14))).toBe( + "/winter?colonyId=c1&season=AUTUMN", + ); + expect(captureHref("WINTER_CHECK", "c1", new Date(2027, 2, 14))).toBe( + "/winter?colonyId=c1&season=SPRING", + ); + expect(captureHref("WINTER_CHECK", null)).toBe("/winter"); expect(captureHref("INSPECT", null)).toBe("/inspections/new"); }); }); diff --git a/ApiCore-Frontend/src/lib/suggestions/labels.ts b/ApiCore-Frontend/src/lib/suggestions/labels.ts index e329078..8797c34 100644 --- a/ApiCore-Frontend/src/lib/suggestions/labels.ts +++ b/ApiCore-Frontend/src/lib/suggestions/labels.ts @@ -68,13 +68,23 @@ const CAPTURE_PATHS: Partial> = { WINTER_CHECK: "/winter", }; -/** Winter checks are entered from the winter page, which has no colony parameter. */ -const PATHS_WITHOUT_COLONY = new Set(["/winter"]); - const DEFAULT_CAPTURE_PATH = "/inspections/new"; -export function captureHref(intent: SuggestionIntent, colonyId: string | null): string { +/** From July on the winter check is the autumn one (as the autopilot's own link says). */ +const FIRST_AUTUMN_MONTH = 7; + +export function captureHref( + intent: SuggestionIntent, + colonyId: string | null, + now: Date = new Date(), +): string { const path = CAPTURE_PATHS[intent] ?? DEFAULT_CAPTURE_PATH; - if (!colonyId || PATHS_WITHOUT_COLONY.has(path)) return path; - return `${path}?colonyId=${encodeURIComponent(colonyId)}`; + if (!colonyId) return path; + const query = `colonyId=${encodeURIComponent(colonyId)}`; + if (path === "/winter") { + // The winter page opens the dialog of that colony for the season. + const season = now.getMonth() + 1 >= FIRST_AUTUMN_MONTH ? "AUTUMN" : "SPRING"; + return `${path}?${query}&season=${season}`; + } + return `${path}?${query}`; } diff --git a/ApiCore-Frontend/src/lib/tasks/api.ts b/ApiCore-Frontend/src/lib/tasks/api.ts index b412657..ac2cfd1 100644 --- a/ApiCore-Frontend/src/lib/tasks/api.ts +++ b/ApiCore-Frontend/src/lib/tasks/api.ts @@ -1,6 +1,10 @@ import { apiFetch } from "@/lib/auth/api-client"; import { CreateTaskInput, Task, TaskFilter, UpdateTaskInput } from "./types"; +export function fetchTask(id: string): Promise { + return apiFetch(`/tasks/${encodeURIComponent(id)}`); +} + export function fetchTasks(filter: TaskFilter): Promise { const params = new URLSearchParams(); if (filter.status) params.set("status", filter.status); diff --git a/ApiCore-Frontend/src/lib/warnings/toast-warnings.spec.ts b/ApiCore-Frontend/src/lib/warnings/toast-warnings.spec.ts new file mode 100644 index 0000000..41cc32d --- /dev/null +++ b/ApiCore-Frontend/src/lib/warnings/toast-warnings.spec.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { toast } from "sonner"; +import { toastWarnings } from "./toast-warnings"; +import type { PlausibilityWarning } from "./types"; + +vi.mock("sonner", () => ({ toast: { warning: vi.fn() } })); + +const withdrawal: PlausibilityWarning = { + code: "WITHDRAWAL_ACTIVE", + severity: "WARNUNG", + message: "B3: Wartezeit nach Oxuvar endet am 20.08.2026.", + refs: [], +}; + +describe("toastWarnings", () => { + beforeEach(() => vi.mocked(toast.warning).mockReset()); + + it("says Gespeichert with the count and names the texts", () => { + toastWarnings([withdrawal]); + expect(toast.warning).toHaveBeenCalledWith( + "Gespeichert · 1 Warnung", + expect.objectContaining({ + description: "B3: Wartezeit nach Oxuvar endet am 20.08.2026.", + }), + ); + }); + + it("keeps a WARNUNG toast until dismissed, one line per warning, with a 44 px close action", () => { + toastWarnings([withdrawal, { ...withdrawal, message: "Zweiter Text." }]); + const options = vi.mocked(toast.warning).mock.calls[0][1] as unknown as { + duration: number; + closeButton: boolean; + description: string; + classNames: Record; + action: { label: string }; + }; + expect(options.duration).toBe(Infinity); + expect(options.closeButton).toBe(true); + expect(options.description).toBe(`${withdrawal.message}\nZweiter Text.`); + expect(options.classNames.description).toContain("whitespace-pre-line"); + expect(options.action.label).toBe("Verstanden"); + expect(options.classNames.actionButton).toContain("min-h-11"); + }); + + it("keeps a BESTAETIGEN toast too but lets a plain HINWEIS time out", () => { + toastWarnings([{ ...withdrawal, severity: "BESTAETIGEN" }]); + expect( + (vi.mocked(toast.warning).mock.calls[0][1] as { duration?: number }) + .duration, + ).toBe(Infinity); + vi.mocked(toast.warning).mockReset(); + toastWarnings([{ ...withdrawal, severity: "HINWEIS" }]); + expect( + (vi.mocked(toast.warning).mock.calls[0][1] as { duration?: number }) + .duration, + ).toBeUndefined(); + }); + + it("starts the title with the given saved label", () => { + toastWarnings([withdrawal], "Abfüllung angelegt"); + expect(vi.mocked(toast.warning).mock.calls[0][0]).toBe( + "Abfüllung angelegt · 1 Warnung", + ); + }); + + it("uses the plural for several warnings", () => { + toastWarnings([withdrawal, { ...withdrawal, message: "Zweiter Text." }]); + expect(vi.mocked(toast.warning).mock.calls[0][0]).toBe( + "Gespeichert · 2 Warnungen", + ); + }); + + it("stays silent without warnings", () => { + toastWarnings([]); + expect(toast.warning).not.toHaveBeenCalled(); + }); +}); diff --git a/ApiCore-Frontend/src/lib/warnings/toast-warnings.ts b/ApiCore-Frontend/src/lib/warnings/toast-warnings.ts new file mode 100644 index 0000000..a233bf6 --- /dev/null +++ b/ApiCore-Frontend/src/lib/warnings/toast-warnings.ts @@ -0,0 +1,44 @@ +import { toast } from "sonner"; +import type { PlausibilityWarning } from "./types"; + +/** Warnings about food safety must not time out: they stay until dismissed. */ +function mustStay(warnings: readonly PlausibilityWarning[]): boolean { + return warnings.some( + (warning) => + warning.severity === "WARNUNG" || warning.severity === "BESTAETIGEN", + ); +} + +/** + * After a successful save: " · 1 Warnung" plus one line per text. The + * record is saved either way; warnings only point at something to check. + * A WARNUNG or BESTAETIGEN stays until the person dismisses it. Does nothing + * for an empty list - the caller shows its own success then. + */ +export function toastWarnings( + warnings: readonly PlausibilityWarning[], + savedLabel = "Gespeichert", +): void { + if (warnings.length === 0) return; + const count = warnings.length; + const title = `${savedLabel} \u00b7 ${count} ${count === 1 ? "Warnung" : "Warnungen"}`; + const description = warnings.map((warning) => warning.message).join("\n"); + if (!mustStay(warnings)) { + toast.warning(title, { + description, + classNames: { description: "whitespace-pre-line" }, + }); + return; + } + toast.warning(title, { + description, + duration: Infinity, + closeButton: true, + action: { label: "Verstanden", onClick: () => undefined }, + classNames: { + description: "whitespace-pre-line", + actionButton: "min-h-11 min-w-11", + closeButton: "size-11", + }, + }); +} diff --git a/ApiCore-Frontend/src/lib/warnings/types.ts b/ApiCore-Frontend/src/lib/warnings/types.ts new file mode 100644 index 0000000..3f671b2 --- /dev/null +++ b/ApiCore-Frontend/src/lib/warnings/types.ts @@ -0,0 +1,14 @@ +export type WarningSeverity = "HINWEIS" | "WARNUNG" | "BESTAETIGEN"; + +/** One format for every plausibility finding on honey records (spec 10.3). */ +export interface PlausibilityWarning { + code: string; + severity: WarningSeverity; + message: string; + refs: { type: string; id: string }[]; +} + +/** A save response may carry warnings; they never block the save. */ +export interface WithWarnings { + warnings?: PlausibilityWarning[]; +}