Compare commits

...
8 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5.5 c2f71bcb03 fix: T4a browser check findings
Backend Image / build-and-push (push) Failing after 2m45s
Frontend Image / build-and-push (push) Successful in 5m52s
- Withdrawal wording from one backend helper everywhere ("bis
  einschließlich TT.MM. – ab TT.MM. wieder erntefähig", year when it
  crosses New Year); withdrawal-status returns lastBlockedDay and the
  ready text, the frontend no longer computes dates
- Calendar events name colony or apiary, withdrawal events carry
  product and detail; compact withdrawal rows on mobile
- One bottling warning per colony and treatment; pre-save hint
- Offline: online-only actions disabled with a reason, cached data
  shows its timestamp, open sheets block submit offline
- Mobile: no horizontal scroll at 360 px, touch targets >= 44 px,
  toasts and the add button no longer cover content
- Cleanup sheet without destructive preselection; natural sort of
  colony codes; collapsible automation rule cards; varroa list newest
  first with chart axes; text fixes

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 18:26:09 +02:00
Nick SzaunigandClaude Opus 5.5 32c61c8e14 feat: T4 wave 6 - reminders and morning digest, next steps UI, warnings and notifications UI
- 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) <[email protected]>
2026-10-14 17:05:32 +02:00
Nick SzaunigandClaude Opus 5.5 ef33e5549b feat: T4 wave 5 - colony end cleanup, withdrawal chain, next steps, inspection takeover UI
- Colony end releases the queen in the same transaction; open
  suggestions become obsolete and a CLEANUP suggestion lists open
  tasks and the queen; apply-cleanup validates evidence and blocks
  reactivated colonies
- Withdrawal chain: plausibility warnings on harvest and bottling,
  conflict notifications also when a treatment is entered or corrected
  after the harvest (naming bottled lots), withdrawal spans in the
  calendar and M365 sync, Berlin-day end events
- Next 3 steps per colony and the "Heute" endpoint with ranking and an
  explicit merge table by intent
- Inspection-triggered rule suggestions follow the colony's latest
  inspection (resolve when the finding is gone, refresh when it stays)
- Frontend: colony card takeover in the inspection form, feed status as
  condition, occupied frames 0-40; legacy offline entries keep syncing
- Notifications and preferences are deleted with their user

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 16:28:07 +02:00
Nick SzaunigandClaude Opus 5.5 02eb16924e feat: T4 wave 4 - rules suggest, takeover, chains, autopilot A1-A3, suggestions page, varroa bands UI
- Automation rules propose suggestions instead of creating tasks;
  notify per rule config; corrected varroa measurements refresh or
  resolve their suggestion
- Inspection values can be taken over into the colony card
  (occupiedFrames 0-40, strength deprecated); warning counts use the
  same visibility rule as the notification list
- Biotech chains: swarm check after queen cells, queen acceptance
  check, split schedules (raise own, queen cell, queen)
- Season autopilot core with A1 inspection due, A2 varroa measurement,
  A3 recheck after treatment; colony sync and daily step
- Frontend: suggestions page with groups, bulk accept, undo, paging,
  focus management and permission gating
- Frontend: varroa traffic light from seasonal bands, sample size,
  one range request for the colony chart
- Notifications: mark-read answers 404 for rows the user may no
  longer see

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 15:22:36 +02:00
Nick SzaunigandClaude Opus 5.5 64a2b7dad9 docs: T4 plan review and consistency fixes
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 14:26:23 +02:00
Nick SzaunigandClaude Opus 5.5 1964286459 chore: remove stray test output file
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 14:26:10 +02:00
Nick SzaunigandClaude Opus 5.5 f468dc5f75 feat: T4 wave 3 - notifications per user, suggestions API, varroa bands, split/queen events
- Schema III: per-user notifications (broadcast fan-out migration,
  category, link, requiredPermissions filter), notification preferences
  and settings endpoints, calendar withdrawal switch, occupiedFrames
- Suggestions API: list/summary with permission visibility and paging,
  accept/dismiss/reopen incl. bulk, SuggestionView on all responses
- Varroa traffic light by season and method, young colonies, treatment
  effect window, sample-based percentage; shared withdrawal-window helper
  on Berlin calendar days; GET /varroa-measurements/thresholds
- ColonySplit and QueenAssigned domain events with calendar and webhook
  handlers
- Outbox claims compare against now() rounded to timestamp(3); fixes the
  flaky e2e claims of rows written with SQL now()

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 14:25:24 +02:00
Nick SzaunigandClaude Opus 5.5 d99a697739 feat: T4 waves 1-2 - rule configs, schema, suggestions core, season, rule forms
- Rule configs with typed parsers and registry; Berlin day helpers
- Schema: T4 enums, Suggestion table, feed vocabulary (feedStatus as
  ColonyCondition with legacy text mapping), split queen source,
  varroa sample size; migration harness for data migrations
- SuggestionsService: propose/refresh/resolve/obsolete/expire,
  accept/dismiss/reopen incl. bulk, lock order, concurrency e2e
- SeasonService: phenology-based segments, inspection/varroa due dates,
  queen age; TimeEventsStep with JobPartialFailure
- Frontend: per-rule settings forms with visible value sources

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 13:42:32 +02:00
477 changed files with 46446 additions and 2759 deletions

No files matched your search

+207 -25
View File
@@ -27,6 +27,11 @@ Ein NestJS-Modul pro fachlichem Bereich, Reihenfolge entspricht Baureihenfolge i
| `SearchModule` | globale Suche, Filter-Bausteine | alle o.g. | | `SearchModule` | globale Suche, Filter-Bausteine | alle o.g. |
| `ExportModule` | CSV/JSON/PDF-Export | 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 | | `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. 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]/ │ ├── queens/[id]/
│ ├── inspections/new/ │ ├── inspections/new/
│ ├── tasks/ │ ├── tasks/
│ ├── suggestions/ Vorschläge (Übernehmen, Verwerfen, Aufräumen)
│ ├── varroa/new/ │ ├── varroa/new/
│ ├── treatments/new/ │ ├── treatments/new/
│ ├── feedings/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. **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. 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 ## 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. 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. 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. 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. 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. 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`. 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 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. 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 „<Volkscode>: <Titel>“ (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. 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. 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). 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. 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 `"<iss>|<oid>"` (`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. 18. **SSO-Bindung an die IdP-Identität**: Die erste Microsoft-Anmeldung bindet das Konto an `"<iss>|<oid>"` (`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. 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`. 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. 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>:<iv>.<tag>.<data>` (Base64-Teile, AES-256-GCM, 128-Bit-Tag Pflicht); AES-Schlüssel = HKDF-SHA256(Secret, Salt `apicore-key-ring`, Info `apicore-<zweck>-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. 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>:<iv>.<tag>.<data>` (Base64-Teile, AES-256-GCM, 128-Bit-Tag Pflicht); AES-Schlüssel = HKDF-SHA256(Secret, Salt `apicore-key-ring`, Info `apicore-<zweck>-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. 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 `<NAME>_PREVIOUS` (öffnet weiter, versiegelt nie), neuen `<NAME>` 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. **Rotation über `*_PREVIOUS`:** alten Wert nach `<NAME>_PREVIOUS` (öffnet weiter, versiegelt nie), neuen `<NAME>` 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). **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" = <alter Wert>`, 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. **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" = <alter Wert>`, 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“. 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). 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. 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. 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**: 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. 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. 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 = <ruleKey>:<relatedId|none>:<anker>` 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): <Text>` 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): <JSON>`). 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 = "<Präfix>:<userId>"` 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 ## 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: **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 `<id>:<userId>`, 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“). - **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. - **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). **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: **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`). 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, '<consumer>.<name>')` 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. **Handler-Vertrag.** `@OnDomainEvent(types, '<consumer>.<name>')` 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. **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. **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`. - 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. **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 „<Volk> 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 ### 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: **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 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`, `queen.replaced`, `product.stock_changed`), reiht nach dem Commit über `runAfterCommit` ein und wirft nie. - 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, "<t>.<id>.<body>")`) 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. **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, "<t>.<id>.<body>")`) 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.
@@ -297,7 +340,7 @@ Daraus folgen die Webhooks `batch.updated` und `stock.changed` (`webhooks.forwar
### Jobs ### Jobs
**Aufruf.** `node dist/cli.js <job> [args]` (`src/cli.ts`, Rolle `job`) prüft wie `main.ts` und `worker.ts` zuerst Rolle und Umgebung (`resolveRole('cli')`, `assertStartupEnvironment`) und startet dann `JobsModule` per `createApplicationContext`: die Module, die die Jobs brauchen, ohne HTTP-Schicht und ohne Worker-Schleifen. Jobs (`JOB_NAMES` in `src/jobs/job-names.ts`): `daily-evaluation`, `task-templates`, `cleanup`, `app-backup`, `rekey`. Weitere Argumente gehen unverändert an den Job, erlaubt sind nur die aus `JOB_ALLOWED_ARGS` (heute einzig `rekey --dry-run`). Exit-Code `0` Lauf erfolgreich, `1` Lauf gescheitert (auch wenn er gar nicht erst aufgezeichnet werden konnte, etwa bei einem Modul- oder DI-Fehler – der Kontext startet mit `abortOnError: false`), `2` falscher Aufruf (kein oder unbekannter Job oder ein nicht erlaubtes Argument wie `cleanup --dry-run` oder `rekey --dry-rn` – dann wird nichts verbunden und kein `JobRun` geschrieben). Shutdown-Hooks gibt es keine: Ein abgebrochener Pod hinterlässt seinen Lauf als `RUNNING`, `cleanup` erkennt ihn (siehe unten). `JobRegistry` prüft beim Start, dass es zu jedem Namen genau einen Job gibt. **Aufruf.** `APICORE_ROLE=job node dist/cli.js <job> [args]`, etwa `APICORE_ROLE=job node dist/cli.js daily-evaluation` (`src/cli.ts`; die Rolle muss `job` sein, `all` und `api` werden abgelehnt, und eine Form wie `cli.js jobs daily-evaluation` scheitert mit „Unknown job“; im Cluster setzt das CronJob-Manifest die Rolle) prüft wie `main.ts` und `worker.ts` zuerst Rolle und Umgebung (`resolveRole('cli')`, `assertStartupEnvironment`) und startet dann `JobsModule` per `createApplicationContext`: die Module, die die Jobs brauchen, ohne HTTP-Schicht und ohne Worker-Schleifen. Jobs (`JOB_NAMES` in `src/jobs/job-names.ts`): `daily-evaluation`, `task-templates`, `cleanup`, `app-backup`, `rekey`. Weitere Argumente gehen unverändert an den Job, erlaubt sind nur die aus `JOB_ALLOWED_ARGS` (heute einzig `rekey --dry-run`). Exit-Code `0` Lauf erfolgreich, `1` Lauf gescheitert (auch wenn er gar nicht erst aufgezeichnet werden konnte, etwa bei einem Modul- oder DI-Fehler – der Kontext startet mit `abortOnError: false`), `2` falscher Aufruf (kein oder unbekannter Job oder ein nicht erlaubtes Argument wie `cleanup --dry-run` oder `rekey --dry-rn` – dann wird nichts verbunden und kein `JobRun` geschrieben). Shutdown-Hooks gibt es keine: Ein abgebrochener Pod hinterlässt seinen Lauf als `RUNNING`, `cleanup` erkennt ihn (siehe unten). `JobRegistry` prüft beim Start, dass es zu jedem Namen genau einen Job gibt.
**`JobRun`.** `JobRunService.run(job, work)` schreibt je Lauf eine Zeile: `RUNNING`, danach `SUCCEEDED` mit `result` (nur Zähler, Schalter, kurze Namen) oder `FAILED` mit `error` aus `loggableError()`; `finishedAt` setzt immer die DB (`now()`), auch bei abgebrochenen Läufen. Ein gescheiterter Lauf benachrichtigt die Inhaber des Job-Rechts (`JOB_FAILURE_PERMISSION`: `daily-evaluation` und `task-templates` → `automation:update`, `app-backup` → `backup:configure`, `cleanup` und `rekey` → `system:manage`) mit „Job „<Name>“ fehlgeschlagen“ (`WARNUNG`, `relatedType: 'JobRun'`, `relatedId` = Lauf). Höchstens eine solche Benachrichtigung je Job und UTC-Tag (DB-Uhr), sonst meldete die stündliche `app-backup`-Prüfung denselben Ausfall jede Stunde; Prüfung und Schreiben laufen in einer Transaktion hinter einem Advisory-Lock je Job. `recent(limit, job?)` und `latestPerJob()` lesen die Läufe für die System-Seite. `SystemModule` stellt `JobRunService` für API und Jobs bereit. **`JobRun`.** `JobRunService.run(job, work)` schreibt je Lauf eine Zeile: `RUNNING`, danach `SUCCEEDED` mit `result` (nur Zähler, Schalter, kurze Namen) oder `FAILED` mit `error` aus `loggableError()`; `finishedAt` setzt immer die DB (`now()`), auch bei abgebrochenen Läufen. Ein gescheiterter Lauf benachrichtigt die Inhaber des Job-Rechts (`JOB_FAILURE_PERMISSION`: `daily-evaluation` und `task-templates` → `automation:update`, `app-backup` → `backup:configure`, `cleanup` und `rekey` → `system:manage`) mit „Job „<Name>“ fehlgeschlagen“ (`WARNUNG`, `relatedType: 'JobRun'`, `relatedId` = Lauf). Höchstens eine solche Benachrichtigung je Job und UTC-Tag (DB-Uhr), sonst meldete die stündliche `app-backup`-Prüfung denselben Ausfall jede Stunde; Prüfung und Schreiben laufen in einer Transaktion hinter einem Advisory-Lock je Job. `recent(limit, job?)` und `latestPerJob()` lesen die Läufe für die System-Seite. `SystemModule` stellt `JobRunService` für API und Jobs bereit.
@@ -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`. **`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: **`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`).
- `WithdrawalEnded` (Aggregat `Treatment`, Payload `colonyId`, `endedOn`): `(endDate ?? date) + withdrawalPeriodDays`, nur mit Wartezeit. - **`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`). - `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 `<Jahr>-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. - `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:<queenId>:<Jahr>-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`) 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. - `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.
„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 = "<type>:<aggregateId>:<YYYY-MM-DD>"` (`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: <Typen>`. 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. - 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 = "<type>:<aggregateId>:<YYYY-MM-DD>"` (`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“. **`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. **`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 ### 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). 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<n> = 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-<Jahr>` | 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 „<Formular> mit <erster spezifischer Titel>“ (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 – <Stand>“ mit Saison-Chip und „Runde starten“, Chips „Vorschläge <n>“ (nur mit `tasks:read`) und „<Stand>: <Saison>“; 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-<endedOn>-<Ereignis-ID>`, 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/<id>`, Schlüssel `withdrawal-conflict:<Ernte>:<Volk>`), 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 „<Volk> ist wieder erntefähig“ (Kategorie `WITHDRAWAL`, `harvests:create` + `treatments:read`, Link `/colonies/<id>`, Schlüssel `withdrawal-ended:<Behandlung>:<endedOn>`). 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:<Behandlung>`), 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 <Volk> · <Mittel>“ (`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:<Aufgabe>:<dueOn>:<userId>`): „Heute fällig: <Titel>“, bei einem Nachhol-Ereignis „Fällig seit TT.MM.: <Titel>“. 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:<Tag>:<userId>`). 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_<id>` 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“.
+3
View File
@@ -18,3 +18,6 @@ prisma/seed.d.ts
# Locally uploaded documents/images (Modul 29) and their test-run counterpart # Locally uploaded documents/images (Modul 29) and their test-run counterpart
/uploads/ /uploads/
/test-uploads/ /test-uploads/
# T4 migration test harness: scratch copies of the migration folders
/.tmp-migrations/
@@ -0,0 +1,19 @@
-- T4 migration 1/2: new enum values only. PostgreSQL cannot use a value in
-- the transaction that adds it, so everything that uses them (the Suggestion
-- table, rule rows) comes in later migrations.
-- AlterEnum
ALTER TYPE "AutomationTriggerType" ADD VALUE 'INSPECTION_INTERVAL';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'QUEEN_CELLS_SWARM_CHECK';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'QUEEN_ACCEPTANCE_CHECK';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'SPLIT_SCHEDULE';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'COLONY_ENDED_CLEANUP';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'WITHDRAWAL_ENDED';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'HONEY_WATER_CONTENT';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'SEASON_AUTOPILOT';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'PHENOLOGY';
-- AlterEnum
ALTER TYPE "BatchMovementType" ADD VALUE 'BOTTLING';
-- AlterEnum
ALTER TYPE "CalendarSyncSourceType" ADD VALUE 'WITHDRAWAL';
@@ -0,0 +1,71 @@
-- T4 migration 2/2: suggestions (rule, chain and autopilot proposals the
-- beekeeper decides on). dedupeKey is nullable and unique: Postgres allows many
-- NULLs, so EXPIRED/RESOLVED rows release their key.
-- CreateEnum
CREATE TYPE "SuggestionStatus" AS ENUM ('OPEN', 'ACCEPTED', 'DISMISSED', 'RESOLVED', 'EXPIRED', 'OBSOLETE');
-- CreateEnum
CREATE TYPE "SuggestionMode" AS ENUM ('PLAN', 'DO');
-- CreateEnum
CREATE TYPE "SuggestionSource" AS ENUM ('RULE', 'CHAIN', 'AUTOPILOT');
-- CreateEnum
CREATE TYPE "SuggestionIntent" AS ENUM ('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');
-- AlterTable
ALTER TABLE "Task" ADD COLUMN "sourceSuggestionId" TEXT;
-- CreateTable
CREATE TABLE "Suggestion" (
"id" TEXT NOT NULL,
"ruleKey" "AutomationTriggerType" NOT NULL,
"source" "SuggestionSource" NOT NULL,
"mode" "SuggestionMode" NOT NULL,
"intent" "SuggestionIntent" NOT NULL,
"status" "SuggestionStatus" NOT NULL DEFAULT 'OPEN',
"priority" "TaskPriority" NOT NULL DEFAULT 'NORMAL',
"relatedType" "TaskRelatedType" NOT NULL,
"relatedId" TEXT,
"colonyId" TEXT,
"reasonCode" TEXT NOT NULL,
"evidence" JSONB NOT NULL,
"requiredPermission" TEXT,
"dueDate" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3),
"plan" JSONB,
"dedupeKey" TEXT,
"sourceEventId" TEXT,
"decidedAt" TIMESTAMP(3),
"decidedByUserId" TEXT,
"decisionNote" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Suggestion_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Suggestion_dedupeKey_key" ON "Suggestion"("dedupeKey");
-- CreateIndex
CREATE INDEX "Suggestion_status_colonyId_idx" ON "Suggestion"("status", "colonyId");
-- CreateIndex
CREATE INDEX "Suggestion_status_dueDate_idx" ON "Suggestion"("status", "dueDate");
-- CreateIndex
CREATE INDEX "Suggestion_ruleKey_relatedType_relatedId_status_idx" ON "Suggestion"("ruleKey", "relatedType", "relatedId", "status");
-- CreateIndex
CREATE INDEX "Task_sourceSuggestionId_idx" ON "Task"("sourceSuggestionId");
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_sourceSuggestionId_fkey" FOREIGN KEY ("sourceSuggestionId") REFERENCES "Suggestion"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Suggestion" ADD CONSTRAINT "Suggestion_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Suggestion" ADD CONSTRAINT "Suggestion_decidedByUserId_fkey" FOREIGN KEY ("decidedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,34 @@
-- T4 migration 3/n: one vocabulary for the feed condition. Inspection.feedStatus
-- was free text; it becomes ColonyCondition, like the colony card. Known words
-- (ausreichend, niedrig, kritisch; trimmed of spaces, tabs and line breaks like
-- JS trim, any case) map to the enum, any other text moves to the end of
-- "notes" as 'Futter (alt): <text>'. Nothing is lost.
-- AlterTable
ALTER TABLE "Inspection" ADD COLUMN "feedCondition" "ColonyCondition";
-- Known words map to the enum.
UPDATE "Inspection"
SET "feedCondition" = CASE lower(btrim("feedStatus", E' \t\r\n'))
WHEN 'ausreichend' THEN 'GOOD'::"ColonyCondition"
WHEN 'niedrig' THEN 'WEAK'::"ColonyCondition"
WHEN 'kritisch' THEN 'CRITICAL'::"ColonyCondition"
END
WHERE "feedStatus" IS NOT NULL;
-- Every other non-empty text goes to the notes.
DO $$
DECLARE
moved integer;
BEGIN
UPDATE "Inspection"
SET "notes" = concat_ws(E'\n', NULLIF("notes", ''), 'Futter (alt): ' || btrim("feedStatus", E' \t\r\n'))
WHERE "feedCondition" IS NULL
AND btrim("feedStatus", E' \t\r\n') <> '';
GET DIAGNOSTICS moved = ROW_COUNT;
RAISE NOTICE 't4_feed_vocabulary: % free-text feed values moved into notes', moved;
END $$;
-- AlterTable
ALTER TABLE "Inspection" DROP COLUMN "feedStatus";
ALTER TABLE "Inspection" RENAME COLUMN "feedCondition" TO "feedStatus";
@@ -0,0 +1,8 @@
-- T4 migration 5/n: where the queen of a split colony comes from. Optional:
-- an observation is never filled in for old rows.
-- CreateEnum
CREATE TYPE "SplitQueenSource" AS ENUM ('RAISE_OWN', 'QUEEN_CELL', 'QUEEN');
-- AlterTable
ALTER TABLE "ColonyLineage" ADD COLUMN "queenSource" "SplitQueenSource";
@@ -0,0 +1,5 @@
-- T4 migration 7/n (part sampleBees): number of bees in a powdered-sugar or
-- washing sample, so that the infestation percentage can be computed.
-- AlterTable
ALTER TABLE "VarroaMeasurement" ADD COLUMN "sampleBees" INTEGER;
@@ -0,0 +1,33 @@
-- T4 migration 8/n: every notification belongs to exactly one user. A
-- broadcast (userId IS NULL) is copied once per active user with its read
-- flag and creation time, then the original is deleted. After that userId is
-- NOT NULL; dedupeKey ("<prefix>:<userId>") makes repeated writes harmless.
-- Check afterwards: SELECT count(*) FROM "Notification" WHERE "userId" IS NULL;
-- must be 0 (the SET NOT NULL below fails otherwise).
-- DropForeignKey (was ON DELETE SET NULL, impossible for a NOT NULL column)
ALTER TABLE "Notification" DROP CONSTRAINT "Notification_userId_fkey";
-- AlterTable
ALTER TABLE "Notification" ADD COLUMN "dedupeKey" TEXT;
-- Fan-out of the broadcasts to ALL active users (isActive only, no role or
-- permission filter): a broadcast was visible to every user before, so the
-- copies keep exactly that visibility. With no active user, broadcasts are
-- dropped. The copies get no dedupeKey and the default requiredPermissions.
INSERT INTO "Notification" ("id", "userId", "priority", "message", "relatedType", "relatedId", "read", "createdAt")
SELECT gen_random_uuid()::text, u."id", n."priority", n."message", n."relatedType", n."relatedId", n."read", n."createdAt"
FROM "Notification" n
CROSS JOIN "User" u
WHERE n."userId" IS NULL AND u."isActive" = true;
DELETE FROM "Notification" WHERE "userId" IS NULL;
-- AlterTable
ALTER TABLE "Notification" ALTER COLUMN "userId" SET NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "Notification_dedupeKey_key" ON "Notification"("dedupeKey");
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,5 @@
-- T4 migration 9/n: switch for the withdrawal-period entries of the calendar
-- sync (on by default, like the other sources).
-- AlterTable
ALTER TABLE "CalendarSyncSettings" ADD COLUMN "syncWithdrawal" BOOLEAN NOT NULL DEFAULT true;
@@ -0,0 +1,11 @@
-- T4 migration 10/n: Colony.occupiedFrames, the number of occupied frames
-- (0-40, same unit and bounds as Inspection.colonyStrength). Colony.strength
-- (scale 1-10) is left untouched, nothing is converted.
--
-- Check BEFORE the deploy (hits must be clarified with the owner, never cut):
-- SELECT count(*) FROM "Inspection" WHERE "colonyStrength" > 40;
-- AlterTable
ALTER TABLE "Colony" ADD COLUMN "occupiedFrames" INTEGER;
ALTER TABLE "Colony" ADD CONSTRAINT "Colony_occupiedFrames_range" CHECK ("occupiedFrames" BETWEEN 0 AND 40);
@@ -0,0 +1,39 @@
-- T4 migration 11/n (part category, link, permissions, preferences). Runs
-- after migration 8. category of the old rows: job failures carry
-- relatedType 'JobRun' (and rows without relatedType are system messages) ->
-- SYSTEM, everything else -> SUGGESTION. Only then NOT NULL.
-- CreateEnum
CREATE TYPE "NotificationCategory" AS ENUM ('TASK_DUE', 'DIGEST', 'SUGGESTION', 'WITHDRAWAL', 'SEASON', 'SYSTEM');
-- AlterTable
ALTER TABLE "Notification" ADD COLUMN "category" "NotificationCategory",
ADD COLUMN "link" TEXT,
ADD COLUMN "requiredPermissions" TEXT[] DEFAULT ARRAY[]::TEXT[];
UPDATE "Notification"
SET "category" = CASE
WHEN "relatedType" = 'JobRun' OR "relatedType" IS NULL THEN 'SYSTEM'::"NotificationCategory"
ELSE 'SUGGESTION'::"NotificationCategory"
END;
ALTER TABLE "Notification" ALTER COLUMN "category" SET NOT NULL;
-- CreateTable
CREATE TABLE "NotificationPreference" (
"userId" TEXT NOT NULL,
"taskDueEnabled" BOOLEAN NOT NULL DEFAULT true,
"digestEnabled" BOOLEAN NOT NULL DEFAULT true,
"digestTime" TEXT NOT NULL DEFAULT '06:30',
"quietFrom" TEXT DEFAULT '21:00',
"quietUntil" TEXT DEFAULT '06:30',
"telegramEnabled" BOOLEAN NOT NULL DEFAULT false,
"telegramCategories" "NotificationCategory"[] DEFAULT ARRAY['TASK_DUE', 'DIGEST']::"NotificationCategory"[],
"telegramDetail" BOOLEAN NOT NULL DEFAULT true,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NotificationPreference_pkey" PRIMARY KEY ("userId")
);
-- AddForeignKey
ALTER TABLE "NotificationPreference" ADD CONSTRAINT "NotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,16 @@
-- T4 follow-up: notifications and their settings go with their user.
-- Users are deactivated, never hard-deleted, in production, so RESTRICT
-- protected nothing; but fan-out writes rows to users of other test files,
-- and deleting such a user must not fail on a foreign notification.
-- DropForeignKey
ALTER TABLE "Notification" DROP CONSTRAINT "Notification_userId_fkey";
-- DropForeignKey
ALTER TABLE "NotificationPreference" DROP CONSTRAINT "NotificationPreference_userId_fkey";
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NotificationPreference" ADD CONSTRAINT "NotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+146 -3
View File
@@ -115,6 +115,7 @@ model User {
stockMovements StockMovement[] stockMovements StockMovement[]
assignedTasks Task[] @relation("TaskAssignee") assignedTasks Task[] @relation("TaskAssignee")
createdTasks Task[] @relation("TaskCreator") createdTasks Task[] @relation("TaskCreator")
decidedSuggestions Suggestion[] @relation("SuggestionDecider")
assignedTaskTemplates TaskTemplate[] @relation("TaskTemplateAssignee") assignedTaskTemplates TaskTemplate[] @relation("TaskTemplateAssignee")
createdTaskTemplates TaskTemplate[] @relation("TaskTemplateCreator") createdTaskTemplates TaskTemplate[] @relation("TaskTemplateCreator")
colonyMoves ColonyApiaryHistory[] colonyMoves ColonyApiaryHistory[]
@@ -124,6 +125,7 @@ model User {
batchMovements BatchMovement[] batchMovements BatchMovement[]
rawMaterialLots RawMaterialLot[] rawMaterialLots RawMaterialLot[]
notifications Notification[] notifications Notification[]
notificationPreference NotificationPreference?
queenEvaluations QueenEvaluation[] queenEvaluations QueenEvaluation[]
webhookSubscriptions WebhookSubscription[] webhookSubscriptions WebhookSubscription[]
documents Document[] documents Document[]
@@ -290,6 +292,7 @@ model Colony {
colonyType ColonyType colonyType ColonyType
status ColonyStatus @default(ACTIVE) status ColonyStatus @default(ACTIVE)
strength Int? strength Int?
occupiedFrames Int?
broodStatus ColonyCondition? broodStatus ColonyCondition?
feedStatus ColonyCondition? feedStatus ColonyCondition?
healthStatus ColonyCondition? healthStatus ColonyCondition?
@@ -312,6 +315,7 @@ model Colony {
feedings Feeding[] feedings Feeding[]
harvests HarvestColony[] harvests HarvestColony[]
queenEvaluations QueenEvaluation[] queenEvaluations QueenEvaluation[]
suggestions Suggestion[]
@@index([apiaryId]) @@index([apiaryId])
@@index([status]) @@index([status])
@@ -340,6 +344,12 @@ enum LineageType {
// Ableger bilden (SPLIT: from=Muttervolk, to=Ableger) und Völker vereinigen // Ableger bilden (SPLIT: from=Muttervolk, to=Ableger) und Völker vereinigen
// (MERGE: mehrere from-Zeilen auf denselben to=Zielvolk) – Abschnitt 4.4. // (MERGE: mehrere from-Zeilen auf denselben to=Zielvolk) – Abschnitt 4.4.
enum SplitQueenSource {
RAISE_OWN
QUEEN_CELL
QUEEN
}
model ColonyLineage { model ColonyLineage {
id String @id @default(cuid()) id String @id @default(cuid())
type LineageType type LineageType
@@ -348,6 +358,7 @@ model ColonyLineage {
toColonyId String toColonyId String
toColony Colony @relation("LineageTo", fields: [toColonyId], references: [id]) toColony Colony @relation("LineageTo", fields: [toColonyId], references: [id])
date DateTime date DateTime
queenSource SplitQueenSource?
notes String? notes String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -467,7 +478,7 @@ model Inspection {
swarmMood Int? swarmMood Int?
gentleness Int? gentleness Int?
combBehavior Int? combBehavior Int?
feedStatus String? feedStatus ColonyCondition?
honeySuperStatus String? honeySuperStatus String?
healthIssues String? healthIssues String?
varroaIssues String? varroaIssues String?
@@ -529,12 +540,99 @@ model Task {
// rule see that it already left an open task for the same object instead // rule see that it already left an open task for the same object instead
// of piling up a duplicate on every further trigger. // of piling up a duplicate on every further trigger.
sourceAutomationTrigger AutomationTriggerType? sourceAutomationTrigger AutomationTriggerType?
// Set when the task was created by accepting a suggestion.
sourceSuggestionId String?
sourceSuggestion Suggestion? @relation(fields: [sourceSuggestionId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
completedAt DateTime? completedAt DateTime?
@@index([status, dueDate]) @@index([status, dueDate])
@@index([relatedType, relatedId]) @@index([relatedType, relatedId])
@@index([sourceAutomationTrigger, relatedType, relatedId, status]) @@index([sourceAutomationTrigger, relatedType, relatedId, status])
@@index([sourceSuggestionId])
}
// ---------------------------------------------------------------------------
// Vorschlaege (T4): Regeln, Ketten und der Saison-Autopilot schlagen vor, der
// Imker entscheidet. Uebernehmen legt eine Aufgabe an (Task.sourceSuggestionId).
// ---------------------------------------------------------------------------
enum SuggestionStatus {
OPEN
ACCEPTED
DISMISSED
RESOLVED
EXPIRED
OBSOLETE
}
enum SuggestionMode {
PLAN
DO
}
enum SuggestionSource {
RULE
CHAIN
AUTOPILOT
}
enum SuggestionIntent {
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
}
model Suggestion {
id String @id @default(cuid())
ruleKey AutomationTriggerType
source SuggestionSource
mode SuggestionMode
intent SuggestionIntent
status SuggestionStatus @default(OPEN)
priority TaskPriority @default(NORMAL)
relatedType TaskRelatedType
relatedId String?
colonyId String?
colony Colony? @relation(fields: [colonyId], references: [id])
reasonCode String
// Only IDs, numbers and YYYY-MM-DD.
evidence Json
// e.g. 'varroa:read'; without that permission the suggestion is invisible.
requiredPermission String?
dueDate DateTime?
expiresAt DateTime?
// PLAN with several dates: [{ step, offsetDays, windowDays? }]
plan Json?
// '<ruleKey>:<relatedId|none>:<anchor>'; EXPIRED/RESOLVED clear it.
dedupeKey String? @unique
sourceEventId String?
decidedAt DateTime?
decidedByUserId String?
decidedByUser User? @relation("SuggestionDecider", fields: [decidedByUserId], references: [id])
decisionNote String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tasks Task[]
@@index([status, colonyId])
@@index([status, dueDate])
@@index([ruleKey, relatedType, relatedId, status])
} }
enum TaskTemplateRecurrenceType { enum TaskTemplateRecurrenceType {
@@ -589,6 +687,7 @@ model VarroaMeasurement {
method VarroaMethod method VarroaMethod
durationMinutes Int? durationMinutes Int?
miteCount Int? miteCount Int?
sampleBees Int?
calculatedValue Float? calculatedValue Float?
assessment String? assessment String?
notes String? notes String?
@@ -1172,6 +1271,8 @@ enum BatchMovementType {
// Rohstoff in einer Herstellungscharge verbraucht (minus) bzw. beim // Rohstoff in einer Herstellungscharge verbraucht (minus) bzw. beim
// Loeschen einer irrtuemlich angelegten Charge zurueckgegeben (plus). // Loeschen einer irrtuemlich angelegten Charge zurueckgegeben (plus).
CONSUMPTION CONSUMPTION
// Abfuellung entnimmt Honig aus der Charge (Task 23: Bewegungsbuch)
BOTTLING
} }
enum WithdrawalReason { enum WithdrawalReason {
@@ -1275,14 +1376,30 @@ enum NotificationPriority {
KRITISCH KRITISCH
} }
enum NotificationCategory {
TASK_DUE
DIGEST
SUGGESTION
WITHDRAWAL
SEASON
SYSTEM
}
// Jede Zeile gehoert genau einem Empfaenger (T4: Fan-out mit Rechtepruefung
// beim Schreiben). requiredPermissions = Rechte des Empfaengers plus Rechte
// des Inhalts; dedupeKey = "<praefix>:<userId>".
model Notification { model Notification {
id String @id @default(cuid()) id String @id @default(cuid())
userId String? userId String
user User? @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
priority NotificationPriority priority NotificationPriority
category NotificationCategory
message String message String
relatedType String? relatedType String?
relatedId String? relatedId String?
link String?
requiredPermissions String[] @default([])
dedupeKey String? @unique
read Boolean @default(false) read Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -1290,6 +1407,21 @@ model Notification {
@@index([priority]) @@index([priority])
} }
// Einstellungen je Nutzer; Standard beim ersten Lesen angelegt.
model NotificationPreference {
userId String @id
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
taskDueEnabled Boolean @default(true)
digestEnabled Boolean @default(true)
digestTime String @default("06:30")
quietFrom String? @default("21:00")
quietUntil String? @default("06:30")
telegramEnabled Boolean @default(false)
telegramCategories NotificationCategory[] @default([TASK_DUE, DIGEST])
telegramDetail Boolean @default(true)
updatedAt DateTime @updatedAt
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Webhooks (Abschnitt 27) – v1.x Modul 28 // Webhooks (Abschnitt 27) – v1.x Modul 28
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1358,6 +1490,15 @@ enum AutomationTriggerType {
RAW_MATERIAL_LOT_NUMBER_FORMAT RAW_MATERIAL_LOT_NUMBER_FORMAT
PRODUCTION_BATCH_NUMBER_FORMAT PRODUCTION_BATCH_NUMBER_FORMAT
RAW_MATERIAL_LOW RAW_MATERIAL_LOW
INSPECTION_INTERVAL
QUEEN_CELLS_SWARM_CHECK
QUEEN_ACCEPTANCE_CHECK
SPLIT_SCHEDULE
COLONY_ENDED_CLEANUP
WITHDRAWAL_ENDED
HONEY_WATER_CONTENT
SEASON_AUTOPILOT
PHENOLOGY
} }
model AutomationRule { model AutomationRule {
@@ -1446,6 +1587,7 @@ enum CalendarSyncSourceType {
FEEDING FEEDING
HARVEST HARVEST
QUEEN_ASSIGNMENT QUEEN_ASSIGNMENT
WITHDRAWAL
} }
enum CalendarSyncStatus { enum CalendarSyncStatus {
@@ -1465,6 +1607,7 @@ model CalendarSyncSettings {
syncFeeding Boolean @default(true) syncFeeding Boolean @default(true)
syncHarvest Boolean @default(true) syncHarvest Boolean @default(true)
syncQueenAssignment Boolean @default(true) syncQueenAssignment Boolean @default(true)
syncWithdrawal Boolean @default(true)
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AutopilotModule } from '../autopilot/autopilot.module';
import { ApiariesController } from './apiaries.controller'; import { ApiariesController } from './apiaries.controller';
import { ApiariesService } from './apiaries.service'; import { ApiariesService } from './apiaries.service';
import { ApiaryFieldService } from './apiary-field.service'; import { ApiaryFieldService } from './apiary-field.service';
@Module({ @Module({
imports: [AutopilotModule],
controllers: [ApiariesController], controllers: [ApiariesController],
providers: [ApiariesService, ApiaryFieldService], providers: [ApiariesService, ApiaryFieldService],
exports: [ApiariesService], exports: [ApiariesService],
@@ -2,6 +2,8 @@ import { NotFoundException } from '@nestjs/common';
import { ApiaryFieldService } from './apiary-field.service'; import { ApiaryFieldService } from './apiary-field.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import type { ColonyNextSteps } from '../autopilot/next-step-ranking';
import { NextStepsService } from '../autopilot/next-steps.service';
describe('ApiaryFieldService', () => { describe('ApiaryFieldService', () => {
let service: ApiaryFieldService; let service: ApiaryFieldService;
@@ -9,11 +11,26 @@ describe('ApiaryFieldService', () => {
apiary: { findUnique: jest.Mock }; apiary: { findUnique: jest.Mock };
colony: { findMany: jest.Mock }; colony: { findMany: jest.Mock };
inspection: { groupBy: jest.Mock; findMany: jest.Mock }; inspection: { groupBy: jest.Mock; findMany: jest.Mock };
notification: { groupBy: jest.Mock }; $queryRaw: jest.Mock;
queen: { findMany: jest.Mock }; queen: { findMany: jest.Mock };
task: { findMany: jest.Mock }; task: { findMany: jest.Mock };
}; };
let permissionResolver: { getPermissions: jest.Mock }; let permissionResolver: { getPermissions: jest.Mock };
let nextSteps: { forColonies: jest.Mock; warningCounts: jest.Mock };
const stepsOf = (
colonyId: string,
overrides: Partial<ColonyNextSteps> = {},
): ColonyNextSteps => ({
colonyId,
segment: 'SCHWARMZEIT',
phase: null,
phaseSource: null,
inspectionDueOn: null,
withdrawalUntil: null,
steps: [],
...overrides,
});
const colonyRow = (id: string, code: string, queenId: string | null) => ({ const colonyRow = (id: string, code: string, queenId: string | null) => ({
id, id,
@@ -23,6 +40,7 @@ describe('ApiaryFieldService', () => {
colonyType: 'PRODUCTION_COLONY', colonyType: 'PRODUCTION_COLONY',
hiveType: null, hiveType: null,
strength: 5, strength: 5,
occupiedFrames: 12,
broodStatus: null, broodStatus: null,
feedStatus: null, feedStatus: null,
healthStatus: null, healthStatus: null,
@@ -55,14 +73,24 @@ describe('ApiaryFieldService', () => {
groupBy: jest.fn().mockResolvedValue([]), groupBy: jest.fn().mockResolvedValue([]),
findMany: jest.fn().mockResolvedValue([]), findMany: jest.fn().mockResolvedValue([]),
}, },
notification: { groupBy: jest.fn().mockResolvedValue([]) }, $queryRaw: jest.fn().mockResolvedValue([]),
queen: { findMany: jest.fn().mockResolvedValue([]) }, queen: { findMany: jest.fn().mockResolvedValue([]) },
task: { findMany: jest.fn().mockResolvedValue([]) }, task: { findMany: jest.fn().mockResolvedValue([]) },
}; };
permissionResolver = { getPermissions: jest.fn() }; permissionResolver = { getPermissions: jest.fn() };
nextSteps = {
forColonies: jest.fn().mockResolvedValue(
new Map([
['c10', stepsOf('c10')],
['c2', stepsOf('c2')],
]),
),
warningCounts: jest.fn().mockResolvedValue(new Map()),
};
service = new ApiaryFieldService( service = new ApiaryFieldService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
permissionResolver as unknown as PermissionResolverService, permissionResolver as unknown as PermissionResolverService,
nextSteps as unknown as NextStepsService,
); );
}); });
@@ -172,26 +200,18 @@ describe('ApiaryFieldService', () => {
expect(inspectionArgs.omit).toEqual({ userId: true }); expect(inspectionArgs.omit).toEqual({ userId: true });
}); });
it('counts only unread WARNUNG and KRITISCH colony notifications visible to the caller', async () => { it('takes the warning counts from the open suggestions the caller may see', async () => {
grant('colonies:read'); grant('colonies:read', 'tasks:read');
prisma.notification.groupBy.mockResolvedValue([ nextSteps.warningCounts.mockResolvedValue(
{ relatedId: 'c2', priority: 'WARNUNG', _count: { _all: 2 } }, new Map([['c2', { warnings: 2, critical: 1 }]]),
{ relatedId: 'c2', priority: 'KRITISCH', _count: { _all: 1 } }, );
]);
const overview = await service.colonyOverview('apiary-1', 'u1'); const overview = await service.colonyOverview('apiary-1', 'u1');
expect(prisma.notification.groupBy).toHaveBeenCalledWith( expect(nextSteps.warningCounts).toHaveBeenCalledWith(
expect.objectContaining({ ['c2', 'c10'],
by: ['relatedId', 'priority'], 'u1',
where: { new Set(['colonies:read', 'tasks:read']),
relatedType: 'COLONY',
relatedId: { in: ['c2', 'c10'] },
read: false,
priority: { in: ['WARNUNG', 'KRITISCH'] },
OR: [{ userId: 'u1' }, { userId: null }],
},
}),
); );
expect(overview.rows[0]).toMatchObject({ expect(overview.rows[0]).toMatchObject({
code: 'V-2', code: 'V-2',
@@ -202,5 +222,69 @@ describe('ApiaryFieldService', () => {
openWarnings: 0, openWarnings: 0,
openCritical: 0, openCritical: 0,
}); });
expect(prisma.$queryRaw).not.toHaveBeenCalled();
});
it('carries inspectionDueOn, segment and the title of the first next step', async () => {
grant('colonies:read', 'tasks:read');
nextSteps.forColonies.mockResolvedValue(
new Map([
[
'c2',
stepsOf('c2', {
segment: 'SOMMER',
inspectionDueOn: '2026-05-10',
steps: [
{
key: 'suggestion:s1',
kind: 'SUGGESTION',
intent: 'INSPECT',
title: 'Durchsicht mit Schwarmkontrolle',
reasons: [],
suggestionIds: ['s1'],
dueOn: '2026-05-10',
urgency: 'OVERDUE',
action: { type: 'ACCEPT', suggestionId: 's1' },
dismissible: false,
},
],
}),
],
['c10', stepsOf('c10', { segment: 'WINTERRUHE' })],
]),
);
const overview = await service.colonyOverview('apiary-1', 'u1');
expect(overview.rows[0]).toMatchObject({
inspectionDueOn: '2026-05-10',
segment: 'SOMMER',
nextStepTitle: 'Durchsicht mit Schwarmkontrolle',
});
expect(overview.rows[1]).toMatchObject({
inspectionDueOn: null,
segment: 'WINTERRUHE',
nextStepTitle: null,
});
expect(nextSteps.forColonies).toHaveBeenCalledTimes(1);
});
it('leaves the segment null when the colony has no season state', async () => {
grant('colonies:read');
nextSteps.forColonies.mockResolvedValue(new Map());
const overview = await service.colonyOverview('apiary-1', 'u1');
expect(overview.rows.every((row) => row.segment === null)).toBe(true);
});
it('adds the next steps per colony to the offline pack', async () => {
grant('colonies:read');
const pack = await service.offlinePack('apiary-1', 'u1');
expect(pack.version).toBe(1);
expect(pack.nextSteps).toEqual({ c2: [], c10: [] });
expect(nextSteps.forColonies).toHaveBeenCalledTimes(1);
}); });
}); });
@@ -1,5 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import type { ColonyNextSteps } from '../autopilot/next-step-ranking';
import { NextStepsService } from '../autopilot/next-steps.service';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status'; import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import type { import type {
@@ -17,6 +19,7 @@ interface FieldData {
colonies: PackColony[]; colonies: PackColony[];
permissions: ReadonlySet<string>; permissions: ReadonlySet<string>;
overview: ColonyOverview; overview: ColonyOverview;
nextSteps: ReadonlyMap<string, ColonyNextSteps>;
} }
/** /**
@@ -29,6 +32,7 @@ export class ApiaryFieldService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly permissionResolver: PermissionResolverService, private readonly permissionResolver: PermissionResolverService,
private readonly nextSteps: NextStepsService,
) {} ) {}
async colonyOverview( async colonyOverview(
@@ -39,10 +43,8 @@ export class ApiaryFieldService {
} }
async offlinePack(apiaryId: string, userId: string): Promise<OfflinePackV1> { async offlinePack(apiaryId: string, userId: string): Promise<OfflinePackV1> {
const { apiary, colonies, permissions, overview } = await this.load( const { apiary, colonies, permissions, overview, nextSteps } =
apiaryId, await this.load(apiaryId, userId);
userId,
);
const [queens, inspections, tasks] = await Promise.all([ const [queens, inspections, tasks] = await Promise.all([
permissions.has('queens:read') ? this.loadQueens(colonies) : null, permissions.has('queens:read') ? this.loadQueens(colonies) : null,
permissions.has('inspections:read') permissions.has('inspections:read')
@@ -59,6 +61,12 @@ export class ApiaryFieldService {
queens, queens,
inspections, inspections,
tasks, tasks,
nextSteps: Object.fromEntries(
colonies.map((colony) => [
colony.id,
nextSteps.get(colony.id)?.steps ?? [],
]),
),
}; };
} }
@@ -83,6 +91,7 @@ export class ApiaryFieldService {
colonyType: true, colonyType: true,
hiveType: true, hiveType: true,
strength: true, strength: true,
occupiedFrames: true,
broodStatus: true, broodStatus: true,
feedStatus: true, feedStatus: true,
healthStatus: true, healthStatus: true,
@@ -94,27 +103,34 @@ export class ApiaryFieldService {
a.code.localeCompare(b.code, 'de', { numeric: true }), a.code.localeCompare(b.code, 'de', { numeric: true }),
); );
const inspectionsVisible = permissions.has('inspections:read'); const inspectionsVisible = permissions.has('inspections:read');
const [lastInspections, warnings] = await Promise.all([ const colonyIds = colonies.map((colony) => colony.id);
const [lastInspections, warnings, nextSteps] = await Promise.all([
inspectionsVisible ? this.lastInspectionDates(colonies) : null, inspectionsVisible ? this.lastInspectionDates(colonies) : null,
this.warningCounts(colonies, userId), this.nextSteps.warningCounts(colonyIds, userId, permissions),
this.nextSteps.forColonies(colonyIds, userId, undefined, permissions),
]); ]);
const rows = colonies.map((colony): ColonyOverviewRow => ({ const rows = colonies.map((colony): ColonyOverviewRow => ({
id: colony.id, id: colony.id,
code: colony.code, code: colony.code,
status: colony.status, status: colony.status,
strength: colony.strength, strength: colony.strength,
occupiedFrames: colony.occupiedFrames,
broodStatus: colony.broodStatus, broodStatus: colony.broodStatus,
feedStatus: colony.feedStatus, feedStatus: colony.feedStatus,
healthStatus: colony.healthStatus, healthStatus: colony.healthStatus,
lastInspectionDate: lastInspections?.get(colony.id) ?? null, lastInspectionDate: lastInspections?.get(colony.id) ?? null,
openWarnings: warnings.get(colony.id)?.WARNUNG ?? 0, inspectionDueOn: nextSteps.get(colony.id)?.inspectionDueOn ?? null,
openCritical: warnings.get(colony.id)?.KRITISCH ?? 0, segment: nextSteps.get(colony.id)?.segment ?? null,
nextStepTitle: nextSteps.get(colony.id)?.steps.at(0)?.title ?? null,
openWarnings: warnings.get(colony.id)?.warnings ?? 0,
openCritical: warnings.get(colony.id)?.critical ?? 0,
})); }));
return { return {
apiary, apiary,
colonies, colonies,
permissions, permissions,
overview: { apiaryId, inspectionsVisible, rows }, overview: { apiaryId, inspectionsVisible, rows },
nextSteps,
}; };
} }
@@ -135,36 +151,6 @@ export class ApiaryFieldService {
return dates; return dates;
} }
private async warningCounts(
colonies: PackColony[],
userId: string,
): Promise<Map<string, { WARNUNG?: number; KRITISCH?: number }>> {
const groups = await this.prisma.notification.groupBy({
by: ['relatedId', 'priority'],
where: {
relatedType: 'COLONY',
relatedId: { in: colonies.map((colony) => colony.id) },
read: false,
priority: { in: ['WARNUNG', 'KRITISCH'] },
OR: [{ userId }, { userId: null }],
},
_count: { _all: true },
});
const counts = new Map<string, { WARNUNG?: number; KRITISCH?: number }>();
for (const group of groups) {
if (
group.relatedId &&
(group.priority === 'WARNUNG' || group.priority === 'KRITISCH')
) {
counts.set(group.relatedId, {
...counts.get(group.relatedId),
[group.priority]: group._count._all,
});
}
}
return counts;
}
private loadQueens(colonies: PackColony[]): Promise<OfflinePackV1['queens']> { private loadQueens(colonies: PackColony[]): Promise<OfflinePackV1['queens']> {
const queenIds = colonies.flatMap((colony) => const queenIds = colonies.flatMap((colony) =>
colony.currentQueenId ? [colony.currentQueenId] : [], colony.currentQueenId ? [colony.currentQueenId] : [],
@@ -7,6 +7,8 @@ import type {
Queen, Queen,
Task, Task,
} from '../../generated/prisma'; } from '../../generated/prisma';
import type { NextStep } from '../autopilot/next-step-ranking';
import type { SeasonSegmentKey } from '../season/season-types';
/** One living colony on the apiary page, reduced to what the traffic light needs. */ /** One living colony on the apiary page, reduced to what the traffic light needs. */
export interface ColonyOverviewRow { export interface ColonyOverviewRow {
@@ -14,14 +16,24 @@ export interface ColonyOverviewRow {
code: string; code: string;
status: ColonyStatus; status: ColonyStatus;
strength: number | null; strength: number | null;
occupiedFrames: number | null;
broodStatus: ColonyCondition | null; broodStatus: ColonyCondition | null;
feedStatus: ColonyCondition | null; feedStatus: ColonyCondition | null;
healthStatus: ColonyCondition | null; healthStatus: ColonyCondition | null;
/** ISO timestamp; always null when the caller may not read inspections. */ /** ISO timestamp; always null when the caller may not read inspections. */
lastInspectionDate: string | null; 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: SeasonSegmentKey | 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; openWarnings: number;
/** Unread KRITISCH notifications visible to the caller. */ /** Open suggestions of priority URGENT the caller may see. */
openCritical: number; openCritical: number;
} }
@@ -56,6 +68,7 @@ export interface OfflinePackV1 {
| 'colonyType' | 'colonyType'
| 'hiveType' | 'hiveType'
| 'strength' | 'strength'
| 'occupiedFrames'
| 'broodStatus' | 'broodStatus'
| 'feedStatus' | 'feedStatus'
| 'healthStatus' | 'healthStatus'
@@ -88,4 +101,9 @@ export interface OfflinePackV1 {
| 'relatedId' | 'relatedId'
>[] >[]
| null; | null;
/**
* The next steps per colony id (what `GET /colonies/:id/next-steps`
* returns as `steps`). Absent in packs cached before T4.
*/
nextSteps?: Record<string, NextStep[]>;
} }
+10
View File
@@ -28,6 +28,7 @@ import { DashboardModule } from './dashboard/dashboard.module';
import { SearchModule } from './search/search.module'; import { SearchModule } from './search/search.module';
import { ExportModule } from './export/export.module'; import { ExportModule } from './export/export.module';
import { QrModule } from './qr/qr.module'; import { QrModule } from './qr/qr.module';
import { RemindersModule } from './reminders/reminders.module';
import { ProductsModule } from './products/products.module'; import { ProductsModule } from './products/products.module';
import { BottlingModule } from './bottling/bottling.module'; import { BottlingModule } from './bottling/bottling.module';
import { ProductionBatchesModule } from './production-batches/production-batches.module'; import { ProductionBatchesModule } from './production-batches/production-batches.module';
@@ -53,7 +54,11 @@ import { BackupModule } from './backup/backup.module';
import { ShopSyncModule } from './shop-sync/shop-sync.module'; import { ShopSyncModule } from './shop-sync/shop-sync.module';
import { ErpExportModule } from './erp-export/erp-export.module'; import { ErpExportModule } from './erp-export/erp-export.module';
import { ApiKeysModule } from './api-keys/api-keys.module'; import { ApiKeysModule } from './api-keys/api-keys.module';
import { CleanupModule } from './suggestions/cleanup.module';
import { SuggestionsModule } from './suggestions/suggestions.module';
import { AutopilotModule } from './autopilot/autopilot.module';
import { SystemModule } from './system/system.module'; import { SystemModule } from './system/system.module';
import { WithdrawalModule } from './withdrawal/withdrawal.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { PermissionsGuard } from './auth/guards/permissions.guard'; import { PermissionsGuard } from './auth/guards/permissions.guard';
import { PermissionsModule } from './auth/permissions/permissions.module'; import { PermissionsModule } from './auth/permissions/permissions.module';
@@ -106,6 +111,11 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
WebhooksModule, WebhooksModule,
DocumentsModule, DocumentsModule,
AutomationModule, AutomationModule,
SuggestionsModule,
CleanupModule,
AutopilotModule,
WithdrawalModule,
RemindersModule,
LabelTemplatesModule, LabelTemplatesModule,
ImportModule, ImportModule,
PublicModule, PublicModule,
@@ -0,0 +1,16 @@
import { Global, Module } from '@nestjs/common';
import { PermissionResolverService } from './permission-resolver.service';
/**
* PermissionResolverService for the job and worker context (BackupService
* clears its cache after a restore, which no job does). Not
* PermissionsModule: its PermissionCatalogSyncService would rewrite the
* permission catalog on every job or worker start. Global like
* PermissionsModule, so modules that inject the resolver load here unchanged.
*/
@Global()
@Module({
providers: [PermissionResolverService],
exports: [PermissionResolverService],
})
export class JobPermissionsModule {}
@@ -42,6 +42,7 @@ describe('AutomationEventHandlers', () => {
let tx: Record<RecordModel, { findUnique: jest.Mock }>; let tx: Record<RecordModel, { findUnique: jest.Mock }>;
let automation: Record< let automation: Record<
| 'evaluateInspectionIn' | 'evaluateInspectionIn'
| 'evaluateInspectionCorrectionIn'
| 'evaluateVarroaMeasurementIn' | 'evaluateVarroaMeasurementIn'
| 'evaluateTreatmentIn' | 'evaluateTreatmentIn'
| 'evaluateFeedingIn' | 'evaluateFeedingIn'
@@ -63,6 +64,7 @@ describe('AutomationEventHandlers', () => {
) as Record<RecordModel, { findUnique: jest.Mock }>; ) as Record<RecordModel, { findUnique: jest.Mock }>;
automation = { automation = {
evaluateInspectionIn: jest.fn().mockResolvedValue(undefined), evaluateInspectionIn: jest.fn().mockResolvedValue(undefined),
evaluateInspectionCorrectionIn: jest.fn().mockResolvedValue(undefined),
evaluateVarroaMeasurementIn: jest.fn().mockResolvedValue(undefined), evaluateVarroaMeasurementIn: jest.fn().mockResolvedValue(undefined),
evaluateTreatmentIn: jest.fn().mockResolvedValue(undefined), evaluateTreatmentIn: jest.fn().mockResolvedValue(undefined),
evaluateFeedingIn: jest.fn().mockResolvedValue(undefined), evaluateFeedingIn: jest.fn().mockResolvedValue(undefined),
@@ -84,7 +86,7 @@ describe('AutomationEventHandlers', () => {
expect(registered('inspectionRules')).toEqual([ expect(registered('inspectionRules')).toEqual([
{ {
types: ['InspectionRecorded'], types: ['InspectionRecorded', 'InspectionCorrected'],
handlerId: 'automation.inspection-rules', handlerId: 'automation.inspection-rules',
}, },
]); ]);
@@ -154,12 +156,17 @@ describe('AutomationEventHandlers', () => {
async (method, type, model, core) => { async (method, type, model, core) => {
const record = { id: 'record-1', colonyId: 'colony-1' }; const record = { id: 'record-1', colonyId: 'colony-1' };
tx[model].findUnique.mockResolvedValueOnce(record); tx[model].findUnique.mockResolvedValueOnce(record);
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
await run(method, type); await run(method, type);
expect(tx[model].findUnique).toHaveBeenCalledWith({ expect(tx[model].findUnique).toHaveBeenCalledWith({
where: { id: 'record-1' }, where: { id: 'record-1' },
}); });
expect(tx.colony.findUnique).toHaveBeenCalledWith({
where: { id: 'colony-1' },
select: { status: true },
});
expect(automation[core]).toHaveBeenCalledWith(tx, record); expect(automation[core]).toHaveBeenCalledWith(tx, record);
}, },
); );
@@ -174,8 +181,56 @@ describe('AutomationEventHandlers', () => {
}, },
); );
it.each(cases)(
'rule handlers write nothing for an ended colony: %s on %s',
async (method, type, model, core) => {
tx[model].findUnique.mockResolvedValueOnce({
id: 'record-1',
colonyId: 'colony-1',
});
tx.colony.findUnique.mockResolvedValueOnce({ status: 'DISSOLVED' });
await run(method, type);
expect(automation[core]).not.toHaveBeenCalled();
},
);
it.each(cases)(
'rule handlers write nothing for a colony that is gone: %s on %s',
async (method, type, model, core) => {
tx[model].findUnique.mockResolvedValueOnce({
id: 'record-1',
colonyId: 'colony-1',
});
await run(method, type);
expect(automation[core]).not.toHaveBeenCalled();
},
);
it('a corrected inspection only follows open suggestions', async () => {
tx.inspection.findUnique.mockResolvedValueOnce({
id: 'record-1',
colonyId: 'colony-1',
});
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
await run('inspectionRules', 'InspectionCorrected' as never);
expect(automation.evaluateInspectionCorrectionIn).toHaveBeenCalledTimes(
1,
);
expect(automation.evaluateInspectionIn).not.toHaveBeenCalled();
});
it('lets an evaluation failure propagate, so the delivery is retried', async () => { it('lets an evaluation failure propagate, so the delivery is retried', async () => {
tx.inspection.findUnique.mockResolvedValueOnce({ id: 'record-1' }); tx.inspection.findUnique.mockResolvedValueOnce({
id: 'record-1',
colonyId: 'colony-1',
});
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
automation.evaluateInspectionIn.mockRejectedValue( automation.evaluateInspectionIn.mockRejectedValue(
new Error('notifications down'), new Error('notifications down'),
); );
@@ -192,22 +247,24 @@ describe('AutomationEventHandlers', () => {
const event = () => const event = () =>
envelope('ColonyStatusChanged', 'colony-1', { from, to }); envelope('ColonyStatusChanged', 'colony-1', { from, to });
it('hands the transition of the event to evaluateColonyHealthIn', async () => { it('hands the transition and the event to evaluateColonyHealthIn', async () => {
tx.colony.findUnique.mockResolvedValueOnce({ id: 'colony-1' }); tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
await handlers.colonyHealth(event(), asTx()); await handlers.colonyHealth(event(), asTx());
expect(tx.colony.findUnique).toHaveBeenCalledWith({ expect(tx.colony.findUnique).toHaveBeenCalledWith({
where: { id: 'colony-1' }, where: { id: 'colony-1' },
select: { id: true }, select: { status: true },
}); });
// The rule decides on the snapshots of the event, not on the colony // The rule decides on the snapshots of the event, not on the colony
// as it is now: a later change must not hide the transition. // as it is now: a later change must not hide the transition. The
// event is the anchor of the suggestion.
expect(automation.evaluateColonyHealthIn).toHaveBeenCalledWith( expect(automation.evaluateColonyHealthIn).toHaveBeenCalledWith(
tx, tx,
'colony-1', 'colony-1',
from, from,
to, to,
{ id: 'e1', occurredAt: new Date('2026-10-13T08:00:00.000Z') },
); );
}); });
@@ -216,6 +273,14 @@ describe('AutomationEventHandlers', () => {
expect(automation.evaluateColonyHealthIn).not.toHaveBeenCalled(); expect(automation.evaluateColonyHealthIn).not.toHaveBeenCalled();
}); });
it('skips a colony that ended', async () => {
tx.colony.findUnique.mockResolvedValueOnce({ status: 'SOLD' });
await handlers.colonyHealth(event(), asTx());
expect(automation.evaluateColonyHealthIn).not.toHaveBeenCalled();
});
}); });
describe('raw-material-low', () => { describe('raw-material-low', () => {
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types'; import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator'; import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
import { isLivingColonyStatus } from '../colonies/colony-status';
import { Prisma } from '../../generated/prisma'; import { Prisma } from '../../generated/prisma';
import { StockService } from '../stock/stock.service'; import { StockService } from '../stock/stock.service';
import { AutomationService } from './automation.service'; import { AutomationService } from './automation.service';
@@ -10,9 +11,10 @@ type Tx = Prisma.TransactionClient;
/** /**
* Evaluates the automation rules on domain events. Pure DB writer: it reads * Evaluates the automation rules on domain events. Pure DB writer: it reads
* the record the event is about through the delivery transaction and lets * the record the event is about through the delivery transaction and lets
* AutomationService write task, warning and audit entries in it. A record * AutomationService write suggestion, notification and audit entries in it.
* deleted before the event was dispatched is skipped. Errors propagate, so * A record deleted before the event was dispatched, or of a colony that
* the delivery is rolled back as a whole and retried. * ended, is skipped. Errors propagate, so the delivery is rolled back as a
* whole and retried.
*/ */
@Injectable() @Injectable()
export class AutomationEventHandlers { export class AutomationEventHandlers {
@@ -21,22 +23,46 @@ export class AutomationEventHandlers {
private readonly stock: StockService, private readonly stock: StockService,
) {} ) {}
@OnDomainEvent('InspectionRecorded', 'automation.inspection-rules') /**
* Fast path only: proposeForRuleIn re-checks the colony under its lock.
* Whether the colony still lives (a colony that ended takes no proposals).
*/
private async isLivingColonyIn(tx: Tx, colonyId: string): Promise<boolean> {
const colony = await tx.colony.findUnique({
where: { id: colonyId },
select: { status: true },
});
return colony !== null && isLivingColonyStatus(colony.status);
}
// A correction proposes nothing new; it only lets an open suggestion
// follow the corrected values.
@OnDomainEvent(
['InspectionRecorded', 'InspectionCorrected'],
'automation.inspection-rules',
)
async inspectionRules( async inspectionRules(
event: AnyDomainEventEnvelope<'InspectionRecorded'>, event: AnyDomainEventEnvelope<'InspectionRecorded' | 'InspectionCorrected'>,
tx: Tx, tx: Tx,
): Promise<void> { ): Promise<void> {
const inspection = await tx.inspection.findUnique({ const inspection = await tx.inspection.findUnique({
where: { id: event.aggregateId }, where: { id: event.aggregateId },
}); });
if (inspection === null) { if (
inspection === null ||
!(await this.isLivingColonyIn(tx, inspection.colonyId))
) {
return; return;
} }
if (event.type === 'InspectionRecorded') {
await this.automation.evaluateInspectionIn(tx, inspection); await this.automation.evaluateInspectionIn(tx, inspection);
} else {
await this.automation.evaluateInspectionCorrectionIn(tx, inspection);
}
} }
// A correction can lift a count over the threshold, so it is evaluated // A correction can lift a count over the threshold, so it is evaluated
// like a new measurement; the open-task check keeps it to one task. // like a new measurement; the open-suggestion check keeps it to one.
@OnDomainEvent( @OnDomainEvent(
['VarroaMeasured', 'VarroaCorrected'], ['VarroaMeasured', 'VarroaCorrected'],
'automation.varroa-threshold', 'automation.varroa-threshold',
@@ -48,7 +74,10 @@ export class AutomationEventHandlers {
const measurement = await tx.varroaMeasurement.findUnique({ const measurement = await tx.varroaMeasurement.findUnique({
where: { id: event.aggregateId }, where: { id: event.aggregateId },
}); });
if (measurement === null) { if (
measurement === null ||
!(await this.isLivingColonyIn(tx, measurement.colonyId))
) {
return; return;
} }
await this.automation.evaluateVarroaMeasurementIn(tx, measurement); await this.automation.evaluateVarroaMeasurementIn(tx, measurement);
@@ -62,7 +91,10 @@ export class AutomationEventHandlers {
const treatment = await tx.treatment.findUnique({ const treatment = await tx.treatment.findUnique({
where: { id: event.aggregateId }, where: { id: event.aggregateId },
}); });
if (treatment === null) { if (
treatment === null ||
!(await this.isLivingColonyIn(tx, treatment.colonyId))
) {
return; return;
} }
await this.automation.evaluateTreatmentIn(tx, treatment); await this.automation.evaluateTreatmentIn(tx, treatment);
@@ -76,7 +108,10 @@ export class AutomationEventHandlers {
const feeding = await tx.feeding.findUnique({ const feeding = await tx.feeding.findUnique({
where: { id: event.aggregateId }, where: { id: event.aggregateId },
}); });
if (feeding === null) { if (
feeding === null ||
!(await this.isLivingColonyIn(tx, feeding.colonyId))
) {
return; return;
} }
await this.automation.evaluateFeedingIn(tx, feeding); await this.automation.evaluateFeedingIn(tx, feeding);
@@ -89,18 +124,15 @@ export class AutomationEventHandlers {
event: AnyDomainEventEnvelope<'ColonyStatusChanged'>, event: AnyDomainEventEnvelope<'ColonyStatusChanged'>,
tx: Tx, tx: Tx,
): Promise<void> { ): Promise<void> {
const colony = await tx.colony.findUnique({ if (!(await this.isLivingColonyIn(tx, event.aggregateId))) {
where: { id: event.aggregateId },
select: { id: true },
});
if (colony === null) {
return; return;
} }
await this.automation.evaluateColonyHealthIn( await this.automation.evaluateColonyHealthIn(
tx, tx,
colony.id, event.aggregateId,
event.payload.from, event.payload.from,
event.payload.to, event.payload.to,
{ id: event.id, occurredAt: event.occurredAt },
); );
} }
@@ -1,18 +1,18 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { NotificationsModule } from '../notifications/notifications.module';
import { StockModule } from '../stock/stock.module'; import { StockModule } from '../stock/stock.module';
import { TasksModule } from '../tasks/tasks.module'; import { SuggestionsModule } from '../suggestions/suggestions.module';
import { AutomationController } from './automation.controller'; import { AutomationController } from './automation.controller';
import { AutomationEventHandlers } from './automation.handlers'; import { AutomationEventHandlers } from './automation.handlers';
import { AutomationService } from './automation.service'; import { AutomationService } from './automation.service';
/** /**
* Automation rules and the handlers that evaluate them on domain events. * Automation rules and the handlers that evaluate them on domain events.
* Rule tasks go through TasksService (audit entry, TaskCreated - and with * The rules propose through SuggestionsService; the task follows when the
* it calendar entry and task.created webhook), so no WebhooksService here. * beekeeper accepts (TasksService, with calendar entry and task.created
* webhook), so neither a TasksModule nor a WebhooksService is needed here.
*/ */
@Module({ @Module({
imports: [TasksModule, NotificationsModule, StockModule], imports: [SuggestionsModule, StockModule],
controllers: [AutomationController], controllers: [AutomationController],
providers: [AutomationService, AutomationEventHandlers], providers: [AutomationService, AutomationEventHandlers],
exports: [AutomationService], exports: [AutomationService],
File diff suppressed because it is too large. Load diff
@@ -1,11 +1,24 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate } from '../prisma/row-lock';
import { assertTransactionClient, DbClient } from '../prisma/db-client'; import { assertTransactionClient, DbClient } from '../prisma/db-client';
import { runAfterCommit } from '../common/after-commit'; import { runAfterCommit } from '../common/after-commit';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { NotificationsService } from '../notifications/notifications.service'; import { addDays, berlinDayOf, berlinToday } from '../common/dates';
import { TasksService } from '../tasks/tasks.service'; import { isLivingColonyStatus } from '../colonies/colony-status';
import type { PermissionKey } from '../auth/permissions/permission-catalog';
import type { ColonyStateSnapshot } from '../domain-events/domain-event-types'; import type { ColonyStateSnapshot } from '../domain-events/domain-event-types';
import { SuggestionsService } from '../suggestions/suggestions.service';
import {
suggestionDedupeKey,
type SuggestionEvidence,
} from '../suggestions/suggestion.types';
import { treatmentWindowsIn } from '../treatments/treatment-windows';
import { import {
AutomationRule, AutomationRule,
AutomationTriggerType, AutomationTriggerType,
@@ -14,6 +27,8 @@ import {
Prisma, Prisma,
RawMaterialKind, RawMaterialKind,
StockItem, StockItem,
Suggestion,
SuggestionIntent,
SwarmControlCheck, SwarmControlCheck,
TaskRelatedType, TaskRelatedType,
Treatment, Treatment,
@@ -21,7 +36,13 @@ import {
} from '../../generated/prisma'; } from '../../generated/prisma';
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto'; import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
import { formatGeneratedNumber } from './number-format'; import { formatGeneratedNumber } from './number-format';
import { varroaThresholdOf } from './varroa-threshold'; import {
DEFAULT_RULES,
lockRuleIn,
readRule,
validateRuleConfig,
} from './rule-config';
import { rateMeasurement } from './varroa-bands';
type Tx = Prisma.TransactionClient; type Tx = Prisma.TransactionClient;
@@ -32,11 +53,6 @@ export type NumberFormatTrigger =
| 'RAW_MATERIAL_LOT_NUMBER_FORMAT' | 'RAW_MATERIAL_LOT_NUMBER_FORMAT'
| 'PRODUCTION_BATCH_NUMBER_FORMAT'; | 'PRODUCTION_BATCH_NUMBER_FORMAT';
const RAW_MATERIAL_KIND_LABELS: Record<RawMaterialKind, string> = {
BEESWAX: 'Bienenwachs',
PROPOLIS: 'Propolis',
};
/** What {ART} turns into in the raw material number format. */ /** What {ART} turns into in the raw material number format. */
export const RAW_MATERIAL_KIND_CODES: Record<RawMaterialKind, string> = { export const RAW_MATERIAL_KIND_CODES: Record<RawMaterialKind, string> = {
BEESWAX: 'WA', BEESWAX: 'WA',
@@ -56,115 +72,55 @@ function isUniqueViolation(error: unknown): boolean {
); );
} }
interface RuleDefaults { /** The rules that propose instead of creating anything themselves. */
config: Record<string, unknown>; export type RuleSuggestionTrigger =
actionTemplate: string; | 'QUEENLESS_NO_EGGS'
/** Rules without a sensible built-in setting start switched off. */ | 'FEED_STATUS_CRITICAL'
active?: boolean; | 'VARROA_THRESHOLD'
| 'SWARM_MOOD_HIGH'
| 'COLONY_HEALTH_CRITICAL'
| 'STOCK_ITEM_LOW'
| 'RAW_MATERIAL_LOW'
| 'TREATMENT_FOLLOW_UP'
| 'FEEDING_FOLLOW_UP';
export interface RuleSuggestionTarget {
relatedType: TaskRelatedType;
relatedId: string;
colonyId: string | null;
intent: SuggestionIntent;
reasonCode: string;
evidence: SuggestionEvidence;
anchor: string;
requiredPermission: PermissionKey;
dueDate?: string | null;
sourceEventId?: string | null;
} }
const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = { /** The anchor of a finding that has no record to hang on. */
QUEENLESS_NO_EGGS: { const NO_ANCHOR = 'none';
config: {},
actionTemplate:
'Kontrolle empfohlen: Königin nicht gesehen und keine Stifte vorhanden.',
},
VARROA_THRESHOLD: {
config: { thresholdMitesPerDay: 10 },
actionTemplate: 'Behandlung prüfen: Varroa-Schwellenwert überschritten.',
},
FEED_STATUS_CRITICAL: {
config: {},
actionTemplate: 'Fütterung erforderlich: Futterstatus kritisch.',
},
SWARM_MOOD_HIGH: {
config: {},
actionTemplate:
'Schwarmkontrolle dringend: hohe Schwarmstimmung festgestellt.',
},
COLONY_HEALTH_CRITICAL: {
config: {},
actionTemplate: 'Kontrolle erforderlich: Gesundheitsstatus kritisch.',
},
STOCK_ITEM_LOW: {
config: {},
actionTemplate: 'Nachbestellung prüfen: Lagerartikel unter Mindestbestand.',
},
// Not task-generating rules like the ones above - `active` here toggles
// whether HoneyBatchesService/BottlingService auto-fill the batch/lot
// number from `config.format` instead of requiring manual entry.
HONEY_BATCH_NUMBER_FORMAT: {
config: { format: 'WH-{YYYY}-{SEQ:4}' },
actionTemplate: '',
},
BOTTLING_LOT_NUMBER_FORMAT: {
config: { format: 'WH-{YYYY}-{SEQ:4}' },
actionTemplate: '',
},
// {ART} (WA/PR) gives wax and propolis a counter each.
RAW_MATERIAL_LOT_NUMBER_FORMAT: {
config: { format: '{ART}-{YYYY}-{SEQ:4}' },
actionTemplate: '',
},
PRODUCTION_BATCH_NUMBER_FORMAT: {
config: { format: 'HS-{YYYY}-{SEQ:4}' },
actionTemplate: '',
},
// The raw-material counterpart of STOCK_ITEM_LOW. Raw material lots carry
// no minimum of their own, so the minimum per kind (config.minGrams) lives
// here - and without one there is nothing to compare, hence off by default.
RAW_MATERIAL_LOW: {
config: {},
actionTemplate: 'Rohstoff unter Mindestmenge: Nachschub einplanen.',
active: false,
},
// Follow-up checks: how many days after a treatment or feeding a check
// makes sense depends on the product and the season, so there is no
// built-in interval - the beekeeper switches the rule on and sets
// config.dueInDays.
TREATMENT_FOLLOW_UP: {
config: {},
actionTemplate: 'Behandlungserfolg kontrollieren',
active: false,
},
FEEDING_FOLLOW_UP: {
config: {},
actionTemplate: 'Futterabnahme und Futtervorrat kontrollieren',
active: false,
},
};
/** /**
* Optional per-rule follow-up interval (config.dueInDays). There is no * Optional per-rule follow-up interval (config.dueInDays) counted from the
* built-in default: how soon a queenless colony or a high mite fall needs a * business day. There is no built-in default: how soon a queenless colony or
* look depends on season and practice, so the rule stays date-less unless the * a high mite fall needs a look depends on season and practice, so the
* beekeeper configures one. * suggestion stays date-less unless the beekeeper configures one (accepting
* it then creates a task due today).
*/ */
function formatDay(date: Date): string { function dueDayOf(config: object, baseDay: string): string | null {
return date.toLocaleDateString('de-DE', { const { dueInDays } = config as { dueInDays?: unknown };
day: '2-digit', return typeof dueInDays === 'number' ? addDays(baseDay, dueInDays) : null;
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
});
} }
function dueDateFromConfig( function inspectionEvidence(
dueInDays: unknown, inspection: Inspection,
baseDate: Date = new Date(), inspectedOn: string,
): Date | undefined { ): SuggestionEvidence {
if ( return {
typeof dueInDays !== 'number' || colonyId: inspection.colonyId,
!Number.isInteger(dueInDays) || inspectionId: inspection.id,
dueInDays < 0 || inspectedOn,
dueInDays > 365 };
) {
return undefined;
}
const due = new Date(baseDate);
due.setUTCHours(0, 0, 0, 0);
due.setUTCDate(due.getUTCDate() + dueInDays);
return due;
} }
@Injectable() @Injectable()
@@ -174,8 +130,7 @@ export class AutomationService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService, private readonly auditLog: AuditLogService,
private readonly notifications: NotificationsService, private readonly suggestions: SuggestionsService,
private readonly tasks: TasksService,
) {} ) {}
/** /**
@@ -223,13 +178,34 @@ export class AutomationService {
return rule; return rule;
} }
/**
* The row is locked before it is read, so "before" (and the audit entry)
* is the state this write replaces. A config with an invalid known value
* is refused with the fields named; unknown keys pass unchanged.
*/
async update( async update(
id: string, id: string,
dto: UpdateAutomationRuleDto, dto: UpdateAutomationRuleDto,
actingUserId: string, actingUserId: string,
): Promise<AutomationRule> { ): Promise<AutomationRule> {
const before = await this.findById(id); return this.prisma.$transaction(async (tx) => {
const rule = await this.prisma.automationRule.update({ const locked = await lockRowForUpdate(tx, 'AutomationRule', id);
const before = locked
? await tx.automationRule.findUnique({ where: { id } })
: null;
if (!before) {
throw new NotFoundException('Automation rule not found');
}
if (typeof dto.config === 'object' && dto.config !== null) {
const issues = validateRuleConfig(before.triggerType, dto.config);
if (issues.length > 0) {
throw new BadRequestException({
message: 'Die Einstellung ist ungültig.',
issues,
});
}
}
const rule = await tx.automationRule.update({
where: { id }, where: { id },
data: { data: {
...dto, ...dto,
@@ -241,15 +217,19 @@ export class AutomationService {
: (dto.config as Prisma.InputJsonValue | undefined), : (dto.config as Prisma.InputJsonValue | undefined),
}, },
}); });
await this.auditLog.record({ await this.auditLog.record(
{
entityType: 'AutomationRule', entityType: 'AutomationRule',
entityId: id, entityId: id,
action: 'UPDATE', action: 'UPDATE',
userId: actingUserId, userId: actingUserId,
beforeData: before, beforeData: before,
afterData: rule, afterData: rule,
}); },
tx,
);
return rule; return rule;
});
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -260,44 +240,151 @@ export class AutomationService {
// transaction, the legacy wrappers below with a transaction of their own. // transaction, the legacy wrappers below with a transaction of their own.
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
/** A new inspection: proposes new findings and follows the open ones. */
async evaluateInspectionIn(tx: Tx, inspection: Inspection): Promise<void> { async evaluateInspectionIn(tx: Tx, inspection: Inspection): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateInspectionIn'); assertTransactionClient(tx, 'AutomationService.evaluateInspectionIn');
await this.ensureDefaults(tx); await this.syncInspectionRulesIn(tx, inspection, true);
const rules = await tx.automationRule.findMany({ }
where: {
triggerType: { in: ['QUEENLESS_NO_EGGS', 'FEED_STATUS_CRITICAL'] }, /**
active: true, * A corrected inspection proposes nothing new, but an open suggestion it
}, * supersedes follows the corrected values (resolved or refreshed).
*/
async evaluateInspectionCorrectionIn(
tx: Tx,
inspection: Inspection,
): Promise<void> {
assertTransactionClient(
tx,
'AutomationService.evaluateInspectionCorrectionIn',
);
await this.syncInspectionRulesIn(tx, inspection, false);
}
/**
* Colony lock first, then everything that decides is read under it: the
* colony's actual latest inspection (date, createdAt, id) is the only one
* that counts. An event of any other inspection - a late or retried
* delivery, a backdated entry, the correction of an older one - proposes
* nothing and changes nothing; the latest inspection has its own event.
* (An inspection moved to another colony raises no event for the colony
* it left, so a suggestion there stays until that colony's next
* inspection.)
*/
private async syncInspectionRulesIn(
tx: Tx,
inspection: Inspection,
proposeNew: boolean,
): Promise<void> {
if (!(await lockRowForUpdate(tx, 'Colony', inspection.colonyId))) {
return;
}
const latest = await tx.inspection.findFirst({
where: { colonyId: inspection.colonyId },
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
}); });
const ruleByType = new Map(rules.map((rule) => [rule.triggerType, rule])); if (latest === null || latest.id !== inspection.id) {
return;
const queenlessRule = ruleByType.get('QUEENLESS_NO_EGGS'); }
if ( await this.syncInspectionRuleIn(
queenlessRule &&
inspection.queenSeen === false &&
inspection.eggsPresent === false
) {
await this.triggerActionIn(
tx, tx,
queenlessRule, 'QUEENLESS_NO_EGGS',
'COLONY', 'QUEEN_CHECK',
inspection.colonyId, latest,
inspection.id, latest.queenSeen === false && latest.eggsPresent === false,
proposeNew,
);
await this.syncInspectionRuleIn(
tx,
'FEED_STATUS_CRITICAL',
'FEED',
latest,
latest.feedStatus === 'CRITICAL',
proposeNew,
); );
} }
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL'); /**
if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') { * One inspection rule against the colony's latest inspection (colony
await this.triggerActionIn( * locked). No open suggestion: a met condition proposes - new inspections
tx, * only, a correction never proposes (a corrected value is not a new
feedRule, * finding; the beekeeper was told about the original). An open one follows
'COLONY', * the latest inspection: condition gone, it is resolved; still met, its
inspection.colonyId, * evidence is refreshed. The refresh keeps the original dedupeKey/anchor,
inspection.id, * so evidence.inspectionId may differ from the anchor. An inactive rule
); * leaves open suggestions untouched (as the varroa rule does).
*/
private async syncInspectionRuleIn(
tx: Tx,
trigger: 'QUEENLESS_NO_EGGS' | 'FEED_STATUS_CRITICAL',
intent: SuggestionIntent,
latest: Inspection,
met: boolean,
proposeNew: boolean,
): Promise<void> {
const open = await tx.suggestion.findFirst({
where: {
ruleKey: trigger,
colonyId: latest.colonyId,
status: 'OPEN',
},
select: { id: true },
});
if (open === null) {
if (met && proposeNew) {
await this.proposeOnInspectionIn(tx, trigger, intent, latest);
} }
return;
}
const rule = await readRule(tx, trigger);
if (!rule.active) {
return;
}
// Colony (held), then suggestion.
await lockRowForUpdate(tx, 'Suggestion', open.id);
if (!met) {
await this.suggestions.resolveIn(tx, [open.id], 'Bedingung entfällt');
return;
}
const inspectedOn = berlinDayOf(latest.date);
await this.suggestions.refreshIn(tx, open.id, {
evidence: inspectionEvidence(latest, inspectedOn),
dueDate: dueDayOf(rule.config, inspectedOn),
priority: 'HIGH',
expiresAt: null,
});
} }
/** The reason code of an inspection rule is the rule's own name. */
private async proposeOnInspectionIn(
tx: Tx,
trigger: 'QUEENLESS_NO_EGGS' | 'FEED_STATUS_CRITICAL',
intent: SuggestionIntent,
inspection: Inspection,
): Promise<void> {
const rule = await readRule(tx, trigger);
if (!rule.active) {
return;
}
const inspectedOn = berlinDayOf(inspection.date);
await this.proposeForRuleIn(tx, trigger, {
relatedType: 'COLONY',
relatedId: inspection.colonyId,
colonyId: inspection.colonyId,
intent,
reasonCode: trigger,
evidence: inspectionEvidence(inspection, inspectedOn),
anchor: inspection.id,
requiredPermission: 'inspections:read',
dueDate: dueDayOf(rule.config, inspectedOn),
});
}
/**
* A red measurement outside the effect of a treatment proposes a
* treatment check. The light comes from the same bands as the colony view
* (season, young colony, method), so rule and screen never disagree.
*/
async evaluateVarroaMeasurementIn( async evaluateVarroaMeasurementIn(
tx: Tx, tx: Tx,
measurement: VarroaMeasurement, measurement: VarroaMeasurement,
@@ -306,26 +393,78 @@ export class AutomationService {
tx, tx,
'AutomationService.evaluateVarroaMeasurementIn', 'AutomationService.evaluateVarroaMeasurementIn',
); );
await this.ensureDefaults(tx); const rule = await readRule(tx, 'VARROA_THRESHOLD');
const rule = await tx.automationRule.findUnique({ if (!rule.active) {
where: { triggerType: 'VARROA_THRESHOLD' }, return;
}
const colony = await tx.colony.findUnique({
where: { id: measurement.colonyId },
select: { colonyType: true },
}); });
// null for an inactive rule; the one source of the default value. if (colony === null) {
const threshold = varroaThresholdOf(rule);
if (!rule || threshold === null) {
return; return;
} }
// The threshold is a natural mite fall rate (mites per day). Only that const rating = rateMeasurement(
// method yields a value in this unit - a mite count from a sugar roll or measurement,
// wash is per bee sample and would be compared against the wrong scale, colony.colonyType,
// as would a raw board count without the number of days it covered. rule.config,
if (measurement.method !== 'NATURAL_MITE_FALL') { await treatmentWindowsIn(tx, measurement.colonyId),
);
// A correction can move a measurement out of red (or into it): an open
// suggestion of this measurement follows the corrected value.
const open = await tx.suggestion.findFirst({
where: {
ruleKey: 'VARROA_THRESHOLD',
status: 'OPEN',
dedupeKey: suggestionDedupeKey(
'VARROA_THRESHOLD',
measurement.colonyId,
measurement.id,
),
},
select: { id: true },
});
if (
rating.light !== 'red' ||
rating.band === null ||
measurement.calculatedValue === null
) {
if (open !== null) {
await this.suggestions.resolveIn(tx, [open.id], 'Bedingung entfällt');
}
return; return;
} }
const value = measurement.calculatedValue; const measuredOn = berlinDayOf(measurement.date);
if (value !== null && value >= threshold) { const evidence = {
await this.triggerActionIn(tx, rule, 'COLONY', measurement.colonyId); colonyId: measurement.colonyId,
measurementId: measurement.id,
measuredOn,
value: measurement.calculatedValue,
redAbove: rating.band.redAbove,
unit: rating.band.unit,
};
const dueDate = dueDayOf(rule.config, measuredOn);
if (open !== null) {
await this.suggestions.refreshIn(tx, open.id, {
reasonCode: 'VARROA_RED',
evidence,
dueDate,
priority: 'HIGH',
expiresAt: null,
});
return;
} }
await this.proposeForRuleIn(tx, 'VARROA_THRESHOLD', {
relatedType: 'COLONY',
relatedId: measurement.colonyId,
colonyId: measurement.colonyId,
intent: 'VARROA_TREAT',
reasonCode: 'VARROA_RED',
evidence,
anchor: measurement.id,
requiredPermission: 'varroa:read',
dueDate,
});
} }
async evaluateSwarmControlCheckIn( async evaluateSwarmControlCheckIn(
@@ -336,24 +475,38 @@ export class AutomationService {
tx, tx,
'AutomationService.evaluateSwarmControlCheckIn', 'AutomationService.evaluateSwarmControlCheckIn',
); );
await this.ensureDefaults(tx); if (check.swarmMood !== 'HIGH') {
const rule = await tx.automationRule.findUnique({
where: { triggerType: 'SWARM_MOOD_HIGH' },
});
if (!rule || !rule.active) {
return; return;
} }
if (check.swarmMood === 'HIGH') { const rule = await readRule(tx, 'SWARM_MOOD_HIGH');
await this.triggerActionIn(tx, rule, 'COLONY', check.colonyId); if (!rule.active) {
return;
} }
const checkedOn = berlinDayOf(check.date);
await this.proposeForRuleIn(tx, 'SWARM_MOOD_HIGH', {
relatedType: 'COLONY',
relatedId: check.colonyId,
colonyId: check.colonyId,
intent: 'SWARM_CHECK',
reasonCode: 'SWARM_MOOD_HIGH',
evidence: { colonyId: check.colonyId, checkId: check.id, checkedOn },
anchor: check.id,
requiredPermission: 'swarm-control:read',
dueDate: dueDayOf(rule.config, checkedOn),
});
} }
/** Fires only on the transition into CRITICAL, not on every edit while already critical. */ /**
* Fires only on the transition into CRITICAL, not on every edit while
* already critical. The event is the anchor: a later transition into
* CRITICAL is a new finding.
*/
async evaluateColonyHealthIn( async evaluateColonyHealthIn(
tx: Tx, tx: Tx,
colonyId: string, colonyId: string,
from: ColonyStateSnapshot, from: ColonyStateSnapshot,
to: ColonyStateSnapshot, to: ColonyStateSnapshot,
event: { id: string; occurredAt: Date },
): Promise<void> { ): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateColonyHealthIn'); assertTransactionClient(tx, 'AutomationService.evaluateColonyHealthIn');
// ColonyStatusChanged comes with every status or health edit: decide on // ColonyStatusChanged comes with every status or health edit: decide on
@@ -361,16 +514,29 @@ export class AutomationService {
if (to.healthStatus !== 'CRITICAL' || from.healthStatus === 'CRITICAL') { if (to.healthStatus !== 'CRITICAL' || from.healthStatus === 'CRITICAL') {
return; return;
} }
await this.ensureDefaults(tx); const rule = await readRule(tx, 'COLONY_HEALTH_CRITICAL');
const rule = await tx.automationRule.findUnique({ if (!rule.active) {
where: { triggerType: 'COLONY_HEALTH_CRITICAL' },
});
if (!rule || !rule.active) {
return; return;
} }
await this.triggerActionIn(tx, rule, 'COLONY', colonyId); const changedOn = berlinDayOf(event.occurredAt);
await this.proposeForRuleIn(tx, 'COLONY_HEALTH_CRITICAL', {
relatedType: 'COLONY',
relatedId: colonyId,
colonyId,
intent: 'HEALTH_CHECK',
reasonCode: 'COLONY_HEALTH_CRITICAL',
evidence: { colonyId, changedOn },
anchor: event.id,
requiredPermission: 'colonies:read',
dueDate: dueDayOf(rule.config, changedOn),
sourceEventId: event.id,
});
} }
/**
* Anchored on the newest incoming movement: a dismissed reorder stays
* dismissed until the item was restocked and ran low again.
*/
async evaluateStockItemIn(tx: Tx, item: StockItem): Promise<void> { async evaluateStockItemIn(tx: Tx, item: StockItem): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateStockItemIn'); assertTransactionClient(tx, 'AutomationService.evaluateStockItemIn');
// Every movement of the item comes here: only one below its minimum // Every movement of the item comes here: only one below its minimum
@@ -378,22 +544,39 @@ export class AutomationService {
if (item.minThreshold === null || item.quantity >= item.minThreshold) { if (item.minThreshold === null || item.quantity >= item.minThreshold) {
return; return;
} }
await this.ensureDefaults(tx); const rule = await readRule(tx, 'STOCK_ITEM_LOW');
const rule = await tx.automationRule.findUnique({ if (!rule.active) {
where: { triggerType: 'STOCK_ITEM_LOW' },
});
if (!rule || !rule.active) {
return; return;
} }
await this.triggerActionIn(tx, rule, 'STORAGE', item.id); const restock = await tx.stockMovement.findFirst({
where: { stockItemId: item.id, type: 'IN' },
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
select: { id: true },
});
await this.proposeForRuleIn(tx, 'STOCK_ITEM_LOW', {
relatedType: 'STORAGE',
relatedId: item.id,
colonyId: null,
intent: 'STOCK_REORDER',
reasonCode: 'STOCK_ITEM_LOW',
evidence: {
stockItemId: item.id,
quantity: item.quantity,
minThreshold: item.minThreshold,
},
anchor: restock?.id ?? NO_ANCHOR,
requiredPermission: 'storage:read',
dueDate: dueDayOf(rule.config, berlinToday()),
});
} }
/** /**
* After raw material left its lots (consumption, withdrawal, inventory): * After raw material left its lots (consumption, withdrawal, inventory):
* compares what is left of the kind across all lots with config.minGrams. * compares what is left of the kind across all lots with config.minGrams.
* The task is about the kind, not one lot - a new lot of the same kind is * The suggestion is about the kind, not one lot - a new lot of the same
* the remedy - so it hangs on GENERAL with the kind as relatedId, which * kind is the remedy - so it hangs on GENERAL with the kind as relatedId,
* keeps one open task per kind. * which keeps one open suggestion per kind, and is anchored on the newest
* lot of the kind.
*/ */
async evaluateRawMaterialLowIn( async evaluateRawMaterialLowIn(
tx: Tx, tx: Tx,
@@ -401,69 +584,73 @@ export class AutomationService {
remainingGrams: number, remainingGrams: number,
): Promise<void> { ): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateRawMaterialLowIn'); assertTransactionClient(tx, 'AutomationService.evaluateRawMaterialLowIn');
await this.ensureDefaults(tx); const rule = await readRule(tx, 'RAW_MATERIAL_LOW');
const rule = await tx.automationRule.findUnique({ if (!rule.active) {
where: { triggerType: 'RAW_MATERIAL_LOW' },
});
if (!rule || !rule.active) {
return; return;
} }
const config = rule.config as { const minGrams = rule.config.minGrams?.[kind];
minGrams?: Partial<Record<RawMaterialKind, unknown>>;
} | null;
const minGrams = config?.minGrams?.[kind];
if (typeof minGrams !== 'number' || remainingGrams >= minGrams) { if (typeof minGrams !== 'number' || remainingGrams >= minGrams) {
return; return;
} }
await this.triggerActionIn(tx, rule, 'GENERAL', kind, undefined, { const newestLot = await tx.rawMaterialLot.findFirst({
description: `${RAW_MATERIAL_KIND_LABELS[kind]}: noch ${remainingGrams} g auf Lager, Mindestmenge ${minGrams} g.`, where: { kind },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
select: { id: true },
});
await this.proposeForRuleIn(tx, 'RAW_MATERIAL_LOW', {
relatedType: 'GENERAL',
relatedId: kind,
colonyId: null,
intent: 'STOCK_REORDER',
reasonCode: 'RAW_MATERIAL_LOW',
evidence: { kind, remainingGrams, minGrams },
anchor: newestLot?.id ?? NO_ANCHOR,
requiredPermission: 'raw-material:read',
dueDate: dueDayOf(rule.config, berlinToday()),
}); });
} }
async evaluateTreatmentIn(tx: Tx, treatment: Treatment): Promise<void> { async evaluateTreatmentIn(tx: Tx, treatment: Treatment): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateTreatmentIn'); assertTransactionClient(tx, 'AutomationService.evaluateTreatmentIn');
const measureDate = treatment.endDate ?? treatment.date; const rule = await readRule(tx, 'TREATMENT_FOLLOW_UP');
await this.evaluateFollowUpIn( if (!rule.active) {
tx, return;
'TREATMENT_FOLLOW_UP', }
treatment.colonyId, const endedOn = berlinDayOf(treatment.endDate ?? treatment.date);
measureDate, await this.proposeForRuleIn(tx, 'TREATMENT_FOLLOW_UP', {
`Nach Behandlung mit ${treatment.product} am ${formatDay(measureDate)}.`, relatedType: 'COLONY',
); relatedId: treatment.colonyId,
colonyId: treatment.colonyId,
intent: 'FOLLOW_UP',
reasonCode: 'TREATMENT_FOLLOW_UP',
evidence: {
colonyId: treatment.colonyId,
treatmentId: treatment.id,
endedOn,
},
anchor: treatment.id,
requiredPermission: 'treatments:read',
dueDate: dueDayOf(rule.config, endedOn),
});
} }
async evaluateFeedingIn(tx: Tx, feeding: Feeding): Promise<void> { async evaluateFeedingIn(tx: Tx, feeding: Feeding): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateFeedingIn'); assertTransactionClient(tx, 'AutomationService.evaluateFeedingIn');
await this.evaluateFollowUpIn( const rule = await readRule(tx, 'FEEDING_FOLLOW_UP');
tx, if (!rule.active) {
'FEEDING_FOLLOW_UP',
feeding.colonyId,
feeding.date,
`Nach Fütterung (${feeding.amount} ${feeding.unit}) am ${formatDay(feeding.date)}.`,
);
}
/**
* A check counted from the measure's own date, not from today. Its public
* callers check the transaction client, so an error names them.
*/
private async evaluateFollowUpIn(
tx: Tx,
triggerType: 'TREATMENT_FOLLOW_UP' | 'FEEDING_FOLLOW_UP',
colonyId: string,
measureDate: Date,
description: string,
): Promise<void> {
await this.ensureDefaults(tx);
const rule = await tx.automationRule.findUnique({
where: { triggerType },
});
if (!rule || !rule.active) {
return; return;
} }
await this.triggerActionIn(tx, rule, 'COLONY', colonyId, undefined, { const fedOn = berlinDayOf(feeding.date);
baseDate: measureDate, await this.proposeForRuleIn(tx, 'FEEDING_FOLLOW_UP', {
description, relatedType: 'COLONY',
relatedId: feeding.colonyId,
colonyId: feeding.colonyId,
intent: 'FOLLOW_UP',
reasonCode: 'FEEDING_FOLLOW_UP',
evidence: { colonyId: feeding.colonyId, feedingId: feeding.id, fedOn },
anchor: feeding.id,
requiredPermission: 'feedings:read',
dueDate: dueDayOf(rule.config, fedOn),
}); });
} }
@@ -593,76 +780,80 @@ export class AutomationService {
} }
/** /**
* Leaves a suggestion task (never a record of work done) plus a warning, * Proposes what a rule found, on the caller's transaction: the suggestion
* all on the caller's transaction: task, its audit entry and TaskCreated * (SuggestionsService.proposeIn: colony then rule lock, one open thing per
* (TasksService.createInTx - the task.created webhook and the calendar * intent and object, an anchor is proposed once, the notification when the
* entry follow from that event), the warning and the rule's audit entry. * rule says `notify`) and the rule's audit entry. A failing write rolls
* A failing write rolls back all of it; a handler delivery is retried. * back all of it; a handler delivery is retried. Resolves to null when
* nothing was proposed (rule off, colony ended, anchor or intent taken).
* *
* At most one open task per rule and object: while the reorder task for a * The colony is locked and read before the rule is locked - the order
* stock item or the check task for a colony is still open, further triggers * every proposal uses - so a colony that ends in between is not missed.
* (the next removal below minimum, a second high varroa count) add nothing.
* Repeating the same alarm would only bury the one task that matters.
*
* The check runs under a lock on the rule's row, held until the caller
* commits: two evaluations at the same moment (parallel requests, a
* handler and a legacy path, several pods) would otherwise both see
* "no open task" and both create one.
*/ */
private async triggerActionIn( async proposeForRuleIn(
tx: Tx, tx: Tx,
rule: AutomationRule, trigger: RuleSuggestionTrigger,
relatedType: TaskRelatedType, target: RuleSuggestionTarget,
relatedId: string, ): Promise<Suggestion | null> {
sourceInspectionId?: string, assertTransactionClient(tx, 'AutomationService.proposeForRuleIn');
options: { baseDate?: Date; description?: string } = {}, if (
): Promise<void> { target.colonyId !== null &&
await tx.$queryRaw`SELECT id FROM "AutomationRule" WHERE id = ${rule.id} FOR UPDATE`; !(await this.isLivingColonyLockedIn(tx, target.colonyId))
const alreadyOpen = await tx.task.findFirst({ ) {
where: { return null;
sourceAutomationTrigger: rule.triggerType,
relatedType,
relatedId,
status: { in: ['OPEN', 'IN_PROGRESS'] },
},
select: { id: true },
});
if (alreadyOpen) {
return;
} }
const title = rule.actionTemplate ?? 'Automatisierte Aufgabe'; const rule = await lockRuleIn(tx, trigger);
const config = rule.config as { dueInDays?: unknown } | null; if (!rule.active) {
const task = await this.tasks.createInTx( return null;
tx, }
{ const suggestion = await this.suggestions.proposeIn(tx, {
title, ruleKey: trigger,
source: 'RULE',
mode: 'PLAN',
intent: target.intent,
priority: 'HIGH', priority: 'HIGH',
relatedType, relatedType: target.relatedType,
relatedId, relatedId: target.relatedId,
sourceInspectionId, colonyId: target.colonyId,
sourceAutomationTrigger: rule.triggerType, reasonCode: target.reasonCode,
description: options.description, evidence: target.evidence,
dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate), requiredPermission: target.requiredPermission,
}, dueDate: target.dueDate ?? null,
null, anchor: target.anchor,
); sourceEventId: target.sourceEventId ?? null,
await this.notifications.record( notify: rule.config.notify,
{ priority: 'WARNUNG', message: title, relatedType, relatedId }, });
tx, if (suggestion === null) {
); return null;
}
await this.auditLog.record( await this.auditLog.record(
{ {
entityType: 'AutomationRule', entityType: 'AutomationRule',
entityId: rule.id, entityId: rule.id,
action: 'CREATE', action: 'CREATE',
afterData: { afterData: {
taskId: task.id, suggestionId: suggestion.id,
relatedType, relatedType: target.relatedType,
relatedId, relatedId: target.relatedId,
triggerType: rule.triggerType, triggerType: trigger,
}, },
}, },
tx, tx,
); );
return suggestion;
}
private async isLivingColonyLockedIn(
tx: Tx,
colonyId: string,
): Promise<boolean> {
if (!(await lockRowForUpdate(tx, 'Colony', colonyId))) {
return false;
}
const colony = await tx.colony.findUnique({
where: { id: colonyId },
select: { status: true },
});
return colony !== null && isLivingColonyStatus(colony.status);
} }
} }
@@ -0,0 +1,40 @@
import { bool, intIn, makeParser } from './parse-helpers';
import type { ChainConfig } from './rule-config.types';
const MAX_DUE_IN_DAYS = 365;
/** Follow-up chains (queen cells, queen acceptance): one check some days later. */
export function chainParser(trigger: string, defaults: ChainConfig) {
return makeParser<ChainConfig>(trigger, defaults, (scope) => ({
dueInDays: intIn(
scope,
'dueInDays',
defaults.dueInDays,
0,
MAX_DUE_IN_DAYS,
),
notify: bool(scope, 'notify', defaults.notify),
}));
}
export const DEFAULT_CHAIN_CONFIG: ChainConfig = {
dueInDays: 7,
notify: false,
};
export const DEFAULT_WITHDRAWAL_ENDED_CONFIG = { notify: true };
export const withdrawalEndedParser = makeParser<{ notify: boolean }>(
'WITHDRAWAL_ENDED',
DEFAULT_WITHDRAWAL_ENDED_CONFIG,
(scope) => ({
notify: bool(scope, 'notify', DEFAULT_WITHDRAWAL_ENDED_CONFIG.notify),
}),
);
/** The cleanup rule has no setting; it is only switched on or off. */
export const colonyEndedCleanupParser = makeParser<Record<string, never>>(
'COLONY_ENDED_CLEANUP',
{},
() => ({}),
);
@@ -0,0 +1,72 @@
import type { AutomationTriggerType } from '../../../generated/prisma';
import { RULE_CONFIG_PARSERS } from './rule-config.registry';
export interface RuleDefaults {
config: Record<string, unknown>;
actionTemplate: string;
/** Rules without a sensible built-in setting start switched off. */
active?: boolean;
}
/** Texts of the rules that create a task; the new rules have none yet. */
const ACTION_TEMPLATES: Record<AutomationTriggerType, string> = {
QUEENLESS_NO_EGGS:
'Kontrolle empfohlen: Königin nicht gesehen und keine Stifte vorhanden.',
VARROA_THRESHOLD: 'Behandlung prüfen: Varroa-Schwellenwert überschritten.',
FEED_STATUS_CRITICAL: 'Fütterung erforderlich: Futterstatus kritisch.',
SWARM_MOOD_HIGH:
'Schwarmkontrolle dringend: hohe Schwarmstimmung festgestellt.',
COLONY_HEALTH_CRITICAL: 'Kontrolle erforderlich: Gesundheitsstatus kritisch.',
STOCK_ITEM_LOW: 'Nachbestellung prüfen: Lagerartikel unter Mindestbestand.',
// Not task-generating rules like the ones above - `active` here toggles
// whether HoneyBatchesService/BottlingService auto-fill the batch/lot
// number from `config.format` instead of requiring manual entry.
HONEY_BATCH_NUMBER_FORMAT: '',
BOTTLING_LOT_NUMBER_FORMAT: '',
RAW_MATERIAL_LOT_NUMBER_FORMAT: '',
PRODUCTION_BATCH_NUMBER_FORMAT: '',
RAW_MATERIAL_LOW: 'Rohstoff unter Mindestmenge: Nachschub einplanen.',
TREATMENT_FOLLOW_UP: 'Behandlungserfolg kontrollieren',
FEEDING_FOLLOW_UP: 'Futterabnahme und Futtervorrat kontrollieren',
INSPECTION_INTERVAL: '',
QUEEN_CELLS_SWARM_CHECK: '',
QUEEN_ACCEPTANCE_CHECK: '',
SPLIT_SCHEDULE: '',
COLONY_ENDED_CLEANUP: '',
WITHDRAWAL_ENDED: '',
HONEY_WATER_CONTENT: '',
SEASON_AUTOPILOT: '',
PHENOLOGY: '',
};
// - RAW_MATERIAL_LOW: raw material lots carry no minimum of their own, so
// the minimum per kind (config.minGrams) lives on the rule - and without
// one there is nothing to compare, hence off by default.
// - Follow-up checks: how many days after a treatment or feeding a check
// makes sense depends on the product and the season, so there is no
// built-in interval - the beekeeper switches the rule on and sets
// config.dueInDays.
const INACTIVE_BY_DEFAULT: ReadonlySet<AutomationTriggerType> = new Set([
'RAW_MATERIAL_LOW',
'TREATMENT_FOLLOW_UP',
'FEEDING_FOLLOW_UP',
]);
function defaultsOf(trigger: AutomationTriggerType): RuleDefaults {
return {
config: structuredClone(RULE_CONFIG_PARSERS[trigger].defaults) as Record<
string,
unknown
>,
actionTemplate: ACTION_TEMPLATES[trigger],
...(INACTIVE_BY_DEFAULT.has(trigger) ? { active: false } : {}),
};
}
/** The rule rows created on first use; the config is the parser's default. */
export const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> =
Object.fromEntries(
(Object.keys(ACTION_TEMPLATES) as AutomationTriggerType[]).map(
(trigger) => [trigger, defaultsOf(trigger)],
),
) as Record<AutomationTriggerType, RuleDefaults>;
@@ -0,0 +1,8 @@
export * from './rule-config.types';
export {
RULE_CONFIG_PARSERS,
ruleConfigOf,
validateRuleConfig,
} from './rule-config.registry';
export { DEFAULT_RULES, type RuleDefaults } from './default-rules';
export { lockRuleIn, readRule, type ActiveRule } from './rule-config.reader';
@@ -0,0 +1,44 @@
import type { SeasonSegmentKey } from '../../season/season-types';
import { childScope, intIn, makeParser } from './parse-helpers';
import type { InspectionIntervalConfig } from './rule-config.types';
type IntervalSegment = Exclude<SeasonSegmentKey, 'WINTERRUHE'>;
const MIN_INTERVAL_DAYS = 7;
const MAX_INTERVAL_DAYS = 35;
/** In the swarm season a colony is looked at weekly, at the latest every 9 days. */
const MAX_SWARM_INTERVAL_DAYS = 9;
export const DEFAULT_INSPECTION_INTERVAL_CONFIG: InspectionIntervalConfig = {
intervalDays: {
FRUEHJAHR: 14,
SCHWARMZEIT: 7,
SOMMER: 14,
SPAETSOMMER: 21,
HERBST: 21,
},
};
const SEGMENTS = Object.keys(
DEFAULT_INSPECTION_INTERVAL_CONFIG.intervalDays,
) as IntervalSegment[];
export const inspectionIntervalParser = makeParser<InspectionIntervalConfig>(
'INSPECTION_INTERVAL',
DEFAULT_INSPECTION_INTERVAL_CONFIG,
(scope) => {
const days = childScope(scope, 'intervalDays');
const defaults = DEFAULT_INSPECTION_INTERVAL_CONFIG.intervalDays;
const intervalDays = { ...defaults };
for (const segment of SEGMENTS) {
intervalDays[segment] = intIn(
days,
segment,
defaults[segment],
MIN_INTERVAL_DAYS,
segment === 'SCHWARMZEIT' ? MAX_SWARM_INTERVAL_DAYS : MAX_INTERVAL_DAYS,
);
}
return { intervalDays };
},
);
@@ -0,0 +1,349 @@
import { Logger } from '@nestjs/common';
import type { RuleConfigIssue, RuleConfigParser } from './rule-config.types';
/**
* Where a value is read from: the object, its field path (for messages) and
* the list the issues go to. Helpers never throw - an invalid or missing key
* gives the fallback, and an invalid one that is present is reported.
*/
export interface Scope {
readonly raw: Readonly<Record<string, unknown>>;
readonly path: string;
readonly issues: RuleConfigIssue[];
}
const NO_VALUES: Readonly<Record<string, unknown>> = Object.freeze({});
export function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
export function rootScope(json: unknown, issues: RuleConfigIssue[]): Scope {
return { raw: asRecord(json) ?? NO_VALUES, path: '', issues };
}
export function fieldPath(scope: Scope, key: string): string {
return scope.path === '' ? key : `${scope.path}.${key}`;
}
export function report(scope: Scope, key: string, message: string): void {
scope.issues.push({ field: fieldPath(scope, key), message });
}
/**
* A key counts as given when it is present - an explicit null too, which the
* readers then report as invalid instead of silently using the default.
*/
export function has(scope: Scope, key: string): boolean {
if (!Object.hasOwn(scope.raw, key)) {
return false;
}
return scope.raw[key] !== undefined;
}
/** An explicit null: on an optional key it clears the value. */
export function isCleared(scope: Scope, key: string): boolean {
return Object.hasOwn(scope.raw, key) && scope.raw[key] === null;
}
/** The nested object under `key`; a non-object is reported and read as empty. */
export function childScope(scope: Scope, key: string): Scope {
const path = fieldPath(scope, key);
if (!has(scope, key)) {
return { raw: NO_VALUES, path, issues: scope.issues };
}
const nested = asRecord(scope.raw[key]);
if (nested === null) {
report(scope, key, 'Bitte die Angaben als Gruppe senden.');
return { raw: NO_VALUES, path, issues: scope.issues };
}
return { raw: nested, path, issues: scope.issues };
}
/** Runs `read`; if it reported anything, `fallback()` replaces its result. */
export function orFallback<T>(
scope: Scope,
read: () => T,
fallback: () => T,
): T {
const before = scope.issues.length;
const value = read();
return scope.issues.length > before ? fallback() : value;
}
const numberFormat = new Intl.NumberFormat('de-DE', {
maximumFractionDigits: 3,
});
export function formatDe(value: number): string {
return numberFormat.format(value);
}
export function intIn(
scope: Scope,
key: string,
fallback: number,
min: number,
max: number,
): number {
if (!has(scope, key)) {
return fallback;
}
const value = scope.raw[key];
if (
typeof value === 'number' &&
Number.isInteger(value) &&
value >= min &&
value <= max
) {
return value;
}
report(
scope,
key,
`Bitte eine ganze Zahl zwischen ${formatDe(min)} und ${formatDe(max)} wählen.`,
);
return fallback;
}
/** Like intIn, but an absent key stays undefined. */
export function optionalIntIn(
scope: Scope,
key: string,
min: number,
max: number,
): number | undefined {
if (!has(scope, key) || isCleared(scope, key)) {
return undefined;
}
const value = intIn(scope, key, Number.NaN, min, max);
return Number.isNaN(value) ? undefined : value;
}
export interface NumberOptions {
/** The lower limit itself is not allowed (min < x). */
exclusiveMin?: boolean;
}
export function numberIn(
scope: Scope,
key: string,
fallback: number,
min: number,
max: number,
options: NumberOptions = {},
): number {
if (!has(scope, key)) {
return fallback;
}
const value = scope.raw[key];
if (
typeof value === 'number' &&
Number.isFinite(value) &&
(options.exclusiveMin ? value > min : value >= min) &&
value <= max
) {
return value;
}
const lower = options.exclusiveMin
? `größer als ${formatDe(min)}`
: `mindestens ${formatDe(min)}`;
report(
scope,
key,
Number.isFinite(max)
? `Bitte eine Zahl ${lower} und höchstens ${formatDe(max)} angeben.`
: `Bitte eine Zahl ${lower} angeben.`,
);
return fallback;
}
/** Like numberIn, but an absent key stays undefined. */
export function optionalNumberIn(
scope: Scope,
key: string,
min: number,
max: number,
options: NumberOptions = {},
): number | undefined {
if (!has(scope, key) || isCleared(scope, key)) {
return undefined;
}
const value = numberIn(scope, key, Number.NaN, min, max, options);
return Number.isNaN(value) ? undefined : value;
}
export function bool(scope: Scope, key: string, fallback: boolean): boolean {
if (!has(scope, key)) {
return fallback;
}
const value = scope.raw[key];
if (typeof value === 'boolean') {
return value;
}
report(scope, key, 'Bitte Ja oder Nein wählen.');
return fallback;
}
export function stringIn(
scope: Scope,
key: string,
fallback: string,
minLength: number,
maxLength: number,
pattern?: RegExp,
): string {
if (!has(scope, key)) {
return fallback;
}
const value = scope.raw[key];
if (
typeof value === 'string' &&
value.length >= minLength &&
value.length <= maxLength &&
(pattern === undefined || pattern.test(value))
) {
return value;
}
report(
scope,
key,
`Bitte einen Text mit ${minLength} bis ${maxLength} Zeichen angeben.`,
);
return fallback;
}
const MONTH_DAY = /^(\d{2})-(\d{2})$/;
/** 'MM-DD' that exists in a leap year (02-29 is allowed). */
export function isMonthDay(value: unknown): value is string {
if (typeof value !== 'string') {
return false;
}
const match = MONTH_DAY.exec(value);
if (!match) {
return false;
}
const month = Number(match[1]);
const day = Number(match[2]);
const date = new Date(Date.UTC(2024, month - 1, day));
return date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
}
export function monthDay(scope: Scope, key: string, fallback: string): string {
if (!has(scope, key)) {
return fallback;
}
const value = scope.raw[key];
if (isMonthDay(value)) {
return value;
}
report(scope, key, 'Bitte ein Datum als MM-TT angeben, zum Beispiel 03-20.');
return fallback;
}
export function months(
scope: Scope,
key: string,
fallback: readonly number[],
): number[] {
if (!has(scope, key)) {
return [...fallback];
}
const value = scope.raw[key];
if (
Array.isArray(value) &&
value.every(
(month): month is number =>
typeof month === 'number' &&
Number.isInteger(month) &&
month >= 1 &&
month <= 12,
) &&
new Set(value).size === value.length
) {
return [...value];
}
report(scope, key, 'Bitte Monate von 1 bis 12 angeben, jeden nur einmal.');
return [...fallback];
}
export function enumValue<T extends string>(
scope: Scope,
key: string,
fallback: T,
allowed: readonly T[],
): T {
if (!has(scope, key)) {
return fallback;
}
const value = scope.raw[key];
if (
typeof value === 'string' &&
(allowed as readonly string[]).includes(value)
) {
return value as T;
}
report(scope, key, 'Bitte einen gültigen Wert wählen.');
return fallback;
}
export function enumList<T extends string>(
scope: Scope,
key: string,
fallback: readonly T[],
allowed: readonly T[],
): T[] {
if (!has(scope, key)) {
return [...fallback];
}
const value = scope.raw[key];
if (
Array.isArray(value) &&
value.every(
(entry): entry is T =>
typeof entry === 'string' &&
(allowed as readonly string[]).includes(entry),
) &&
new Set(value).size === value.length
) {
return [...value];
}
report(scope, key, 'Bitte nur gültige Werte wählen, jeden nur einmal.');
return [...fallback];
}
const logger = new Logger('RuleConfig');
/**
* Builds the parser of one rule from its reader. The reader turns the scope
* into a complete config and reports every invalid key; `parse` logs those
* (trigger and field, never the value) and returns the config, `validate`
* returns them.
*/
export function makeParser<T>(
trigger: string,
defaults: T,
read: (scope: Scope) => T,
): RuleConfigParser<T> {
return {
defaults,
parse(json) {
const issues: RuleConfigIssue[] = [];
const value = read(rootScope(json, issues));
for (const issue of issues) {
logger.warn(
`Invalid setting ${trigger}.${issue.field}; using the default`,
);
}
return value;
},
validate(json) {
const issues: RuleConfigIssue[] = [];
read(rootScope(json, issues));
return issues;
},
};
}
@@ -0,0 +1,158 @@
import {
PHENOLOGY_PHASES,
type PhenologyPhaseKey,
} from '../../season/season-types';
import {
bool,
childScope,
enumList,
enumValue,
has,
isCleared,
makeParser,
monthDay,
numberIn,
report,
type Scope,
} from './parse-helpers';
import type { PhenologyConfig } from './rule-config.types';
/** Upper limit of a growing-degree-day threshold; far above any phase. */
const MAX_GTS_THRESHOLD = 5000;
const MAX_GAP_SHARE = 0.5;
export const DEFAULT_PHENOLOGY_CONFIG: PhenologyConfig = {
useGts: true,
gtsThresholds: { SALWEIDE: 200 },
calibrateFromReports: true,
calibratePhases: ['KIRSCHE', 'APFEL'],
calendarFallback: {
SALWEIDE: '03-20',
KIRSCHE: '04-15',
APFEL: '04-28',
HOLUNDER: '05-30',
LINDE: '06-20',
TRACHTENDE: '08-01',
HERBST: '09-15',
WINTER: '11-15',
},
swarmSeason: { from: 'KIRSCHE', until: 'LINDE' },
maxGapShare: 0.1,
};
function readThresholds(scope: Scope): PhenologyConfig['gtsThresholds'] {
// Without the group the defaults apply; with it, only the phases given
// have a threshold - leaving one out removes it.
if (!has(scope, 'gtsThresholds')) {
return { ...DEFAULT_PHENOLOGY_CONFIG.gtsThresholds };
}
const thresholds = childScope(scope, 'gtsThresholds');
const result: PhenologyConfig['gtsThresholds'] = {};
for (const phase of PHENOLOGY_PHASES) {
if (isCleared(thresholds, phase)) {
continue;
}
const value = numberIn(
thresholds,
phase,
Number.NaN,
0,
MAX_GTS_THRESHOLD,
{ exclusiveMin: true },
);
if (!Number.isNaN(value)) {
result[phase] = value;
}
}
return result;
}
/**
* The calendar dates of the phases must not run backwards in phase order.
* One violation sends the whole map back to the defaults, so it stays
* consistent; the issue names a key the caller actually sent.
*/
function readCalendar(scope: Scope): PhenologyConfig['calendarFallback'] {
const calendar = childScope(scope, 'calendarFallback');
const defaults = DEFAULT_PHENOLOGY_CONFIG.calendarFallback;
const before = scope.issues.length;
const result = { ...defaults };
for (const phase of PHENOLOGY_PHASES) {
result[phase] = monthDay(calendar, phase, defaults[phase]);
}
if (scope.issues.length > before) {
return { ...defaults };
}
for (let index = 1; index < PHENOLOGY_PHASES.length; index += 1) {
const previous = PHENOLOGY_PHASES[index - 1];
const phase = PHENOLOGY_PHASES[index];
if (result[phase] < result[previous]) {
const culprit = has(calendar, phase) ? phase : previous;
report(
calendar,
culprit,
`Das Datum darf nicht vor dem der vorherigen Phase liegen (${previous}).`,
);
return { ...defaults };
}
}
return result;
}
function readSwarmSeason(scope: Scope): PhenologyConfig['swarmSeason'] {
const season = childScope(scope, 'swarmSeason');
const defaults = DEFAULT_PHENOLOGY_CONFIG.swarmSeason;
const from: PhenologyPhaseKey = enumValue(
season,
'from',
defaults.from,
PHENOLOGY_PHASES,
);
const until: PhenologyPhaseKey = enumValue(
season,
'until',
defaults.until,
PHENOLOGY_PHASES,
);
if (PHENOLOGY_PHASES.indexOf(from) >= PHENOLOGY_PHASES.indexOf(until)) {
report(
season,
has(season, 'from') ? 'from' : 'until',
'Der Beginn der Schwarmzeit muss vor ihrem Ende liegen.',
);
return { ...defaults };
}
return { from, until };
}
export const phenologyParser = makeParser<PhenologyConfig>(
'PHENOLOGY',
DEFAULT_PHENOLOGY_CONFIG,
(scope) => {
const d = DEFAULT_PHENOLOGY_CONFIG;
return {
useGts: bool(scope, 'useGts', d.useGts),
gtsThresholds: readThresholds(scope),
calibrateFromReports: bool(
scope,
'calibrateFromReports',
d.calibrateFromReports,
),
calibratePhases: enumList(
scope,
'calibratePhases',
d.calibratePhases,
PHENOLOGY_PHASES,
),
calendarFallback: readCalendar(scope),
swarmSeason: readSwarmSeason(scope),
maxGapShare: numberIn(
scope,
'maxGapShare',
d.maxGapShare,
0,
MAX_GAP_SHARE,
),
};
},
);
@@ -0,0 +1,100 @@
import { Prisma } from '../../../generated/prisma';
import type { AutomationTriggerType } from '../../../generated/prisma';
import { assertTransactionClient, type DbClient } from '../../prisma/db-client';
import { lockRowForUpdate } from '../../prisma/row-lock';
import { DEFAULT_RULES } from './default-rules';
import { ruleConfigOf } from './rule-config.registry';
import type { RuleConfigMap } from './rule-config.types';
export interface ActiveRule<K extends AutomationTriggerType> {
/** null while no row exists yet: the built-in setting applies. */
id: string | null;
active: boolean;
config: RuleConfigMap[K];
actionTemplate: string | null;
}
interface RuleRow {
id: string;
active: boolean;
config: Prisma.JsonValue;
actionTemplate: string | null;
}
const RULE_COLUMNS = {
id: true,
active: true,
config: true,
actionTemplate: true,
} as const;
function toActiveRule<K extends AutomationTriggerType>(
trigger: K,
row: RuleRow | null,
): ActiveRule<K> {
if (row === null) {
const defaults = DEFAULT_RULES[trigger];
return {
id: null,
active: defaults.active ?? true,
config: ruleConfigOf(trigger, defaults.config),
actionTemplate: defaults.actionTemplate,
};
}
return {
id: row.id,
active: row.active,
config: ruleConfigOf(trigger, row.config),
actionTemplate: row.actionTemplate,
};
}
/**
* The rule as it is in force: its row, or the built-in setting while there is
* none. The config is parsed, so every key is valid.
*/
export async function readRule<K extends AutomationTriggerType>(
db: DbClient,
trigger: K,
): Promise<ActiveRule<K>> {
const row = await db.automationRule.findUnique({
where: { triggerType: trigger },
select: RULE_COLUMNS,
});
return toActiveRule(trigger, row);
}
/**
* Creates the rule row if it is missing (without touching an existing one),
* locks it until the transaction ends and reads it. Serialises everything
* that decides "is there already an open task/suggestion" under the rule.
*/
export async function lockRuleIn<K extends AutomationTriggerType>(
tx: Prisma.TransactionClient,
trigger: K,
): Promise<ActiveRule<K> & { id: string }> {
assertTransactionClient(tx, 'lockRuleIn');
const defaults = DEFAULT_RULES[trigger];
await tx.automationRule.createMany({
data: [
{
triggerType: trigger,
active: defaults.active ?? true,
config: defaults.config as Prisma.InputJsonValue,
actionTemplate: defaults.actionTemplate,
},
],
skipDuplicates: true,
});
const { id } = await tx.automationRule.findUniqueOrThrow({
where: { triggerType: trigger },
select: { id: true },
});
await lockRowForUpdate(tx, 'AutomationRule', id);
// Read after the lock: a concurrent edit of the rule is visible now.
const row = await tx.automationRule.findUniqueOrThrow({
where: { id },
select: RULE_COLUMNS,
});
return { ...toActiveRule(trigger, row), id };
}
@@ -0,0 +1,91 @@
import type { AutomationTriggerType } from '../../../generated/prisma';
import {
chainParser,
colonyEndedCleanupParser,
DEFAULT_CHAIN_CONFIG,
withdrawalEndedParser,
} from './chain.config';
import { inspectionIntervalParser } from './inspection-interval.config';
import { phenologyParser } from './phenology.config';
import { seasonAutopilotParser } from './season-autopilot.config';
import { numberFormatParser, simpleParser } from './simple.config';
import { splitScheduleParser } from './split-schedule.config';
import { varroaThresholdParser } from './varroa-threshold.config';
import { waterContentParser } from './water-content.config';
import type {
RuleConfigIssue,
RuleConfigMap,
RuleConfigParser,
} from './rule-config.types';
/** The number-format rules keep the defaults they always had. */
const DEFAULT_BATCH_FORMAT = 'WH-{YYYY}-{SEQ:4}';
export const RULE_CONFIG_PARSERS: {
[K in AutomationTriggerType]: RuleConfigParser<RuleConfigMap[K]>;
} = {
VARROA_THRESHOLD: varroaThresholdParser,
INSPECTION_INTERVAL: inspectionIntervalParser,
QUEEN_CELLS_SWARM_CHECK: chainParser(
'QUEEN_CELLS_SWARM_CHECK',
DEFAULT_CHAIN_CONFIG,
),
QUEEN_ACCEPTANCE_CHECK: chainParser(
'QUEEN_ACCEPTANCE_CHECK',
DEFAULT_CHAIN_CONFIG,
),
SPLIT_SCHEDULE: splitScheduleParser,
COLONY_ENDED_CLEANUP: colonyEndedCleanupParser,
WITHDRAWAL_ENDED: withdrawalEndedParser,
HONEY_WATER_CONTENT: waterContentParser,
SEASON_AUTOPILOT: seasonAutopilotParser,
PHENOLOGY: phenologyParser,
HONEY_BATCH_NUMBER_FORMAT: numberFormatParser(
'HONEY_BATCH_NUMBER_FORMAT',
DEFAULT_BATCH_FORMAT,
),
BOTTLING_LOT_NUMBER_FORMAT: numberFormatParser(
'BOTTLING_LOT_NUMBER_FORMAT',
DEFAULT_BATCH_FORMAT,
),
// {ART} (WA/PR) gives wax and propolis a counter each.
RAW_MATERIAL_LOT_NUMBER_FORMAT: numberFormatParser(
'RAW_MATERIAL_LOT_NUMBER_FORMAT',
'{ART}-{YYYY}-{SEQ:4}',
),
PRODUCTION_BATCH_NUMBER_FORMAT: numberFormatParser(
'PRODUCTION_BATCH_NUMBER_FORMAT',
'HS-{YYYY}-{SEQ:4}',
),
QUEENLESS_NO_EGGS: simpleParser('QUEENLESS_NO_EGGS', { notify: true }),
FEED_STATUS_CRITICAL: simpleParser('FEED_STATUS_CRITICAL', {
notify: false,
}),
SWARM_MOOD_HIGH: simpleParser('SWARM_MOOD_HIGH', { notify: false }),
COLONY_HEALTH_CRITICAL: simpleParser('COLONY_HEALTH_CRITICAL', {
notify: true,
}),
STOCK_ITEM_LOW: simpleParser('STOCK_ITEM_LOW', { notify: false }),
RAW_MATERIAL_LOW: simpleParser('RAW_MATERIAL_LOW', {
notify: false,
withMinGrams: true,
}),
TREATMENT_FOLLOW_UP: simpleParser('TREATMENT_FOLLOW_UP', { notify: false }),
FEEDING_FOLLOW_UP: simpleParser('FEEDING_FOLLOW_UP', { notify: false }),
};
/** The effective setting of a rule: valid keys as stored, the rest default. */
export function ruleConfigOf<K extends AutomationTriggerType>(
trigger: K,
json: unknown,
): RuleConfigMap[K] {
return RULE_CONFIG_PARSERS[trigger].parse(json);
}
/** The invalid known keys of a config a client wants to save. */
export function validateRuleConfig(
trigger: AutomationTriggerType,
json: unknown,
): RuleConfigIssue[] {
return RULE_CONFIG_PARSERS[trigger].validate(json);
}
@@ -0,0 +1,440 @@
import { Logger } from '@nestjs/common';
import { AutomationTriggerType } from '../../../generated/prisma';
import { DEFAULT_RULES } from './default-rules';
import { ruleConfigOf, RULE_CONFIG_PARSERS, validateRuleConfig } from './index';
const ALL_TRIGGERS = Object.values(AutomationTriggerType);
function fieldsOf(trigger: AutomationTriggerType, config: unknown): string[] {
return validateRuleConfig(trigger, config).map((issue) => issue.field);
}
describe('rule config', () => {
beforeEach(() => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
});
afterEach(() => jest.restoreAllMocks());
it('every trigger has defaults and a parser', () => {
for (const t of ALL_TRIGGERS) {
expect(DEFAULT_RULES[t]).toBeDefined();
expect(RULE_CONFIG_PARSERS[t]).toBeDefined();
}
});
it.each(ALL_TRIGGERS)(
'the default config of %s parses to itself and validates without issues',
(trigger) => {
const { config } = DEFAULT_RULES[trigger];
expect(RULE_CONFIG_PARSERS[trigger].parse(config)).toEqual(config);
expect(RULE_CONFIG_PARSERS[trigger].defaults).toEqual(config);
expect(validateRuleConfig(trigger, config)).toEqual([]);
},
);
it.each(ALL_TRIGGERS)(
'%s falls back to its defaults for null, arrays and scalars',
(trigger) => {
const { config } = DEFAULT_RULES[trigger];
for (const junk of [null, undefined, [], 'x', 5]) {
expect(ruleConfigOf(trigger, junk)).toEqual(config);
expect(validateRuleConfig(trigger, junk)).toEqual([]);
}
},
);
it('reads a legacy thresholdMitesPerDay as naturalFall.redAbove', () =>
expect(
ruleConfigOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 7 }).naturalFall,
).toEqual({
seasonMonths: [3, 4, 5, 6, 7, 8, 9, 10],
yellowFrom: 5,
redAbove: 7,
winterRedAbove: 0.5,
}));
it('lets the band format win over the legacy key', () =>
expect(
ruleConfigOf('VARROA_THRESHOLD', {
thresholdMitesPerDay: 7,
naturalFall: { redAbove: 9 },
}).naturalFall.redAbove,
).toBe(9));
it('ignores an invalid legacy threshold', () =>
expect(
ruleConfigOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 0 }).naturalFall
.redAbove,
).toBe(10));
it('reports an invalid legacy threshold with field and message', () => {
const issues = validateRuleConfig('VARROA_THRESHOLD', {
thresholdMitesPerDay: 0,
});
expect(issues).toEqual([
{
field: 'thresholdMitesPerDay',
message: expect.any(String) as string,
},
]);
expect(fieldsOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 7 })).toEqual(
[],
);
});
it('keeps natural-fall redAbove above zero and bounded, percent bands up to 100', () => {
const fields = (config: unknown) => fieldsOf('VARROA_THRESHOLD', config);
expect(fields({ naturalFall: { redAbove: 0 } })).toEqual([
'naturalFall.redAbove',
]);
expect(fields({ naturalFall: { redAbove: 1001 } })).toEqual([
'naturalFall.redAbove',
]);
expect(
fields({ infestationPercent: { summer: { redAbove: 101 } } }),
).toEqual(['infestationPercent.summer.redAbove']);
expect(
fields({ infestationPercent: { autumn: { redAbove: 100 } } }),
).toEqual([]);
});
it('reports an explicit null on a known key', () => {
expect(fieldsOf('SEASON_AUTOPILOT', { queenMaxAgeYears: null })).toEqual([
'queenMaxAgeYears',
]);
expect(fieldsOf('PHENOLOGY', { swarmSeason: null })).toEqual([
'swarmSeason',
]);
});
it('accepts null on optional keys as clearing them', () => {
expect(fieldsOf('TREATMENT_FOLLOW_UP', { dueInDays: null })).toEqual([]);
expect(ruleConfigOf('TREATMENT_FOLLOW_UP', { dueInDays: null })).toEqual({
notify: false,
});
expect(
ruleConfigOf('RAW_MATERIAL_LOW', {
minGrams: { BEESWAX: null, PROPOLIS: 50 },
}),
).toEqual({ notify: false, minGrams: { PROPOLIS: 50 } });
expect(ruleConfigOf('RAW_MATERIAL_LOW', { minGrams: null })).toEqual({
notify: false,
});
expect(
ruleConfigOf('SPLIT_SCHEDULE', {
QUEEN: [{ step: 'ACCEPTANCE_CHECK', offsetDays: 7, windowDays: null }],
}).QUEEN,
).toEqual([{ step: 'ACCEPTANCE_CHECK', offsetDays: 7 }]);
expect(
fieldsOf('PHENOLOGY', { gtsThresholds: { SALWEIDE: null } }),
).toEqual([]);
expect(
ruleConfigOf('PHENOLOGY', { gtsThresholds: { SALWEIDE: null } })
.gtsThresholds,
).toEqual({});
});
it('lets a client remove a gts threshold by leaving it out', () => {
expect(
ruleConfigOf('PHENOLOGY', { gtsThresholds: { KIRSCHE: 400 } })
.gtsThresholds,
).toEqual({ KIRSCHE: 400 });
expect(
ruleConfigOf('PHENOLOGY', { gtsThresholds: {} }).gtsThresholds,
).toEqual({});
});
it('keeps yellowFrom at or below a legacy threshold under five', () => {
const { naturalFall } = ruleConfigOf('VARROA_THRESHOLD', {
thresholdMitesPerDay: 3,
});
expect(naturalFall.redAbove).toBe(3);
expect(naturalFall.yellowFrom).toBeLessThanOrEqual(3);
});
it('falls back per invalid key and keeps the valid ones', () =>
expect(
ruleConfigOf('SEASON_AUTOPILOT', {
queenMaxAgeYears: 'x',
honeySuperMinFrames: 10,
}),
).toMatchObject({ queenMaxAgeYears: 2, honeySuperMinFrames: 10 }));
it('logs a warning with trigger and field but without the value', () => {
const warn = jest.spyOn(Logger.prototype, 'warn');
ruleConfigOf('SEASON_AUTOPILOT', { queenMaxAgeYears: 'secret-value' });
const logged = warn.mock.calls.map((call) => String(call[0])).join('\n');
expect(logged).toContain('SEASON_AUTOPILOT');
expect(logged).toContain('queenMaxAgeYears');
expect(logged).not.toContain('secret-value');
});
it('rejects SCHWARMZEIT 10 days with the field name', () =>
expect(
validateRuleConfig('INSPECTION_INTERVAL', {
intervalDays: { SCHWARMZEIT: 10 },
}),
).toEqual([
{
field: 'intervalDays.SCHWARMZEIT',
message: expect.stringContaining('7') as string,
},
]));
it('allows 7 to 35 days outside the swarm season and 7 to 9 inside', () => {
expect(
validateRuleConfig('INSPECTION_INTERVAL', {
intervalDays: { SOMMER: 35, FRUEHJAHR: 7, SCHWARMZEIT: 9 },
}),
).toEqual([]);
expect(
fieldsOf('INSPECTION_INTERVAL', { intervalDays: { SOMMER: 36 } }),
).toEqual(['intervalDays.SOMMER']);
expect(
fieldsOf('INSPECTION_INTERVAL', { intervalDays: { HERBST: 6.5 } }),
).toEqual(['intervalDays.HERBST']);
});
it('merges a partial interval config over the defaults', () =>
expect(
ruleConfigOf('INSPECTION_INTERVAL', { intervalDays: { SOMMER: 10 } }),
).toEqual({
intervalDays: {
FRUEHJAHR: 14,
SCHWARMZEIT: 7,
SOMMER: 10,
SPAETSOMMER: 21,
HERBST: 21,
},
}));
it('rejects yellowFrom above redAbove', () =>
expect(
fieldsOf('VARROA_THRESHOLD', {
naturalFall: { yellowFrom: 12, redAbove: 8 },
}),
).toEqual(['naturalFall.yellowFrom']));
it('rejects percent bands where yellowFrom is above redAbove', () =>
expect(
fieldsOf('VARROA_THRESHOLD', {
infestationPercent: { summer: { yellowFrom: 6, redAbove: 5 } },
}),
).toEqual(['infestationPercent.summer.yellowFrom']));
it('rejects duplicate or out-of-range months', () => {
expect(
fieldsOf('VARROA_THRESHOLD', { naturalFall: { seasonMonths: [3, 3] } }),
).toEqual(['naturalFall.seasonMonths']);
expect(
fieldsOf('VARROA_THRESHOLD', {
infestationPercent: { summerMonths: [0, 13] },
}),
).toEqual(['infestationPercent.summerMonths']);
});
it('checks the varroa limits', () => {
const fields = (config: unknown) => fieldsOf('VARROA_THRESHOLD', config);
expect(fields({ youngColonyFactor: 0 })).toEqual(['youngColonyFactor']);
expect(fields({ youngColonyFactor: 1.5 })).toEqual(['youngColonyFactor']);
expect(fields({ youngColonyFactor: 1 })).toEqual([]);
expect(fields({ recheckAfterDays: 61 })).toEqual(['recheckAfterDays']);
expect(fields({ recheckAfterDays: 0 })).toEqual([]);
expect(fields({ youngColonyTypes: ['NOPE'] })).toEqual([
'youngColonyTypes',
]);
expect(fields({ naturalFall: { redAbove: -1 } })).toEqual([
'naturalFall.redAbove',
]);
expect(fields({ naturalFall: { redAbove: Infinity } })).toEqual([
'naturalFall.redAbove',
]);
expect(fields({ notify: 'yes' })).toEqual(['notify']);
});
it('reports no issue for keys it does not know', () =>
expect(
validateRuleConfig('SEASON_AUTOPILOT', { somethingElse: 'x' }),
).toEqual([]));
it('rejects a calendar fallback that runs backwards', () => {
expect(
fieldsOf('PHENOLOGY', { calendarFallback: { KIRSCHE: '03-01' } }),
).toEqual(['calendarFallback.KIRSCHE']);
// The whole map falls back so the phases stay in order.
expect(
ruleConfigOf('PHENOLOGY', { calendarFallback: { KIRSCHE: '03-01' } })
.calendarFallback,
).toEqual(DEFAULT_RULES.PHENOLOGY.config.calendarFallback);
});
it('rejects an invalid month-day', () => {
expect(
fieldsOf('PHENOLOGY', { calendarFallback: { SALWEIDE: '13-01' } }),
).toEqual(['calendarFallback.SALWEIDE']);
expect(
fieldsOf('PHENOLOGY', { calendarFallback: { SALWEIDE: '02-30' } }),
).toEqual(['calendarFallback.SALWEIDE']);
expect(
fieldsOf('PHENOLOGY', { calendarFallback: { SALWEIDE: '03-20' } }),
).toEqual([]);
});
it('checks the phenology limits', () => {
const fields = (config: unknown) => fieldsOf('PHENOLOGY', config);
expect(fields({ maxGapShare: 0.6 })).toEqual(['maxGapShare']);
expect(fields({ maxGapShare: 0.5 })).toEqual([]);
expect(fields({ gtsThresholds: { SALWEIDE: 0 } })).toEqual([
'gtsThresholds.SALWEIDE',
]);
expect(fields({ gtsThresholds: { NOPE: 5 } })).toEqual([]);
expect(fields({ calibratePhases: ['KIRSCHE', 'X'] })).toEqual([
'calibratePhases',
]);
expect(
fields({ swarmSeason: { from: 'LINDE', until: 'KIRSCHE' } }),
).toEqual(['swarmSeason.from']);
});
it('defaults gtsThresholds to SALWEIDE 200 only', () =>
expect(ruleConfigOf('PHENOLOGY', {}).gtsThresholds).toEqual({
SALWEIDE: 200,
}));
it('checks the season autopilot limits', () => {
const fields = (config: unknown) => fieldsOf('SEASON_AUTOPILOT', config);
expect(fields({ honeySuperMinFrames: 0 })).toEqual(['honeySuperMinFrames']);
expect(fields({ honeySuperMinFrames: 41 })).toEqual([
'honeySuperMinFrames',
]);
expect(fields({ varroaMeasureIntervalDays: 6 })).toEqual([
'varroaMeasureIntervalDays',
]);
expect(fields({ queenMaxAgeYears: 0 })).toEqual(['queenMaxAgeYears']);
expect(fields({ queenMaxAgeYears: 11 })).toEqual(['queenMaxAgeYears']);
expect(fields({ suggestionTtlDays: 366 })).toEqual(['suggestionTtlDays']);
expect(fields({ maxStepsPerColony: 4 })).toEqual(['maxStepsPerColony']);
expect(
fields({ winterVarroaWindow: { from: '12-20', to: '11-15' } }),
).toEqual(['winterVarroaWindow.from']);
expect(fields({ rules: { A3: 'no' } })).toEqual(['rules.A3']);
expect(fields({ rules: { A3: false } })).toEqual([]);
expect(
ruleConfigOf('SEASON_AUTOPILOT', { rules: { A3: false } }).rules,
).toEqual(expect.objectContaining({ A1: true, A3: false, A10: true }));
});
it('checks the water content limits', () => {
const fields = (config: unknown) => fieldsOf('HONEY_WATER_CONTENT', config);
expect(fields({ warnAbovePercent: 9 })).toEqual(['warnAbovePercent']);
expect(fields({ confirmAbovePercent: 31 })).toEqual([
'confirmAbovePercent',
]);
expect(fields({ warnAbovePercent: 21 })).toEqual(['warnAbovePercent']);
expect(
fields({ byHoneyType: { Heidehonig: { confirmAbovePercent: 23 } } }),
).toEqual([]);
expect(
fields({ byHoneyType: { Heidehonig: { confirmAbovePercent: 5 } } }),
).toEqual(['byHoneyType.Heidehonig.confirmAbovePercent']);
});
it('checks the split schedule steps', () => {
const fields = (config: unknown) => fieldsOf('SPLIT_SCHEDULE', config);
expect(
fields({ QUEEN: [{ step: 'ACCEPTANCE_CHECK', offsetDays: 121 }] }),
).toEqual(['QUEEN[0].offsetDays']);
expect(
fields({
QUEEN_CELL: [{ step: 'EGGS_CHECK', offsetDays: 21, windowDays: 31 }],
}),
).toEqual(['QUEEN_CELL[0].windowDays']);
expect(fields({ RAISE_OWN: [{ offsetDays: 3 }] })).toEqual([
'RAISE_OWN[0].step',
]);
expect(fields({ RAISE_OWN: 'nope' })).toEqual(['RAISE_OWN']);
expect(
fields({
RAISE_OWN: [{ step: 'MATING_CHECK', offsetDays: 21, windowDays: 3 }],
}),
).toEqual([]);
});
it('checks dueInDays of chains and simple rules', () => {
expect(fieldsOf('QUEEN_CELLS_SWARM_CHECK', { dueInDays: 366 })).toEqual([
'dueInDays',
]);
expect(fieldsOf('TREATMENT_FOLLOW_UP', { dueInDays: -1 })).toEqual([
'dueInDays',
]);
expect(ruleConfigOf('TREATMENT_FOLLOW_UP', { dueInDays: 14 })).toEqual({
notify: false,
dueInDays: 14,
});
});
it('reads minGrams per raw material kind', () => {
expect(
ruleConfigOf('RAW_MATERIAL_LOW', { minGrams: { BEESWAX: 500 } }),
).toEqual({ notify: false, minGrams: { BEESWAX: 500 } });
expect(
fieldsOf('RAW_MATERIAL_LOW', {
minGrams: { BEESWAX: -5, PROPOLIS: 'x' },
}),
).toEqual(['minGrams.BEESWAX', 'minGrams.PROPOLIS']);
});
it('keeps a number format and rejects an empty or oversized one', () => {
expect(
ruleConfigOf('HONEY_BATCH_NUMBER_FORMAT', { format: 'HB-{SEQ:3}' }),
).toEqual({ format: 'HB-{SEQ:3}' });
expect(fieldsOf('HONEY_BATCH_NUMBER_FORMAT', { format: '' })).toEqual([
'format',
]);
expect(
fieldsOf('HONEY_BATCH_NUMBER_FORMAT', { format: 'x'.repeat(101) }),
).toEqual(['format']);
});
it('defaults notify to true only for QUEENLESS_NO_EGGS, VARROA_THRESHOLD and COLONY_HEALTH_CRITICAL (and WITHDRAWAL_ENDED)', () => {
const withNotify = ALL_TRIGGERS.filter(
(trigger) =>
(ruleConfigOf(trigger, {}) as { notify?: boolean }).notify === true,
);
expect([...withNotify].sort()).toEqual(
[
'COLONY_HEALTH_CRITICAL',
'QUEENLESS_NO_EGGS',
'VARROA_THRESHOLD',
'WITHDRAWAL_ENDED',
].sort(),
);
});
it('starts the optional rules switched off', () => {
const off = ALL_TRIGGERS.filter(
(trigger) => DEFAULT_RULES[trigger].active === false,
).sort();
expect(off).toEqual([
'FEEDING_FOLLOW_UP',
'RAW_MATERIAL_LOW',
'TREATMENT_FOLLOW_UP',
]);
});
it('gives every new rule an empty action template', () => {
for (const trigger of [
'INSPECTION_INTERVAL',
'QUEEN_CELLS_SWARM_CHECK',
'QUEEN_ACCEPTANCE_CHECK',
'SPLIT_SCHEDULE',
'COLONY_ENDED_CLEANUP',
'WITHDRAWAL_ENDED',
'HONEY_WATER_CONTENT',
'SEASON_AUTOPILOT',
'PHENOLOGY',
] as const) {
expect(DEFAULT_RULES[trigger].actionTemplate).toBe('');
}
});
});
@@ -0,0 +1,140 @@
import type { ColonyType, RawMaterialKind } from '../../../generated/prisma';
import type {
PhenologyPhaseKey,
SeasonSegmentKey,
} from '../../season/season-types';
/** One invalid setting of a rule: the field path and a German message. */
export interface RuleConfigIssue {
field: string;
message: string;
}
export interface RuleConfigParser<T> {
/** The built-in setting; also what an empty or unusable config reads as. */
readonly defaults: T;
/** Never throws: each invalid key falls back to its default (and is logged). */
parse(json: unknown): T;
/** Reports the known keys that are present and invalid; nothing else. */
validate(json: unknown): RuleConfigIssue[];
}
export interface SimpleRuleConfig {
notify: boolean;
dueInDays?: number;
minGrams?: Partial<Record<RawMaterialKind, number>>;
}
export interface NumberFormatConfig {
format: string;
}
export interface VarroaThresholdConfig {
naturalFall: {
seasonMonths: number[];
yellowFrom: number;
redAbove: number;
winterRedAbove: number;
};
infestationPercent: {
summerMonths: number[];
summer: { yellowFrom: number; redAbove: number };
autumnMonths: number[];
autumn: { yellowFrom: number; redAbove: number };
};
youngColonyFactor: number;
youngColonyTypes: ColonyType[];
recheckAfterDays: number;
notify: boolean;
}
export interface InspectionIntervalConfig {
intervalDays: Record<Exclude<SeasonSegmentKey, 'WINTERRUHE'>, number>;
}
export interface ChainConfig {
dueInDays: number;
notify: boolean;
}
export interface PlanStepConfig {
step: string;
offsetDays: number;
windowDays?: number;
}
export interface SplitScheduleConfig {
RAISE_OWN: PlanStepConfig[];
QUEEN_CELL: PlanStepConfig[];
QUEEN: PlanStepConfig[];
notify: boolean;
}
export interface WaterContentConfig {
warnAbovePercent: number;
confirmAbovePercent: number;
byHoneyType: Record<
string,
{ warnAbovePercent?: number; confirmAbovePercent?: number }
>;
}
export const AUTOPILOT_RULE_IDS = [
'A1',
'A2',
'A3',
'A4',
'A5',
'A6',
'A7',
'A8',
'A9',
'A10',
] as const;
export type AutopilotRuleId = (typeof AUTOPILOT_RULE_IDS)[number];
export interface SeasonAutopilotConfig {
honeySuperMinFrames: number;
varroaMeasureIntervalDays: number;
winterVarroaWindow: { from: string; to: string };
queenMaxAgeYears: number;
suggestionTtlDays: number;
maxStepsPerColony: number;
rules: Record<AutopilotRuleId, boolean>;
}
export interface PhenologyConfig {
useGts: boolean;
gtsThresholds: Partial<Record<PhenologyPhaseKey, number>>;
calibrateFromReports: boolean;
calibratePhases: PhenologyPhaseKey[];
calendarFallback: Record<PhenologyPhaseKey, string>;
swarmSeason: { from: PhenologyPhaseKey; until: PhenologyPhaseKey };
maxGapShare: number;
}
/** One entry per AutomationTriggerType. */
export interface RuleConfigMap {
VARROA_THRESHOLD: VarroaThresholdConfig;
INSPECTION_INTERVAL: InspectionIntervalConfig;
QUEEN_CELLS_SWARM_CHECK: ChainConfig;
QUEEN_ACCEPTANCE_CHECK: ChainConfig;
SPLIT_SCHEDULE: SplitScheduleConfig;
COLONY_ENDED_CLEANUP: Record<string, never>;
WITHDRAWAL_ENDED: { notify: boolean };
HONEY_WATER_CONTENT: WaterContentConfig;
SEASON_AUTOPILOT: SeasonAutopilotConfig;
PHENOLOGY: PhenologyConfig;
HONEY_BATCH_NUMBER_FORMAT: NumberFormatConfig;
BOTTLING_LOT_NUMBER_FORMAT: NumberFormatConfig;
RAW_MATERIAL_LOT_NUMBER_FORMAT: NumberFormatConfig;
PRODUCTION_BATCH_NUMBER_FORMAT: NumberFormatConfig;
QUEENLESS_NO_EGGS: SimpleRuleConfig;
FEED_STATUS_CRITICAL: SimpleRuleConfig;
SWARM_MOOD_HIGH: SimpleRuleConfig;
COLONY_HEALTH_CRITICAL: SimpleRuleConfig;
STOCK_ITEM_LOW: SimpleRuleConfig;
RAW_MATERIAL_LOW: SimpleRuleConfig;
TREATMENT_FOLLOW_UP: SimpleRuleConfig;
FEEDING_FOLLOW_UP: SimpleRuleConfig;
}
@@ -0,0 +1,102 @@
import {
bool,
childScope,
has,
intIn,
makeParser,
monthDay,
report,
} from './parse-helpers';
import {
AUTOPILOT_RULE_IDS,
type SeasonAutopilotConfig,
} from './rule-config.types';
export const DEFAULT_SEASON_AUTOPILOT_CONFIG: SeasonAutopilotConfig = {
honeySuperMinFrames: 8,
varroaMeasureIntervalDays: 28,
winterVarroaWindow: { from: '11-15', to: '12-20' },
queenMaxAgeYears: 2,
suggestionTtlDays: 30,
maxStepsPerColony: 3,
rules: {
A1: true,
A2: true,
A3: true,
A4: true,
A5: true,
A6: true,
A7: true,
A8: true,
A9: true,
A10: true,
},
};
export const seasonAutopilotParser = makeParser<SeasonAutopilotConfig>(
'SEASON_AUTOPILOT',
DEFAULT_SEASON_AUTOPILOT_CONFIG,
(scope) => {
const d = DEFAULT_SEASON_AUTOPILOT_CONFIG;
const windowScope = childScope(scope, 'winterVarroaWindow');
let winterVarroaWindow = {
from: monthDay(windowScope, 'from', d.winterVarroaWindow.from),
to: monthDay(windowScope, 'to', d.winterVarroaWindow.to),
};
if (winterVarroaWindow.from > winterVarroaWindow.to) {
report(
windowScope,
has(windowScope, 'from') ? 'from' : 'to',
'Das Fenster muss innerhalb eines Jahres liegen: Beginn vor Ende.',
);
winterVarroaWindow = { ...d.winterVarroaWindow };
}
const ruleScope = childScope(scope, 'rules');
const rules = { ...d.rules };
for (const id of AUTOPILOT_RULE_IDS) {
rules[id] = bool(ruleScope, id, d.rules[id]);
}
return {
honeySuperMinFrames: intIn(
scope,
'honeySuperMinFrames',
d.honeySuperMinFrames,
1,
40,
),
varroaMeasureIntervalDays: intIn(
scope,
'varroaMeasureIntervalDays',
d.varroaMeasureIntervalDays,
7,
90,
),
winterVarroaWindow,
queenMaxAgeYears: intIn(
scope,
'queenMaxAgeYears',
d.queenMaxAgeYears,
1,
10,
),
suggestionTtlDays: intIn(
scope,
'suggestionTtlDays',
d.suggestionTtlDays,
1,
365,
),
maxStepsPerColony: intIn(
scope,
'maxStepsPerColony',
d.maxStepsPerColony,
1,
3,
),
rules,
};
},
);
@@ -0,0 +1,93 @@
import { RawMaterialKind } from '../../../generated/prisma';
import {
bool,
childScope,
has,
isCleared,
makeParser,
numberIn,
optionalIntIn,
stringIn,
} from './parse-helpers';
import type {
NumberFormatConfig,
RuleConfigParser,
SimpleRuleConfig,
} from './rule-config.types';
const MAX_DUE_IN_DAYS = 365;
/** Far above any minimum stock of wax or propolis. */
const MAX_MIN_GRAMS = 1_000_000_000;
const MAX_NUMBER_FORMAT_LENGTH = 100;
const RAW_MATERIAL_KINDS = Object.values(RawMaterialKind);
export interface SimpleRuleOptions {
/** Whether the rule notifies by default. */
notify: boolean;
/** RAW_MATERIAL_LOW reads a minimum per kind. */
withMinGrams?: boolean;
}
/**
* Rules that propose one suggestion: notify, an optional follow-up interval
* (dueInDays) and, for the raw material rule, minGrams per kind.
*/
export function simpleParser(
trigger: string,
options: SimpleRuleOptions,
): RuleConfigParser<SimpleRuleConfig> {
return makeParser<SimpleRuleConfig>(
trigger,
{ notify: options.notify },
(scope) => {
const config: SimpleRuleConfig = {
notify: bool(scope, 'notify', options.notify),
};
const dueInDays = optionalIntIn(scope, 'dueInDays', 0, MAX_DUE_IN_DAYS);
if (dueInDays !== undefined) {
config.dueInDays = dueInDays;
}
if (
options.withMinGrams &&
has(scope, 'minGrams') &&
!isCleared(scope, 'minGrams')
) {
const minScope = childScope(scope, 'minGrams');
const minGrams: Partial<Record<RawMaterialKind, number>> = {};
for (const kind of RAW_MATERIAL_KINDS) {
if (isCleared(minScope, kind)) {
continue;
}
const grams = numberIn(minScope, kind, Number.NaN, 0, MAX_MIN_GRAMS);
if (!Number.isNaN(grams)) {
minGrams[kind] = grams;
}
}
if (Object.keys(minGrams).length > 0) {
config.minGrams = minGrams;
}
}
return config;
},
);
}
/** The batch/lot number rules: one format string with placeholders. */
export function numberFormatParser(
trigger: string,
defaultFormat: string,
): RuleConfigParser<NumberFormatConfig> {
return makeParser<NumberFormatConfig>(
trigger,
{ format: defaultFormat },
(scope) => ({
format: stringIn(
scope,
'format',
defaultFormat,
1,
MAX_NUMBER_FORMAT_LENGTH,
),
}),
);
}
@@ -0,0 +1,106 @@
import {
asRecord,
bool,
fieldPath,
has,
intIn,
makeParser,
optionalIntIn,
report,
stringIn,
type Scope,
} from './parse-helpers';
import type { PlanStepConfig, SplitScheduleConfig } from './rule-config.types';
type SplitVariant = 'RAISE_OWN' | 'QUEEN_CELL' | 'QUEEN';
const MAX_STEPS_PER_VARIANT = 10;
const MAX_OFFSET_DAYS = 120;
const MAX_WINDOW_DAYS = 30;
const STEP_NAME = /^[A-Z][A-Z_]{1,39}$/;
export const DEFAULT_SPLIT_SCHEDULE_CONFIG: SplitScheduleConfig = {
RAISE_OWN: [
{ step: 'RAISE_CELLS_CHECK', offsetDays: 9 },
{ step: 'MATING_CHECK', offsetDays: 21, windowDays: 3 },
{ step: 'EGGS_CHECK', offsetDays: 28 },
],
QUEEN_CELL: [{ step: 'EGGS_CHECK', offsetDays: 21 }],
QUEEN: [{ step: 'ACCEPTANCE_CHECK', offsetDays: 7 }],
notify: false,
};
function clonedSteps(variant: SplitVariant): PlanStepConfig[] {
return DEFAULT_SPLIT_SCHEDULE_CONFIG[variant].map((step) => ({ ...step }));
}
/** Scope of one list entry: its path is `VARIANT[index]`. */
function stepScope(
scope: Scope,
variant: SplitVariant,
index: number,
raw: unknown,
): Scope | null {
const record = asRecord(raw);
const path = `${fieldPath(scope, variant)}[${index}]`;
if (record === null) {
scope.issues.push({
field: path,
message: 'Bitte jeden Schritt als Gruppe senden.',
});
return null;
}
return { raw: record, path, issues: scope.issues };
}
function readSteps(scope: Scope, variant: SplitVariant): PlanStepConfig[] {
if (!has(scope, variant)) {
return clonedSteps(variant);
}
const list = scope.raw[variant];
if (!Array.isArray(list) || list.length > MAX_STEPS_PER_VARIANT) {
report(
scope,
variant,
`Bitte eine Liste mit höchstens ${MAX_STEPS_PER_VARIANT} Schritten senden.`,
);
return clonedSteps(variant);
}
const before = scope.issues.length;
const steps: PlanStepConfig[] = [];
list.forEach((entry: unknown, index) => {
const item = stepScope(scope, variant, index, entry);
if (item === null) {
return;
}
if (!has(item, 'step')) {
scope.issues.push({
field: `${item.path}.step`,
message: 'Bitte den Schritt angeben.',
});
return;
}
const step: PlanStepConfig = {
step: stringIn(item, 'step', '', 2, 40, STEP_NAME),
offsetDays: intIn(item, 'offsetDays', 0, 0, MAX_OFFSET_DAYS),
};
const windowDays = optionalIntIn(item, 'windowDays', 0, MAX_WINDOW_DAYS);
if (windowDays !== undefined) {
step.windowDays = windowDays;
}
steps.push(step);
});
// One bad entry makes the whole list unreliable: back to the default plan.
return scope.issues.length > before ? clonedSteps(variant) : steps;
}
export const splitScheduleParser = makeParser<SplitScheduleConfig>(
'SPLIT_SCHEDULE',
DEFAULT_SPLIT_SCHEDULE_CONFIG,
(scope) => ({
RAISE_OWN: readSteps(scope, 'RAISE_OWN'),
QUEEN_CELL: readSteps(scope, 'QUEEN_CELL'),
QUEEN: readSteps(scope, 'QUEEN'),
notify: bool(scope, 'notify', DEFAULT_SPLIT_SCHEDULE_CONFIG.notify),
}),
);
@@ -0,0 +1,188 @@
import { ColonyType } from '../../../generated/prisma';
import {
bool,
childScope,
enumList,
has,
intIn,
makeParser,
months,
numberIn,
report,
type Scope,
} from './parse-helpers';
import type { VarroaThresholdConfig } from './rule-config.types';
const MAX_RECHECK_DAYS = 60;
export const DEFAULT_VARROA_THRESHOLD_CONFIG: VarroaThresholdConfig = {
naturalFall: {
seasonMonths: [3, 4, 5, 6, 7, 8, 9, 10],
yellowFrom: 5,
redAbove: 10,
winterRedAbove: 0.5,
},
infestationPercent: {
summerMonths: [5, 6, 7],
summer: { yellowFrom: 1, redAbove: 5 },
autumnMonths: [8, 9, 10],
autumn: { yellowFrom: 2, redAbove: 5 },
},
youngColonyFactor: 0.5,
youngColonyTypes: ['YOUNG_COLONY', 'NUCLEUS_COLONY', 'MATING_NUCLEUS'],
recheckAfterDays: 14,
notify: true,
};
/** Upper limits: mites per day cannot get near 1000; a percentage stops at 100. */
const MAX_MITES_PER_DAY = 1000;
const MAX_PERCENT = 100;
/**
* The key before the bands existed: natural mite fall that counts as red.
* A present but unusable value is reported.
*/
function legacyThreshold(scope: Scope): number | undefined {
if (!has(scope, 'thresholdMitesPerDay')) {
return undefined;
}
const value = scope.raw.thresholdMitesPerDay;
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return value;
}
report(
scope,
'thresholdMitesPerDay',
'Bitte eine Zahl größer als 0 angeben.',
);
return undefined;
}
interface Band {
yellowFrom: number;
redAbove: number;
}
interface BandLimits {
max: number;
/** The red limit itself must be above zero. */
redAboveZero?: boolean;
}
/**
* yellowFrom and redAbove of one band. An absent yellowFrom never ends above
* redAbove; a given one that does is reported and replaced.
*/
function readBand(
scope: Scope,
defaults: Band,
limits: BandLimits,
legacyRed?: number,
): Band {
const redAbove = numberIn(
scope,
'redAbove',
legacyRed ?? defaults.redAbove,
0,
limits.max,
{ exclusiveMin: limits.redAboveZero },
);
const yellowDefault = Math.min(defaults.yellowFrom, redAbove);
const yellowFrom = numberIn(
scope,
'yellowFrom',
yellowDefault,
0,
limits.max,
);
if (yellowFrom > redAbove) {
report(
scope,
'yellowFrom',
'Der gelbe Wert darf nicht über dem roten Wert liegen.',
);
return { yellowFrom: yellowDefault, redAbove };
}
return { yellowFrom, redAbove };
}
const COLONY_TYPES = Object.values(ColonyType);
export const varroaThresholdParser = makeParser<VarroaThresholdConfig>(
'VARROA_THRESHOLD',
DEFAULT_VARROA_THRESHOLD_CONFIG,
(scope) => {
const d = DEFAULT_VARROA_THRESHOLD_CONFIG;
const legacy = legacyThreshold(scope);
const fall = childScope(scope, 'naturalFall');
const fallBand = readBand(
fall,
d.naturalFall,
{ max: MAX_MITES_PER_DAY, redAboveZero: true },
legacy,
);
const naturalFall = {
seasonMonths: months(fall, 'seasonMonths', d.naturalFall.seasonMonths),
...fallBand,
winterRedAbove: numberIn(
fall,
'winterRedAbove',
d.naturalFall.winterRedAbove,
0,
MAX_MITES_PER_DAY,
),
};
const percent = childScope(scope, 'infestationPercent');
const infestationPercent = {
summerMonths: months(
percent,
'summerMonths',
d.infestationPercent.summerMonths,
),
summer: readBand(
childScope(percent, 'summer'),
d.infestationPercent.summer,
{ max: MAX_PERCENT },
),
autumnMonths: months(
percent,
'autumnMonths',
d.infestationPercent.autumnMonths,
),
autumn: readBand(
childScope(percent, 'autumn'),
d.infestationPercent.autumn,
{ max: MAX_PERCENT },
),
};
return {
naturalFall,
infestationPercent,
youngColonyFactor: numberIn(
scope,
'youngColonyFactor',
d.youngColonyFactor,
0,
1,
{ exclusiveMin: true },
),
youngColonyTypes: enumList(
scope,
'youngColonyTypes',
d.youngColonyTypes,
COLONY_TYPES,
),
recheckAfterDays: intIn(
scope,
'recheckAfterDays',
d.recheckAfterDays,
0,
MAX_RECHECK_DAYS,
),
notify: bool(scope, 'notify', d.notify),
};
},
);
@@ -0,0 +1,112 @@
import {
childScope,
has,
makeParser,
numberIn,
optionalNumberIn,
report,
type Scope,
} from './parse-helpers';
import type { WaterContentConfig } from './rule-config.types';
const MIN_WATER_PERCENT = 10;
const MAX_WATER_PERCENT = 30;
const MAX_HONEY_TYPES = 50;
const MAX_HONEY_TYPE_LENGTH = 100;
export const DEFAULT_WATER_CONTENT_CONFIG: WaterContentConfig = {
warnAbovePercent: 18,
confirmAbovePercent: 20,
byHoneyType: {},
};
function percent(scope: Scope, key: string, fallback: number): number {
return numberIn(scope, key, fallback, MIN_WATER_PERCENT, MAX_WATER_PERCENT);
}
const ORDER_MESSAGE =
'Die Warnschwelle darf nicht über der Bestätigungsschwelle liegen.';
function readHoneyTypes(scope: Scope): WaterContentConfig['byHoneyType'] {
const types = childScope(scope, 'byHoneyType');
const names = Object.keys(types.raw);
if (names.length > MAX_HONEY_TYPES) {
report(scope, 'byHoneyType', `Höchstens ${MAX_HONEY_TYPES} Sorten.`);
return {};
}
const entries: [
string,
{ warnAbovePercent?: number; confirmAbovePercent?: number },
][] = [];
for (const name of names) {
if (name.length === 0 || name.length > MAX_HONEY_TYPE_LENGTH) {
report(types, name.slice(0, 30), 'Der Sortenname ist ungültig.');
continue;
}
const entry = childScope(types, name);
const before = entry.issues.length;
const value: { warnAbovePercent?: number; confirmAbovePercent?: number } =
{};
const warn = optionalNumberIn(
entry,
'warnAbovePercent',
MIN_WATER_PERCENT,
MAX_WATER_PERCENT,
);
const confirm = optionalNumberIn(
entry,
'confirmAbovePercent',
MIN_WATER_PERCENT,
MAX_WATER_PERCENT,
);
if (warn !== undefined && confirm !== undefined && warn > confirm) {
report(entry, 'warnAbovePercent', ORDER_MESSAGE);
}
if (entry.issues.length > before) {
continue;
}
if (warn !== undefined) {
value.warnAbovePercent = warn;
}
if (confirm !== undefined) {
value.confirmAbovePercent = confirm;
}
entries.push([name, value]);
}
// fromEntries defines own properties, so a name like "__proto__" is harmless.
return Object.fromEntries(entries);
}
export const waterContentParser = makeParser<WaterContentConfig>(
'HONEY_WATER_CONTENT',
DEFAULT_WATER_CONTENT_CONFIG,
(scope) => {
const d = DEFAULT_WATER_CONTENT_CONFIG;
let warnAbovePercent = percent(
scope,
'warnAbovePercent',
d.warnAbovePercent,
);
let confirmAbovePercent = percent(
scope,
'confirmAbovePercent',
d.confirmAbovePercent,
);
if (warnAbovePercent > confirmAbovePercent) {
report(
scope,
has(scope, 'warnAbovePercent')
? 'warnAbovePercent'
: 'confirmAbovePercent',
ORDER_MESSAGE,
);
warnAbovePercent = d.warnAbovePercent;
confirmAbovePercent = d.confirmAbovePercent;
}
return {
warnAbovePercent,
confirmAbovePercent,
byHoneyType: readHoneyTypes(scope),
};
},
);
@@ -0,0 +1,271 @@
import type { ColonyType, VarroaMethod } from '../../generated/prisma';
import { bandsFor, classify, rateMeasurement } from './varroa-bands';
import { DEFAULT_VARROA_THRESHOLD_CONFIG } from './rule-config/varroa-threshold.config';
const DEFAULTS = DEFAULT_VARROA_THRESHOLD_CONFIG;
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
describe('varroa bands', () => {
it.each<
[
string,
ColonyType,
VarroaMethod,
number,
'green' | 'yellow' | 'red' | null,
]
>([
['2026-08-10', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 10.0, 'yellow'],
['2026-08-10', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 10.1, 'red'],
['2026-08-10', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 4.9, 'green'],
['2026-08-10', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 5, 'yellow'],
['2026-11-20', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 0.6, 'red'],
['2026-11-20', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 0.5, 'green'],
['2026-06-10', 'YOUNG_COLONY', 'NATURAL_MITE_FALL', 5.1, 'red'],
['2026-06-10', 'YOUNG_COLONY', 'NATURAL_MITE_FALL', 2.5, 'yellow'],
['2026-11-20', 'NUCLEUS_COLONY', 'NATURAL_MITE_FALL', 0.3, 'red'],
['2026-11-20', 'NUCLEUS_COLONY', 'NATURAL_MITE_FALL', 0.25, 'green'],
['2026-07-10', 'PRODUCTION_COLONY', 'POWDERED_SUGAR', 5.1, 'red'],
['2026-07-10', 'PRODUCTION_COLONY', 'POWDERED_SUGAR', 1, 'yellow'],
['2026-07-10', 'YOUNG_COLONY', 'POWDERED_SUGAR', 4, 'yellow'],
['2026-09-10', 'PRODUCTION_COLONY', 'WASHING_METHOD', 1.5, 'green'],
['2026-09-10', 'PRODUCTION_COLONY', 'WASHING_METHOD', 2, 'yellow'],
['2026-04-10', 'PRODUCTION_COLONY', 'POWDERED_SUGAR', 9, null],
['2026-11-10', 'PRODUCTION_COLONY', 'WASHING_METHOD', 9, null],
['2026-07-10', 'PRODUCTION_COLONY', 'DRONE_BROOD_CONTROL', 50, null],
])('%s %s %s %p -> %p', (day, type, method, value, light) =>
expect(
classify(value, bandsFor(day, type, DEFAULTS, []).bands[method]),
).toBe(light),
);
it('classifies a missing value or band as no light', () => {
const band = bandsFor('2026-08-10', null, DEFAULTS, []).bands
.NATURAL_MITE_FALL;
expect(classify(null, band)).toBeNull();
expect(classify(3, undefined)).toBeNull();
expect(classify(3, null)).toBeNull();
});
it('describes the band with its reason, unit and colony kind', () => {
const autumn = bandsFor('2026-08-10', 'YOUNG_COLONY', DEFAULTS, []);
expect(autumn.day).toBe('2026-08-10');
expect(autumn.bands.NATURAL_MITE_FALL).toEqual({
method: 'NATURAL_MITE_FALL',
unit: 'MITES_PER_DAY',
yellowFrom: 2.5,
redAbove: 5,
reason: 'NATURAL_FALL_SEASON',
youngColony: true,
});
expect(autumn.bands.POWDERED_SUGAR).toMatchObject({
unit: 'PERCENT',
yellowFrom: 2,
redAbove: 5,
reason: 'INFESTATION_AUTUMN',
});
const winter = bandsFor('2026-01-10', 'PRODUCTION_COLONY', DEFAULTS, []);
expect(winter.bands.NATURAL_MITE_FALL).toMatchObject({
yellowFrom: null,
redAbove: 0.5,
reason: 'NATURAL_FALL_WINTER',
youngColony: false,
});
expect(winter.bands.POWDERED_SUGAR).toBeUndefined();
});
it('treats a colony without a type as a full colony', () =>
expect(
bandsFor('2026-08-10', null, DEFAULTS, []).bands.NATURAL_MITE_FALL,
).toMatchObject({ redAbove: 10, youngColony: false }));
it('names the recheck day while a treatment effect is in force', () => {
const treatments = [{ startDay: '2026-07-20', endDay: '2026-08-02' }];
const until = (day: string) =>
bandsFor(day, null, DEFAULTS, treatments).postTreatmentUntil;
expect(until('2026-07-25')).toBe('2026-08-16');
expect(until('2026-08-15')).toBe('2026-08-16');
expect(until('2026-08-16')).toBeNull();
expect(until('2026-07-19')).toBeNull();
});
it('uses the latest recheck day of overlapping treatments', () =>
expect(
bandsFor('2026-08-01', null, DEFAULTS, [
{ startDay: '2026-07-01', endDay: '2026-07-20' },
{ startDay: '2026-07-25', endDay: '2026-08-10' },
]).postTreatmentUntil,
).toBe('2026-08-24'));
describe('month edges', () => {
const lightOn = (
day: string,
method: VarroaMethod,
value: number,
): string | null =>
classify(
value,
bandsFor(day, 'PRODUCTION_COLONY', DEFAULTS, []).bands[method],
);
it.each<[string, VarroaMethod, number, string | null]>([
['2026-02-28', 'NATURAL_MITE_FALL', 3, 'red'],
['2026-03-01', 'NATURAL_MITE_FALL', 3, 'green'],
['2026-10-31', 'NATURAL_MITE_FALL', 3, 'green'],
['2026-11-01', 'NATURAL_MITE_FALL', 3, 'red'],
['2026-04-30', 'POWDERED_SUGAR', 3, null],
['2026-05-01', 'POWDERED_SUGAR', 3, 'yellow'],
['2026-07-31', 'POWDERED_SUGAR', 1.5, 'yellow'],
['2026-08-01', 'POWDERED_SUGAR', 1.5, 'green'],
['2026-10-31', 'POWDERED_SUGAR', 3, 'yellow'],
['2026-11-01', 'POWDERED_SUGAR', 3, null],
['2026-04-30', 'WASHING_METHOD', 3, null],
['2026-05-01', 'WASHING_METHOD', 3, 'yellow'],
['2026-07-31', 'WASHING_METHOD', 1.5, 'yellow'],
['2026-08-01', 'WASHING_METHOD', 1.5, 'green'],
['2026-10-31', 'WASHING_METHOD', 3, 'yellow'],
['2026-11-01', 'WASHING_METHOD', 3, null],
])('%s %s %p -> %p', (day, method, value, light) =>
expect(lightOn(day, method, value)).toBe(light),
);
});
it('keeps the young factor off the percent bands', () => {
const { bands } = bandsFor('2026-07-10', 'YOUNG_COLONY', DEFAULTS, []);
expect(bands.POWDERED_SUGAR).toMatchObject({
youngColony: false,
yellowFrom: 1,
redAbove: 5,
});
expect(bands.NATURAL_MITE_FALL?.youngColony).toBe(true);
});
describe('treatment effect edges', () => {
const treatments = [{ startDay: '2026-07-20', endDay: '2026-08-02' }];
const ratingOn = (day: string, method: VarroaMethod) =>
rateMeasurement(
{ date: d(day), method, calculatedValue: 12 },
null,
DEFAULTS,
treatments,
);
it('starts on the start day and ends the day before the recheck day', () => {
expect(ratingOn('2026-07-19', 'NATURAL_MITE_FALL').light).toBe('red');
expect(ratingOn('2026-07-20', 'NATURAL_MITE_FALL').light).toBe(
'POST_TREATMENT',
);
expect(ratingOn('2026-08-15', 'NATURAL_MITE_FALL').light).toBe(
'POST_TREATMENT',
);
expect(ratingOn('2026-08-16', 'NATURAL_MITE_FALL').light).toBe('red');
});
it('covers a single-day treatment', () => {
const single = [{ startDay: '2026-08-01', endDay: '2026-08-01' }];
const until = (day: string) =>
bandsFor(day, null, DEFAULTS, single).postTreatmentUntil;
expect(until('2026-08-01')).toBe('2026-08-15');
expect(until('2026-08-14')).toBe('2026-08-15');
expect(until('2026-08-15')).toBeNull();
});
it('applies to the percent methods and never to the drone brood check', () => {
expect(ratingOn('2026-07-25', 'POWDERED_SUGAR').light).toBe(
'POST_TREATMENT',
);
expect(ratingOn('2026-08-05', 'WASHING_METHOD').light).toBe(
'POST_TREATMENT',
);
expect(ratingOn('2026-07-25', 'DRONE_BROOD_CONTROL').light).toBeNull();
});
});
it('rates by the Berlin calendar day of the measurement', () => {
// 2026-10-31T23:30Z is 2026-11-01 00:30 in Berlin: winter band.
const rating = rateMeasurement(
{
date: new Date('2026-11-01T00:30:00+01:00'),
method: 'NATURAL_MITE_FALL',
calculatedValue: 3,
},
null,
DEFAULTS,
[],
);
expect(rating.reasonCode).toBe('NATURAL_FALL_WINTER');
expect(rating.light).toBe('red');
});
describe('rateMeasurement', () => {
it('a measurement 5 days after treatment end has no light and names the recheck day', () =>
expect(
rateMeasurement(
{
date: d('2026-08-07'),
method: 'NATURAL_MITE_FALL',
calculatedValue: 12,
},
'PRODUCTION_COLONY',
DEFAULTS,
[{ startDay: '2026-07-20', endDay: '2026-08-02' }],
),
).toEqual({
light: 'POST_TREATMENT',
reasonCode: 'POST_TREATMENT',
band: null,
postTreatmentUntil: '2026-08-16',
}));
it('rates outside a treatment effect with the band and reason', () => {
const rating = rateMeasurement(
{
date: d('2026-08-20'),
method: 'NATURAL_MITE_FALL',
calculatedValue: 12,
},
'PRODUCTION_COLONY',
DEFAULTS,
[{ startDay: '2026-07-20', endDay: '2026-08-02' }],
);
expect(rating).toMatchObject({
light: 'red',
reasonCode: 'NATURAL_FALL_SEASON',
postTreatmentUntil: null,
});
expect(rating.band).toMatchObject({ redAbove: 10 });
});
it('has no light for a method without a band or a missing value', () => {
expect(
rateMeasurement(
{
date: d('2026-04-10'),
method: 'POWDERED_SUGAR',
calculatedValue: 9,
},
null,
DEFAULTS,
[],
),
).toEqual({
light: null,
reasonCode: null,
band: null,
postTreatmentUntil: null,
});
expect(
rateMeasurement(
{
date: d('2026-08-10'),
method: 'NATURAL_MITE_FALL',
calculatedValue: null,
},
null,
DEFAULTS,
[],
).light,
).toBeNull();
});
});
});
@@ -0,0 +1,198 @@
import type {
ColonyType,
VarroaMeasurement,
VarroaMethod,
} from '../../generated/prisma';
import { addDays, berlinDayOf } from '../common/dates';
import type { TreatmentWindowLite } from '../treatments/withdrawal-window';
import type { VarroaThresholdConfig } from './rule-config/rule-config.types';
export type VarroaLightKey = 'green' | 'yellow' | 'red';
export type VarroaBandReason =
| 'NATURAL_FALL_SEASON'
| 'NATURAL_FALL_WINTER'
| 'INFESTATION_SUMMER'
| 'INFESTATION_AUTUMN';
export interface VarroaBand {
method: VarroaMethod;
unit: 'MITES_PER_DAY' | 'PERCENT';
/** null: no yellow stage (winter). */
yellowFrom: number | null;
redAbove: number;
reason: VarroaBandReason;
youngColony: boolean;
}
export interface VarroaBands {
day: string;
bands: Partial<Record<VarroaMethod, VarroaBand>>;
/** Recheck day while a treatment effect is in force, otherwise null. */
postTreatmentUntil: string | null;
}
export interface VarroaRating {
light: VarroaLightKey | 'POST_TREATMENT' | null;
reasonCode: VarroaBandReason | 'POST_TREATMENT' | null;
band: VarroaBand | null;
postTreatmentUntil: string | null;
}
const PERCENT_METHODS: readonly VarroaMethod[] = [
'POWDERED_SUGAR',
'WASHING_METHOD',
];
function naturalFallBand(
month: number,
factor: number,
young: boolean,
config: VarroaThresholdConfig['naturalFall'],
): VarroaBand {
const inSeason = config.seasonMonths.includes(month);
return {
method: 'NATURAL_MITE_FALL',
unit: 'MITES_PER_DAY',
yellowFrom: inSeason ? config.yellowFrom * factor : null,
redAbove: (inSeason ? config.redAbove : config.winterRedAbove) * factor,
reason: inSeason ? 'NATURAL_FALL_SEASON' : 'NATURAL_FALL_WINTER',
youngColony: young,
};
}
/** The percentage band of the month, absent outside summer and autumn. */
function percentBand(
method: VarroaMethod,
month: number,
config: VarroaThresholdConfig['infestationPercent'],
): VarroaBand | undefined {
if (config.summerMonths.includes(month)) {
return {
method,
unit: 'PERCENT',
...config.summer,
reason: 'INFESTATION_SUMMER',
youngColony: false,
};
}
if (config.autumnMonths.includes(month)) {
return {
method,
unit: 'PERCENT',
...config.autumn,
reason: 'INFESTATION_AUTUMN',
youngColony: false,
};
}
return undefined;
}
/** Latest recheck day of the treatments whose effect covers `day`. */
function postTreatmentUntil(
day: string,
recheckAfterDays: number,
treatments: readonly TreatmentWindowLite[],
): string | null {
let until: string | null = null;
for (const { startDay, endDay } of treatments) {
const recheckDay = addDays(endDay, recheckAfterDays);
const covers = startDay <= day && day < recheckDay;
if (covers && (until === null || recheckDay > until)) {
until = recheckDay;
}
}
return until;
}
/**
* The bands in force on `day` for a colony of `colonyType`: natural mite fall
* by season (young colonies at the reduced factor), the percentage methods in
* summer and autumn only, no band for the drone brood check.
*/
export function bandsFor(
day: string,
colonyType: ColonyType | null,
config: VarroaThresholdConfig,
treatments: readonly TreatmentWindowLite[],
): VarroaBands {
const month = Number(day.slice(5, 7));
const young =
colonyType !== null && config.youngColonyTypes.includes(colonyType);
const factor = young ? config.youngColonyFactor : 1;
const bands: VarroaBands['bands'] = {
NATURAL_MITE_FALL: naturalFallBand(
month,
factor,
young,
config.naturalFall,
),
};
for (const method of PERCENT_METHODS) {
const band = percentBand(method, month, config.infestationPercent);
if (band) {
bands[method] = band;
}
}
return {
day,
bands,
postTreatmentUntil: postTreatmentUntil(
day,
config.recheckAfterDays,
treatments,
),
};
}
/** Red above redAbove, yellow from yellowFrom, otherwise green. */
export function classify(
value: number | null,
band: VarroaBand | null | undefined,
): VarroaLightKey | null {
if (value === null || !band) {
return null;
}
if (value > band.redAbove) {
return 'red';
}
if (band.yellowFrom !== null && value >= band.yellowFrom) {
return 'yellow';
}
return 'green';
}
/**
* The light of one measurement. A measurement taken from the start of a
* treatment until the recheck day has no light (treatment effect), except the
* drone brood check, which never has one.
*/
export function rateMeasurement(
m: Pick<VarroaMeasurement, 'date' | 'method' | 'calculatedValue'>,
colonyType: ColonyType | null,
config: VarroaThresholdConfig,
treatments: readonly TreatmentWindowLite[],
): VarroaRating {
const day = berlinDayOf(m.date);
const { bands, postTreatmentUntil: recheckDay } = bandsFor(
day,
colonyType,
config,
treatments,
);
const band = bands[m.method] ?? null;
if (recheckDay !== null && m.method !== 'DRONE_BROOD_CONTROL') {
return {
light: 'POST_TREATMENT',
reasonCode: 'POST_TREATMENT',
band: null,
postTreatmentUntil: recheckDay,
};
}
return {
light: classify(m.calculatedValue, band),
reasonCode: band?.reason ?? null,
band,
postTreatmentUntil: null,
};
}
@@ -1,41 +0,0 @@
import {
DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY,
varroaThresholdOf,
} from './varroa-threshold';
describe('varroaThresholdOf', () => {
it('uses the default when no rule exists yet (default rule is active)', () => {
expect(varroaThresholdOf(null)).toBe(
DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY,
);
expect(DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY).toBe(10);
});
it('returns null for an inactive rule', () => {
expect(
varroaThresholdOf({
active: false,
config: { thresholdMitesPerDay: 7 },
}),
).toBeNull();
});
it('returns the configured threshold of an active rule', () => {
expect(
varroaThresholdOf({ active: true, config: { thresholdMitesPerDay: 7 } }),
).toBe(7);
});
it.each([
['a string', { thresholdMitesPerDay: 'x' }],
['zero', { thresholdMitesPerDay: 0 }],
['a negative number', { thresholdMitesPerDay: -1 }],
['NaN', { thresholdMitesPerDay: Number.NaN }],
['a missing key', {}],
['a null config', null],
])('falls back to the default for %s', (_label, config) => {
expect(varroaThresholdOf({ active: true, config })).toBe(
DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY,
);
});
});
@@ -1,30 +0,0 @@
import type { AutomationRule } from '../../generated/prisma';
/** Default natural mite fall (mites per day) that triggers the varroa rule. */
export const DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY = 10;
/**
* Effective varroa threshold of the VARROA_THRESHOLD rule.
* - no rule row yet: the default rule applies (ensureDefaults creates it active)
* - inactive rule: null (no threshold in force)
* - active rule: the configured value, or the default if it is not a
* positive finite number (a hand-edited config must never disable the check).
*/
export function varroaThresholdOf(
rule: Pick<AutomationRule, 'active' | 'config'> | null,
): number | null {
if (rule === null) {
return DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY;
}
if (!rule.active) {
return null;
}
const config = rule.config;
if (typeof config !== 'object' || config === null || Array.isArray(config)) {
return DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY;
}
const value = (config as Record<string, unknown>).thresholdMitesPerDay;
return typeof value === 'number' && Number.isFinite(value) && value > 0
? value
: DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY;
}
@@ -0,0 +1,90 @@
import { DEFAULT_RULES, ruleConfigOf } from '../automation/rule-config';
import { calendarSeasonState } from '../season/season-calendar';
import { loadAutopilotInputIn } from './autopilot-input';
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
const season = calendarSeasonState(
'api-1',
'2026-08-20',
ruleConfigOf('PHENOLOGY', DEFAULT_RULES.PHENOLOGY.config),
);
function tx(treatments: object[], varroaCount = 0) {
return {
colony: {
findUnique: jest.fn().mockResolvedValue({
id: 'c1',
code: 'B3',
status: 'ACTIVE',
colonyType: 'PRODUCTION_COLONY',
apiaryId: 'api-1',
createdAt: d('2026-04-01'),
feedStatus: null,
currentQueen: null,
}),
},
inspection: { findFirst: jest.fn().mockResolvedValue(null) },
varroaMeasurement: {
findFirst: jest.fn().mockResolvedValue(null),
count: jest.fn().mockResolvedValue(varroaCount),
},
treatment: {
findMany: jest.fn().mockResolvedValue(treatments),
count: jest.fn().mockResolvedValue(0),
},
feeding: { count: jest.fn().mockResolvedValue(0) },
winterCheck: { findMany: jest.fn().mockResolvedValue([]) },
};
}
describe('loadAutopilotInputIn', () => {
it('takes the treatment with the latest end day, not the latest date', async () => {
const db = tx([
{
id: 'old',
date: d('2026-07-01'),
startDate: null,
endDate: d('2026-08-10'),
},
{
id: 'backdated',
date: d('2026-07-20'),
startDate: null,
endDate: null,
},
{ id: 'single', date: d('2026-08-01'), startDate: null, endDate: null },
]);
const input = await loadAutopilotInputIn(db as never, 'c1', season);
expect(input?.lastTreatment).toEqual({
id: 'old',
startDay: '2026-07-01',
endDay: '2026-08-10',
});
});
it('counts only measurements after the end day', async () => {
const db = tx([
{
id: 't',
date: d('2026-08-01'),
startDate: null,
endDate: d('2026-08-10'),
},
]);
await loadAutopilotInputIn(db as never, 'c1', season);
expect(db.varroaMeasurement.count).toHaveBeenCalledWith({
where: { colonyId: 'c1', date: { gt: d('2026-08-10') } },
});
});
it('has no last treatment without treatments', async () => {
const input = await loadAutopilotInputIn(tx([]) as never, 'c1', season);
expect(input?.lastTreatment).toBeNull();
expect(input?.varroaSinceLastTreatmentEnd).toBe(false);
});
});
@@ -0,0 +1,140 @@
import type { Prisma } from '../../generated/prisma';
import { berlinDayOf, dayToDate } from '../common/dates';
import type { SeasonState } from '../season/season-types';
import type { AutopilotInput } from './autopilot.types';
/**
* Until Task 27 passes the real start of the late summer, the feedings and
* treatments of A4-A6 are counted from this day of the year (the built-in
* calendar day of TRACHTENDE).
*/
const LATE_SUMMER_FALLBACK_START = '08-01';
const LATEST_FIRST = [{ date: 'desc' }, { createdAt: 'desc' }] as const;
function lateSummerStart(season: SeasonState): string | null {
return season.segment === 'SPAETSOMMER' || season.segment === 'HERBST'
? `${season.year}-${LATE_SUMMER_FALLBACK_START}`
: null;
}
/**
* What the autopilot rules need of one colony, in a fixed sequence of
* queries (no query per rule, none per record). Resolves to null for a
* colony that does not exist. The caller holds the colony row lock, so the
* picture stays consistent while the rules run.
*/
export async function loadAutopilotInputIn(
tx: Prisma.TransactionClient,
colonyId: string,
season: SeasonState,
): Promise<AutopilotInput | null> {
const colony = await tx.colony.findUnique({
where: { id: colonyId },
select: {
id: true,
code: true,
status: true,
colonyType: true,
apiaryId: true,
createdAt: true,
feedStatus: true,
currentQueen: { select: { id: true, hatchYear: true } },
},
});
if (colony === null) {
return null;
}
const inspection = await tx.inspection.findFirst({
where: { colonyId },
orderBy: [...LATEST_FIRST],
select: {
id: true,
date: true,
colonyStrength: true,
feedStatus: true,
queenCellsPresent: true,
},
});
const varroa = await tx.varroaMeasurement.findFirst({
where: { colonyId },
orderBy: [...LATEST_FIRST],
select: { id: true, date: true },
});
// The latest END day wins, not the latest entry: a backdated or
// overlapping treatment must not hide one that ends later.
const treatments = await tx.treatment.findMany({
where: { colonyId },
orderBy: [{ date: 'desc' }, { id: 'asc' }],
select: { id: true, date: true, startDate: true, endDate: true },
});
const lastTreatment = treatments
.map((treatment) => ({
id: treatment.id,
startDay: berlinDayOf(treatment.startDate ?? treatment.date),
endDay: berlinDayOf(treatment.endDate ?? treatment.date),
}))
.reduce<{ id: string; startDay: string; endDay: string } | null>(
(latest, candidate) =>
latest === null || candidate.endDay > latest.endDay
? candidate
: latest,
null,
);
const varroaSinceLastTreatmentEnd =
lastTreatment !== null &&
(await tx.varroaMeasurement.count({
where: { colonyId, date: { gt: dayToDate(lastTreatment.endDay) } },
})) > 0;
const feedingsSinceInspection = await tx.feeding.count({
where: {
colonyId,
...(inspection === null ? {} : { date: { gte: inspection.date } }),
},
});
const lateSummer = lateSummerStart(season);
const sinceLateSummer =
lateSummer === null ? null : { gte: dayToDate(lateSummer) };
const feedingsSinceLateSummer =
sinceLateSummer === null
? 0
: await tx.feeding.count({ where: { colonyId, date: sinceLateSummer } });
const treatmentsSinceLateSummer =
sinceLateSummer === null
? 0
: await tx.treatment.count({
where: { colonyId, date: sinceLateSummer },
});
const winterChecks = await tx.winterCheck.findMany({
where: { colonyId, winterYear: { gte: season.year - 1 } },
select: { winterYear: true, season: true },
orderBy: [{ winterYear: 'asc' }, { season: 'asc' }],
});
return {
colony: {
id: colony.id,
code: colony.code,
status: colony.status,
colonyType: colony.colonyType,
apiaryId: colony.apiaryId,
createdOn: berlinDayOf(colony.createdAt),
feedStatus: colony.feedStatus,
},
lastInspection:
inspection === null
? null
: { ...inspection, date: berlinDayOf(inspection.date) },
lastVarroa:
varroa === null
? null
: { id: varroa.id, date: berlinDayOf(varroa.date) },
lastTreatment,
varroaSinceLastTreatmentEnd,
feedingsSinceInspection,
feedingsSinceLateSummer,
treatmentsSinceLateSummer,
queen: colony.currentQueen,
winterChecks,
season,
};
}
@@ -0,0 +1,372 @@
import { DEFAULT_RULES, ruleConfigOf } from '../automation/rule-config';
import { calendarSeasonState } from '../season/season-calendar';
import { evaluateAutopilot } from './autopilot-rules';
import type { AutopilotConfigs, AutopilotInput } from './autopilot.types';
type RuleName = 'SEASON_AUTOPILOT' | 'INSPECTION_INTERVAL' | 'VARROA_THRESHOLD';
function activeRule(trigger: RuleName, override?: object) {
return {
id: null,
active: true,
config: ruleConfigOf(trigger, {
...DEFAULT_RULES[trigger].config,
...override,
}),
actionTemplate: null,
};
}
function configs(autopilotOverride?: object): AutopilotConfigs {
return {
autopilot: activeRule('SEASON_AUTOPILOT', autopilotOverride),
interval: activeRule('INSPECTION_INTERVAL'),
varroa: activeRule('VARROA_THRESHOLD'),
phenology: ruleConfigOf('PHENOLOGY', DEFAULT_RULES.PHENOLOGY.config),
} as AutopilotConfigs;
}
function input(
today: string,
patch: Partial<AutopilotInput> = {},
): AutopilotInput {
return {
colony: {
id: 'col_1',
code: 'B3',
status: 'ACTIVE',
colonyType: 'PRODUCTION_COLONY',
apiaryId: 'api_1',
createdOn: '2025-04-01',
feedStatus: null,
},
lastInspection: null,
lastVarroa: { id: 'var_1', date: today },
lastTreatment: null,
varroaSinceLastTreatmentEnd: false,
feedingsSinceInspection: 0,
feedingsSinceLateSummer: 0,
treatmentsSinceLateSummer: 0,
queen: null,
winterChecks: [],
season: calendarSeasonState('api_1', today, configs().phenology),
...patch,
};
}
const inspectionOn = (date: string) => ({
id: 'insp_9',
date,
colonyStrength: null,
feedStatus: null,
queenCellsPresent: null,
});
const TREATMENT = { id: 'tre_1', startDay: '2026-07-20', endDay: '2026-08-02' };
describe('autopilot rules', () => {
it('A1: last inspection 8 days ago in SCHWARMZEIT -> INSPECT overdue, HIGH, anchor = inspection id', () => {
const today = '2026-05-12';
const result = evaluateAutopilot(
input(today, { lastInspection: inspectionOn('2026-05-04') }),
configs(),
today,
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
rule: 'A1',
ruleKey: 'INSPECTION_INTERVAL',
intent: 'INSPECT',
mode: 'DO',
reasonCode: 'INSPECTION_DUE',
dueDate: '2026-05-11',
priority: 'HIGH',
anchor: 'insp_9',
requiredPermission: 'inspections:read',
});
expect(result[0].evidence).toMatchObject({
colonyId: 'col_1',
inspectionId: 'insp_9',
lastInspectedOn: '2026-05-04',
intervalDays: 7,
segment: 'SCHWARMZEIT',
});
});
it('A1: due in 2 days -> NORMAL; due in 3 days -> nothing; WINTERRUHE -> nothing', () => {
const today = '2026-05-12';
const inTwo = evaluateAutopilot(
input(today, { lastInspection: inspectionOn('2026-05-07') }),
configs(),
today,
);
expect(inTwo[0]).toMatchObject({
dueDate: '2026-05-14',
priority: 'NORMAL',
});
const inThree = evaluateAutopilot(
input(today, { lastInspection: inspectionOn('2026-05-08') }),
configs(),
today,
);
expect(inThree).toEqual([]);
const winter = '2026-12-05';
expect(
evaluateAutopilot(
input(winter, {
lastInspection: inspectionOn('2026-09-01'),
lastVarroa: { id: 'var_1', date: '2026-12-01' },
}),
configs(),
winter,
),
).toEqual([]);
});
it('A1: due today is not overdue; no inspection ever uses the anchor none', () => {
const today = '2026-05-12';
const dueToday = evaluateAutopilot(
input(today, { lastInspection: inspectionOn('2026-05-05') }),
configs(),
today,
);
expect(dueToday[0].priority).toBe('NORMAL');
const base = input(today);
const never = evaluateAutopilot(
input(today, { colony: { ...base.colony, createdOn: '2026-05-01' } }),
configs(),
today,
);
expect(never[0]).toMatchObject({ anchor: 'none', dueDate: '2026-05-08' });
expect(never[0].evidence).toMatchObject({
inspectionId: null,
lastInspectedOn: null,
});
});
it('A1: last inspection before spring start -> INSPECTION_FIRST_OF_YEAR', () => {
const today = '2026-04-01';
const result = evaluateAutopilot(
input(today, { lastInspection: inspectionOn('2025-09-20') }),
configs(),
today,
);
expect(result[0]).toMatchObject({
reasonCode: 'INSPECTION_FIRST_OF_YEAR',
dueDate: '2026-03-20',
priority: 'HIGH',
anchor: 'insp_9',
});
});
it('A2: last measurement 35 days ago -> VARROA_MEASURE due day 28', () => {
const today = '2026-06-10';
const result = evaluateAutopilot(
input(today, {
lastInspection: inspectionOn('2026-06-08'),
lastVarroa: { id: 'var_7', date: '2026-05-06' },
}),
configs(),
today,
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
rule: 'A2',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'VARROA_MEASURE',
mode: 'DO',
reasonCode: 'VARROA_MEASURE_DUE',
dueDate: '2026-06-03',
priority: 'NORMAL',
anchor: 'var_7',
requiredPermission: 'varroa:read',
});
});
it('A2: a measurement 27 days ago yields nothing', () => {
const today = '2026-06-10';
expect(
evaluateAutopilot(
input(today, {
lastInspection: inspectionOn('2026-06-08'),
lastVarroa: { id: 'var_7', date: '2026-05-14' },
}),
configs(),
today,
),
).toEqual([]);
});
it('A2: winter window without a measurement since 11-01 -> anchor winter-2026', () => {
const today = '2026-11-20';
const result = evaluateAutopilot(
input(today, { lastVarroa: { id: 'var_2', date: '2026-09-01' } }),
configs(),
today,
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
intent: 'VARROA_MEASURE',
reasonCode: 'VARROA_WINTER_MEASURE',
dueDate: '2026-11-15',
anchor: 'winter-2026',
});
});
it('A3: treatment ended 14 days ago without a measurement since -> VARROA_RECHECK, anchor treatment id', () => {
const today = '2026-08-16';
const result = evaluateAutopilot(
input(today, {
lastInspection: inspectionOn('2026-08-10'),
lastVarroa: { id: 'var_3', date: '2026-08-01' },
lastTreatment: TREATMENT,
}),
configs(),
today,
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
rule: 'A3',
intent: 'VARROA_RECHECK',
mode: 'DO',
reasonCode: 'VARROA_RECHECK_DUE',
dueDate: '2026-08-16',
priority: 'NORMAL',
anchor: 'tre_1',
requiredPermission: 'varroa:read',
});
expect(result[0].evidence).toMatchObject({
treatmentId: 'tre_1',
endedOn: '2026-08-02',
});
});
it('A3: nothing 13 days after the end, nothing once a measurement exists since', () => {
const early = '2026-08-15';
expect(
evaluateAutopilot(
input(early, {
lastInspection: inspectionOn('2026-08-10'),
lastTreatment: TREATMENT,
}),
configs(),
early,
),
).toEqual([]);
const late = '2026-08-20';
expect(
evaluateAutopilot(
input(late, {
lastInspection: inspectionOn('2026-08-15'),
lastTreatment: TREATMENT,
varroaSinceLastTreatmentEnd: true,
}),
configs(),
late,
),
).toEqual([]);
});
it('A3: honours recheckAfterDays of the varroa rule', () => {
const today = '2026-08-09';
const cfg = configs();
cfg.varroa = activeRule('VARROA_THRESHOLD', {
recheckAfterDays: 7,
}) as AutopilotConfigs['varroa'];
const result = evaluateAutopilot(
input(today, {
lastInspection: inspectionOn('2026-08-05'),
lastTreatment: TREATMENT,
}),
cfg,
today,
);
expect(result.map((c) => c.rule)).toEqual(['A3']);
expect(result[0].dueDate).toBe('2026-08-09');
});
it('returns the candidates in the order A1, A2', () => {
const today = '2026-06-10';
const result = evaluateAutopilot(
input(today, {
lastInspection: inspectionOn('2026-05-20'),
lastVarroa: { id: 'var_7', date: '2026-04-20' },
}),
configs(),
today,
);
expect(result.map((c) => c.rule)).toEqual(['A1', 'A2']);
});
it('A2 is not proposed while A3 applies: the recheck is the measurement', () => {
const today = '2026-06-10';
const result = evaluateAutopilot(
input(today, {
lastInspection: inspectionOn('2026-05-20'),
lastVarroa: { id: 'var_7', date: '2026-04-20' },
lastTreatment: {
id: 'tre_1',
startDay: '2026-05-01',
endDay: '2026-05-10',
},
}),
configs(),
today,
);
expect(result.map((c) => c.rule)).toEqual(['A1', 'A3']);
});
it('A3 only looks at treatments that ended at most 60 days ago', () => {
const today = '2026-08-16';
const base = { lastInspection: inspectionOn('2026-08-10') };
const at = (endDay: string) =>
evaluateAutopilot(
input(today, {
...base,
lastTreatment: { id: 'tre_1', startDay: '2026-05-01', endDay },
}),
configs(),
today,
).filter((c) => c.rule === 'A3');
expect(at('2026-06-17')).toHaveLength(1); // 60 days ago
expect(at('2026-06-16')).toHaveLength(0); // 61 days ago
});
it('a rule switched off in config.rules yields nothing', () => {
const today = '2026-05-12';
const base = input(today, { lastInspection: inspectionOn('2026-05-04') });
expect(
evaluateAutopilot(base, configs(), today).map((c) => c.rule),
).toEqual(['A1']);
const a1Off = configs({
rules: { ...configs().autopilot.config.rules, A1: false },
});
expect(evaluateAutopilot(base, a1Off, today)).toEqual([]);
});
it('SEASON_AUTOPILOT switched off yields nothing; INSPECTION_INTERVAL off only silences A1', () => {
const today = '2026-05-12';
const base = input(today, { lastInspection: inspectionOn('2026-05-04') });
const off = configs();
off.autopilot = { ...off.autopilot, active: false };
expect(evaluateAutopilot(base, off, today)).toEqual([]);
const noInterval = configs();
noInterval.interval = { ...noInterval.interval, active: false };
expect(evaluateAutopilot(base, noInterval, today)).toEqual([]);
});
});
@@ -0,0 +1,180 @@
import type { AutopilotRuleId } from '../automation/rule-config';
import { addDays, daysBetween } from '../common/dates';
import { inspectionDueOn } from '../season/inspection-due';
import { varroaMeasurementDue } from '../season/varroa-due';
import type {
AutopilotCandidate,
AutopilotConfigs,
AutopilotInput,
AutopilotRule,
} from './autopilot.types';
/**
* A3 only follows treatments that ended at most this many days ago, so old
* history without a later measurement does not produce suggestions.
*/
const RECHECK_MAX_AGE_DAYS = 60;
/** A1: an inspection is due (or nearly) and the colony is not in winter rest. */
const INSPECTION_LEAD_DAYS = 2;
const a1Inspection: AutopilotRule = (input, configs, today) => {
if (!configs.interval.active) {
return null;
}
const due = inspectionDueOn(
{
createdOn: input.colony.createdOn,
lastInspectedOn: input.lastInspection?.date ?? null,
},
input.season,
configs.interval.config,
);
if (due === null || due.dueOn > addDays(today, INSPECTION_LEAD_DAYS)) {
return null;
}
return {
rule: 'A1',
ruleKey: 'INSPECTION_INTERVAL',
intent: 'INSPECT',
mode: 'DO',
reasonCode: due.firstOfYear ? 'INSPECTION_FIRST_OF_YEAR' : 'INSPECTION_DUE',
evidence: {
colonyId: input.colony.id,
inspectionId: input.lastInspection?.id ?? null,
lastInspectedOn: input.lastInspection?.date ?? null,
dueOn: due.dueOn,
intervalDays: due.intervalDays,
segment: input.season.segment,
},
dueDate: due.dueOn,
priority: due.dueOn < today ? 'HIGH' : 'NORMAL',
anchor: input.lastInspection?.id ?? 'none',
requiredPermission: 'inspections:read',
};
};
/** A2: the varroa measurement interval ran out, or the winter window is open. */
const a2VarroaMeasure: AutopilotRule = (input, configs, today) => {
const due = varroaMeasurementDue(
{
createdOn: input.colony.createdOn,
lastMeasuredOn: input.lastVarroa?.date ?? null,
lastMeasurementId: input.lastVarroa?.id ?? null,
},
input.season,
configs.autopilot.config,
today,
);
if (due === null || due.dueOn > today) {
return null;
}
return {
rule: 'A2',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'VARROA_MEASURE',
mode: 'DO',
reasonCode:
due.kind === 'WINTER_WINDOW'
? 'VARROA_WINTER_MEASURE'
: 'VARROA_MEASURE_DUE',
evidence: {
colonyId: input.colony.id,
measurementId: input.lastVarroa?.id ?? null,
lastMeasuredOn: input.lastVarroa?.date ?? null,
dueOn: due.dueOn,
intervalDays: configs.autopilot.config.varroaMeasureIntervalDays,
},
dueDate: due.dueOn,
priority: 'NORMAL',
anchor: due.anchor,
requiredPermission: 'varroa:read',
};
};
/** A3: a treatment ended long enough ago and nothing was measured since. */
const a3VarroaRecheck: AutopilotRule = (input, configs, today) => {
const treatment = input.lastTreatment;
if (
treatment === null ||
input.varroaSinceLastTreatmentEnd ||
daysBetween(treatment.endDay, today) > RECHECK_MAX_AGE_DAYS
) {
return null;
}
const recheckAfterDays = configs.varroa.config.recheckAfterDays;
const dueOn = addDays(treatment.endDay, recheckAfterDays);
if (dueOn > today) {
return null;
}
return {
rule: 'A3',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'VARROA_RECHECK',
mode: 'DO',
reasonCode: 'VARROA_RECHECK_DUE',
evidence: {
colonyId: input.colony.id,
treatmentId: treatment.id,
endedOn: treatment.endDay,
recheckOn: dueOn,
},
dueDate: dueOn,
priority: 'NORMAL',
anchor: treatment.id,
// The action is a varroa measurement.
requiredPermission: 'varroa:read',
};
};
/** A4-A10 follow with Task 27. */
export const AUTOPILOT_RULES: Readonly<
Partial<Record<AutopilotRuleId, AutopilotRule>>
> = {
A1: a1Inspection,
A2: a2VarroaMeasure,
A3: a3VarroaRecheck,
};
const RULE_ORDER: readonly AutopilotRuleId[] = [
'A1',
'A2',
'A3',
'A4',
'A5',
'A6',
'A7',
'A8',
'A9',
'A10',
];
/**
* The candidates for one colony, always in the order A1...A10 so concurrent
* syncs take the proposal locks in the same order. A switched-off autopilot
* or rule yields nothing.
*/
export function evaluateAutopilot(
input: AutopilotInput,
configs: AutopilotConfigs,
today: string,
): AutopilotCandidate[] {
if (!configs.autopilot.active) {
return [];
}
const candidates: AutopilotCandidate[] = [];
for (const id of RULE_ORDER) {
const rule = AUTOPILOT_RULES[id];
if (rule === undefined || !configs.autopilot.config.rules[id]) {
continue;
}
const candidate = rule(input, configs, today);
if (candidate !== null) {
candidates.push(candidate);
}
}
// The recheck after a treatment is the measurement: A2 would ask twice.
return candidates.some((candidate) => candidate.rule === 'A3')
? candidates.filter((candidate) => candidate.rule !== 'A2')
: candidates;
}
@@ -0,0 +1,26 @@
import { Controller, Get } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { AutopilotTodayDto } from './dto/next-steps-response.dto';
import { NextStepsService, type AutopilotToday } from './next-steps.service';
@ApiTags('autopilot')
@ApiBearerAuth()
@Controller('autopilot')
export class AutopilotTodayController {
constructor(private readonly nextSteps: NextStepsService) {}
/**
* "Today": the most urgent step over all colonies, the due inspections
* per apiary, the number of open suggestions and the season per apiary.
* Tasks and suggestions only with `tasks:read`.
*/
@ApiOkResponse({ type: AutopilotTodayDto })
@RequirePermission('colonies:read')
@Get('today')
today(@CurrentUser() actor: AuthenticatedUser): Promise<AutopilotToday> {
return this.nextSteps.today(actor.id);
}
}
@@ -0,0 +1,181 @@
import type { Prisma } from '../../generated/prisma';
import type {
AnyDomainEventEnvelope,
DomainEventType,
} from '../domain-events/domain-event-types';
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
import { AutopilotEventHandlers } from './autopilot.handlers';
import type { AutopilotService } from './autopilot.service';
const EXPECTED_TYPES: DomainEventType[] = [
'InspectionRecorded',
'InspectionCorrected',
'VarroaMeasured',
'VarroaCorrected',
'TreatmentRecorded',
'TreatmentCorrected',
'FeedingRecorded',
'FeedingCorrected',
'HarvestRecorded',
'HarvestCorrected',
'ColonyStatusChanged',
'ColonySplit',
'QueenAssigned',
'TaskCompleted',
'TaskUpdated',
'WithdrawalEnded',
'QueenAged',
'InspectionOverdue',
'VarroaMeasurementDue',
];
function event(
type: DomainEventType,
aggregateId: string,
payload: object,
): AnyDomainEventEnvelope {
return {
id: 'evt-1',
type,
aggregateType: 'Colony',
aggregateId,
payload,
actorUserId: null,
correlationId: 'cor-1',
occurredAt: new Date(),
attempts: 0,
} as AnyDomainEventEnvelope;
}
describe('AutopilotEventHandlers', () => {
let autopilot: { syncColonyIn: jest.Mock };
let tx: { task: { findUnique: jest.Mock } };
let handlers: AutopilotEventHandlers;
beforeEach(() => {
jest.useFakeTimers({ now: new Date('2026-05-12T08:00:00.000Z') });
autopilot = { syncColonyIn: jest.fn().mockResolvedValue({}) };
tx = { task: { findUnique: jest.fn() } };
handlers = new AutopilotEventHandlers(
autopilot as unknown as AutopilotService,
);
});
afterEach(() => {
jest.useRealTimers();
});
const run = (e: AnyDomainEventEnvelope) =>
handlers.colonySync(
e as Parameters<AutopilotEventHandlers['colonySync']>[0],
tx as unknown as Prisma.TransactionClient,
);
const synced = () =>
autopilot.syncColonyIn.mock.calls.map((call: unknown[]) => call[1]);
it('is registered as autopilot.colony-sync for the listed events', () => {
const entries = domainEventHandlerMetadata(
// eslint-disable-next-line @typescript-eslint/unbound-method
AutopilotEventHandlers.prototype.colonySync,
);
expect(entries).toHaveLength(1);
expect(entries[0].handlerId).toBe('autopilot.colony-sync');
expect([...entries[0].types].sort()).toEqual([...EXPECTED_TYPES].sort());
});
it.each([
['InspectionRecorded', { colonyId: 'c1' }],
['InspectionCorrected', { colonyId: 'c1' }],
['VarroaMeasured', { colonyId: 'c1' }],
['VarroaCorrected', { colonyId: 'c1' }],
['TreatmentRecorded', { colonyId: 'c1' }],
['TreatmentCorrected', { colonyId: 'c1' }],
['FeedingRecorded', { colonyId: 'c1' }],
['FeedingCorrected', { colonyId: 'c1' }],
['WithdrawalEnded', { colonyId: 'c1', endedOn: '2026-05-12' }],
['QueenAged', { colonyId: 'c1', hatchYear: 2024 }],
] as [DomainEventType, object][])(
'%s syncs the colony of its payload for the Berlin day',
async (type, payload) => {
await run(event(type, 'aggregate-1', payload));
expect(autopilot.syncColonyIn).toHaveBeenCalledTimes(1);
expect(autopilot.syncColonyIn).toHaveBeenCalledWith(
tx,
'c1',
'2026-05-12',
);
},
);
it.each([
'ColonyStatusChanged',
'InspectionOverdue',
'VarroaMeasurementDue',
] as const)('%s syncs the aggregate colony', async (type) => {
await run(event(type, 'c9', {}));
expect(synced()).toEqual(['c9']);
});
it('syncs every colony of a harvest, in id order', async () => {
await run(
event('HarvestRecorded', 'h1', {
apiaryId: 'a',
colonyIds: ['c3', 'c1', 'c2', 'c1'],
}),
);
expect(synced()).toEqual(['c1', 'c2', 'c3']);
});
it('syncs the new and the parent colony of a split', async () => {
await run(event('ColonySplit', 'new', { parentColonyId: 'mother' }));
expect(synced()).toEqual(['mother', 'new']);
});
it('syncs the colony and the previous colony of a queen assignment', async () => {
await run(
event('QueenAssigned', 'q1', { colonyId: 'c2', previousColonyId: 'c1' }),
);
await run(
event('QueenAssigned', 'q1', { colonyId: 'c2', previousColonyId: null }),
);
expect(synced()).toEqual(['c1', 'c2', 'c2']);
});
it('syncs a colony task on completion and update, nothing for other tasks', async () => {
await run(
event('TaskCompleted', 't1', { relatedType: 'COLONY', relatedId: 'c1' }),
);
await run(
event('TaskCompleted', 't2', { relatedType: 'GENERAL', relatedId: null }),
);
tx.task.findUnique
.mockResolvedValueOnce({ relatedType: 'COLONY', relatedId: 'c4' })
.mockResolvedValueOnce({ relatedType: 'QUEEN', relatedId: 'q1' })
.mockResolvedValueOnce(null);
await run(event('TaskUpdated', 't3', { status: 'OPEN', dueDate: null }));
await run(event('TaskUpdated', 't4', { status: 'OPEN', dueDate: null }));
await run(event('TaskUpdated', 't5', { status: 'OPEN', dueDate: null }));
expect(synced()).toEqual(['c1', 'c4']);
});
it('does nothing for a queen event without a colony', async () => {
await run(event('QueenAged', 'q1', { colonyId: null, hatchYear: 2023 }));
expect(autopilot.syncColonyIn).not.toHaveBeenCalled();
});
it('lets errors propagate so the delivery is retried', async () => {
autopilot.syncColonyIn.mockRejectedValue(new Error('db down'));
await expect(
run(event('InspectionRecorded', 'i1', { colonyId: 'c1' })),
).rejects.toThrow('db down');
});
});
@@ -0,0 +1,112 @@
import { Injectable } from '@nestjs/common';
import type { Prisma } from '../../generated/prisma';
import { berlinToday } from '../common/dates';
import type {
AnyDomainEventEnvelope,
DomainEventType,
} from '../domain-events/domain-event-types';
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
import { AutopilotService } from './autopilot.service';
const COLONY_SYNC_EVENTS = [
'InspectionRecorded',
'InspectionCorrected',
'VarroaMeasured',
'VarroaCorrected',
'TreatmentRecorded',
'TreatmentCorrected',
'FeedingRecorded',
'FeedingCorrected',
'HarvestRecorded',
'HarvestCorrected',
'ColonyStatusChanged',
'ColonySplit',
'QueenAssigned',
'TaskCompleted',
'TaskUpdated',
'WithdrawalEnded',
'QueenAged',
'InspectionOverdue',
'VarroaMeasurementDue',
] as const satisfies readonly DomainEventType[];
type ColonySyncEvent = AnyDomainEventEnvelope<
(typeof COLONY_SYNC_EVENTS)[number]
>;
/** The colony of a task, if it is about one. */
async function taskColonyIdIn(
tx: Prisma.TransactionClient,
taskId: string,
): Promise<string | null> {
const task = await tx.task.findUnique({
where: { id: taskId },
select: { relatedType: true, relatedId: true },
});
return task?.relatedType === 'COLONY' ? task.relatedId : null;
}
/** The colonies an event is about; none if it is about no colony. */
async function colonyIdsOf(
event: ColonySyncEvent,
tx: Prisma.TransactionClient,
): Promise<(string | null)[]> {
switch (event.type) {
case 'InspectionRecorded':
case 'InspectionCorrected':
case 'VarroaMeasured':
case 'VarroaCorrected':
case 'TreatmentRecorded':
case 'TreatmentCorrected':
case 'FeedingRecorded':
case 'FeedingCorrected':
case 'WithdrawalEnded':
case 'QueenAged':
return [event.payload.colonyId];
case 'HarvestRecorded':
case 'HarvestCorrected':
return event.payload.colonyIds;
case 'ColonyStatusChanged':
case 'InspectionOverdue':
case 'VarroaMeasurementDue':
return [event.aggregateId];
case 'ColonySplit':
// The aggregate is the new colony.
return [event.aggregateId, event.payload.parentColonyId];
case 'QueenAssigned':
return [event.payload.colonyId, event.payload.previousColonyId];
case 'TaskCompleted':
return [
event.payload.relatedType === 'COLONY' ? event.payload.relatedId : null,
];
case 'TaskUpdated':
return [await taskColonyIdIn(tx, event.aggregateId)];
}
}
/**
* Reconciles the autopilot suggestions of the colonies an event touches.
* Pure DB writer through the delivery transaction; the colonies are synced
* in id order, so two deliveries never lock them the other way round.
*/
@Injectable()
export class AutopilotEventHandlers {
constructor(private readonly autopilot: AutopilotService) {}
@OnDomainEvent([...COLONY_SYNC_EVENTS], 'autopilot.colony-sync')
async colonySync(
event: ColonySyncEvent,
tx: Prisma.TransactionClient,
): Promise<void> {
const colonyIds = [
...new Set((await colonyIdsOf(event, tx)).filter((id) => id !== null)),
].sort();
if (colonyIds.length === 0) {
return;
}
const today = berlinToday();
for (const colonyId of colonyIds) {
await this.autopilot.syncColonyIn(tx, colonyId, today);
}
}
}
@@ -0,0 +1,35 @@
import { Module } from '@nestjs/common';
import { SeasonModule } from '../season/season.module';
import { SuggestionsModule } from '../suggestions/suggestions.module';
import { AutopilotEventHandlers } from './autopilot.handlers';
import { AutopilotTodayController } from './autopilot-today.controller';
import { AutopilotService } from './autopilot.service';
import { AutopilotStep } from './autopilot.step';
import { ColonyNextStepsController } from './colony-next-steps.controller';
import { NextStepsService } from './next-steps.service';
import { SuggestionsExpiryStep } from './suggestions-expiry.step';
/**
* The season autopilot: the event handler and the daily steps that keep the
* autopilot suggestions of each colony in line with the rules, and the read
* side "next steps" / "today". Imported by AppModule and WorkerModule (the
* handler), JobsModule (the steps) and ApiariesModule (the colony overview).
*/
@Module({
imports: [SeasonModule, SuggestionsModule],
controllers: [ColonyNextStepsController, AutopilotTodayController],
providers: [
AutopilotService,
NextStepsService,
AutopilotEventHandlers,
AutopilotStep,
SuggestionsExpiryStep,
],
exports: [
AutopilotService,
NextStepsService,
AutopilotStep,
SuggestionsExpiryStep,
],
})
export class AutopilotModule {}
@@ -0,0 +1,333 @@
import { Logger } from '@nestjs/common';
import {
DEFAULT_RULES,
readRule,
ruleConfigOf,
} from '../automation/rule-config';
import { lockRowForUpdate } from '../prisma/row-lock';
import { calendarSeasonState } from '../season/season-calendar';
import type { SuggestionsService } from '../suggestions/suggestions.service';
import { loadAutopilotInputIn } from './autopilot-input';
import { AutopilotService } from './autopilot.service';
import type { AutopilotInput } from './autopilot.types';
jest.mock('../prisma/row-lock');
jest.mock('../automation/rule-config', () => ({
...jest.requireActual<object>('../automation/rule-config'),
readRule: jest.fn(),
}));
jest.mock('./autopilot-input');
const lockRow = jest.mocked(lockRowForUpdate);
const readRuleMock = jest.mocked(readRule);
const loadInput = jest.mocked(loadAutopilotInputIn);
const TODAY = '2026-05-12';
const PHENOLOGY = ruleConfigOf('PHENOLOGY', DEFAULT_RULES.PHENOLOGY.config);
function rule(trigger: string, override: object = {}, active = true) {
return {
id: null,
active,
config: ruleConfigOf(trigger as never, {
...DEFAULT_RULES[trigger as 'SEASON_AUTOPILOT'].config,
...override,
}),
actionTemplate: null,
};
}
function input(lastInspectedOn: string | null): AutopilotInput {
return {
colony: {
id: 'col-1',
code: 'B3',
status: 'ACTIVE',
colonyType: 'PRODUCTION_COLONY',
apiaryId: 'api-1',
createdOn: '2026-04-20',
feedStatus: null,
},
lastInspection:
lastInspectedOn === null
? null
: {
id: 'insp-9',
date: lastInspectedOn,
colonyStrength: null,
feedStatus: null,
queenCellsPresent: null,
},
lastVarroa: { id: 'var-1', date: '2026-05-10' },
lastTreatment: null,
varroaSinceLastTreatmentEnd: false,
feedingsSinceInspection: 0,
feedingsSinceLateSummer: 0,
treatmentsSinceLateSummer: 0,
queen: null,
winterChecks: [],
season: calendarSeasonState('api-1', TODAY, PHENOLOGY),
};
}
describe('AutopilotService', () => {
let tx: {
$connect?: undefined;
colony: { findUnique: jest.Mock };
suggestion: { findMany: jest.Mock };
};
let suggestions: {
proposeIn: jest.Mock;
refreshIn: jest.Mock;
resolveIn: jest.Mock;
};
let seasons: { statesFor: jest.Mock };
let service: AutopilotService;
let rules: Record<string, ReturnType<typeof rule>>;
beforeEach(() => {
jest.resetAllMocks();
tx = {
colony: {
findUnique: jest
.fn()
.mockResolvedValue({ status: 'ACTIVE', apiaryId: 'api-1' }),
},
suggestion: { findMany: jest.fn().mockResolvedValue([]) },
};
suggestions = {
proposeIn: jest.fn().mockResolvedValue({ id: 'new' }),
refreshIn: jest.fn().mockResolvedValue(undefined),
resolveIn: jest
.fn()
.mockImplementation((_tx, ids: string[]) => ids.length),
};
seasons = {
statesFor: jest
.fn()
.mockResolvedValue(
new Map([['api-1', calendarSeasonState('api-1', TODAY, PHENOLOGY)]]),
),
};
rules = {
SEASON_AUTOPILOT: rule('SEASON_AUTOPILOT'),
INSPECTION_INTERVAL: rule('INSPECTION_INTERVAL'),
VARROA_THRESHOLD: rule('VARROA_THRESHOLD'),
PHENOLOGY: rule('PHENOLOGY'),
};
readRuleMock.mockImplementation((_db, trigger) =>
Promise.resolve(rules[trigger] as never),
);
lockRow.mockResolvedValue(true);
loadInput.mockResolvedValue(input('2026-05-03'));
service = new AutopilotService(
suggestions as unknown as SuggestionsService,
seasons,
);
});
const sync = () => service.syncColonyIn(tx as never, 'col-1', TODAY);
it('locks the colony row first and does nothing for an ended colony', async () => {
tx.colony.findUnique.mockResolvedValue({
status: 'DECEASED',
apiaryId: 'api-1',
});
const result = await sync();
expect(lockRow).toHaveBeenCalledWith(tx, 'Colony', 'col-1');
expect(lockRow.mock.invocationCallOrder[0]).toBeLessThan(
tx.colony.findUnique.mock.invocationCallOrder[0],
);
expect(result).toEqual({ opened: 0, refreshed: 0, resolved: 0 });
expect(suggestions.proposeIn).not.toHaveBeenCalled();
expect(loadInput).not.toHaveBeenCalled();
});
it('does nothing when the colony row is gone', async () => {
lockRow.mockResolvedValue(false);
expect(await sync()).toEqual({ opened: 0, refreshed: 0, resolved: 0 });
expect(tx.colony.findUnique).not.toHaveBeenCalled();
});
it('proposes a new candidate with the autopilot source, dedupe inputs and expiry', async () => {
const result = await sync();
expect(result).toEqual({ opened: 1, refreshed: 0, resolved: 0 });
expect(suggestions.proposeIn).toHaveBeenCalledWith(
tx,
expect.objectContaining({
ruleKey: 'INSPECTION_INTERVAL',
source: 'AUTOPILOT',
mode: 'DO',
intent: 'INSPECT',
relatedType: 'COLONY',
relatedId: 'col-1',
colonyId: 'col-1',
reasonCode: 'INSPECTION_DUE',
dueDate: '2026-05-10',
anchor: 'insp-9',
requiredPermission: 'inspections:read',
expiresAt: '2026-06-11',
}),
);
});
it('keeps an overdue suggestion alive for the full ttl from today', async () => {
loadInput.mockResolvedValue(input('2026-03-25'));
await sync();
expect(suggestions.proposeIn).toHaveBeenCalledWith(
tx,
expect.objectContaining({
dueDate: '2026-04-01',
expiresAt: '2026-06-11',
}),
);
});
it('refreshes an open suggestion with the same key instead of creating one', async () => {
tx.suggestion.findMany.mockResolvedValue([
{
id: 'sug-1',
dedupeKey: 'INSPECTION_INTERVAL:col-1:insp-9',
expiresAt: new Date('2026-06-01T00:00:00.000Z'),
},
]);
const result = await sync();
expect(result).toEqual({ opened: 0, refreshed: 1, resolved: 0 });
expect(suggestions.proposeIn).not.toHaveBeenCalled();
expect(suggestions.refreshIn).toHaveBeenCalledWith(tx, 'sug-1', {
reasonCode: 'INSPECTION_DUE',
evidence: expect.objectContaining({ inspectionId: 'insp-9' }) as object,
dueDate: '2026-05-10',
priority: 'HIGH',
expiresAt: '2026-06-01',
});
});
it('drops a candidate when proposeIn finds an open task of the same intent', async () => {
suggestions.proposeIn.mockResolvedValue(null);
expect(await sync()).toEqual({ opened: 0, refreshed: 0, resolved: 0 });
});
it('resolves open autopilot suggestions whose key vanished, before proposing, never rule or chain ones', async () => {
tx.suggestion.findMany.mockResolvedValue([
{
id: 'sug-old',
dedupeKey: 'INSPECTION_INTERVAL:col-1:insp-1',
expiresAt: null,
},
]);
const result = await sync();
expect(tx.suggestion.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { colonyId: 'col-1', source: 'AUTOPILOT', status: 'OPEN' },
}),
);
expect(suggestions.resolveIn).toHaveBeenCalledWith(
tx,
['sug-old'],
'Bedingung entfällt',
);
expect(result).toEqual({ opened: 1, refreshed: 0, resolved: 1 });
expect(suggestions.resolveIn.mock.invocationCallOrder[0]).toBeLessThan(
suggestions.proposeIn.mock.invocationCallOrder[0],
);
});
it('resolves every open autopilot suggestion when SEASON_AUTOPILOT is off', async () => {
rules.SEASON_AUTOPILOT = rule('SEASON_AUTOPILOT', {}, false);
tx.suggestion.findMany.mockResolvedValue([
{ id: 'a', dedupeKey: 'k1', expiresAt: null },
{ id: 'b', dedupeKey: 'k2', expiresAt: null },
]);
const result = await sync();
expect(suggestions.resolveIn).toHaveBeenCalledWith(
tx,
['a', 'b'],
'Autopilot aus',
);
expect(result).toEqual({ opened: 0, refreshed: 0, resolved: 2 });
expect(suggestions.proposeIn).not.toHaveBeenCalled();
expect(loadInput).not.toHaveBeenCalled();
});
it('proposes the candidates in the order A1, A2', async () => {
loadInput.mockResolvedValue({
...input('2026-04-20'),
lastVarroa: { id: 'var-1', date: '2026-04-01' },
});
await sync();
expect(
suggestions.proposeIn.mock.calls.map(
(call: [unknown, { intent: string }]) => call[1].intent,
),
).toEqual(['INSPECT', 'VARROA_MEASURE']);
});
it('uses preloaded configs and seasons without reading rules or seasons again', async () => {
const season = calendarSeasonState('api-1', TODAY, PHENOLOGY);
const preload = {
configs: {
autopilot: rules.SEASON_AUTOPILOT,
interval: rules.INSPECTION_INTERVAL,
varroa: rules.VARROA_THRESHOLD,
phenology: PHENOLOGY,
},
seasons: new Map([['api-1', season]]),
};
readRuleMock.mockClear();
const result = await service.syncColonyIn(
tx as never,
'col-1',
TODAY,
preload,
);
expect(result.opened).toBe(1);
expect(readRuleMock).not.toHaveBeenCalled();
expect(seasons.statesFor).not.toHaveBeenCalled();
expect(loadInput).toHaveBeenCalledWith(tx, 'col-1', season);
});
it('preloadFor reads the four rules and the seasons once', async () => {
const preload = await service.preloadFor(tx as never, ['api-1'], TODAY);
expect(readRuleMock).toHaveBeenCalledTimes(4);
expect(seasons.statesFor).toHaveBeenCalledWith(tx, ['api-1'], TODAY);
expect(preload.seasons.has('api-1')).toBe(true);
expect(preload.configs.phenology).toEqual(PHENOLOGY);
});
it('warns and does nothing when the apiary has no season state or the input is gone', async () => {
const warn = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
seasons.statesFor.mockResolvedValue(new Map());
expect(await sync()).toEqual({ opened: 0, refreshed: 0, resolved: 0 });
expect(warn).toHaveBeenCalledWith(expect.stringContaining('season'));
seasons.statesFor.mockResolvedValue(
new Map([['api-1', calendarSeasonState('api-1', TODAY, PHENOLOGY)]]),
);
loadInput.mockResolvedValue(null);
expect(await sync()).toEqual({ opened: 0, refreshed: 0, resolved: 0 });
expect(warn).toHaveBeenCalledTimes(2);
warn.mockRestore();
});
});
@@ -0,0 +1,184 @@
import { Injectable, Logger } from '@nestjs/common';
import type { Prisma } from '../../generated/prisma';
import { readRule } from '../automation/rule-config';
import { isLivingColonyStatus } from '../colonies/colony-status';
import { addDays } from '../common/dates';
import { utcDay } from '../domain-events/domain-event-types';
import { assertTransactionClient, type DbClient } from '../prisma/db-client';
import { lockRowForUpdate } from '../prisma/row-lock';
import type { SeasonState } from '../season/season-types';
import { SeasonService } from '../season/season.service';
import { SuggestionsService } from '../suggestions/suggestions.service';
import { suggestionDedupeKey } from '../suggestions/suggestion.types';
import { loadAutopilotInputIn } from './autopilot-input';
import { evaluateAutopilot } from './autopilot-rules';
import type {
AutopilotCandidate,
AutopilotConfigs,
AutopilotSyncResult,
} from './autopilot.types';
/** Rules and seasons a daily step reads once for all its colonies. */
export interface AutopilotPreload {
configs: AutopilotConfigs;
seasons: ReadonlyMap<string, SeasonState>;
}
const NOTHING: AutopilotSyncResult = { opened: 0, refreshed: 0, resolved: 0 };
/**
* A suggestion lives suggestionTtlDays from its due day, from today if it is
* already overdue: a condition that has lasted longer than the ttl would
* otherwise expire the day it is proposed and never be seen.
*/
function expiresOn(dueDate: string, today: string, ttlDays: number): string {
return addDays(dueDate > today ? dueDate : today, ttlDays);
}
/**
* Reconciles the open autopilot suggestions of one colony with what the
* rules say today. It writes suggestions only, never tasks or colony data,
* and leaves rule and chain suggestions alone.
*/
@Injectable()
export class AutopilotService {
private readonly logger = new Logger(AutopilotService.name);
constructor(
private readonly suggestions: SuggestionsService,
private readonly seasons: SeasonService,
) {}
/** The rules and the season of the given apiaries, read once for many colonies. */
async preloadFor(
db: DbClient,
apiaryIds: readonly string[],
today: string,
): Promise<AutopilotPreload> {
return {
configs: await this.readConfigs(db),
seasons: await this.seasons.statesFor(db, apiaryIds, today),
};
}
/**
* Locks the colony row, then: ended colony - nothing; autopilot off - all
* open autopilot suggestions RESOLVED; otherwise suggestions whose key is
* gone are RESOLVED, those with the same key refreshed, new candidates
* proposed (in the order A1...A10, so concurrent syncs take the proposal
* locks in the same order). A candidate is dropped by `proposeIn` when an
* open suggestion or task of the same intent exists. A colony that has
* ended is skipped here; its open suggestions become OBSOLETE through the
* handler `suggestions.colony-ended`. `maxStepsPerColony` is not applied:
* it limits the next-steps view, not what is proposed. `preload` spares a
* daily step the rule and season reads per colony.
*/
async syncColonyIn(
tx: Prisma.TransactionClient,
colonyId: string,
today: string,
preload?: AutopilotPreload,
): Promise<AutopilotSyncResult> {
assertTransactionClient(tx, 'AutopilotService.syncColonyIn');
if (!(await lockRowForUpdate(tx, 'Colony', colonyId))) {
return NOTHING;
}
const colony = await tx.colony.findUnique({
where: { id: colonyId },
select: { status: true, apiaryId: true },
});
if (colony === null || !isLivingColonyStatus(colony.status)) {
return NOTHING;
}
const configs = preload?.configs ?? (await this.readConfigs(tx));
const autopilot = configs.autopilot;
const open = await tx.suggestion.findMany({
where: { colonyId, source: 'AUTOPILOT', status: 'OPEN' },
select: { id: true, dedupeKey: true, expiresAt: true },
orderBy: { id: 'asc' },
});
if (!autopilot.active) {
const resolved = await this.suggestions.resolveIn(
tx,
open.map((suggestion) => suggestion.id),
'Autopilot aus',
);
return { ...NOTHING, resolved };
}
const season = (
preload?.seasons ??
(await this.seasons.statesFor(tx, [colony.apiaryId], today))
).get(colony.apiaryId);
if (season === undefined) {
this.logger.warn(`No season state for the apiary of colony ${colonyId}`);
return NOTHING;
}
const input = await loadAutopilotInputIn(tx, colonyId, season);
if (input === null) {
this.logger.warn(`Colony ${colonyId} vanished during the autopilot sync`);
return NOTHING;
}
const candidates = evaluateAutopilot(input, configs, today);
const keyOf = (candidate: AutopilotCandidate): string =>
suggestionDedupeKey(candidate.ruleKey, colonyId, candidate.anchor);
const currentKeys = new Set(candidates.map(keyOf));
const resolved = await this.suggestions.resolveIn(
tx,
open
.filter((s) => s.dedupeKey === null || !currentKeys.has(s.dedupeKey))
.map((s) => s.id),
'Bedingung entfällt',
);
const openByKey = new Map(
open.flatMap((s) => (s.dedupeKey === null ? [] : [[s.dedupeKey, s]])),
);
const ttlDays = autopilot.config.suggestionTtlDays;
let opened = 0;
let refreshed = 0;
for (const candidate of candidates) {
const existing = openByKey.get(keyOf(candidate));
if (existing !== undefined) {
await this.suggestions.refreshIn(tx, existing.id, {
reasonCode: candidate.reasonCode,
evidence: candidate.evidence,
dueDate: candidate.dueDate,
priority: candidate.priority,
expiresAt:
existing.expiresAt === null ? null : utcDay(existing.expiresAt),
});
refreshed += 1;
continue;
}
const created = await this.suggestions.proposeIn(tx, {
ruleKey: candidate.ruleKey,
source: 'AUTOPILOT',
mode: candidate.mode,
intent: candidate.intent,
priority: candidate.priority,
relatedType: 'COLONY',
relatedId: colonyId,
colonyId,
reasonCode: candidate.reasonCode,
evidence: candidate.evidence,
requiredPermission: candidate.requiredPermission,
dueDate: candidate.dueDate,
expiresAt: expiresOn(candidate.dueDate, today, ttlDays),
anchor: candidate.anchor,
notify: false,
});
if (created !== null) {
opened += 1;
}
}
return { opened, refreshed, resolved };
}
private async readConfigs(db: DbClient): Promise<AutopilotConfigs> {
return {
autopilot: await readRule(db, 'SEASON_AUTOPILOT'),
interval: await readRule(db, 'INSPECTION_INTERVAL'),
varroa: await readRule(db, 'VARROA_THRESHOLD'),
phenology: (await readRule(db, 'PHENOLOGY')).config,
};
}
}
@@ -0,0 +1,95 @@
import { Logger } from '@nestjs/common';
import type { PrismaService } from '../prisma/prisma.service';
import { JobPartialFailure } from '../system/job-run.service';
import { AutopilotStep } from './autopilot.step';
import type { AutopilotService } from './autopilot.service';
describe('AutopilotStep', () => {
const tx = { tx: true };
let findMany: jest.Mock;
let transaction: jest.Mock;
let syncColonyIn: jest.Mock;
let preloadFor: jest.Mock;
const PRELOAD = { configs: {}, seasons: new Map() };
let step: AutopilotStep;
beforeEach(() => {
findMany = jest.fn().mockResolvedValue([
{ id: 'c1', apiaryId: 'a1' },
{ id: 'c2', apiaryId: 'a1' },
{ id: 'c3', apiaryId: 'a2' },
]);
transaction = jest.fn((work: (client: unknown) => unknown) => work(tx));
syncColonyIn = jest
.fn()
.mockResolvedValue({ opened: 1, refreshed: 0, resolved: 2 });
preloadFor = jest.fn().mockResolvedValue(PRELOAD);
step = new AutopilotStep(
{
colony: { findMany },
$transaction: transaction,
} as unknown as PrismaService,
{ syncColonyIn, preloadFor } as unknown as AutopilotService,
);
});
afterEach(() => jest.restoreAllMocks());
it('is the autopilot step', () => {
expect(step.name).toBe('autopilot');
});
it('syncs every living colony in a transaction of its own and sums up', async () => {
const result = await step.run('2026-05-12');
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
},
orderBy: { id: 'asc' },
}),
);
expect(transaction).toHaveBeenCalledTimes(3);
expect(syncColonyIn).toHaveBeenCalledWith(tx, 'c1', '2026-05-12', PRELOAD);
expect(syncColonyIn).toHaveBeenCalledWith(tx, 'c3', '2026-05-12', PRELOAD);
expect(result).toEqual({
autopilotColonies: 3,
suggestionsOpened: 3,
suggestionsResolved: 6,
});
});
it('reads rules and seasons once for all colonies', async () => {
await step.run('2026-05-12');
expect(preloadFor).toHaveBeenCalledTimes(1);
expect(preloadFor).toHaveBeenCalledWith(
expect.anything(),
['a1', 'a2'],
'2026-05-12',
);
});
it('a failing colony does not stop the others; the step fails with the counters', async () => {
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
syncColonyIn
.mockRejectedValueOnce(new Error('boom'))
.mockResolvedValueOnce({ opened: 1, refreshed: 0, resolved: 0 });
const failure = await step.run('2026-05-12').then(
() => null,
(error: unknown) => error,
);
expect(failure).toBeInstanceOf(JobPartialFailure);
expect(failure).toMatchObject({
result: {
autopilotColonies: 2,
suggestionsOpened: 2,
suggestionsResolved: 2,
},
failedSteps: ['1 colony'],
});
});
});
@@ -0,0 +1,72 @@
import { Injectable, Logger } from '@nestjs/common';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { loggableError } from '../common/loggable-error';
import type { DailyEvaluationStep } from '../jobs/daily-evaluation-steps';
import type { JobResult } from '../jobs/job';
import { PrismaService } from '../prisma/prisma.service';
import { JobPartialFailure } from '../system/job-run.service';
import { AutopilotService } from './autopilot.service';
/**
* Step "autopilot" of the daily evaluation: reconciles the suggestions of
* every living colony, one transaction (and one colony row lock) per colony.
* A failing colony is logged and does not stop the others; the step then
* fails with the counters of the colonies that went through, and the next
* run tries again.
*/
@Injectable()
export class AutopilotStep implements DailyEvaluationStep {
readonly name = 'autopilot';
private readonly logger = new Logger(AutopilotStep.name);
constructor(
private readonly prisma: PrismaService,
private readonly autopilot: AutopilotService,
) {}
async run(today: string): Promise<JobResult> {
const colonies = await this.prisma.colony.findMany({
where: LIVING_COLONY_WHERE,
select: { id: true, apiaryId: true },
orderBy: { id: 'asc' },
});
// Rules and seasons are the same for every colony: read them once.
const preload = await this.autopilot.preloadFor(
this.prisma,
[...new Set(colonies.map((colony) => colony.apiaryId))],
today,
);
let synced = 0;
let failed = 0;
let opened = 0;
let resolved = 0;
for (const { id } of colonies) {
try {
const result = await this.prisma.$transaction((tx) =>
this.autopilot.syncColonyIn(tx, id, today, preload),
);
synced += 1;
opened += result.opened;
resolved += result.resolved;
} catch (error: unknown) {
const { message, stack } = loggableError(error);
this.logger.error(
`Autopilot of colony ${id} failed: ${message}`,
stack,
);
failed += 1;
}
}
const counts: JobResult = {
autopilotColonies: synced,
suggestionsOpened: opened,
suggestionsResolved: resolved,
};
if (failed > 0) {
throw new JobPartialFailure(counts, [
`${failed} ${failed === 1 ? 'colony' : 'colonies'}`,
]);
}
return counts;
}
}
@@ -0,0 +1,81 @@
import type {
ColonyCondition,
ColonyStatus,
ColonyType,
SuggestionIntent,
SuggestionMode,
TaskPriority,
WinterCheckSeason,
} from '../../generated/prisma';
import type { PermissionKey } from '../auth/permissions/permission-catalog';
import type {
ActiveRule,
AutopilotRuleId,
PhenologyConfig,
} from '../automation/rule-config';
import type { SeasonState } from '../season/season-types';
import type { SuggestionEvidence } from '../suggestions/suggestion.types';
/** What the autopilot rules see of one living colony. Days are YYYY-MM-DD. */
export interface AutopilotInput {
colony: {
id: string;
code: string;
status: ColonyStatus;
colonyType: ColonyType;
apiaryId: string;
createdOn: string;
feedStatus: ColonyCondition | null;
};
lastInspection: {
id: string;
date: string;
colonyStrength: number | null;
feedStatus: ColonyCondition | null;
queenCellsPresent: boolean | null;
} | null;
lastVarroa: { id: string; date: string } | null;
lastTreatment: { id: string; startDay: string; endDay: string } | null;
/** A measurement after the end of the last treatment exists. */
varroaSinceLastTreatmentEnd: boolean;
// A4-A6 (Task 27).
feedingsSinceInspection: number;
feedingsSinceLateSummer: number;
treatmentsSinceLateSummer: number;
queen: { id: string; hatchYear: number | null } | null;
winterChecks: { winterYear: number; season: WinterCheckSeason }[];
season: SeasonState;
}
export interface AutopilotConfigs {
autopilot: ActiveRule<'SEASON_AUTOPILOT'>;
interval: ActiveRule<'INSPECTION_INTERVAL'>;
/** Only `recheckAfterDays` is used here (A3). */
varroa: ActiveRule<'VARROA_THRESHOLD'>;
phenology: PhenologyConfig;
}
export interface AutopilotCandidate {
rule: AutopilotRuleId;
ruleKey: 'SEASON_AUTOPILOT' | 'INSPECTION_INTERVAL';
intent: SuggestionIntent;
mode: SuggestionMode;
reasonCode: string;
evidence: SuggestionEvidence;
dueDate: string;
priority: TaskPriority;
anchor: string;
requiredPermission: PermissionKey;
}
export type AutopilotRule = (
input: AutopilotInput,
configs: AutopilotConfigs,
today: string,
) => AutopilotCandidate | null;
export interface AutopilotSyncResult {
opened: number;
refreshed: number;
resolved: number;
}
@@ -0,0 +1,26 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { ColonyNextStepsDto } from './dto/next-steps-response.dto';
import type { ColonyNextSteps } from './next-step-ranking';
import { NextStepsService } from './next-steps.service';
@ApiTags('colonies')
@ApiBearerAuth()
@Controller('colonies')
export class ColonyNextStepsController {
constructor(private readonly nextSteps: NextStepsService) {}
/** The next steps of one colony, only what the caller may see. */
@ApiOkResponse({ type: ColonyNextStepsDto })
@RequirePermission('colonies:read')
@Get(':id/next-steps')
nextStepsOf(
@Param('id') id: string,
@CurrentUser() actor: AuthenticatedUser,
): Promise<ColonyNextSteps> {
return this.nextSteps.forColony(id, actor.id);
}
}
@@ -0,0 +1,100 @@
import { ApiProperty } from '@nestjs/swagger';
import { SuggestionIntent } from '../../../generated/prisma';
import {
PHASE_SOURCES,
PHENOLOGY_PHASES,
SEASON_SEGMENTS,
} from '../../season/season-types';
import type { ColonyNextSteps, NextStep } from '../next-step-ranking';
import type { AutopilotToday } from '../next-steps.service';
const FORMS = [
'inspection',
'varroa',
'feeding',
'treatment',
'winter-check',
] as const;
class NextStepActionDto {
@ApiProperty({ enum: ['CAPTURE', 'ACCEPT', 'CLEANUP', 'OPEN_TASK'] })
type!: NextStep['action']['type'];
@ApiProperty({ enum: FORMS, required: false }) form?: (typeof FORMS)[number];
@ApiProperty({ required: false }) href?: string;
@ApiProperty({ required: false }) suggestionId?: string;
@ApiProperty({ required: false }) taskId?: string;
}
export class NextStepDto {
@ApiProperty() key!: string;
@ApiProperty({ enum: ['TASK', 'SUGGESTION'] }) kind!: NextStep['kind'];
@ApiProperty({ enum: SuggestionIntent, nullable: true })
intent!: SuggestionIntent | null;
@ApiProperty() title!: string;
@ApiProperty({ type: [String] }) reasons!: string[];
@ApiProperty({ nullable: true, type: String }) dueOn!: string | null;
@ApiProperty({ enum: ['OVERDUE', 'TODAY', 'SOON', 'LATER', 'NONE'] })
urgency!: NextStep['urgency'];
@ApiProperty({ type: [String] }) suggestionIds!: string[];
@ApiProperty({ type: NextStepActionDto }) action!: NextStepActionDto;
@ApiProperty() dismissible!: boolean;
}
export class ColonyNextStepsDto {
@ApiProperty() colonyId!: string;
@ApiProperty({ enum: SEASON_SEGMENTS }) segment!: ColonyNextSteps['segment'];
@ApiProperty({ enum: PHENOLOGY_PHASES, nullable: true })
phase!: ColonyNextSteps['phase'];
@ApiProperty({ enum: PHASE_SOURCES, nullable: true })
phaseSource!: ColonyNextSteps['phaseSource'];
@ApiProperty({ nullable: true, type: String }) inspectionDueOn!:
string | null;
@ApiProperty({ nullable: true, type: String }) withdrawalUntil!:
string | null;
@ApiProperty({ type: [NextStepDto] }) steps!: NextStepDto[];
}
class TodayNextStepDto extends NextStepDto {
@ApiProperty() colonyId!: string;
@ApiProperty() colonyCode!: string;
}
class DueColonyDto {
@ApiProperty() id!: string;
@ApiProperty() code!: string;
@ApiProperty() dueOn!: string;
@ApiProperty() overdueDays!: number;
}
class InspectionDueDto {
@ApiProperty() apiaryId!: string;
@ApiProperty() apiaryName!: string;
@ApiProperty({ enum: SEASON_SEGMENTS })
segment!: AutopilotToday['seasons'][number]['segment'];
@ApiProperty({ nullable: true, type: Number }) intervalDays!: number | null;
@ApiProperty({ type: [DueColonyDto] }) colonies!: DueColonyDto[];
}
class SeasonOfApiaryDto {
@ApiProperty() apiaryId!: string;
@ApiProperty() apiaryName!: string;
@ApiProperty({ enum: SEASON_SEGMENTS })
segment!: AutopilotToday['seasons'][number]['segment'];
@ApiProperty({ enum: PHENOLOGY_PHASES, nullable: true })
phase!: AutopilotToday['seasons'][number]['phase'];
@ApiProperty({ enum: PHASE_SOURCES, nullable: true })
phaseSource!: AutopilotToday['seasons'][number]['phaseSource'];
}
export class AutopilotTodayDto {
@ApiProperty({ type: TodayNextStepDto, nullable: true })
nextStep!: TodayNextStepDto | null;
@ApiProperty({
type: [InspectionDueDto],
description: 'Colonies whose inspection is due today or overdue.',
})
inspectionDue!: InspectionDueDto[];
@ApiProperty() suggestionsOpen!: number;
@ApiProperty({ type: [SeasonOfApiaryDto] }) seasons!: SeasonOfApiaryDto[];
@ApiProperty() tasksVisible!: boolean;
}
@@ -0,0 +1,460 @@
import type { SuggestionIntent } from '../../generated/prisma';
import {
FORM_OF_INTENT,
INTENT_ORDER,
mergeSameForm,
rankSteps,
topSteps,
toNextStep,
type StepSource,
} from './next-step-ranking';
const TODAY = '2026-05-12';
let counter = 0;
function source(overrides: Partial<StepSource> = {}): StepSource {
counter += 1;
const id = overrides.id ?? `s${String(counter).padStart(3, '0')}`;
return {
kind: 'SUGGESTION',
id,
intent: 'INSPECT',
mode: 'DO',
status: 'OPEN',
priority: 'NORMAL',
dueOn: TODAY,
createdAt: new Date('2026-05-01T08:00:00Z'),
title: 'Durchsicht',
reasons: ['Grund.'],
colonyId: 'col-1',
...overrides,
};
}
const ids = (items: readonly StepSource[]): string[] => items.map((i) => i.id);
describe('rankSteps', () => {
it('IN_PROGRESS comes first, then overdue, then by priority and the intent table', () => {
const inProgress = source({
id: 'a-progress',
kind: 'TASK',
mode: null,
intent: null,
status: 'IN_PROGRESS',
priority: 'URGENT',
dueOn: '2026-06-30',
});
const overdueLow = source({
id: 'b-overdue-low',
priority: 'LOW',
dueOn: '2026-05-01',
});
const todayHigh = source({
id: 'c-today-high',
priority: 'HIGH',
intent: 'FEED',
});
const todayHighVarroa = source({
id: 'd-today-high-varroa',
priority: 'HIGH',
intent: 'VARROA_TREAT',
});
const soon = source({ id: 'e-soon', dueOn: '2026-05-14' });
const later = source({ id: 'f-later', dueOn: '2026-05-30' });
const dateless = source({ id: 'g-none', dueOn: null });
const ranked = rankSteps(
[
dateless,
later,
soon,
todayHigh,
overdueLow,
todayHighVarroa,
inProgress,
],
TODAY,
);
// Urgency 0 holds the in-progress task, today and overdue steps; inside
// it priority decides, then the intent table (varroa before feed).
expect(ids(ranked)).toEqual([
'a-progress',
'd-today-high-varroa',
'c-today-high',
'b-overdue-low',
'e-soon',
'f-later',
'g-none',
]);
});
it('a dateless URGENT step counts as urgent', () => {
const urgentNoDate = source({
id: 'urgent',
dueOn: null,
priority: 'URGENT',
});
const soon = source({ id: 'soon', dueOn: '2026-05-14', priority: 'HIGH' });
const normalDateless = source({ id: 'plain', dueOn: null });
expect(ids(rankSteps([normalDateless, soon, urgentNoDate], TODAY))).toEqual(
['urgent', 'soon', 'plain'],
);
});
it('treats a task without an intent like FOLLOW_UP', () => {
const task = source({ id: 'task', kind: 'TASK', mode: null, intent: null });
const cleanup = source({ id: 'cleanup', intent: 'CLEANUP', mode: 'DO' });
const varroa = source({ id: 'varroa', intent: 'VARROA_TREAT' });
expect(ids(rankSteps([cleanup, task, varroa], TODAY))).toEqual([
'varroa',
'task',
'cleanup',
]);
});
it('orders ties by due day, creation time and id', () => {
const early = source({ id: 'z', dueOn: '2026-05-10' });
const sameDayOlder = source({
id: 'y',
dueOn: '2026-05-11',
createdAt: new Date('2026-05-01T00:00:00Z'),
});
const sameDayNewer = source({
id: 'x',
dueOn: '2026-05-11',
createdAt: new Date('2026-05-02T00:00:00Z'),
});
const tieB = source({ id: 'b', dueOn: '2026-05-11' });
const tieA = source({ id: 'a', dueOn: '2026-05-11' });
const ranked = rankSteps(
[sameDayNewer, tieB, early, tieA, sameDayOlder],
TODAY,
);
expect(ids(ranked)).toEqual(['z', 'y', 'a', 'b', 'x']);
});
it('does not change its input', () => {
const input = [source({ id: 'b' }), source({ id: 'a' })];
const copy = [...input];
rankSteps(input, TODAY);
expect(input).toEqual(copy);
});
});
describe('INTENT_ORDER and FORM_OF_INTENT', () => {
it('lists every intent exactly once', () => {
expect(new Set(INTENT_ORDER).size).toBe(INTENT_ORDER.length);
expect(INTENT_ORDER).toHaveLength(17);
expect(INTENT_ORDER[0]).toBe('VARROA_TREAT');
});
it('maps the intents to the capture forms of the spec', () => {
const forms: [SuggestionIntent, string][] = [
['INSPECT', 'inspection'],
['SWARM_CHECK', 'inspection'],
['QUEEN_CHECK', 'inspection'],
['QUEEN_ACCEPTANCE', 'inspection'],
['HONEY_SUPER', 'inspection'],
['HEALTH_CHECK', 'inspection'],
['VARROA_MEASURE', 'varroa'],
['VARROA_RECHECK', 'varroa'],
['FEED', 'feeding'],
['VARROA_TREAT', 'treatment'],
['SUMMER_TREATMENT', 'treatment'],
['WINTER_CHECK', 'winter-check'],
];
for (const [intent, form] of forms) {
expect(FORM_OF_INTENT[intent]).toBe(form);
}
expect(FORM_OF_INTENT.SPLIT_PLAN).toBeUndefined();
expect(FORM_OF_INTENT.CLEANUP).toBeUndefined();
});
});
describe('mergeSameForm', () => {
it('INSPECT (DO) and SWARM_CHECK merge into one step with two reasons and CAPTURE', () => {
const swarm = source({
id: 'swarm',
intent: 'SWARM_CHECK',
mode: 'PLAN',
title: 'Schwarmkontrolle',
reasons: ['Weiselzellen am 05.05. gesehen.'],
dueOn: '2026-05-11',
});
const inspect = source({
id: 'inspect',
intent: 'INSPECT',
mode: 'DO',
title: 'Durchsicht',
reasons: [
'Letzte Durchsicht vor 8 Tagen.',
'In der Schwarmzeit alle 7 Tage.',
],
dueOn: '2026-05-11',
});
const groups = mergeSameForm(rankSteps([inspect, swarm], TODAY));
expect(groups).toHaveLength(1);
const step = toNextStep(groups[0], TODAY);
expect(step.title).toBe('Durchsicht mit Schwarmkontrolle');
expect(step.reasons).toEqual([
'Weiselzellen am 05.05. gesehen.',
'Letzte Durchsicht vor 8 Tagen.',
]);
expect(step.action).toEqual({
type: 'CAPTURE',
form: 'inspection',
href: '/inspections/new?colonyId=col-1',
});
expect(step.urgency).toBe('OVERDUE');
expect(step.dismissible).toBe(true);
});
it('merges the inspection family, but never HONEY_SUPER (spec example B3)', () => {
const inspect = source({ id: 'inspect', intent: 'INSPECT', mode: 'DO' });
const swarm = source({ id: 'swarm', intent: 'SWARM_CHECK', mode: 'PLAN' });
const queen = source({ id: 'queen', intent: 'QUEEN_CHECK', mode: 'PLAN' });
const honeyPlan = source({
id: 'honey-plan',
intent: 'HONEY_SUPER',
mode: 'PLAN',
});
const honeyDo = source({
id: 'honey-do',
intent: 'HONEY_SUPER',
mode: 'DO',
});
const groups = mergeSameForm([queen, swarm, inspect, honeyPlan, honeyDo]);
expect(groups.map(ids)).toEqual([
['queen', 'swarm', 'inspect'],
['honey-plan'],
['honey-do'],
]);
});
it('merges VARROA_MEASURE and VARROA_RECHECK, not VARROA_TREAT', () => {
const measure = source({ id: 'm', intent: 'VARROA_MEASURE', mode: 'DO' });
const recheck = source({ id: 'r', intent: 'VARROA_RECHECK', mode: 'DO' });
const treat = source({ id: 't', intent: 'VARROA_TREAT', mode: 'DO' });
expect(mergeSameForm([treat, recheck, measure]).map(ids)).toEqual([
['t'],
['r', 'm'],
]);
});
it('a merged step takes the earliest due day, all suggestion ids and is dismissible', () => {
const inspect = source({
id: 'inspect',
intent: 'INSPECT',
dueOn: '2026-05-14',
title: 'Durchsicht',
reasons: ['Letzte Durchsicht vor 8 Tagen.'],
});
const queen = source({
id: 'queen',
intent: 'QUEEN_ACCEPTANCE',
mode: 'PLAN',
dueOn: '2026-05-10',
title: 'Annahme der Königin prüfen',
reasons: ['Königin am 05.05. zugesetzt.'],
});
const step = toNextStep([inspect, queen], TODAY);
expect(step.title).toBe('Durchsicht mit Annahme der Königin prüfen');
expect(step.dueOn).toBe('2026-05-10');
expect(step.urgency).toBe('OVERDUE');
expect(step.suggestionIds).toEqual(['inspect', 'queen']);
expect(step.dismissible).toBe(true);
expect(step.reasons[0]).toContain('zugesetzt');
});
it('an IN_PROGRESS task counts as TODAY', () => {
const step = toNextStep(
[
source({
kind: 'TASK',
mode: null,
intent: null,
status: 'IN_PROGRESS',
dueOn: '2026-07-01',
}),
],
TODAY,
);
expect(step.urgency).toBe('TODAY');
expect(step.suggestionIds).toEqual([]);
});
it('two PLAN steps of the same form do not merge', () => {
const a = source({ id: 'a', intent: 'SWARM_CHECK', mode: 'PLAN' });
const b = source({ id: 'b', intent: 'QUEEN_ACCEPTANCE', mode: 'PLAN' });
expect(mergeSameForm([a, b]).map(ids)).toEqual([['a'], ['b']]);
});
it('never merges tasks or steps of other colonies', () => {
const doStep = source({ id: 'do', intent: 'INSPECT', mode: 'DO' });
const task = source({
id: 'task',
kind: 'TASK',
mode: null,
intent: 'SWARM_CHECK',
});
const otherColony = source({
id: 'other',
intent: 'SWARM_CHECK',
mode: 'PLAN',
colonyId: 'col-2',
});
expect(mergeSameForm([doStep, task, otherColony]).map(ids)).toEqual([
['do'],
['task'],
['other'],
]);
});
it('keeps the rank order of the groups (a group sits where its best member sits)', () => {
const varroa = source({
id: 'varroa',
intent: 'VARROA_TREAT',
mode: 'PLAN',
});
const swarm = source({ id: 'swarm', intent: 'SWARM_CHECK', mode: 'PLAN' });
const feed = source({ id: 'feed', intent: 'FEED', mode: 'PLAN' });
const inspect = source({ id: 'inspect', intent: 'INSPECT', mode: 'DO' });
const groups = mergeSameForm([varroa, swarm, feed, inspect]);
expect(groups.map(ids)).toEqual([
['varroa'],
['swarm', 'inspect'],
['feed'],
]);
});
});
describe('toNextStep', () => {
it('opens a task with OPEN_TASK and never lets it be dismissed', () => {
const step = toNextStep(
[
source({
id: 't1',
kind: 'TASK',
mode: null,
intent: null,
title: 'Wachs',
}),
],
TODAY,
);
expect(step).toMatchObject({
key: 'task:t1',
kind: 'TASK',
intent: null,
action: { type: 'OPEN_TASK', taskId: 't1', href: '/tasks' },
dismissible: false,
});
});
it('accepts a PLAN suggestion and lets it be dismissed', () => {
const step = toNextStep(
[source({ id: 's1', intent: 'VARROA_TREAT', mode: 'PLAN' })],
TODAY,
);
expect(step).toMatchObject({
key: 'suggestion:s1',
action: { type: 'ACCEPT', suggestionId: 's1' },
dismissible: true,
});
});
it('falls back to the capture link of a PLAN suggestion when accepting is not allowed', () => {
const step = toNextStep(
[source({ id: 's1', intent: 'VARROA_TREAT', mode: 'PLAN' })],
TODAY,
{ canAccept: false },
);
expect(step.action).toEqual({
type: 'CAPTURE',
form: 'treatment',
href: '/treatments/new?colonyId=col-1',
});
});
it('points a CLEANUP suggestion at the cleanup', () => {
const step = toNextStep(
[source({ id: 'c1', intent: 'CLEANUP', mode: 'DO' })],
TODAY,
);
expect(step.action).toEqual({ type: 'CLEANUP', suggestionId: 'c1' });
});
it('builds the capture links of every form', () => {
const href = (intent: SuggestionIntent, today = TODAY): string => {
const { action } = toNextStep([source({ intent, mode: 'DO' })], today);
return action.type === 'CAPTURE' ? action.href : 'no-capture';
};
expect(href('VARROA_MEASURE')).toBe('/varroa/new?colonyId=col-1');
expect(href('FEED')).toBe('/feedings/new?colonyId=col-1');
expect(href('SUMMER_TREATMENT')).toBe('/treatments/new?colonyId=col-1');
expect(href('WINTER_CHECK', '2026-11-20')).toBe(
'/winter?colonyId=col-1&season=AUTUMN',
);
expect(href('WINTER_CHECK', '2026-03-20')).toBe(
'/winter?colonyId=col-1&season=SPRING',
);
});
it('derives the urgency from the due day', () => {
const urgency = (dueOn: string | null) =>
toNextStep([source({ dueOn })], TODAY).urgency;
expect(urgency('2026-05-11')).toBe('OVERDUE');
expect(urgency('2026-05-12')).toBe('TODAY');
expect(urgency('2026-05-15')).toBe('SOON');
expect(urgency('2026-05-16')).toBe('LATER');
expect(urgency(null)).toBe('NONE');
});
});
describe('topSteps', () => {
it('never returns more than 3 and is stable by id', () => {
const items = ['e', 'c', 'a', 'd', 'b'].map((id) =>
source({ id, intent: 'FEED', mode: 'PLAN' }),
);
const first = topSteps(items, TODAY, { limit: 3 });
const second = topSteps([...items].reverse(), TODAY, { limit: 3 });
expect(first.map((step) => step.key)).toEqual([
'suggestion:a',
'suggestion:b',
'suggestion:c',
]);
expect(second.map((step) => step.key)).toEqual(
first.map((step) => step.key),
);
});
it('drops a step the user can neither accept nor capture', () => {
const split = source({ id: 'split', intent: 'SPLIT_PLAN', mode: 'PLAN' });
const feed = source({ id: 'feed', intent: 'FEED', mode: 'PLAN' });
const steps = topSteps([split, feed], TODAY, {
limit: 3,
canAccept: false,
});
expect(steps.map((step) => step.key)).toEqual(['suggestion:feed']);
});
});
@@ -0,0 +1,438 @@
import type {
SuggestionIntent,
SuggestionMode,
TaskPriority,
} from '../../generated/prisma';
import { daysBetween } from '../common/dates';
import type {
PhaseSourceKey,
PhenologyPhaseKey,
SeasonSegmentKey,
} from '../season/season-types';
export type NextStepForm =
'inspection' | 'varroa' | 'feeding' | 'treatment' | 'winter-check';
export interface NextStep {
/** 'task:<id>' | 'suggestion:<id>' */
key: string;
kind: 'TASK' | 'SUGGESTION';
intent: SuggestionIntent | null;
title: string;
/** At most 2 short sentences. */
reasons: string[];
/** YYYY-MM-DD */
dueOn: string | null;
urgency: 'OVERDUE' | 'TODAY' | 'SOON' | 'LATER' | 'NONE';
/** The suggestions behind the step (all members of a merged step); [] for a task. */
suggestionIds: string[];
action:
| { type: 'CAPTURE'; form: NextStepForm; href: string }
| { type: 'ACCEPT'; suggestionId: string }
| { type: 'CLEANUP'; suggestionId: string }
| { type: 'OPEN_TASK'; taskId: string; href: string };
dismissible: boolean;
}
export interface ColonyNextSteps {
colonyId: string;
segment: SeasonSegmentKey;
phase: PhenologyPhaseKey | null;
phaseSource: PhaseSourceKey | null;
inspectionDueOn: string | null;
withdrawalUntil: string | null;
/** At most `maxStepsPerColony` (3). */
steps: NextStep[];
}
/** An open task or an open suggestion of one colony, ready to be ranked. */
export interface StepSource {
kind: 'TASK' | 'SUGGESTION';
id: string;
intent: SuggestionIntent | null;
mode: SuggestionMode | null;
status: string;
priority: TaskPriority;
/** YYYY-MM-DD */
dueOn: string | null;
createdAt: Date;
title: string;
reasons: string[];
colonyId: string;
}
/** Spec 8.4 point 3. A task without a suggestion counts as FOLLOW_UP. */
export const INTENT_ORDER: readonly SuggestionIntent[] = [
'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',
];
export const FORM_OF_INTENT: Readonly<
Partial<Record<SuggestionIntent, NextStepForm>>
> = {
INSPECT: 'inspection',
SWARM_CHECK: 'inspection',
QUEEN_CHECK: 'inspection',
QUEEN_ACCEPTANCE: 'inspection',
HONEY_SUPER: 'inspection',
HEALTH_CHECK: 'inspection',
VARROA_MEASURE: 'varroa',
VARROA_RECHECK: 'varroa',
FEED: 'feeding',
VARROA_TREAT: 'treatment',
SUMMER_TREATMENT: 'treatment',
WINTER_CHECK: 'winter-check',
};
/** Title of the merged step; the second title is appended with "mit". */
const FORM_TITLES: Readonly<Record<NextStepForm, string>> = {
inspection: 'Durchsicht',
varroa: 'Varroa messen',
feeding: 'Füttern',
treatment: 'Behandlung',
'winter-check': 'Winterkontrolle',
};
const PRIORITY_RANK: Readonly<Record<TaskPriority, number>> = {
URGENT: 0,
HIGH: 1,
NORMAL: 2,
LOW: 3,
};
const INTENT_RANK = new Map(
INTENT_ORDER.map((intent, index) => [intent, index] as const),
);
const SOON_DAYS = 3;
const LATER_TIER_DAYS = 14;
const MAX_REASONS = 2;
const FIRST_AUTUMN_MONTH = 7;
const TASKS_HREF = '/tasks';
const DATELESS_TIER = 4;
export interface StepOptions {
/** The user may accept suggestions (tasks:create). Default true. */
canAccept?: boolean;
}
export interface TopStepsOptions extends StepOptions {
limit: number;
}
function intentRank(source: StepSource): number {
return INTENT_RANK.get(source.intent ?? 'FOLLOW_UP') ?? INTENT_ORDER.length;
}
/** Spec 8.4 point 1: 0 = now, 1 <= 3 days, 2 <= 14 days, 3 later, 4 no date. */
function urgencyTier(source: StepSource, today: string): number {
if (source.status === 'IN_PROGRESS') {
return 0;
}
if (source.dueOn === null) {
return source.priority === 'URGENT' ? 0 : DATELESS_TIER;
}
const days = daysBetween(today, source.dueOn);
if (days <= 0) {
return 0;
}
if (days <= SOON_DAYS) {
return 1;
}
return days <= LATER_TIER_DAYS ? 2 : 3;
}
function compareText(a: string, b: string): number {
if (a === b) {
return 0;
}
return a < b ? -1 : 1;
}
/** The total order of the spec: urgency, priority, intent, due day, createdAt, id. */
export function compareSources(
a: StepSource,
b: StepSource,
today: string,
): number {
return (
urgencyTier(a, today) - urgencyTier(b, today) ||
PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority] ||
intentRank(a) - intentRank(b) ||
compareText(a.dueOn ?? '9999-12-31', b.dueOn ?? '9999-12-31') ||
a.createdAt.getTime() - b.createdAt.getTime() ||
compareText(a.id, b.id)
);
}
export function rankSteps(
items: readonly StepSource[],
today: string,
): StepSource[] {
return [...items].sort((a, b) => compareSources(a, b, today));
}
function formOf(source: StepSource): NextStepForm | undefined {
return source.kind === 'SUGGESTION' && source.intent !== null
? FORM_OF_INTENT[source.intent]
: undefined;
}
/**
* Intents that merge into one step with the "plain" member of their family
* (spec 8.4 says: same form = one step; the B3 example of spec 8.5 shows
* "Durchsicht mit Schwarmkontrolle" next to a separate "Honigraum prüfen",
* although both open the inspection form - the example wins).
* inspection family: INSPECT, SWARM_CHECK, QUEEN_CHECK, QUEEN_ACCEPTANCE
* varroa family: VARROA_MEASURE, VARROA_RECHECK
* Everything else (HONEY_SUPER, HEALTH_CHECK, FEED, VARROA_TREAT,
* SUMMER_TREATMENT, WINTER_CHECK ...) never merges.
*/
const MERGE_FAMILY: Readonly<Partial<Record<SuggestionIntent, NextStepForm>>> =
{
INSPECT: 'inspection',
SWARM_CHECK: 'inspection',
QUEEN_CHECK: 'inspection',
QUEEN_ACCEPTANCE: 'inspection',
VARROA_MEASURE: 'varroa',
VARROA_RECHECK: 'varroa',
};
/** The member whose title is the form's own title, by intent. */
const PLAIN_INTENT: Readonly<
Record<'inspection' | 'varroa', SuggestionIntent>
> = { inspection: 'INSPECT', varroa: 'VARROA_MEASURE' };
function familyOf(source: StepSource): NextStepForm | undefined {
return source.kind === 'SUGGESTION' && source.intent !== null
? MERGE_FAMILY[source.intent]
: undefined;
}
/**
* Suggestions of one merge family (table above) of one colony become one
* step, when at least one of them is a DO suggestion (PLAN suggestions alone
* are decisions, not a form to fill in). The group keeps the position of its
* best-ranked member; `ranked` must already be ranked.
*/
export function mergeSameForm(ranked: readonly StepSource[]): StepSource[][] {
const members = new Map<string, StepSource[]>();
for (const item of ranked) {
const family = familyOf(item);
if (family !== undefined) {
const key = `${item.colonyId}|${family}`;
members.set(key, [...(members.get(key) ?? []), item]);
}
}
const groups: StepSource[][] = [];
const placed = new Set<string>();
for (const item of ranked) {
const family = familyOf(item);
const key = family === undefined ? null : `${item.colonyId}|${family}`;
const group = key === null ? undefined : members.get(key);
const mergeable =
group !== undefined &&
group.length > 1 &&
group.some((member) => member.mode === 'DO');
if (key === null || !mergeable) {
groups.push([item]);
} else if (!placed.has(key)) {
placed.add(key);
groups.push(group);
}
}
return groups;
}
function urgencyOf(dueOn: string | null, today: string): NextStep['urgency'] {
if (dueOn === null) {
return 'NONE';
}
const days = daysBetween(today, dueOn);
if (days < 0) {
return 'OVERDUE';
}
if (days === 0) {
return 'TODAY';
}
return days <= SOON_DAYS ? 'SOON' : 'LATER';
}
function captureHref(
form: NextStepForm,
colonyId: string,
today: string,
): string {
const query = `colonyId=${encodeURIComponent(colonyId)}`;
switch (form) {
case 'inspection':
return `/inspections/new?${query}`;
case 'varroa':
return `/varroa/new?${query}`;
case 'feeding':
return `/feedings/new?${query}`;
case 'treatment':
return `/treatments/new?${query}`;
case 'winter-check': {
const month = Number(today.slice(5, 7));
const season = month >= FIRST_AUTUMN_MONTH ? 'AUTUMN' : 'SPRING';
return `/winter?${query}&season=${season}`;
}
}
}
function capture(
form: NextStepForm,
colonyId: string,
today: string,
): NextStep['action'] {
return {
type: 'CAPTURE',
form,
href: captureHref(form, colonyId, today),
};
}
function actionOf(
source: StepSource,
today: string,
canAccept: boolean,
): NextStep['action'] | null {
if (source.kind === 'TASK') {
return { type: 'OPEN_TASK', taskId: source.id, href: TASKS_HREF };
}
if (source.intent === 'CLEANUP') {
return { type: 'CLEANUP', suggestionId: source.id };
}
if (source.mode === 'PLAN' && canAccept) {
return { type: 'ACCEPT', suggestionId: source.id };
}
const form = formOf(source);
return form === undefined ? null : capture(form, source.colonyId, today);
}
/** Whether the user can do anything with this step (else it is not shown). */
function isActionable(
source: StepSource,
today: string,
canAccept: boolean,
): boolean {
return actionOf(source, today, canAccept) !== null;
}
/**
* The specific cause first (the step that is not the plain form, "Weiselzellen
* gesehen"), then the plain one ("Letzte Durchsicht vor 8 Tagen").
*/
function isPlain(form: NextStepForm, item: StepSource): boolean {
return (
(form === 'inspection' || form === 'varroa') &&
item.intent === PLAIN_INTENT[form]
);
}
/**
* The specific cause first ("Weiselzellen gesehen"), then the plain member
* ("Letzte Durchsicht vor 8 Tagen"), at most two.
*/
function mergedReasons(
form: NextStepForm,
group: readonly StepSource[],
): string[] {
const ordered = [
...group.filter((item) => !isPlain(form, item)),
...group.filter((item) => isPlain(form, item)),
];
const firsts = ordered.flatMap((item) => item.reasons.slice(0, 1));
const rest = ordered.flatMap((item) => item.reasons.slice(1));
return [...new Set([...firsts, ...rest])].slice(0, MAX_REASONS);
}
function mergedTitle(form: NextStepForm, group: readonly StepSource[]): string {
const second = group.find((item) => !isPlain(form, item));
const formTitle = FORM_TITLES[form];
return second === undefined ? formTitle : `${formTitle} mit ${second.title}`;
}
/** The earliest due day of the members (null when none has one). */
function earliestDue(group: readonly StepSource[]): string | null {
const days = group.flatMap((item) =>
item.dueOn === null ? [] : [item.dueOn],
);
return days.length === 0 ? null : days.reduce((a, b) => (a <= b ? a : b));
}
/** One step from a ranked group (a single item, or a merged group). */
export function toNextStep(
group: readonly StepSource[],
today: string,
options: StepOptions = {},
): NextStep {
const [head] = group;
const canAccept = options.canAccept ?? true;
const form = formOf(head);
if (group.length > 1 && form !== undefined) {
return {
key: `suggestion:${head.id}`,
kind: 'SUGGESTION',
intent: head.intent,
title: mergedTitle(form, group),
reasons: mergedReasons(form, group),
dueOn: earliestDue(group),
urgency: urgencyOf(earliestDue(group), today),
suggestionIds: group.map((item) => item.id),
action: capture(form, head.colonyId, today),
dismissible: true,
};
}
const action = actionOf(head, today, canAccept);
if (action === null) {
throw new Error(`Step ${head.id} has no action`);
}
return {
key: `${head.kind === 'TASK' ? 'task' : 'suggestion'}:${head.id}`,
kind: head.kind,
intent: head.intent,
title: head.title,
reasons: head.reasons.slice(0, MAX_REASONS),
dueOn: head.dueOn,
urgency:
head.status === 'IN_PROGRESS' ? 'TODAY' : urgencyOf(head.dueOn, today),
suggestionIds: head.kind === 'SUGGESTION' ? [head.id] : [],
action,
dismissible: head.kind === 'SUGGESTION',
};
}
/**
* The next steps of one colony: what the user can act on, ranked, merged by
* form and cut to `limit`.
*/
export function topSteps(
items: readonly StepSource[],
today: string,
options: TopStepsOptions,
): NextStep[] {
const canAccept = options.canAccept ?? true;
const actionable = items.filter((item) =>
isActionable(item, today, canAccept),
);
return mergeSameForm(rankSteps(actionable, today))
.slice(0, options.limit)
.map((group) => toNextStep(group, today, { canAccept }));
}
@@ -0,0 +1,413 @@
import { NotFoundException } from '@nestjs/common';
import type { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import type { PrismaService } from '../prisma/prisma.service';
import { SeasonService } from '../season/season.service';
import { SuggestionViewsService } from '../suggestions/suggestion-views.service';
import { NextStepsService } from './next-steps.service';
const TODAY = '2026-05-12';
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
describe('NextStepsService', () => {
let service: NextStepsService;
let prisma: {
colony: { findMany: jest.Mock };
suggestion: { findMany: jest.Mock; groupBy: jest.Mock; count: jest.Mock };
task: { findMany: jest.Mock };
inspection: { groupBy: jest.Mock };
treatment: { findMany: jest.Mock };
automationRule: { findUnique: jest.Mock; findMany: jest.Mock };
apiary: { findMany: jest.Mock };
};
let permissionResolver: { getPermissions: jest.Mock };
const grant = (...keys: string[]) =>
permissionResolver.getPermissions.mockResolvedValue(new Set(keys));
const suggestionRow = (overrides: Record<string, unknown> = {}) => ({
id: 's1',
ruleKey: 'VARROA_THRESHOLD',
reasonCode: 'UNKNOWN_CODE',
evidence: {},
intent: 'VARROA_TREAT',
mode: 'PLAN',
status: 'OPEN',
priority: 'HIGH',
dueDate: d('2026-05-12'),
createdAt: new Date('2026-05-10T08:00:00Z'),
colonyId: 'c1',
...overrides,
});
beforeEach(() => {
prisma = {
colony: {
findMany: jest.fn().mockResolvedValue([
{
id: 'c1',
code: 'B3',
apiaryId: 'a1',
createdAt: new Date('2026-04-01T08:00:00Z'),
},
]),
},
suggestion: {
findMany: jest.fn().mockResolvedValue([]),
groupBy: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0),
},
task: { findMany: jest.fn().mockResolvedValue([]) },
inspection: {
groupBy: jest
.fn()
.mockResolvedValue([
{ colonyId: 'c1', _max: { date: d('2026-05-03') } },
]),
},
treatment: { findMany: jest.fn().mockResolvedValue([]) },
automationRule: {
findUnique: jest.fn().mockResolvedValue(null),
findMany: jest.fn().mockResolvedValue([]),
},
apiary: { findMany: jest.fn().mockResolvedValue([]) },
};
permissionResolver = { getPermissions: jest.fn() };
service = new NextStepsService(
prisma as unknown as PrismaService,
permissionResolver as unknown as PermissionResolverService,
new SeasonService(),
new SuggestionViewsService(
prisma as unknown as PrismaService,
permissionResolver as unknown as PermissionResolverService,
),
);
});
it('answers 404 for an unknown colony', async () => {
grant('colonies:read');
prisma.colony.findMany.mockResolvedValue([]);
await expect(service.forColony('nope', 'u1', TODAY)).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('reads no suggestions and no tasks without tasks:read', async () => {
grant('colonies:read', 'varroa:read');
const steps = await service.forColony('c1', 'u1', TODAY);
expect(steps.steps).toEqual([]);
expect(prisma.suggestion.findMany).not.toHaveBeenCalled();
expect(prisma.task.findMany).not.toHaveBeenCalled();
});
it('filters the suggestions by the permissions the user holds', async () => {
grant('colonies:read', 'tasks:read', 'inspections:read');
await service.forColony('c1', 'u1', TODAY);
expect(prisma.suggestion.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
AND: [
{
OR: [
{ requiredPermission: null },
{
requiredPermission: {
in: ['colonies:read', 'tasks:read', 'inspections:read'],
},
},
],
},
{ status: 'OPEN', colonyId: { in: ['c1'] } },
],
},
}),
);
});
it('offers ACCEPT only with tasks:create, else the capture link', async () => {
prisma.suggestion.findMany.mockResolvedValue([suggestionRow()]);
grant('colonies:read', 'tasks:read', 'tasks:create');
const withCreate = await service.forColony('c1', 'u1', TODAY);
grant('colonies:read', 'tasks:read');
const withoutCreate = await service.forColony('c1', 'u1', TODAY);
expect(withCreate.steps[0].action).toEqual({
type: 'ACCEPT',
suggestionId: 's1',
});
expect(withoutCreate.steps[0].action).toEqual({
type: 'CAPTURE',
form: 'treatment',
href: '/treatments/new?colonyId=c1',
});
});
it('cuts the steps to maxStepsPerColony', async () => {
grant('colonies:read', 'tasks:read');
prisma.suggestion.findMany.mockResolvedValue(
[
['a', 'WINTER_CHECK'],
['b', 'VARROA_MEASURE'],
['c', 'INSPECT'],
['d', 'FEED'],
['e', 'VARROA_TREAT'],
].map(([id, intent]) => suggestionRow({ id, intent, mode: 'DO' })),
);
const steps = await service.forColony('c1', 'u1', TODAY);
expect(steps.steps.map((step) => step.key)).toEqual([
'suggestion:e',
'suggestion:d',
'suggestion:c',
]);
});
it('shows inspectionDueOn only with inspections:read', async () => {
grant('colonies:read');
const hidden = await service.forColony('c1', 'u1', TODAY);
grant('colonies:read', 'inspections:read');
const shown = await service.forColony('c1', 'u1', TODAY);
expect(hidden.inspectionDueOn).toBeNull();
// Swarm season by calendar: 7 days after 2026-05-03.
expect(shown.inspectionDueOn).toBe('2026-05-10');
expect(shown.segment).toBe('SCHWARMZEIT');
expect(prisma.inspection.groupBy).toHaveBeenCalledTimes(1);
});
it('reports the latest running withdrawal period as a hint with treatments:read', async () => {
prisma.treatment.findMany.mockResolvedValue([
{
id: 't1',
colonyId: 'c1',
product: 'A',
date: d('2026-05-01'),
startDate: null,
endDate: d('2026-05-05'),
withdrawalPeriodDays: 10,
},
{
id: 't2',
colonyId: 'c1',
product: 'B',
date: d('2026-05-02'),
startDate: null,
endDate: d('2026-05-08'),
withdrawalPeriodDays: 30,
},
{
id: 't3',
colonyId: 'c1',
product: 'C',
date: d('2026-01-02'),
startDate: null,
endDate: null,
withdrawalPeriodDays: 10,
},
]);
grant('colonies:read', 'treatments:read');
const shown = await service.forColony('c1', 'u1', TODAY);
grant('colonies:read');
const hidden = await service.forColony('c1', 'u1', TODAY);
expect(shown.withdrawalUntil).toBe('2026-06-07');
expect(shown.steps).toEqual([]);
expect(hidden.withdrawalUntil).toBeNull();
expect(prisma.treatment.findMany).toHaveBeenCalledTimes(1);
});
it('ranks a task like its intent and shows it with OPEN_TASK', async () => {
grant('colonies:read', 'tasks:read');
prisma.task.findMany.mockResolvedValue([
{
id: 't1',
title: 'Wachs einschmelzen',
status: 'IN_PROGRESS',
priority: 'NORMAL',
dueDate: null,
createdAt: new Date('2026-05-01T00:00:00Z'),
relatedId: 'c1',
sourceAutomationTrigger: null,
sourceSuggestion: null,
},
]);
const steps = await service.forColony('c1', 'u1', TODAY);
expect(steps.steps).toHaveLength(1);
expect(steps.steps[0]).toMatchObject({
key: 'task:t1',
title: 'Wachs einschmelzen',
action: { type: 'OPEN_TASK', taskId: 't1', href: '/tasks' },
});
});
it('hides an open suggestion when an open task of the colony has the same intent', async () => {
grant('colonies:read', 'tasks:read');
prisma.suggestion.findMany.mockResolvedValue([
suggestionRow({ id: 'varroa', intent: 'VARROA_TREAT' }),
suggestionRow({ id: 'feed', intent: 'FEED', mode: 'DO' }),
suggestionRow({ id: 'swarm', intent: 'SWARM_CHECK', colonyId: 'c9' }),
]);
prisma.task.findMany.mockResolvedValue([
{
id: 'task-accepted',
title: 'Behandeln',
status: 'OPEN',
priority: 'NORMAL',
dueDate: null,
createdAt: new Date('2026-05-01T00:00:00Z'),
relatedId: 'c1',
sourceAutomationTrigger: null,
sourceSuggestion: { intent: 'VARROA_TREAT' },
},
{
id: 'task-legacy',
title: 'Füttern',
status: 'OPEN',
priority: 'NORMAL',
dueDate: null,
createdAt: new Date('2026-05-01T00:00:00Z'),
relatedId: 'c1',
sourceAutomationTrigger: 'FEED_STATUS_CRITICAL',
sourceSuggestion: null,
},
]);
const steps = await service.forColony('c1', 'u1', TODAY);
expect(steps.steps.map((step) => step.key).sort()).toEqual([
'task:task-accepted',
'task:task-legacy',
]);
});
it('reads a fixed number of queries for many colonies', async () => {
grant('colonies:read', 'tasks:read', 'inspections:read', 'treatments:read');
prisma.colony.findMany.mockResolvedValue(
['c1', 'c2', 'c3'].map((id) => ({
id,
code: id,
apiaryId: 'a1',
createdAt: new Date('2026-04-01T08:00:00Z'),
})),
);
const result = await service.forColonies(['c1', 'c2', 'c3'], 'u1', TODAY);
expect([...result.keys()]).toEqual(['c1', 'c2', 'c3']);
expect(prisma.colony.findMany).toHaveBeenCalledTimes(1);
expect(prisma.suggestion.findMany).toHaveBeenCalledTimes(1);
expect(prisma.task.findMany).toHaveBeenCalledTimes(1);
expect(prisma.inspection.groupBy).toHaveBeenCalledTimes(1);
expect(prisma.treatment.findMany).toHaveBeenCalledTimes(1);
});
describe('today', () => {
it('picks the most urgent first step over all colonies', async () => {
grant('colonies:read', 'tasks:read');
prisma.colony.findMany.mockImplementation((args: { select: object }) =>
Promise.resolve(
'code' in args.select
? ['c1', 'c2'].map((id) => ({
id,
code: id.toUpperCase(),
apiaryId: 'a1',
createdAt: new Date('2026-04-01T08:00:00Z'),
}))
: [{ id: 'c1' }, { id: 'c2' }],
),
);
prisma.apiary.findMany.mockResolvedValue([
{ id: 'a1', name: 'Heimstand' },
]);
prisma.suggestion.count.mockResolvedValue(2);
prisma.suggestion.findMany.mockResolvedValue([
suggestionRow({ id: 'late', colonyId: 'c1', priority: 'LOW' }),
suggestionRow({ id: 'urgent', colonyId: 'c2', priority: 'URGENT' }),
]);
const today = await service.today('u1', TODAY);
expect(today.nextStep).toMatchObject({
key: 'suggestion:urgent',
colonyId: 'c2',
colonyCode: 'C2',
});
expect(today.suggestionsOpen).toBe(2);
expect(today.tasksVisible).toBe(true);
expect(today.seasons).toEqual([
{
apiaryId: 'a1',
apiaryName: 'Heimstand',
segment: 'SCHWARMZEIT',
phase: 'APFEL',
phaseSource: 'CALENDAR',
},
]);
});
it('has no next step and counts no suggestions without tasks:read', async () => {
grant('colonies:read');
const today = await service.today('u1', TODAY);
expect(today.nextStep).toBeNull();
expect(today.suggestionsOpen).toBe(0);
expect(today.tasksVisible).toBe(false);
expect(prisma.suggestion.count).not.toHaveBeenCalled();
});
it('lists only colonies whose inspection is due, with the overdue days', async () => {
grant('colonies:read', 'inspections:read');
prisma.apiary.findMany.mockResolvedValue([
{ id: 'a1', name: 'Heimstand' },
]);
const today = await service.today('u1', TODAY);
expect(today.inspectionDue).toEqual([
{
apiaryId: 'a1',
apiaryName: 'Heimstand',
segment: 'SCHWARMZEIT',
intervalDays: 7,
colonies: [
{ id: 'c1', code: 'B3', dueOn: '2026-05-10', overdueDays: 2 },
],
},
]);
});
});
describe('warningCounts', () => {
it('counts open HIGH and URGENT suggestions per colony', async () => {
grant('colonies:read', 'tasks:read');
prisma.suggestion.groupBy.mockResolvedValue([
{ colonyId: 'c1', priority: 'HIGH', _count: { _all: 2 } },
{ colonyId: 'c1', priority: 'URGENT', _count: { _all: 1 } },
{ colonyId: null, priority: 'HIGH', _count: { _all: 5 } },
]);
const counts = await service.warningCounts(['c1'], 'u1');
expect(counts).toEqual(new Map([['c1', { warnings: 2, critical: 1 }]]));
});
it('counts nothing without tasks:read', async () => {
grant('colonies:read');
const counts = await service.warningCounts(['c1'], 'u1');
expect(counts.size).toBe(0);
expect(prisma.suggestion.groupBy).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,631 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import type {
AutomationTriggerType,
SuggestionIntent,
TaskPriority,
} from '../../generated/prisma';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { readRule } from '../automation/rule-config';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import {
addDays,
berlinDayOf,
berlinToday,
daysBetween,
dayToDate,
} from '../common/dates';
import { PrismaService } from '../prisma/prisma.service';
import { SeasonService } from '../season/season.service';
import { inspectionDueOn } from '../season/inspection-due';
import type { SeasonSegmentKey, SeasonState } from '../season/season-types';
import { buildEvidenceLookup } from '../suggestions/evidence-lookup';
import { renderSuggestion } from '../suggestions/suggestion-texts';
import { SuggestionViewsService } from '../suggestions/suggestion-views.service';
import {
INTENT_OF_LEGACY_TRIGGER,
type SuggestionEvidence,
} from '../suggestions/suggestion.types';
import {
isInWindow,
withdrawalWindowOf,
} from '../treatments/withdrawal-window';
import {
compareSources,
topSteps,
type ColonyNextSteps,
type NextStep,
type StepSource,
} from './next-step-ranking';
export interface AutopilotToday {
nextStep: (NextStep & { colonyId: string; colonyCode: string }) | null;
/** Colonies whose inspection is due today or overdue (`dueOn <= today`), not those coming up. */
inspectionDue: {
apiaryId: string;
apiaryName: string;
segment: SeasonSegmentKey;
intervalDays: number | null;
colonies: {
id: string;
code: string;
dueOn: string;
overdueDays: number;
}[];
}[];
suggestionsOpen: number;
seasons: {
apiaryId: string;
apiaryName: string;
segment: SeasonSegmentKey;
phase: ColonyNextSteps['phase'];
phaseSource: ColonyNextSteps['phaseSource'];
}[];
tasksVisible: boolean;
}
/** Open suggestions of HIGH / URGENT priority that the caller may see. */
export interface WarningCount {
warnings: number;
critical: number;
}
interface ColonyComputation {
code: string;
apiaryId: string;
/** Leading source of the first step, for the comparison across colonies. */
head: StepSource | null;
view: ColonyNextSteps;
}
interface Computation {
colonies: Map<string, ColonyComputation>;
seasons: Map<string, SeasonState>;
intervalDays: Readonly<
Record<Exclude<SeasonSegmentKey, 'WINTERRUHE'>, number>
>;
}
/** A withdrawal period longer than this is not looked for. */
const WITHDRAWAL_LOOKBACK_DAYS = 400;
const OPEN_TASK_STATUSES = ['OPEN', 'IN_PROGRESS'] as const;
const WARNING_PRIORITIES: readonly TaskPriority[] = ['HIGH', 'URGENT'];
/** The intent an open task stands for; null for a plain task. */
function taskIntentOf(task: {
sourceSuggestion: { intent: SuggestionIntent } | null;
sourceAutomationTrigger: AutomationTriggerType | null;
}): SuggestionIntent | null {
return (
task.sourceSuggestion?.intent ??
(task.sourceAutomationTrigger === null
? null
: (INTENT_OF_LEGACY_TRIGGER[task.sourceAutomationTrigger] ?? null))
);
}
function byCode(a: { code: string }, b: { code: string }): number {
return a.code.localeCompare(b.code, 'de', { numeric: true });
}
/**
* "The next 3 steps" of a colony (Spec 8.4, 8.5) and the day overview: what
* the user may see of the open tasks and suggestions, ranked, merged by form
* and cut to `maxStepsPerColony`. Reads only; a fixed number of queries for
* any number of colonies.
*/
@Injectable()
export class NextStepsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissionResolver: PermissionResolverService,
private readonly seasons: SeasonService,
private readonly suggestionViews: SuggestionViewsService,
) {}
async forColony(
colonyId: string,
userId: string,
today: string = berlinToday(),
): Promise<ColonyNextSteps> {
const view = (await this.forColonies([colonyId], userId, today)).get(
colonyId,
);
if (view === undefined) {
throw new NotFoundException('Colony not found');
}
return view;
}
/** Unknown colonies are left out of the map. */
async forColonies(
colonyIds: readonly string[],
userId: string,
today: string = berlinToday(),
permissions?: ReadonlySet<string>,
): Promise<Map<string, ColonyNextSteps>> {
const held =
permissions ?? (await this.permissionResolver.getPermissions(userId));
const { colonies } = await this.compute(colonyIds, held, today);
return new Map(
[...colonies].map(([id, colony]) => [id, colony.view] as const),
);
}
/**
* Open HIGH / URGENT suggestions per colony: the "warnings" and
* "critical" of the colony traffic light. Only what the user may see
* (`tasks:read` and the permission of each suggestion).
*/
async warningCounts(
colonyIds: readonly string[],
userId: string,
permissions?: ReadonlySet<string>,
): Promise<Map<string, WarningCount>> {
const held =
permissions ?? (await this.permissionResolver.getPermissions(userId));
const counts = new Map<string, WarningCount>();
if (!held.has('tasks:read') || colonyIds.length === 0) {
return counts;
}
const groups = await this.prisma.suggestion.groupBy({
by: ['colonyId', 'priority'],
where: {
AND: [
this.suggestionViews.visibleWhere(held),
{
status: 'OPEN',
colonyId: { in: [...colonyIds] },
priority: { in: [...WARNING_PRIORITIES] },
},
],
},
_count: { _all: true },
});
for (const group of groups) {
if (group.colonyId === null) {
continue;
}
const current = counts.get(group.colonyId) ?? {
warnings: 0,
critical: 0,
};
counts.set(
group.colonyId,
group.priority === 'URGENT'
? { ...current, critical: group._count._all }
: { ...current, warnings: group._count._all },
);
}
return counts;
}
async today(
userId: string,
today: string = berlinToday(),
): Promise<AutopilotToday> {
const held = await this.permissionResolver.getPermissions(userId);
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),
held,
today,
);
const apiaries = await this.prisma.apiary.findMany({
where: { id: { in: [...seasons.keys()] } },
select: { id: true, name: true },
});
const apiaryName = new Map(apiaries.map((a) => [a.id, a.name] as const));
const nameOf = (apiaryId: string): string => apiaryName.get(apiaryId) ?? '';
const suggestionsOpen = held.has('tasks:read')
? await this.prisma.suggestion.count({
where: {
AND: [this.suggestionViews.visibleWhere(held), { status: 'OPEN' }],
},
})
: 0;
const sortedApiaries = [...seasons.values()].sort((a, b) =>
nameOf(a.apiaryId).localeCompare(nameOf(b.apiaryId), 'de', {
numeric: true,
}),
);
return {
nextStep: this.mostUrgent(colonies, today),
inspectionDue: this.dueInspections(
colonies,
sortedApiaries,
intervalDays,
nameOf,
today,
),
suggestionsOpen,
seasons: sortedApiaries.map((season) => ({
apiaryId: season.apiaryId,
apiaryName: nameOf(season.apiaryId),
segment: season.segment,
phase: season.phase,
phaseSource: season.phaseSource,
})),
tasksVisible: held.has('tasks:read'),
};
}
/**
* 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<AutopilotToday['inspectionDue']> {
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<string, ColonyComputation>,
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<string, ColonyComputation>,
today: string,
): AutopilotToday['nextStep'] {
const candidates = [...colonies].flatMap(([colonyId, colony]) =>
colony.head === null || colony.view.steps.length === 0
? []
: [{ colonyId, colony, head: colony.head }],
);
candidates.sort((a, b) => compareSources(a.head, b.head, today));
const best = candidates.at(0);
return best === undefined
? null
: {
...best.colony.view.steps[0],
colonyId: best.colonyId,
colonyCode: best.colony.code,
};
}
private async compute(
colonyIds: readonly string[],
held: ReadonlySet<string>,
today: string,
): Promise<Computation> {
const ids = [...new Set(colonyIds)];
const rows =
ids.length === 0
? []
: await this.prisma.colony.findMany({
where: { id: { in: ids } },
select: { id: true, code: true, apiaryId: true, createdAt: true },
});
const [states, interval, autopilot] = await Promise.all([
this.seasons.statesFor(
this.prisma,
rows.map((row) => row.apiaryId),
today,
),
readRule(this.prisma, 'INSPECTION_INTERVAL'),
readRule(this.prisma, 'SEASON_AUTOPILOT'),
]);
const foundIds = rows.map((row) => row.id);
const [lastInspections, sources, withdrawals] = await Promise.all([
this.lastInspectionDays(foundIds, held),
this.stepSources(foundIds, held, today),
this.withdrawalUntil(foundIds, held, today),
]);
const colonies = new Map<string, ColonyComputation>();
for (const row of rows) {
const season = states.get(row.apiaryId);
if (season === undefined) {
continue;
}
const due = interval.active
? inspectionDueOn(
{
createdOn: berlinDayOf(row.createdAt),
lastInspectedOn: lastInspections.get(row.id) ?? null,
},
season,
interval.config,
)
: null;
const own = sources.get(row.id) ?? [];
const steps = topSteps(own, today, {
limit: autopilot.config.maxStepsPerColony,
canAccept: held.has('tasks:create'),
});
colonies.set(row.id, {
code: row.code,
apiaryId: row.apiaryId,
head: this.headOf(own, steps),
view: {
colonyId: row.id,
segment: season.segment,
phase: season.phase,
phaseSource: season.phaseSource,
inspectionDueOn:
due !== null && held.has('inspections:read') ? due.dueOn : null,
withdrawalUntil: withdrawals.get(row.id) ?? null,
steps,
},
});
}
return {
colonies,
seasons: states,
intervalDays: interval.config.intervalDays,
};
}
/** The source that stands for the first step (for the cross-colony order). */
private headOf(
own: readonly StepSource[],
steps: readonly NextStep[],
): StepSource | null {
const first = steps.at(0);
if (first === undefined) {
return null;
}
const byId = new Map(own.map((source) => [source.id, source] as const));
const id =
first.suggestionIds.at(0) ??
(first.action.type === 'OPEN_TASK' ? first.action.taskId : null);
return id === null ? null : (byId.get(id) ?? null);
}
/** Last inspection day per colony; nothing without `inspections:read`. */
private async lastInspectionDays(
colonyIds: readonly string[],
held: ReadonlySet<string>,
): Promise<Map<string, string>> {
const days = new Map<string, string>();
if (!held.has('inspections:read') || colonyIds.length === 0) {
return days;
}
const groups = await this.prisma.inspection.groupBy({
by: ['colonyId'],
where: { colonyId: { in: [...colonyIds] } },
_max: { date: true },
});
for (const group of groups) {
if (group._max.date) {
days.set(group.colonyId, berlinDayOf(group._max.date));
}
}
return days;
}
/** The youngest running withdrawal period per colony (treatments:read). */
private async withdrawalUntil(
colonyIds: readonly string[],
held: ReadonlySet<string>,
today: string,
): Promise<Map<string, string>> {
const until = new Map<string, string>();
if (!held.has('treatments:read') || colonyIds.length === 0) {
return until;
}
const cutoff = dayToDate(addDays(today, -WITHDRAWAL_LOOKBACK_DAYS));
const treatments = await this.prisma.treatment.findMany({
where: {
colonyId: { in: [...colonyIds] },
withdrawalPeriodDays: { not: null },
OR: [{ endDate: { gte: cutoff } }, { date: { gte: cutoff } }],
},
select: {
id: true,
colonyId: true,
product: true,
date: true,
startDate: true,
endDate: true,
withdrawalPeriodDays: true,
},
});
for (const treatment of treatments) {
const window = withdrawalWindowOf(treatment);
if (window === null || !isInWindow(window, today)) {
continue;
}
const current = until.get(treatment.colonyId);
if (current === undefined || window.untilDay > current) {
until.set(treatment.colonyId, window.untilDay);
}
}
return until;
}
/**
* Open suggestions (visible ones, and only with `tasks:read`) and open
* tasks (`tasks:read`) of the colonies, as rankable sources.
*/
private async stepSources(
colonyIds: readonly string[],
held: ReadonlySet<string>,
today: string,
): Promise<Map<string, StepSource[]>> {
const sources = new Map<string, StepSource[]>();
if (!held.has('tasks:read') || colonyIds.length === 0) {
return sources;
}
const [suggestions, tasks] = await Promise.all([
this.prisma.suggestion.findMany({
where: {
AND: [
this.suggestionViews.visibleWhere(held),
{ status: 'OPEN', colonyId: { in: [...colonyIds] } },
],
},
select: {
id: true,
ruleKey: true,
reasonCode: true,
evidence: true,
intent: true,
mode: true,
status: true,
priority: true,
dueDate: true,
createdAt: true,
colonyId: true,
},
}),
this.prisma.task.findMany({
where: {
status: { in: [...OPEN_TASK_STATUSES] },
relatedType: 'COLONY',
relatedId: { in: [...colonyIds] },
},
select: {
id: true,
title: true,
status: true,
priority: true,
dueDate: true,
createdAt: true,
relatedId: true,
sourceAutomationTrigger: true,
sourceSuggestion: { select: { intent: true } },
},
}),
]);
const [lookup, templates] = await Promise.all([
buildEvidenceLookup(
this.prisma,
suggestions.map((row) => row.evidence as SuggestionEvidence),
),
this.titleTemplates(suggestions.map((row) => row.ruleKey)),
]);
const add = (colonyId: string, source: StepSource): void => {
sources.set(colonyId, [...(sources.get(colonyId) ?? []), source]);
};
// One open thing per intent and colony: an open task of the same intent
// (accepted suggestion or legacy rule task) hides an open suggestion.
const openTaskIntents = new Set(
tasks.flatMap((row) => {
const intent = taskIntentOf(row);
return row.relatedId === null || intent === null
? []
: [`${row.relatedId}|${intent}`];
}),
);
for (const row of suggestions) {
if (
row.colonyId === null ||
openTaskIntents.has(`${row.colonyId}|${row.intent}`)
) {
continue;
}
const text = renderSuggestion(
row,
lookup,
today,
templates.get(row.ruleKey),
);
add(row.colonyId, {
kind: 'SUGGESTION',
id: row.id,
intent: row.intent,
mode: row.mode,
status: row.status,
priority: row.priority,
dueOn: row.dueDate === null ? null : berlinDayOf(row.dueDate),
createdAt: row.createdAt,
title: text.title,
reasons: text.reasons,
colonyId: row.colonyId,
});
}
for (const row of tasks) {
if (row.relatedId === null) {
continue;
}
add(row.relatedId, {
kind: 'TASK',
id: row.id,
intent: taskIntentOf(row),
mode: null,
status: row.status,
priority: row.priority,
dueOn: row.dueDate === null ? null : berlinDayOf(row.dueDate),
createdAt: row.createdAt,
title: row.title,
reasons: [],
colonyId: row.relatedId,
});
}
return sources;
}
/** The `actionTemplate` of each rule replaces the title of its suggestions. */
private async titleTemplates(
ruleKeys: readonly AutomationTriggerType[],
): Promise<Map<AutomationTriggerType, string | null>> {
if (ruleKeys.length === 0) {
return new Map();
}
const rules = await this.prisma.automationRule.findMany({
where: { triggerType: { in: [...new Set(ruleKeys)] } },
select: { triggerType: true, actionTemplate: true },
});
return new Map(
rules.map((rule) => [rule.triggerType, rule.actionTemplate] as const),
);
}
}
@@ -0,0 +1,29 @@
import type { SuggestionsService } from '../suggestions/suggestions.service';
import { SuggestionsExpiryStep } from './suggestions-expiry.step';
describe('SuggestionsExpiryStep', () => {
it('expires the open suggestions past their day and reports the count', async () => {
const expireDue = jest.fn().mockResolvedValue(3);
const step = new SuggestionsExpiryStep({
expireDue,
} as unknown as SuggestionsService);
expect(step.name).toBe('suggestions-expiry');
expect(await step.run('2026-05-12')).toEqual({ suggestionsExpired: 3 });
expect(expireDue).toHaveBeenCalledWith('2026-05-12');
});
it('is idempotent: a second run expires nothing more', async () => {
const expireDue = jest
.fn()
.mockResolvedValueOnce(2)
.mockResolvedValueOnce(0);
const step = new SuggestionsExpiryStep({
expireDue,
} as unknown as SuggestionsService);
await step.run('2026-05-12');
expect(await step.run('2026-05-12')).toEqual({ suggestionsExpired: 0 });
});
});
@@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import type { DailyEvaluationStep } from '../jobs/daily-evaluation-steps';
import type { JobResult } from '../jobs/job';
import { SuggestionsService } from '../suggestions/suggestions.service';
/**
* Step "suggestions-expiry": OPEN suggestions past their expiry day become
* EXPIRED (one statement, which also frees their dedupe key). Running it
* twice for one day changes nothing the second time.
*/
@Injectable()
export class SuggestionsExpiryStep implements DailyEvaluationStep {
readonly name = 'suggestions-expiry';
constructor(private readonly suggestions: SuggestionsService) {}
async run(today: string): Promise<JobResult> {
return { suggestionsExpired: await this.suggestions.expireDue(today) };
}
}
@@ -29,6 +29,7 @@ const BUSINESS_ENTITY_KEYS = [
'queenColonyHistory', 'queenColonyHistory',
'inspection', 'inspection',
'taskTemplate', 'taskTemplate',
'suggestion',
'task', 'task',
'varroaMeasurement', 'varroaMeasurement',
'swarmControlCheck', 'swarmControlCheck',
@@ -71,6 +72,7 @@ const ALL_ENTITY_KEYS = [
...BUSINESS_ENTITY_KEYS, ...BUSINESS_ENTITY_KEYS,
'document', 'document',
'notification', 'notification',
'notificationPreference',
'auditLog', 'auditLog',
] as const; ] as const;
@@ -112,6 +114,10 @@ describe('BackupService', () => {
upsert: jest.fn().mockResolvedValue({ id: `${key}-upserted` }), upsert: jest.fn().mockResolvedValue({ id: `${key}-upserted` }),
}; };
} }
tx.notification = {
upsert: jest.fn().mockResolvedValue({ id: 'notification-upserted' }),
findUnique: jest.fn().mockResolvedValue(null),
} as never;
tx.queen.update = jest.fn().mockResolvedValue({}); tx.queen.update = jest.fn().mockResolvedValue({});
tx.queen.findMany = jest.fn().mockResolvedValue([]); tx.queen.findMany = jest.fn().mockResolvedValue([]);
tx.document.findUnique = jest.fn().mockResolvedValue(null); tx.document.findUnique = jest.fn().mockResolvedValue(null);
@@ -237,6 +243,157 @@ describe('BackupService', () => {
]; ];
expect(call.create.broodFrames).toBe(5); expect(call.create.broodFrames).toBe(5);
}); });
it('maps a legacy free-text feedStatus of an old bundle on import', async () => {
const base = { colonyId: 'colony-1', userId: 'known-user' };
const bundle = {
version: 3,
data: {
inspection: [
{ ...base, id: 'i-1', feedStatus: ' Niedrig ', notes: null },
{ ...base, id: 'i-2', feedStatus: 'halb voll', notes: 'x' },
{ ...base, id: 'i-3', feedStatus: 'CRITICAL', notes: null },
{ ...base, id: 'i-4', feedStatus: '', notes: 'y' },
],
},
};
await service.importAll(bundle as never, 'admin-1', TRUSTED);
const created = (
tx.inspection.upsert.mock.calls as [
[{ where: { id: string }; create: Record<string, unknown> }],
]
).map(([call]) => call.create);
expect(created).toEqual([
expect.objectContaining({ id: 'i-1', feedStatus: 'WEAK', notes: null }),
expect.objectContaining({
id: 'i-2',
feedStatus: null,
notes: 'x\nFutter (alt): halb voll',
}),
expect.objectContaining({
id: 'i-3',
feedStatus: 'CRITICAL',
notes: null,
}),
expect.objectContaining({ id: 'i-4', feedStatus: null, notes: 'y' }),
]);
});
});
describe('legacy feedStatus of unexpected type', () => {
it('keeps a non-string feedStatus in the notes as JSON instead of dropping it', async () => {
const base = { colonyId: 'colony-1', userId: 'known-user' };
const bundle = {
version: 3,
data: {
inspection: [
{ ...base, id: 'i-1', feedStatus: 3, notes: null },
{ ...base, id: 'i-2', feedStatus: { level: 'low' }, notes: 'x' },
{ ...base, id: 'i-3', feedStatus: null, notes: 'y' },
],
},
};
await service.importAll(bundle as never, 'admin-1', TRUSTED);
const created = (
tx.inspection.upsert.mock.calls as [
[{ create: Record<string, unknown> }],
]
).map(([call]) => call.create);
expect(created).toEqual([
expect.objectContaining({
feedStatus: null,
notes: 'Futter (alt): 3',
}),
expect.objectContaining({
feedStatus: null,
notes: 'x\nFutter (alt): {"level":"low"}',
}),
expect.objectContaining({ feedStatus: null, notes: 'y' }),
]);
});
});
describe('suggestions', () => {
it('exports and restores suggestions with their decision before tasks', async () => {
const suggestion = {
id: 'suggestion-1',
colonyId: 'colony-1',
status: 'ACCEPTED',
dedupeKey: 'INSPECTION_INTERVAL:colony-1:insp-9',
decidedAt: '2026-10-14T08:00:00.000Z',
decidedByUserId: 'known-user',
decisionNote: 'Passt',
};
const orphanDecision = {
...suggestion,
id: 'suggestion-2',
dedupeKey: null,
decidedByUserId: 'deleted-user',
};
const open = {
...suggestion,
id: 'suggestion-3',
status: 'OPEN',
decidedAt: null,
decidedByUserId: null,
decisionNote: null,
};
prisma.suggestion.findMany.mockResolvedValue([
suggestion,
orphanDecision,
open,
]);
prisma.colony.findMany.mockResolvedValue([{ id: 'colony-1' }]);
prisma.task.findMany.mockResolvedValue([
{ id: 'task-1', sourceSuggestionId: 'suggestion-1' },
]);
const bundle = await service.exportAll();
expect(bundle.data.suggestion).toEqual([
suggestion,
orphanDecision,
open,
]);
const summary = await service.importAll(bundle, 'admin-1', TRUSTED);
expect(summary.imported.suggestion).toBe(3);
const creates = (
tx.suggestion.upsert.mock.calls as [
{ create: { id: string; decidedByUserId: string | null } },
][]
).map(([call]) => call.create);
// A decider that is gone resolves like any other user id; no decision
// stays without one.
expect(creates.map((row) => row.decidedByUserId)).toEqual([
'known-user',
'admin-1',
null,
]);
const suggestionOrder = tx.suggestion.upsert.mock.invocationCallOrder[0];
const taskOrder = tx.task.upsert.mock.invocationCallOrder[0];
expect(suggestionOrder).toBeLessThan(taskOrder);
const colonyOrder = tx.colony.upsert.mock.invocationCallOrder[0];
expect(colonyOrder).toBeLessThan(suggestionOrder);
});
it('does not restore suggestions from an unsealed file', async () => {
await service.importAll(
{
version: 3,
exportedAt: '2026-10-14T00:00:00.000Z',
data: { suggestion: [{ id: 'suggestion-1' }] },
},
'admin-1',
{ trusted: false },
);
expect(tx.suggestion.upsert).not.toHaveBeenCalled();
});
}); });
describe('importAll', () => { describe('importAll', () => {
@@ -1152,6 +1309,148 @@ describe('BackupService', () => {
}); });
}); });
describe('notifications', () => {
const note = (overrides: Row = {}): Row => ({
id: 'n1',
userId: 'u1',
priority: 'INFO',
category: 'SUGGESTION',
message: 'm',
relatedType: 'COLONY',
relatedId: 'c1',
read: true,
createdAt: '2026-01-01T00:00:00.000Z',
...overrides,
});
const without = (row: Row, ...keys: string[]): Row =>
Object.fromEntries(
Object.entries(row).filter(([key]) => !keys.includes(key)),
);
const importNotes = (rows: Row[]) =>
service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: { notification: rows },
},
'admin-1',
TRUSTED,
);
const written = () =>
(
tx.notification.upsert.mock.calls as [
{ where: { id: string }; create: Row },
][]
).map(([arg]) => arg.create);
beforeEach(() => {
tx.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]);
});
it('restore keeps the userId of a notification and skips a row whose user does not exist in the target', async () => {
const summary = await importNotes([
note({ id: 'n1', userId: 'u2' }),
note({ id: 'n2', userId: 'gone' }),
]);
expect(written().map((row) => [row.id, row.userId])).toEqual([
['n1', 'u2'],
]);
expect(summary.imported.notification).toBe(1);
expect(summary.imported.notificationSkipped).toBe(1);
});
it('skips and counts a row whose dedupeKey already exists under another id', async () => {
(
tx.notification as unknown as { findUnique: jest.Mock }
).findUnique.mockImplementation(
({ where }: { where: { dedupeKey: string } }) =>
Promise.resolve(
where.dedupeKey === 'k:u1' ? { id: 'other-id' } : null,
),
);
const summary = await importNotes([
note({ id: 'n1', dedupeKey: 'k:u1' }),
note({ id: 'n2', dedupeKey: 'k:u2' }),
// Same id as the stored row: a plain re-restore, written as before.
note({ id: 'other-id', dedupeKey: 'k:u1' }),
]);
expect(written().map((row) => row.id)).toEqual(['n2', 'other-id']);
expect(summary.imported.notification).toBe(2);
expect(summary.imported.notificationSkipped).toBe(1);
});
it('a repeated restore of broadcast copies does not reset their read flags', async () => {
const { userId: _u, category: _c, ...old } = note({ id: 'b' });
void _u;
void _c;
await importNotes([old]);
const calls = tx.notification.upsert.mock.calls as [{ update: Row }][];
expect(calls).toHaveLength(2);
expect(calls.every(([arg]) => Object.keys(arg.update).length === 0)).toBe(
true,
);
});
it('never writes a notification without a userId', async () => {
await importNotes([
note({ userId: null }),
note({ id: 'n3', userId: 'gone' }),
]);
expect(written().every((row) => typeof row.userId === 'string')).toBe(
true,
);
});
it('an old bundle notification without userId is copied per active user: category SYSTEM for relatedType JobRun or none, SUGGESTION otherwise', async () => {
const old = (id: string, relatedType: string | null): Row => {
return without(note({ id, relatedType }), 'userId', 'category');
};
await importNotes([
old('a', 'JobRun'),
old('b', null),
old('c', 'COLONY'),
]);
const rows = written().map((row) => [row.userId, row.category, row.read]);
expect(rows).toEqual([
['u1', 'SYSTEM', true],
['u2', 'SYSTEM', true],
['u1', 'SYSTEM', true],
['u2', 'SYSTEM', true],
['u1', 'SUGGESTION', true],
['u2', 'SUGGESTION', true],
]);
expect(new Set(written().map((row) => row.id)).size).toBe(6);
});
it('fills the category of an old row that has a userId but none', async () => {
await importNotes([without(note({ relatedType: 'JobRun' }), 'category')]);
expect(written()[0]).toMatchObject({ userId: 'u1', category: 'SYSTEM' });
});
it('exports and restores the notification preferences of the users', async () => {
prisma.notificationPreference.findMany.mockResolvedValue([
{ userId: 'u1', digestTime: '07:00' },
{ userId: 'gone', digestTime: '08:00' },
]);
const bundle = await service.exportAll();
expect(bundle.data.notificationPreference).toHaveLength(2);
await service.importAll(bundle, 'admin-1', TRUSTED);
const calls = tx.notificationPreference.upsert.mock.calls as [
{ where: { userId: string } },
][];
expect(calls.map(([arg]) => arg.where.userId)).toEqual(['u1']);
});
});
// Drift guard: a model added to the schema has to be backed up or listed // Drift guard: a model added to the schema has to be backed up or listed
// in MODELS_EXCLUDED_FROM_BACKUP with its reason - never silently missing. // in MODELS_EXCLUDED_FROM_BACKUP with its reason - never silently missing.
describe('model coverage', () => { describe('model coverage', () => {
+150 -22
View File
@@ -9,6 +9,10 @@ import {
isAllowedDocumentMimeType, isAllowedDocumentMimeType,
} from '../documents/document-mime-types'; } from '../documents/document-mime-types';
import { matchesFileSignature } from '../common/file-signature'; import { matchesFileSignature } from '../common/file-signature';
import {
appendLegacyFeedNote,
feedConditionFromInput,
} from '../common/feed-vocabulary';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { DocumentEntityType, Prisma } from '../../generated/prisma'; import { DocumentEntityType, Prisma } from '../../generated/prisma';
import { import {
@@ -67,7 +71,8 @@ export interface BackupImportSummary {
* - siteSettings: keeper name/address printed on the Bestandsbuch and the * - siteSettings: keeper name/address printed on the Bestandsbuch and the
* public links. * public links.
* - document: writes files to the server's disk. * - document: writes files to the server's disk.
* - notification: messages shown to users as coming from ApiCore. * - notification, notificationPreference: messages shown to users as coming
* from ApiCore, and settings of a user.
*/ */
export const LEGACY_UNSEALED_TABLES = [ export const LEGACY_UNSEALED_TABLES = [
'apiary', 'apiary',
@@ -162,6 +167,45 @@ function asString(row: Row, key: string): string | null {
return typeof value === 'string' ? value : null; return typeof value === 'string' ? value : null;
} }
// Bundles written before the feed vocabulary change carry free text in
// Inspection.feedStatus: known words map to the enum, the rest goes to notes.
function withCurrentFeedVocabulary(row: Row): Row {
if (!('feedStatus' in row)) {
return row;
}
const raw = row.feedStatus;
// A value that is neither text nor empty cannot map; keep it in the notes.
const { feedStatus, notesSuffix } =
raw === null || raw === undefined || typeof raw === 'string'
? feedConditionFromInput(raw)
: {
feedStatus: null,
notesSuffix: `Futter (alt): ${JSON.stringify(raw)}`,
};
return {
...row,
feedStatus,
notes: appendLegacyFeedNote(asString(row, 'notes'), notesSuffix),
};
}
// Bundles written before the notification categories carry none: job failures
// (relatedType 'JobRun') and rows without relatedType are system messages,
// everything else a suggestion - the rule of migration 11.
function withCurrentNotificationCategory(row: Row): Row {
if (typeof row.category === 'string') {
return row;
}
const relatedType = asString(row, 'relatedType');
return {
...row,
category:
relatedType === null || relatedType === 'JobRun'
? 'SYSTEM'
: 'SUGGESTION',
};
}
function documentsStorageRoot(): string { function documentsStorageRoot(): string {
return process.env.DOCUMENTS_STORAGE_PATH ?? './uploads'; return process.env.DOCUMENTS_STORAGE_PATH ?? './uploads';
} }
@@ -233,6 +277,7 @@ export class BackupService {
data.queenColonyHistory = await this.prisma.queenColonyHistory.findMany(); data.queenColonyHistory = await this.prisma.queenColonyHistory.findMany();
data.inspection = await this.prisma.inspection.findMany(); data.inspection = await this.prisma.inspection.findMany();
data.taskTemplate = await this.prisma.taskTemplate.findMany(); data.taskTemplate = await this.prisma.taskTemplate.findMany();
data.suggestion = await this.prisma.suggestion.findMany();
data.task = await this.prisma.task.findMany(); data.task = await this.prisma.task.findMany();
data.varroaMeasurement = await this.prisma.varroaMeasurement.findMany(); data.varroaMeasurement = await this.prisma.varroaMeasurement.findMany();
data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany(); data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany();
@@ -289,6 +334,8 @@ export class BackupService {
); );
data.notification = await this.prisma.notification.findMany(); data.notification = await this.prisma.notification.findMany();
data.notificationPreference =
await this.prisma.notificationPreference.findMany();
data.auditLog = await this.prisma.auditLog.findMany(); data.auditLog = await this.prisma.auditLog.findMany();
return { return {
@@ -538,16 +585,14 @@ export class BackupService {
imported.queenColonyHistory = rowsOf('queenColonyHistory').length; imported.queenColonyHistory = rowsOf('queenColonyHistory').length;
for (const row of rowsOf('inspection')) { for (const row of rowsOf('inspection')) {
const inspection = {
...withCurrentFeedVocabulary(row),
userId: resolveUserId(asString(row, 'userId')),
};
await tx.inspection.upsert({ await tx.inspection.upsert({
where: { id: row.id as string }, where: { id: row.id as string },
create: { create: inspection as never,
...row, update: updateWith(inspection) as never,
userId: resolveUserId(asString(row, 'userId')),
} as never,
update: updateWith({
...row,
userId: resolveUserId(asString(row, 'userId')),
}) as never,
}); });
} }
imported.inspection = rowsOf('inspection').length; imported.inspection = rowsOf('inspection').length;
@@ -577,6 +622,23 @@ export class BackupService {
} }
imported.taskTemplate = rowsOf('taskTemplate').length; imported.taskTemplate = rowsOf('taskTemplate').length;
// Before the tasks: Task.sourceSuggestionId points at them. After the
// colonies: a suggestion may hang on one.
for (const row of rowsOf('suggestion')) {
const data = {
...row,
decidedByUserId: resolveOptionalUserId(
asString(row, 'decidedByUserId'),
),
};
await tx.suggestion.upsert({
where: { id: row.id as string },
create: data as never,
update: updateWith(data) as never,
});
}
imported.suggestion = rowsOf('suggestion').length;
for (const row of rowsOf('task')) { for (const row of rowsOf('task')) {
await tx.task.upsert({ await tx.task.upsert({
where: { id: row.id as string }, where: { id: row.id as string },
@@ -995,7 +1057,7 @@ export class BackupService {
} }
imported.calendarSyncSettings = rowsOf('calendarSyncSettings').length; imported.calendarSyncSettings = rowsOf('calendarSyncSettings').length;
// Keyed by sourceType+sourceId, like CalendarSyncService.enqueue - // Keyed by sourceType+sourceId, like CalendarSyncService.enqueueIn -
// the target database may already track the same source under // the target database may already track the same source under
// another id. graphEventId comes along, so a restored entry keeps // another id. graphEventId comes along, so a restored entry keeps
// updating its existing M365 event instead of creating a duplicate. // updating its existing M365 event instead of creating a duplicate.
@@ -1083,20 +1145,86 @@ export class BackupService {
} }
imported.document = documentCount; imported.document = documentCount;
const activeUserIds = (
await tx.user.findMany({
where: { isActive: true },
select: { id: true },
orderBy: { id: 'asc' },
})
).map((user) => user.id);
let notificationsWritten = 0;
let notificationsSkipped = 0;
for (const row of rowsOf('notification')) { for (const row of rowsOf('notification')) {
await tx.notification.upsert({ const withCategory = withCurrentNotificationCategory(row);
where: { id: row.id as string }, const rawUserId = asString(row, 'userId');
create: { // A bundle from before the per-user notifications carries
...row, // broadcasts (no userId): one copy per active user, as migration 8.
userId: resolveOptionalUserId(asString(row, 'userId')), // A row of a user this database does not know is skipped - the
} as never, // userId is mandatory and never falls back to somebody else.
update: { const owners =
...row, rawUserId === null
userId: resolveOptionalUserId(asString(row, 'userId')), ? activeUserIds
} as never, : knownUserIds.has(rawUserId)
}); ? [rawUserId]
: [];
if (owners.length === 0) {
notificationsSkipped += 1;
continue;
} }
imported.notification = rowsOf('notification').length; for (const userId of owners) {
const data = {
...withCategory,
id:
rawUserId === null
? `${row.id as string}:${userId}`
: (row.id as string),
userId,
};
// The unique dedupeKey of a row that lives here under another id
// would abort the whole restore: skip that row instead.
const dedupeKey = asString(data, 'dedupeKey');
if (dedupeKey !== null) {
const holder = await tx.notification.findUnique({
where: { dedupeKey },
select: { id: true },
});
if (holder && holder.id !== data.id) {
notificationsSkipped += 1;
continue;
}
}
await tx.notification.upsert({
where: { id: data.id },
create: data as never,
// A copy made from a broadcast is only added: a repeated
// restore must not reset what the user already read.
update: (rawUserId === null ? {} : data) as never,
});
notificationsWritten += 1;
}
}
imported.notification = notificationsWritten;
if (notificationsSkipped > 0) {
imported.notificationSkipped = notificationsSkipped;
this.logger.warn(
`${notificationsSkipped} Meldung(en) übersprungen: Nutzer im Zielsystem unbekannt.`,
);
}
let preferencesWritten = 0;
for (const row of rowsOf('notificationPreference')) {
const userId = asString(row, 'userId');
if (userId === null || !knownUserIds.has(userId)) {
continue;
}
await tx.notificationPreference.upsert({
where: { userId },
create: row as never,
update: row as never,
});
preferencesWritten += 1;
}
imported.notificationPreference = preferencesWritten;
// Append-only: entries already here are never rewritten by a file. // Append-only: entries already here are never rewritten by a file.
for (const row of rowsOf('auditLog')) { for (const row of rowsOf('auditLog')) {
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module'; import { AutomationModule } from '../automation/automation.module';
import { StockModule } from '../stock/stock.module'; import { StockModule } from '../stock/stock.module';
import { WithdrawalModule } from '../withdrawal/withdrawal.module';
import { BottlingController } from './bottling.controller'; import { BottlingController } from './bottling.controller';
import { BottlingService } from './bottling.service'; import { BottlingService } from './bottling.service';
@Module({ @Module({
imports: [AutomationModule, StockModule], imports: [AutomationModule, StockModule, WithdrawalModule],
controllers: [BottlingController], controllers: [BottlingController],
providers: [BottlingService], providers: [BottlingService],
}) })
@@ -9,6 +9,8 @@ import { AuditLogService } from '../audit/audit-log.service';
import { DomainEventsService } from '../domain-events/domain-events.service'; import { DomainEventsService } from '../domain-events/domain-events.service';
import { AutomationService } from '../automation/automation.service'; import { AutomationService } from '../automation/automation.service';
import { StockService } from '../stock/stock.service'; import { StockService } from '../stock/stock.service';
import type { PlausibilityWarning } from '../common/warnings/plausibility-warning';
import { WithdrawalCheckService } from '../withdrawal/withdrawal-check.service';
type AmountChange = number | { increment?: number; decrement?: number }; type AmountChange = number | { increment?: number; decrement?: number };
@@ -63,6 +65,10 @@ describe('BottlingService', () => {
}; };
let auditLog: { record: jest.Mock }; let auditLog: { record: jest.Mock };
let events: { recordAll: jest.Mock }; let events: { recordAll: jest.Mock };
let withdrawalCheck: {
conflictsForHoneyBatch: jest.Mock;
warningsFor: jest.Mock;
};
let tx: { let tx: {
$queryRaw: jest.Mock; $queryRaw: jest.Mock;
product: { findUnique: jest.Mock }; product: { findUnique: jest.Mock };
@@ -185,12 +191,17 @@ describe('BottlingService', () => {
), ),
hasMovements: jest.fn().mockResolvedValue(false), hasMovements: jest.fn().mockResolvedValue(false),
}; };
withdrawalCheck = {
conflictsForHoneyBatch: jest.fn().mockResolvedValue([]),
warningsFor: jest.fn().mockResolvedValue([]),
};
service = new BottlingService( service = new BottlingService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService, auditLog as unknown as AuditLogService,
events as unknown as DomainEventsService, events as unknown as DomainEventsService,
automation as unknown as AutomationService, automation as unknown as AutomationService,
stock as unknown as StockService, stock as unknown as StockService,
withdrawalCheck as unknown as WithdrawalCheckService,
); );
}); });
@@ -253,7 +264,7 @@ describe('BottlingService', () => {
honeyBatchId: 'h1', honeyBatchId: 'h1',
totalAmount: 25, totalAmount: 25,
}); });
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({ tx.bottlingBatch.findUnique.mockResolvedValueOnce({
id: 'b1', id: 'b1',
totalAmount: 25, totalAmount: 25,
}); });
@@ -270,7 +281,7 @@ describe('BottlingService', () => {
'user-1', 'user-1',
); );
expect(result).toEqual({ id: 'b1', totalAmount: 25 }); expect(result).toEqual({ id: 'b1', totalAmount: 25, warnings: [] });
expect(honeyBatches.get('h1')).toEqual({ amount: 0, status: 'BOTTLED' }); expect(honeyBatches.get('h1')).toEqual({ amount: 0, status: 'BOTTLED' });
expect(auditLog.record).toHaveBeenCalledWith( expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -292,7 +303,7 @@ describe('BottlingService', () => {
honeyBatchId: 'h1', honeyBatchId: 'h1',
totalAmount: 5, totalAmount: 5,
}); });
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' }); tx.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' });
await service.create( await service.create(
{ {
@@ -359,7 +370,7 @@ describe('BottlingService', () => {
const result = await service.update('b1', { lotNumber: 'L-2' }, 'user-1'); const result = await service.update('b1', { lotNumber: 'L-2' }, 'user-1');
// Without movements the remaining stock equals the jar count. // Without movements the remaining stock equals the jar count.
expect(result).toEqual({ ...updated, remainingCount: 10 }); expect(result).toEqual({ ...updated, remainingCount: 10, warnings: [] });
expect(auditLog.record).toHaveBeenCalledWith( expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
entityType: 'BottlingBatch', entityType: 'BottlingBatch',
@@ -675,4 +686,81 @@ describe('BottlingService', () => {
NotFoundException, NotFoundException,
); );
}); });
describe('withdrawal warnings', () => {
const conflict = {
harvestId: 'harvest-1',
harvestedOn: '2026-07-01',
colonyId: 'c1',
treatmentId: 't1',
product: 'Oxuvar',
untilDay: '2026-07-20',
};
const warning: PlausibilityWarning = {
code: 'WITHDRAWAL_ACTIVE',
severity: 'WARNUNG',
message:
'B3: Wartezeit nach Oxuvar bis einschließlich 19.07. – ab 20.07. wieder erntefähig.',
refs: [{ type: 'Colony', id: 'c1' }],
};
beforeEach(() => {
withdrawalCheck.conflictsForHoneyBatch.mockResolvedValue([conflict]);
withdrawalCheck.warningsFor.mockResolvedValue([warning]);
});
it('create answers with the warning of the harvests behind the honey batch and still bottles', async () => {
prisma.honeyBatch.findUnique.mockResolvedValueOnce({
id: 'h1',
amount: 25,
});
honeyBatches.set('h1', { amount: 25 });
tx.bottlingBatch.create.mockResolvedValueOnce({
id: 'b1',
honeyBatchId: 'h1',
totalAmount: 5,
});
tx.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' });
const result = await service.create(
{
honeyBatchId: 'h1',
date: '2026-01-01',
jarSize: 500,
jarCount: 10,
bestBeforeDate: '2027-01-01',
lotNumber: 'L-1',
},
'user-1',
);
expect(result).toEqual({ id: 'b1', warnings: [warning] });
expect(withdrawalCheck.conflictsForHoneyBatch).toHaveBeenCalledWith(
tx,
'h1',
);
expect(tx.bottlingBatch.create).toHaveBeenCalledTimes(1);
});
it('update checks the honey batch the bottling draws on now', async () => {
const before = {
id: 'b1',
lotNumber: 'L-1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 10 },
jarSize: 500,
jarCount: 10,
totalAmount: 5,
};
lockedRows(before, { ...before, lotNumber: 'L-2' });
const result = await service.update('b1', { lotNumber: 'L-2' }, 'user-1');
expect(result.warnings).toEqual([warning]);
expect(withdrawalCheck.conflictsForHoneyBatch).toHaveBeenCalledWith(
tx,
'h1',
);
});
});
}); });
@@ -12,7 +12,9 @@ import { DomainEventsService } from '../domain-events/domain-events.service';
import type { AnyNewDomainEvent } from '../domain-events/domain-event-types'; import type { AnyNewDomainEvent } from '../domain-events/domain-event-types';
import { AutomationService } from '../automation/automation.service'; import { AutomationService } from '../automation/automation.service';
import { StockService } from '../stock/stock.service'; import { StockService } from '../stock/stock.service';
import { BottlingBatch, Prisma } from '../../generated/prisma'; import { withWarnings } from '../common/warnings/plausibility-warning';
import { WithdrawalCheckService } from '../withdrawal/withdrawal-check.service';
import { Prisma } from '../../generated/prisma';
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto'; import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto'; import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto';
@@ -116,8 +118,29 @@ export class BottlingService {
private readonly events: DomainEventsService, private readonly events: DomainEventsService,
private readonly automation: AutomationService, private readonly automation: AutomationService,
private readonly stock: StockService, private readonly stock: StockService,
private readonly withdrawalCheck: WithdrawalCheckService,
) {} ) {}
/**
* The bottling as the response body plus the withdrawal warnings of the
* harvests behind its honey batch. Bottling inside a window is written all
* the same (Annahme 10): the warning is information, never a 4xx.
*/
private async withWithdrawalWarnings<T extends object>(
db: DbClient,
honeyBatchId: string,
bottling: T,
) {
const conflicts = await this.withdrawalCheck.conflictsForHoneyBatch(
db,
honeyBatchId,
);
return withWarnings(
bottling,
await this.withdrawalCheck.warningsFor(db, conflicts),
);
}
async findAll(filter: BottlingBatchFilter = {}) { async findAll(filter: BottlingBatchFilter = {}) {
const batches = await this.prisma.bottlingBatch.findMany({ const batches = await this.prisma.bottlingBatch.findMany({
where: { honeyBatchId: filter.honeyBatchId }, where: { honeyBatchId: filter.honeyBatchId },
@@ -194,8 +217,7 @@ export class BottlingService {
); );
} }
const bottlingBatch: BottlingBatch = await this.prisma.$transaction( return this.prisma.$transaction(async (tx) => {
async (tx) => {
// Booked against the stored amount inside the transaction, never // Booked against the stored amount inside the transaction, never
// against the value read above: two bottlings submitted at the same // against the value read above: two bottlings submitted at the same
// time (double tap, two devices) must not both pass the check and // time (double tap, two devices) must not both pass the check and
@@ -231,11 +253,12 @@ export class BottlingService {
payload: { honeyBatchId: created.honeyBatchId }, payload: { honeyBatchId: created.honeyBatchId },
actorUserId: actingUserId, actorUserId: actingUserId,
}); });
return created; return this.withWithdrawalWarnings(
}, tx,
created.honeyBatchId,
await this.findById(created.id, tx),
); );
});
return this.findById(bottlingBatch.id);
} }
/** /**
@@ -337,7 +360,7 @@ export class BottlingService {
payload: { honeyBatchId, removed: false }, payload: { honeyBatchId, removed: false },
actorUserId: actingUserId, actorUserId: actingUserId,
}); });
return bottlingBatch; return this.withWithdrawalWarnings(tx, honeyBatchId, bottlingBatch);
}); });
} }
@@ -23,6 +23,7 @@ describe('CalendarSyncSettingsService', () => {
syncFeeding: true, syncFeeding: true,
syncHarvest: true, syncHarvest: true,
syncQueenAssignment: true, syncQueenAssignment: true,
syncWithdrawal: true,
updatedAt: new Date('2026-01-01'), updatedAt: new Date('2026-01-01'),
}; };
@@ -87,6 +88,7 @@ describe('CalendarSyncSettingsService', () => {
syncFeeding: undefined, syncFeeding: undefined,
syncHarvest: undefined, syncHarvest: undefined,
syncQueenAssignment: undefined, syncQueenAssignment: undefined,
syncWithdrawal: undefined,
}, },
}); });
expect(result.groupId).toBe('group-abc'); expect(result.groupId).toBe('group-abc');
@@ -149,8 +151,25 @@ describe('CalendarSyncSettingsService', () => {
syncFeeding: undefined, syncFeeding: undefined,
syncHarvest: undefined, syncHarvest: undefined,
syncQueenAssignment: undefined, syncQueenAssignment: undefined,
syncWithdrawal: undefined,
}, },
}); });
}); });
it('passes the withdrawal switch on', async () => {
prisma.calendarSyncSettings.findUnique.mockResolvedValue(existing);
prisma.calendarSyncSettings.update.mockResolvedValue({
...existing,
syncWithdrawal: false,
});
await service.update({ syncWithdrawal: false }, 'user-1');
expect(prisma.calendarSyncSettings.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ syncWithdrawal: false }) as unknown,
}),
);
});
}); });
}); });
@@ -47,6 +47,7 @@ export class CalendarSyncSettingsService {
syncFeeding: dto.syncFeeding, syncFeeding: dto.syncFeeding,
syncHarvest: dto.syncHarvest, syncHarvest: dto.syncHarvest,
syncQueenAssignment: dto.syncQueenAssignment, syncQueenAssignment: dto.syncQueenAssignment,
syncWithdrawal: dto.syncWithdrawal,
}, },
}); });
await this.auditLog.record({ await this.auditLog.record({
@@ -47,6 +47,22 @@ describe('CalendarSyncEventHandlers', () => {
}, },
); );
it.each(['TreatmentRecorded', 'TreatmentCorrected'] as const)(
'withdrawal handler enqueues the treatment as WITHDRAWAL on %s, so the span follows its days',
async (type) => {
await handlers.withdrawal(
envelope(type, 'treatment-1', { colonyId: 'c1' }),
tx,
);
expect(calendarSync.enqueueIn).toHaveBeenCalledWith(
tx,
'WITHDRAWAL',
'treatment-1',
);
},
);
it.each(['TaskCreated', 'TaskUpdated', 'TaskCompleted'] as const)( it.each(['TaskCreated', 'TaskUpdated', 'TaskCompleted'] as const)(
'task handler enqueues the task on %s', 'task handler enqueues the task on %s',
async (type) => { async (type) => {
@@ -59,6 +75,24 @@ describe('CalendarSyncEventHandlers', () => {
}, },
); );
it('QueenAssigned enqueues QUEEN_ASSIGNMENT with the history id', async () => {
await handlers.queenAssignment(
envelope('QueenAssigned', 'queen-1', {
colonyId: 'colony-1',
historyId: 'history-1',
assignedOn: '2026-10-14',
previousColonyId: null,
}),
tx,
);
expect(calendarSync.enqueueIn).toHaveBeenCalledWith(
tx,
'QUEEN_ASSIGNMENT',
'history-1',
);
});
it('TaskCreated without a due date enqueues nothing', async () => { it('TaskCreated without a due date enqueues nothing', async () => {
await handlers.task( await handlers.task(
envelope('TaskCreated', 'task-1', { dueDate: null }), envelope('TaskCreated', 'task-1', { dueDate: null }),
@@ -103,8 +137,20 @@ describe('CalendarSyncEventHandlers', () => {
expect(registered('treatment')[0].handlerId).toBe( expect(registered('treatment')[0].handlerId).toBe(
'calendar-sync.treatment', 'calendar-sync.treatment',
); );
expect(registered('withdrawal')).toEqual([
{
types: ['TreatmentRecorded', 'TreatmentCorrected'],
handlerId: 'calendar-sync.withdrawal',
},
]);
expect(registered('feeding')[0].handlerId).toBe('calendar-sync.feeding'); expect(registered('feeding')[0].handlerId).toBe('calendar-sync.feeding');
expect(registered('harvest')[0].handlerId).toBe('calendar-sync.harvest'); expect(registered('harvest')[0].handlerId).toBe('calendar-sync.harvest');
expect(registered('queenAssignment')).toEqual([
{
types: ['QueenAssigned'],
handlerId: 'calendar-sync.queen-assignment',
},
]);
expect(registered('task')).toEqual([ expect(registered('task')).toEqual([
{ {
types: ['TaskCreated', 'TaskUpdated', 'TaskCompleted'], types: ['TaskCreated', 'TaskUpdated', 'TaskCompleted'],
@@ -35,6 +35,20 @@ export class CalendarSyncEventHandlers {
await this.calendarSync.enqueueIn(tx, 'TREATMENT', event.aggregateId); await this.calendarSync.enqueueIn(tx, 'TREATMENT', event.aggregateId);
} }
// The same events, a second entry: the withdrawal span of the treatment.
// Enqueued also when the treatment has no withdrawal days (any more): the
// worker then removes an event created earlier.
@OnDomainEvent(
['TreatmentRecorded', 'TreatmentCorrected'],
'calendar-sync.withdrawal',
)
async withdrawal(
event: AnyDomainEventEnvelope<'TreatmentRecorded' | 'TreatmentCorrected'>,
tx: Prisma.TransactionClient,
): Promise<void> {
await this.calendarSync.enqueueIn(tx, 'WITHDRAWAL', event.aggregateId);
}
@OnDomainEvent( @OnDomainEvent(
['FeedingRecorded', 'FeedingCorrected'], ['FeedingRecorded', 'FeedingCorrected'],
'calendar-sync.feeding', 'calendar-sync.feeding',
@@ -74,4 +88,17 @@ export class CalendarSyncEventHandlers {
} }
await this.calendarSync.enqueueIn(tx, 'TASK', event.aggregateId); await this.calendarSync.enqueueIn(tx, 'TASK', event.aggregateId);
} }
// The aggregate is the queen; the calendar entry belongs to the history row.
@OnDomainEvent(['QueenAssigned'], 'calendar-sync.queen-assignment')
async queenAssignment(
event: AnyDomainEventEnvelope<'QueenAssigned'>,
tx: Prisma.TransactionClient,
): Promise<void> {
await this.calendarSync.enqueueIn(
tx,
'QUEEN_ASSIGNMENT',
event.payload.historyId,
);
}
} }
@@ -5,10 +5,10 @@ import { CalendarSyncService } from './calendar-sync.service';
import { CalendarSyncSettingsService } from './calendar-sync-settings.service'; import { CalendarSyncSettingsService } from './calendar-sync-settings.service';
import { GraphClientService } from './graph-client.service'; import { GraphClientService } from './graph-client.service';
// @Global(): CalendarSyncService.enqueue() is called from otherwise-unrelated // @Global(): WorkerRuntimeModule (the calendar-outbox loop) injects
// domain modules (Inspections, Treatments, Feedings, Harvests, Queens) until // CalendarSyncService without importing this module, in the worker process
// they record domain events - matches WebhooksModule's own @Global() // and in role "all". Domain modules no longer call it: they record events and
// rationale rather than having each of those modules import this one. // CalendarSyncEventHandlers enqueues.
@Global() @Global()
@Module({ @Module({
controllers: [CalendarSyncController], controllers: [CalendarSyncController],
@@ -1,4 +1,3 @@
import { Logger } from '@nestjs/common';
import { CalendarSyncService } from './calendar-sync.service'; import { CalendarSyncService } from './calendar-sync.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CalendarSyncSettingsService } from './calendar-sync-settings.service'; import { CalendarSyncSettingsService } from './calendar-sync-settings.service';
@@ -42,6 +41,7 @@ describe('CalendarSyncService', () => {
syncFeeding: true, syncFeeding: true,
syncHarvest: true, syncHarvest: true,
syncQueenAssignment: true, syncQueenAssignment: true,
syncWithdrawal: true,
updatedAt: new Date(), updatedAt: new Date(),
}; };
@@ -110,14 +110,35 @@ describe('CalendarSyncService', () => {
); );
}); });
describe('enqueue', () => { describe('enqueueIn on the root client', () => {
const root = () => prisma as unknown as PrismaService;
it('does nothing when sync is disabled overall', async () => { it('does nothing when sync is disabled overall', async () => {
settingsService.get.mockResolvedValue({ settingsService.get.mockResolvedValue({
...enabledSettings, ...enabledSettings,
enabled: false, enabled: false,
}); });
await service.enqueue('TASK', 'task-1'); await service.enqueueIn(root(), 'TASK', 'task-1');
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
});
it('enqueues a WITHDRAWAL entry while syncWithdrawal is on', async () => {
settingsService.get.mockResolvedValue(enabledSettings);
await service.enqueueIn(root(), 'WITHDRAWAL', 'treatment-1');
expect(prisma.calendarSyncOutboxEntry.upsert).toHaveBeenCalled();
});
it('does not enqueue a WITHDRAWAL entry when syncWithdrawal is off', async () => {
settingsService.get.mockResolvedValue({
...enabledSettings,
syncWithdrawal: false,
});
await service.enqueueIn(root(), 'WITHDRAWAL', 'treatment-1');
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled(); expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
}); });
@@ -128,13 +149,13 @@ describe('CalendarSyncService', () => {
syncTask: false, syncTask: false,
}); });
await service.enqueue('TASK', 'task-1'); await service.enqueueIn(root(), 'TASK', 'task-1');
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled(); expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
}); });
it('upserts a PENDING row, resetting attempts and lastError and counting the revision up', async () => { it('upserts a PENDING row, resetting attempts and lastError and counting the revision up', async () => {
await service.enqueue('TASK', 'task-1'); await service.enqueueIn(root(), 'TASK', 'task-1');
expect(prisma.calendarSyncOutboxEntry.upsert).toHaveBeenCalledWith({ expect(prisma.calendarSyncOutboxEntry.upsert).toHaveBeenCalledWith({
where: { where: {
@@ -149,34 +170,9 @@ describe('CalendarSyncService', () => {
}, },
}); });
}); });
});
// enqueue() runs after the source record is committed: a failure must not it('no longer has a post-commit enqueue', () => {
// fail that request (the client would retry and store it twice). expect('enqueue' in service).toBe(false);
describe('post-commit calls', () => {
let errorSpy: jest.SpyInstance;
beforeEach(() => {
errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
});
afterEach(() => {
errorSpy.mockRestore();
});
it('enqueue never throws and logs the failure with the record', async () => {
settingsService.get.mockRejectedValue(new Error('db down'));
await expect(
service.enqueue('INSPECTION', 'inspection-1'),
).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalledTimes(1);
const [message] = errorSpy.mock.calls[0] as [string];
expect(message).toContain('INSPECTION inspection-1');
expect(message).toContain('db down');
}); });
}); });
@@ -640,6 +636,100 @@ describe('CalendarSyncService', () => {
expectSettled({ status: 'FAILED', attempts: 5, lastError: 'db error' }); expectSettled({ status: 'FAILED', attempts: 5, lastError: 'db error' });
}); });
describe('WITHDRAWAL entries', () => {
const withdrawalEntry = () =>
pendingEntry({ sourceType: 'WITHDRAWAL', sourceId: 't1' });
const treatment = (overrides: Record<string, unknown> = {}) => ({
id: 't1',
colonyId: 'c1',
product: 'Oxuvar',
date: new Date('2026-07-31T00:00:00.000Z'),
startDate: null,
endDate: null,
withdrawalPeriodDays: 20,
colony: { code: 'B3' },
...overrides,
});
it('writes the window as an all-day span from the first covered day to the first harvestable day', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
withdrawalEntry(),
]);
prisma.treatment.findUnique.mockResolvedValue(treatment());
graphClient.createEvent.mockResolvedValue('g1');
await service.processOutbox();
expect(graphClient.createEvent).toHaveBeenCalledWith(
'group-1',
{
title: 'Wartezeit B3 · Oxuvar',
date: new Date('2026-07-31T00:00:00.000Z'),
endDate: new Date('2026-08-20T00:00:00.000Z'),
},
TRANSACTION_ID,
);
});
it('starts at startDate when it lies before the treatment date', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
withdrawalEntry(),
]);
prisma.treatment.findUnique.mockResolvedValue(
treatment({
startDate: new Date('2026-07-28T00:00:00.000Z'),
endDate: new Date('2026-08-04T00:00:00.000Z'),
}),
);
graphClient.createEvent.mockResolvedValue('g1');
await service.processOutbox();
expect(graphClient.createEvent).toHaveBeenCalledWith(
'group-1',
expect.objectContaining({
date: new Date('2026-07-28T00:00:00.000Z'),
endDate: new Date('2026-08-24T00:00:00.000Z'),
}),
TRANSACTION_ID,
);
});
it.each([
['has no withdrawal days any more', { withdrawalPeriodDays: null }],
['has a window of zero days', { withdrawalPeriodDays: 0 }],
])(
'removes the event when the treatment %s',
async (_case, overrides) => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
{ ...withdrawalEntry(), graphEventId: 'graph-event-1' },
]);
prisma.treatment.findUnique.mockResolvedValue(treatment(overrides));
await service.processOutbox();
expect(graphClient.createEvent).not.toHaveBeenCalled();
expect(graphClient.deleteEvent).toHaveBeenCalledWith(
'group-1',
'graph-event-1',
);
expectSettled({ status: 'SKIPPED', graphEventId: null });
},
);
it('writes nothing for a treatment that is gone', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
withdrawalEntry(),
]);
prisma.treatment.findUnique.mockResolvedValue(null);
await service.processOutbox();
expect(graphClient.createEvent).not.toHaveBeenCalled();
expectSettled({ status: 'SKIPPED', graphEventId: null });
});
});
it('builds titles per source type matching the read-only calendar view', async () => { it('builds titles per source type matching the read-only calendar view', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry({ sourceType: 'INSPECTION', sourceId: 'i1' }), pendingEntry({ sourceType: 'INSPECTION', sourceId: 'i1' }),
@@ -2,9 +2,10 @@ import { createHash } from 'node:crypto';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DbClient } from '../prisma/db-client'; import { DbClient } from '../prisma/db-client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { runAfterCommit } from '../common/after-commit'; import { dayToDate } from '../common/dates';
import { loggableError } from '../common/loggable-error'; import { loggableError } from '../common/loggable-error';
import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup'; import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup';
import { withdrawalSpanOf } from '../withdrawal/withdrawal-span';
import { CalendarSyncSettingsService } from './calendar-sync-settings.service'; import { CalendarSyncSettingsService } from './calendar-sync-settings.service';
import { import {
GraphClientService, GraphClientService,
@@ -103,25 +104,11 @@ export class CalendarSyncService {
FEEDING: settings.syncFeeding, FEEDING: settings.syncFeeding,
HARVEST: settings.syncHarvest, HARVEST: settings.syncHarvest,
QUEEN_ASSIGNMENT: settings.syncQueenAssignment, QUEEN_ASSIGNMENT: settings.syncQueenAssignment,
WITHDRAWAL: settings.syncWithdrawal,
}; };
return flagBySourceType[sourceType]; return flagBySourceType[sourceType];
} }
// enqueue() follows a committed write in the domain services and is
// called without a guard - so it never throws (see runAfterCommit).
private afterCommit(
action: string,
sourceType: CalendarSyncSourceType,
sourceId: string,
work: () => Promise<void>,
): Promise<void> {
return runAfterCommit(
this.logger,
{ action, entityType: sourceType, entityId: sourceId },
work,
);
}
// The core of every enqueue: upserts rather than always inserting, so // The core of every enqueue: upserts rather than always inserting, so
// re-enqueuing an already-synced row (e.g. a Task's due date changed) // re-enqueuing an already-synced row (e.g. a Task's due date changed)
// resets it to PENDING while keeping its graphEventId - the worker then // resets it to PENDING while keeping its graphEventId - the worker then
@@ -154,19 +141,6 @@ export class CalendarSyncService {
}); });
} }
// Legacy path for sources that do not record a domain event yet
// (inspections, treatments, feedings and harvests until their producer
// tasks; queen assignments), called right after a committed write: never
// throws.
async enqueue(
sourceType: CalendarSyncSourceType,
sourceId: string,
): Promise<void> {
await this.afterCommit('Calendar sync enqueue', sourceType, sourceId, () =>
this.enqueueIn(this.prisma, sourceType, sourceId),
);
}
// Best-effort removal from M365: logged, never thrown. // Best-effort removal from M365: logged, never thrown.
private async deleteGraphEvent(graphEventId: string): Promise<void> { private async deleteGraphEvent(graphEventId: string): Promise<void> {
const settings = await this.settingsService.get(); const settings = await this.settingsService.get();
@@ -499,6 +473,22 @@ export class CalendarSyncService {
date: assignment.startDate, date: assignment.startDate,
}; };
} }
case 'WITHDRAWAL': {
const treatment = await this.prisma.treatment.findUnique({
where: { id: sourceId },
include: { colony: true },
});
const span = treatment ? withdrawalSpanOf(treatment) : null;
if (!treatment || !span) {
return null;
}
// Graph's end day is exclusive, like untilDay (first harvestable day).
return {
title: `Wartezeit ${treatment.colony.code} · ${treatment.product}`,
date: dayToDate(span.fromDay),
endDate: dayToDate(span.untilDay),
};
}
} }
} }
@@ -66,4 +66,9 @@ export class UpdateCalendarSyncSettingsDto {
@IsOptionalNotNull() @IsOptionalNotNull()
@IsBoolean() @IsBoolean()
syncQueenAssignment?: boolean; syncQueenAssignment?: boolean;
@ApiPropertyOptional()
@IsOptionalNotNull()
@IsBoolean()
syncWithdrawal?: boolean;
} }
@@ -93,6 +93,64 @@ describe('GraphClientService', () => {
expect(body.end.dateTime).toBe('2026-03-08T00:00:00.0000000'); expect(body.end.dateTime).toBe('2026-03-08T00:00:00.0000000');
}); });
it.each([
['create', 'POST'],
['update', 'PATCH'],
] as const)(
'sends a multi-day all-day event on %s up to the exclusive end day',
async (operation, method) => {
fetchMock
.mockResolvedValueOnce(
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
)
.mockResolvedValueOnce(jsonResponse(201, { id: 'graph-event-1' }));
const input = {
title: 'Wartezeit B3 · Oxuvar',
date: new Date(Date.UTC(2026, 6, 31)),
endDate: new Date(Date.UTC(2026, 7, 20)),
};
if (operation === 'create') {
await service.createEvent('group-1', input, 'tx-1');
} else {
await service.updateEvent('group-1', 'event-1', input);
}
const [, init] = fetchMock.mock.calls[1] as [
string,
{ method: string; body: string },
];
const body = JSON.parse(init.body) as {
isAllDay: boolean;
start: { dateTime: string };
end: { dateTime: string };
};
expect(init.method).toBe(method);
expect(body.isAllDay).toBe(true);
expect(body.start.dateTime).toBe('2026-07-31T00:00:00.0000000');
expect(body.end.dateTime).toBe('2026-08-20T00:00:00.0000000');
},
);
it('keeps a one-day event when the end day is not after the start day', async () => {
fetchMock
.mockResolvedValueOnce(
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
)
.mockResolvedValueOnce(jsonResponse(201, { id: 'graph-event-1' }));
const day = new Date(Date.UTC(2026, 6, 31));
await service.createEvent(
'group-1',
{ title: 'x', date: day, endDate: day },
'tx-1',
);
const [, init] = fetchMock.mock.calls[1] as [string, { body: string }];
const body = JSON.parse(init.body) as { end: { dateTime: string } };
expect(body.end.dateTime).toBe('2026-08-01T00:00:00.0000000');
});
it('caches the token and does not fetch it again for a second call', async () => { it('caches the token and does not fetch it again for a second call', async () => {
fetchMock fetchMock
.mockResolvedValueOnce( .mockResolvedValueOnce(
@@ -3,6 +3,11 @@ import { Injectable } from '@nestjs/common';
export interface GraphCalendarEventInput { export interface GraphCalendarEventInput {
title: string; title: string;
date: Date; date: Date;
/**
* All-day events are exclusive at the end, like Graph itself: the day
* after the last covered day. Without it the event covers `date` only.
*/
endDate?: Date;
} }
interface CachedToken { interface CachedToken {
@@ -145,7 +150,13 @@ export class GraphClientService {
const day = input.date.toISOString().slice(0, 10); const day = input.date.toISOString().slice(0, 10);
const nextDay = new Date(input.date); const nextDay = new Date(input.date);
nextDay.setUTCDate(nextDay.getUTCDate() + 1); nextDay.setUTCDate(nextDay.getUTCDate() + 1);
const nextDayString = nextDay.toISOString().slice(0, 10); const nextDayString = (
input.endDate !== undefined && input.endDate > input.date
? input.endDate
: nextDay
)
.toISOString()
.slice(0, 10);
return { return {
subject: input.title, subject: input.title,
isAllDay: true, isAllDay: true,
@@ -1,6 +1,8 @@
import { BadRequestException, Controller, Get, Query } from '@nestjs/common'; import { BadRequestException, Controller, Get, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator'; import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CalendarService } from './calendar.service'; import { CalendarService } from './calendar.service';
@ApiTags('calendar') @ApiTags('calendar')
@@ -12,6 +14,7 @@ export class CalendarController {
@RequirePermission('calendar:read') @RequirePermission('calendar:read')
@Get() @Get()
getEvents( getEvents(
@CurrentUser() actor: AuthenticatedUser,
@Query('from') from: string, @Query('from') from: string,
@Query('to') to: string, @Query('to') to: string,
@Query('apiaryId') apiaryId?: string, @Query('apiaryId') apiaryId?: string,
@@ -21,10 +24,13 @@ export class CalendarController {
'Query parameters from and to are required', 'Query parameters from and to are required',
); );
} }
return this.calendarService.getEvents({ const range = { from: new Date(from), to: new Date(to) };
from: new Date(from), if (
to: new Date(to), Number.isNaN(range.from.getTime()) ||
apiaryId, Number.isNaN(range.to.getTime())
}); ) {
throw new BadRequestException('from and to must be valid dates');
}
return this.calendarService.getEvents({ ...range, apiaryId }, actor.id);
} }
} }
@@ -1,7 +1,16 @@
import { CalendarService } from './calendar.service'; import { CalendarService } from './calendar.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
describe('CalendarService', () => { describe('CalendarService', () => {
// The texts add the year outside the current one: pin 'today'.
beforeAll(() => {
jest.useFakeTimers({ now: new Date('2026-06-01T10:00:00Z') });
});
afterAll(() => {
jest.useRealTimers();
});
let service: CalendarService; let service: CalendarService;
let prisma: { let prisma: {
task: { findMany: jest.Mock }; task: { findMany: jest.Mock };
@@ -10,9 +19,14 @@ describe('CalendarService', () => {
feeding: { findMany: jest.Mock }; feeding: { findMany: jest.Mock };
harvest: { findMany: jest.Mock }; harvest: { findMany: jest.Mock };
queenColonyHistory: { findMany: jest.Mock }; queenColonyHistory: { findMany: jest.Mock };
colony: { findMany: jest.Mock };
apiary: { findMany: jest.Mock };
}; };
let resolver: { getPermissions: jest.Mock };
beforeEach(() => { beforeEach(() => {
resolver = { getPermissions: jest.fn().mockResolvedValue(new Set()) };
prisma = { prisma = {
task: { findMany: jest.fn().mockResolvedValue([]) }, task: { findMany: jest.fn().mockResolvedValue([]) },
inspection: { findMany: jest.fn().mockResolvedValue([]) }, inspection: { findMany: jest.fn().mockResolvedValue([]) },
@@ -20,8 +34,13 @@ describe('CalendarService', () => {
feeding: { findMany: jest.fn().mockResolvedValue([]) }, feeding: { findMany: jest.fn().mockResolvedValue([]) },
harvest: { findMany: jest.fn().mockResolvedValue([]) }, harvest: { findMany: jest.fn().mockResolvedValue([]) },
queenColonyHistory: { findMany: jest.fn().mockResolvedValue([]) }, queenColonyHistory: { findMany: jest.fn().mockResolvedValue([]) },
colony: { findMany: jest.fn().mockResolvedValue([]) },
apiary: { findMany: jest.fn().mockResolvedValue([]) },
}; };
service = new CalendarService(prisma as unknown as PrismaService); service = new CalendarService(
prisma as unknown as PrismaService,
resolver as unknown as PermissionResolverService,
);
}); });
it('merges tasks, inspections, and harvests into one sorted event list', async () => { it('merges tasks, inspections, and harvests into one sorted event list', async () => {
@@ -37,13 +56,21 @@ describe('CalendarService', () => {
}, },
]); ]);
prisma.harvest.findMany.mockResolvedValueOnce([ prisma.harvest.findMany.mockResolvedValueOnce([
{ id: 'h1', date: new Date('2026-06-10'), apiaryId: 'a1' }, {
id: 'h1',
date: new Date('2026-06-10'),
apiaryId: 'a1',
apiary: { name: 'Teststand Nord' },
},
]); ]);
const events = await service.getEvents({ const events = await service.getEvents(
{
from: new Date('2026-06-01'), from: new Date('2026-06-01'),
to: new Date('2026-06-30'), to: new Date('2026-06-30'),
}); },
'user-1',
);
expect(events.map((event) => event.type)).toEqual([ expect(events.map((event) => event.type)).toEqual([
'INSPECTION', 'INSPECTION',
@@ -52,12 +79,82 @@ describe('CalendarService', () => {
]); ]);
}); });
it('names the colony or apiary of every event in context', async () => {
prisma.task.findMany.mockResolvedValueOnce([
{
id: 't1',
title: 'Füttern',
dueDate: new Date('2026-06-05'),
relatedType: 'COLONY',
relatedId: 'c1',
},
{
id: 't2',
title: 'Stand putzen',
dueDate: new Date('2026-06-06'),
relatedType: 'APIARY',
relatedId: 'a1',
},
{
id: 't3',
title: 'Allgemein',
dueDate: new Date('2026-06-07'),
relatedType: 'GENERAL',
relatedId: null,
},
]);
prisma.colony.findMany.mockResolvedValueOnce([{ id: 'c1', code: 'V-001' }]);
prisma.apiary.findMany.mockResolvedValueOnce([
{ id: 'a1', name: 'Teststand Nord' },
]);
prisma.inspection.findMany.mockResolvedValueOnce([
{
id: 'i1',
date: new Date('2026-06-01'),
colonyId: 'c1',
colony: { code: 'V-001' },
},
]);
prisma.feeding.findMany.mockResolvedValueOnce([
{
id: 'f1',
date: new Date('2026-06-02'),
colonyId: 'c1',
colony: { code: 'V-001' },
},
]);
prisma.harvest.findMany.mockResolvedValueOnce([
{
id: 'h1',
date: new Date('2026-06-10'),
apiaryId: 'a1',
apiary: { name: 'Teststand Nord' },
},
]);
const events = await service.getEvents(
{ from: new Date('2026-06-01'), to: new Date('2026-06-30') },
'user-1',
);
const contextOf = (id: string) => events.find((e) => e.id === id)?.context;
expect(contextOf('i1')).toBe('V-001');
expect(contextOf('f1')).toBe('V-001');
expect(contextOf('t1')).toBe('V-001');
expect(contextOf('t2')).toBe('Teststand Nord');
expect(contextOf('t3')).toBeUndefined();
expect(contextOf('h1')).toBe('Teststand Nord');
});
it('filters colony-linked events by apiary when apiaryId is given', async () => { it('filters colony-linked events by apiary when apiaryId is given', async () => {
await service.getEvents({ await service.getEvents(
{
from: new Date('2026-06-01'), from: new Date('2026-06-01'),
to: new Date('2026-06-30'), to: new Date('2026-06-30'),
apiaryId: 'a1', apiaryId: 'a1',
}); },
'user-1',
);
const [[inspectionArg]] = prisma.inspection.findMany.mock.calls as [ const [[inspectionArg]] = prisma.inspection.findMany.mock.calls as [
[{ where: { colony?: { apiaryId?: string } } }], [{ where: { colony?: { apiaryId?: string } } }],
@@ -75,11 +172,158 @@ describe('CalendarService', () => {
{ id: 't1', title: 'Ohne Datum', dueDate: null }, { id: 't1', title: 'Ohne Datum', dueDate: null },
]); ]);
const events = await service.getEvents({ const events = await service.getEvents(
{
from: new Date('2026-06-01'), from: new Date('2026-06-01'),
to: new Date('2026-06-30'), to: new Date('2026-06-30'),
}); },
'user-1',
);
expect(events).toHaveLength(0); expect(events).toHaveLength(0);
}); });
describe('withdrawal spans', () => {
const treatment = (overrides: Record<string, unknown> = {}) => ({
id: 'tr1',
colonyId: 'c1',
product: 'Oxuvar',
date: new Date('2026-06-10T00:00:00.000Z'),
startDate: null,
endDate: null,
withdrawalPeriodDays: 20,
colony: { code: 'V-001' },
...overrides,
});
const query = {
from: new Date('2026-06-01T00:00:00.000Z'),
to: new Date('2026-06-30T00:00:00.000Z'),
};
beforeEach(() => {
resolver.getPermissions.mockResolvedValue(new Set(['treatments:read']));
});
it('lists the window as one WITHDRAWAL event with its last covered day as endDate', async () => {
prisma.treatment.findMany.mockResolvedValue([treatment()]);
const events = await service.getEvents(query, 'user-1');
const withdrawal = events.filter((event) => event.type === 'WITHDRAWAL');
expect(withdrawal).toEqual([
{
id: 'withdrawal:tr1',
type: 'WITHDRAWAL',
date: new Date('2026-06-10T00:00:00.000Z'),
endDate: new Date('2026-06-29T00:00:00.000Z'),
title: 'Wartezeit V-001 · Oxuvar',
relatedType: 'Colony',
relatedId: 'c1',
context: 'V-001',
product: 'Oxuvar',
detail: 'bis einschließlich 29.06. – ab 30.06. wieder erntefähig',
},
]);
});
it('shows a window that began before the range but reaches into it', async () => {
prisma.treatment.findMany.mockResolvedValue([
treatment({ date: new Date('2026-05-20T00:00:00.000Z') }),
]);
const events = await service.getEvents(query, 'user-1');
expect(events.filter((e) => e.type === 'WITHDRAWAL')).toHaveLength(1);
});
it('leaves out a window that ended before the range (the first harvestable day is not covered)', async () => {
prisma.treatment.findMany.mockResolvedValue([
treatment({ date: new Date('2026-05-12T00:00:00.000Z') }),
]);
const events = await service.getEvents(query, 'user-1');
expect(events.filter((e) => e.type === 'WITHDRAWAL')).toHaveLength(0);
});
it('leaves out a window that begins after the range', async () => {
prisma.treatment.findMany.mockResolvedValue([
treatment({ date: new Date('2026-07-01T00:00:00.000Z') }),
]);
const events = await service.getEvents(query, 'user-1');
expect(events.filter((e) => e.type === 'WITHDRAWAL')).toHaveLength(0);
});
it('leaves out treatments without withdrawal days', async () => {
prisma.treatment.findMany.mockResolvedValue([
treatment({ withdrawalPeriodDays: null }),
treatment({ id: 'tr2', withdrawalPeriodDays: 0 }),
]);
const events = await service.getEvents(query, 'user-1');
expect(events.filter((e) => e.type === 'WITHDRAWAL')).toHaveLength(0);
});
it('does not show them without treatments:read, and does not read them either', async () => {
resolver.getPermissions.mockResolvedValue(new Set(['calendar:read']));
prisma.treatment.findMany.mockResolvedValue([treatment()]);
const events = await service.getEvents(query, 'user-1');
expect(events.filter((e) => e.type === 'WITHDRAWAL')).toHaveLength(0);
expect(prisma.treatment.findMany).not.toHaveBeenCalled();
});
it('filters by apiary through the colony like the other colony events', async () => {
prisma.treatment.findMany.mockResolvedValue([]);
await service.getEvents({ ...query, apiaryId: 'a1' }, 'user-1');
const withdrawalCall = prisma.treatment.findMany.mock.calls.find(
([arg]) =>
(arg as { where: Record<string, unknown> }).where
.withdrawalPeriodDays !== undefined,
) as [{ where: { colony?: { apiaryId?: string } } }] | undefined;
expect(withdrawalCall?.[0].where.colony).toEqual({ apiaryId: 'a1' });
});
});
describe('treatment events', () => {
const query = {
from: new Date('2026-06-01T00:00:00.000Z'),
to: new Date('2026-06-30T00:00:00.000Z'),
};
const row = {
id: 'tr1',
colonyId: 'c1',
product: 'Oxuvar',
date: new Date('2026-06-10T00:00:00.000Z'),
startDate: null,
endDate: null,
withdrawalPeriodDays: null,
colony: { code: 'V-001' },
};
it('lists TREATMENT events with treatments:read', async () => {
resolver.getPermissions.mockResolvedValue(new Set(['treatments:read']));
prisma.treatment.findMany.mockResolvedValue([row]);
const events = await service.getEvents(query, 'user-1');
expect(events.map((e) => e.type)).toEqual(['TREATMENT']);
});
it('leaves them out, and does not read them, without treatments:read', async () => {
resolver.getPermissions.mockResolvedValue(new Set(['calendar:read']));
prisma.treatment.findMany.mockResolvedValue([row]);
const events = await service.getEvents(query, 'user-1');
expect(events.filter((e) => e.type === 'TREATMENT')).toEqual([]);
expect(prisma.treatment.findMany).not.toHaveBeenCalled();
});
});
}); });
@@ -1,5 +1,17 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import type { Prisma } from '../../generated/prisma';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { addDays, berlinDayOf, dayToDate } from '../common/dates';
import { withdrawalSpanOf } from '../withdrawal/withdrawal-span';
import {
lastBlockedDay,
withdrawalRangeText,
} from '../withdrawal/withdrawal-text';
type TreatmentWithColony = Prisma.TreatmentGetPayload<{
include: { colony: true };
}>;
export type CalendarEventType = export type CalendarEventType =
| 'TASK' | 'TASK'
@@ -7,13 +19,22 @@ export type CalendarEventType =
| 'TREATMENT' | 'TREATMENT'
| 'FEEDING' | 'FEEDING'
| 'HARVEST' | 'HARVEST'
| 'QUEEN_ASSIGNMENT'; | 'QUEEN_ASSIGNMENT'
| 'WITHDRAWAL';
export interface CalendarEvent { export interface CalendarEvent {
id: string; id: string;
type: CalendarEventType; type: CalendarEventType;
date: Date; date: Date;
/** WITHDRAWAL only: the last day the span covers (inclusive). */
endDate?: Date;
title: string; title: string;
/** Colony code or apiary name the event belongs to; absent for general tasks. */
context?: string;
/** WITHDRAWAL only: the treatment product. */
product?: string;
/** WITHDRAWAL only: 'bis einschließlich TT.MM. – ab TT.MM. wieder erntefähig'. */
detail?: string;
relatedType: string; relatedType: string;
relatedId: string; relatedId: string;
} }
@@ -26,10 +47,125 @@ export interface CalendarQuery {
@Injectable() @Injectable()
export class CalendarService { export class CalendarService {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
private readonly permissionResolver: PermissionResolverService,
) {}
async getEvents(query: CalendarQuery): Promise<CalendarEvent[]> { /**
* The withdrawal windows (Wartezeit) that touch the range, one span each.
* They come from treatments, so they need the right to read treatments.
*/
private async withdrawalEvents(
query: CalendarQuery,
canReadTreatments: boolean,
): Promise<CalendarEvent[]> {
if (!canReadTreatments) {
return [];
}
const fromDay = berlinDayOf(query.from);
const toDay = berlinDayOf(query.to);
// A window never begins after its treatment's dates, so a treatment
// dated after the range cannot reach into it.
const beforeEnd = { lt: dayToDate(addDays(toDay, 1)) };
const treatments = await this.prisma.treatment.findMany({
where: {
withdrawalPeriodDays: { not: null },
OR: [{ date: beforeEnd }, { startDate: beforeEnd }],
...(query.apiaryId ? { colony: { apiaryId: query.apiaryId } } : {}),
},
include: { colony: true },
});
return treatments.flatMap((treatment) => {
const span = withdrawalSpanOf(treatment);
if (span === null) {
return [];
}
const lastDay = lastBlockedDay(span.untilDay);
if (span.fromDay > toDay || lastDay < fromDay) {
return [];
}
return [
{
id: `withdrawal:${treatment.id}`,
type: 'WITHDRAWAL' as const,
date: dayToDate(span.fromDay),
endDate: dayToDate(lastDay),
title: `Wartezeit ${treatment.colony.code} · ${treatment.product}`,
context: treatment.colony.code,
product: treatment.product,
detail: withdrawalRangeText(span.untilDay),
relatedType: 'Colony',
relatedId: treatment.colonyId,
},
];
});
}
/**
* Colony code or apiary name of the tasks that point at one. By ruling only
* COLONY and APIARY tasks get a context; other tasks (queen, honey batch,
* storage, general) have none.
*/
private async taskContexts(
tasks: readonly {
id: string;
relatedType: string;
relatedId: string | null;
}[],
): Promise<Map<string, string>> {
const idsOf = (type: string) => [
...new Set(
tasks.flatMap((task) =>
task.relatedType === type && task.relatedId !== null
? [task.relatedId]
: [],
),
),
];
const colonyIds = idsOf('COLONY');
const apiaryIds = idsOf('APIARY');
const noColonies: { id: string; code: string }[] = [];
const noApiaries: { id: string; name: string }[] = [];
const [colonies, apiaries] = await Promise.all([
colonyIds.length === 0
? Promise.resolve(noColonies)
: this.prisma.colony.findMany({
where: { id: { in: colonyIds } },
select: { id: true, code: true },
}),
apiaryIds.length === 0
? Promise.resolve(noApiaries)
: this.prisma.apiary.findMany({
where: { id: { in: apiaryIds } },
select: { id: true, name: true },
}),
]);
const names = new Map<string, string>([
...colonies.map((c) => [c.id, c.code] as const),
...apiaries.map((a) => [a.id, a.name] as const),
]);
const contexts = new Map<string, string>();
for (const task of tasks) {
const name =
task.relatedId === null ? undefined : names.get(task.relatedId);
if (name !== undefined) {
contexts.set(task.id, name);
}
}
return contexts;
}
async getEvents(
query: CalendarQuery,
userId: string,
): Promise<CalendarEvent[]> {
const { from, to, apiaryId } = query; const { from, to, apiaryId } = query;
// Treatments (and the withdrawal spans derived from them) are shown only
// to users who may read treatments, as on the treatment routes.
const canReadTreatments = (
await this.permissionResolver.getPermissions(userId)
).has('treatments:read');
const colonyFilter = apiaryId ? { colony: { apiaryId } } : {}; const colonyFilter = apiaryId ? { colony: { apiaryId } } : {};
const [ const [
@@ -39,6 +175,7 @@ export class CalendarService {
feedings, feedings,
harvests, harvests,
queenAssignments, queenAssignments,
withdrawals,
] = await Promise.all([ ] = await Promise.all([
this.prisma.task.findMany({ this.prisma.task.findMany({
where: { dueDate: { gte: from, lte: to } }, where: { dueDate: { gte: from, lte: to } },
@@ -47,10 +184,12 @@ export class CalendarService {
where: { date: { gte: from, lte: to }, ...colonyFilter }, where: { date: { gte: from, lte: to }, ...colonyFilter },
include: { colony: true }, include: { colony: true },
}), }),
this.prisma.treatment.findMany({ canReadTreatments
? this.prisma.treatment.findMany({
where: { date: { gte: from, lte: to }, ...colonyFilter }, where: { date: { gte: from, lte: to }, ...colonyFilter },
include: { colony: true }, include: { colony: true },
}), })
: Promise.resolve<TreatmentWithColony[]>([]),
this.prisma.feeding.findMany({ this.prisma.feeding.findMany({
where: { date: { gte: from, lte: to }, ...colonyFilter }, where: { date: { gte: from, lte: to }, ...colonyFilter },
include: { colony: true }, include: { colony: true },
@@ -60,13 +199,17 @@ export class CalendarService {
date: { gte: from, lte: to }, date: { gte: from, lte: to },
...(apiaryId ? { apiaryId } : {}), ...(apiaryId ? { apiaryId } : {}),
}, },
include: { apiary: { select: { name: true } } },
}), }),
this.prisma.queenColonyHistory.findMany({ this.prisma.queenColonyHistory.findMany({
where: { startDate: { gte: from, lte: to }, ...colonyFilter }, where: { startDate: { gte: from, lte: to }, ...colonyFilter },
include: { colony: true, queen: true }, include: { colony: true, queen: true },
}), }),
this.withdrawalEvents(query, canReadTreatments),
]); ]);
const taskContexts = await this.taskContexts(tasks);
const events: CalendarEvent[] = [ const events: CalendarEvent[] = [
...tasks ...tasks
.filter((task) => task.dueDate !== null) .filter((task) => task.dueDate !== null)
@@ -75,6 +218,7 @@ export class CalendarService {
type: 'TASK' as const, type: 'TASK' as const,
date: task.dueDate!, date: task.dueDate!,
title: task.title, title: task.title,
context: taskContexts.get(task.id),
relatedType: 'Task', relatedType: 'Task',
relatedId: task.id, relatedId: task.id,
})), })),
@@ -83,6 +227,7 @@ export class CalendarService {
type: 'INSPECTION' as const, type: 'INSPECTION' as const,
date: inspection.date, date: inspection.date,
title: `Durchsicht ${inspection.colony.code}`, title: `Durchsicht ${inspection.colony.code}`,
context: inspection.colony.code,
relatedType: 'Colony', relatedType: 'Colony',
relatedId: inspection.colonyId, relatedId: inspection.colonyId,
})), })),
@@ -91,6 +236,7 @@ export class CalendarService {
type: 'TREATMENT' as const, type: 'TREATMENT' as const,
date: treatment.date, date: treatment.date,
title: `Behandlung ${treatment.colony.code} · ${treatment.product}`, title: `Behandlung ${treatment.colony.code} · ${treatment.product}`,
context: treatment.colony.code,
relatedType: 'Colony', relatedType: 'Colony',
relatedId: treatment.colonyId, relatedId: treatment.colonyId,
})), })),
@@ -99,6 +245,7 @@ export class CalendarService {
type: 'FEEDING' as const, type: 'FEEDING' as const,
date: feeding.date, date: feeding.date,
title: `Fütterung ${feeding.colony.code}`, title: `Fütterung ${feeding.colony.code}`,
context: feeding.colony.code,
relatedType: 'Colony', relatedType: 'Colony',
relatedId: feeding.colonyId, relatedId: feeding.colonyId,
})), })),
@@ -107,6 +254,7 @@ export class CalendarService {
type: 'HARVEST' as const, type: 'HARVEST' as const,
date: harvest.date, date: harvest.date,
title: 'Ernte', title: 'Ernte',
context: harvest.apiary.name,
relatedType: 'Apiary', relatedType: 'Apiary',
relatedId: harvest.apiaryId, relatedId: harvest.apiaryId,
})), })),
@@ -115,9 +263,11 @@ export class CalendarService {
type: 'QUEEN_ASSIGNMENT' as const, type: 'QUEEN_ASSIGNMENT' as const,
date: assignment.startDate, date: assignment.startDate,
title: `Königin ${assignment.queen.code} → ${assignment.colony.code}`, title: `Königin ${assignment.queen.code} → ${assignment.colony.code}`,
context: assignment.colony.code,
relatedType: 'Queen', relatedType: 'Queen',
relatedId: assignment.queenId, relatedId: assignment.queenId,
})), })),
...withdrawals,
]; ];
return events.sort((a, b) => a.date.getTime() - b.date.getTime()); return events.sort((a, b) => a.date.getTime() - b.date.getTime());
@@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service'; import { WebhooksService } from '../webhooks/webhooks.service';
import { DomainEventsService } from '../domain-events/domain-events.service'; import { DomainEventsService } from '../domain-events/domain-events.service';
import { berlinToday } from '../common/dates';
describe('ColoniesService', () => { describe('ColoniesService', () => {
let service: ColoniesService; let service: ColoniesService;
@@ -13,6 +14,7 @@ describe('ColoniesService', () => {
colonyApiaryHistory: { create: jest.Mock }; colonyApiaryHistory: { create: jest.Mock };
colonyLineage: { create: jest.Mock }; colonyLineage: { create: jest.Mock };
queenColonyHistory: { updateMany: jest.Mock }; queenColonyHistory: { updateMany: jest.Mock };
suggestion: { updateMany: jest.Mock };
}; };
let prisma: { let prisma: {
colony: { colony: {
@@ -45,6 +47,7 @@ describe('ColoniesService', () => {
colonyApiaryHistory: { create: jest.fn() }, colonyApiaryHistory: { create: jest.fn() },
colonyLineage: { create: jest.fn() }, colonyLineage: { create: jest.fn() },
queenColonyHistory: { updateMany: jest.fn() }, queenColonyHistory: { updateMany: jest.fn() },
suggestion: { updateMany: jest.fn() },
}; };
prisma = { prisma = {
colony: { colony: {
@@ -139,6 +142,96 @@ describe('ColoniesService', () => {
expect(lineageArg.data.toColonyId).toBe('colony-2'); expect(lineageArg.data.toColonyId).toBe('colony-2');
}); });
it('split records ColonySplit with queenSource and the given date, audit inside the transaction', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
const child = { id: 'colony-2', code: 'V-001-A', apiaryId: 'apiary-1' };
tx.colony.create.mockResolvedValue(child);
tx.colonyLineage.create.mockResolvedValue({ id: 'lineage-1' });
await service.split(
'colony-1',
{
code: 'V-001-A',
colonyType: 'NUCLEUS_COLONY',
date: '2026-05-05',
queenSource: 'QUEEN_CELL',
},
'user-1',
);
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
const [[lineageArg]] = tx.colonyLineage.create.mock.calls as [
[{ data: { date: Date; queenSource: string } }],
];
expect(lineageArg.data.date).toEqual(new Date('2026-05-05T00:00:00.000Z'));
expect(lineageArg.data.queenSource).toBe('QUEEN_CELL');
expect(events.record).toHaveBeenCalledWith(
tx,
expect.objectContaining({
type: 'ColonySplit',
aggregateType: 'Colony',
aggregateId: 'colony-2',
actorUserId: 'user-1',
payload: {
parentColonyId: 'colony-1',
lineageId: 'lineage-1',
queenSource: 'QUEEN_CELL',
splitOn: '2026-05-05',
},
}),
);
expect(auditLog.record).toHaveBeenCalledWith(expect.anything(), tx);
});
it('split without a date uses today and a null queenSource', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.create.mockResolvedValue({ id: 'colony-2' });
tx.colonyLineage.create.mockResolvedValue({ id: 'lineage-1' });
await service.split(
'colony-1',
{ code: 'V-001-A', colonyType: 'NUCLEUS_COLONY' },
'user-1',
);
const [[, event]] = events.record.mock.calls as [
[unknown, { payload: { queenSource: unknown; splitOn: string } }],
];
expect(event.payload.queenSource).toBeNull();
expect(event.payload.splitOn).toBe(berlinToday());
});
it('split with a future date answers 400', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
await expect(
service.split(
'colony-1',
{ code: 'V-001-A', colonyType: 'NUCLEUS_COLONY', date: '2999-01-01' },
'user-1',
),
).rejects.toMatchObject({
status: 400,
message: 'Das Datum liegt in der Zukunft.',
});
expect(tx.colony.create).not.toHaveBeenCalled();
});
it('split of a colony that ended meanwhile (seen after the lock) answers 409', async () => {
prisma.colony.findUnique
.mockResolvedValueOnce(parentColony)
.mockResolvedValueOnce({ ...parentColony, status: 'DISSOLVED' });
await expect(
service.split(
'colony-1',
{ code: 'V-001-A', colonyType: 'NUCLEUS_COLONY' },
'user-1',
),
).rejects.toBeInstanceOf(ConflictException);
expect(tx.colony.create).not.toHaveBeenCalled();
});
it('merge marks the merged colony as MERGED, releases its queen, and records a MERGE lineage entry', async () => { it('merge marks the merged colony as MERGED, releases its queen, and records a MERGE lineage entry', async () => {
prisma.colony.findUnique prisma.colony.findUnique
.mockResolvedValueOnce(parentColony) .mockResolvedValueOnce(parentColony)
@@ -164,17 +257,15 @@ describe('ColoniesService', () => {
'user-1', 'user-1',
); );
const [[updateArg]] = tx.colony.update.mock.calls as [ const updates = tx.colony.update.mock.calls as [
[ { where: { id: string }; data: Record<string, unknown> },
{ ][];
where: { id: string }; expect(updates[0][0]).toEqual({
data: { status: string; currentQueenId: string | null }; where: { id: 'colony-3' },
}, data: { currentQueenId: null },
], });
]; expect(updates[1][0].where.id).toBe('colony-3');
expect(updateArg.where.id).toBe('colony-3'); expect(updates[1][0].data.status).toBe('MERGED');
expect(updateArg.data.status).toBe('MERGED');
expect(updateArg.data.currentQueenId).toBeNull();
const [[historyCloseArg]] = tx.queenColonyHistory.updateMany.mock.calls as [ const [[historyCloseArg]] = tx.queenColonyHistory.updateMany.mock.calls as [
[ [
@@ -277,22 +368,16 @@ describe('ColoniesService', () => {
await service.dissolve('colony-1', 'user-1'); await service.dissolve('colony-1', 'user-1');
const [[dissolveUpdateArg]] = tx.colony.update.mock.calls as [ const updates = tx.colony.update.mock.calls as [
[ { where: { id: string }; data: Record<string, unknown> },
{ ][];
where: { id: string }; expect(updates[0][0]).toEqual({
data: { where: { id: 'colony-1' },
status: string; data: { currentQueenId: null },
deactivatedAt: Date; });
currentQueenId: string | null; expect(updates[1][0].where).toEqual({ id: 'colony-1' });
}; expect(updates[1][0].data.status).toBe('DISSOLVED');
}, expect(updates[1][0].data.deactivatedAt).toBeInstanceOf(Date);
],
];
expect(dissolveUpdateArg.where).toEqual({ id: 'colony-1' });
expect(dissolveUpdateArg.data.status).toBe('DISSOLVED');
expect(dissolveUpdateArg.data.currentQueenId).toBeNull();
expect(dissolveUpdateArg.data.deactivatedAt).toBeInstanceOf(Date);
const [[historyCloseArg]] = tx.queenColonyHistory.updateMany.mock.calls as [ const [[historyCloseArg]] = tx.queenColonyHistory.updateMany.mock.calls as [
[ [
@@ -369,6 +454,38 @@ describe('ColoniesService', () => {
expect(transitionTypes()).toEqual(['ColonyStatusChanged']); expect(transitionTypes()).toEqual(['ColonyStatusChanged']);
}); });
it('reactivate turns the open CLEANUP suggestions of the colony OBSOLETE after locking it', async () => {
prisma.colony.findUnique.mockResolvedValue({
...parentColony,
status: 'SOLD',
});
tx.colony.update.mockResolvedValue({ ...parentColony, status: 'ACTIVE' });
await service.reactivate('colony-1', 'user-1');
expect(tx.suggestion.updateMany).toHaveBeenCalledWith({
where: { colonyId: 'colony-1', intent: 'CLEANUP', status: 'OPEN' },
data: { status: 'OBSOLETE', decidedAt: expect.any(Date) as Date },
});
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
tx.suggestion.updateMany.mock.invocationCallOrder[0],
);
});
it('an edit between two ended statuses releases no queen (nothing living ended)', async () => {
prisma.colony.findUnique.mockResolvedValue({
...parentColony,
status: 'DISSOLVED',
healthStatus: null,
currentQueenId: 'q1',
});
tx.colony.update.mockResolvedValue({ ...parentColony, status: 'SOLD' });
await service.update('colony-1', { status: 'SOLD' }, 'user-1');
expect(tx.queenColonyHistory.updateMany).not.toHaveBeenCalled();
});
it('writes an apiary-history entry when an edit corrects the apiary', async () => { it('writes an apiary-history entry when an edit corrects the apiary', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony); prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue({ tx.colony.update.mockResolvedValue({
@@ -427,6 +544,44 @@ describe('ColoniesService', () => {
); );
}); });
it('writing occupiedFrames leaves strength untouched', async () => {
prisma.colony.findUnique.mockResolvedValue({
...parentColony,
strength: 7,
});
tx.colony.update.mockResolvedValue({
...parentColony,
strength: 7,
occupiedFrames: 14,
});
await service.update('colony-1', { occupiedFrames: 14 }, 'user-1');
expect(tx.colony.update).toHaveBeenCalledWith({
where: { id: 'colony-1' },
data: { occupiedFrames: 14 },
});
});
it('updateIn locks the colony row before reading before', async () => {
tx.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue(parentColony);
const result = await service.updateIn(
tx as never,
'colony-1',
{ notes: 'x' },
'user-1',
);
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
tx.colony.findUnique.mock.invocationCallOrder[0],
);
expect(result.before.id).toBe('colony-1');
expect(result.after).toBe(parentColony);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it('update records no event when status and health stay the same', async () => { it('update records no event when status and health stay the same', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony); prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue(parentColony); tx.colony.update.mockResolvedValue(parentColony);
@@ -450,7 +605,12 @@ describe('ColoniesService', () => {
expect.objectContaining({ expect.objectContaining({
type: 'ColonyEnded', type: 'ColonyEnded',
aggregateId: 'colony-1', aggregateId: 'colony-1',
payload: { status: 'DISSOLVED' }, payload: {
status: 'DISSOLVED',
endedOn: berlinToday(),
releasedQueenId: null,
via: 'DISSOLVE',
},
}), }),
]); ]);
}); });
@@ -493,6 +653,10 @@ describe('ColoniesService', () => {
'MERGED', 'MERGED',
'MERGED', 'MERGED',
]); ]);
expect(ended.map((event) => event.payload)).toEqual([
expect.objectContaining({ via: 'MERGE', releasedQueenId: null }),
expect.objectContaining({ via: 'MERGE', releasedQueenId: null }),
]);
expect(auditLog.record).toHaveBeenCalledWith( expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityId: 'colony-1', action: 'UPDATE' }), expect.objectContaining({ entityId: 'colony-1', action: 'UPDATE' }),
tx, tx,
@@ -502,6 +666,7 @@ describe('ColoniesService', () => {
it('writes the audit entry of create, move and split inside the transaction', async () => { it('writes the audit entry of create, move and split inside the transaction', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony); prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.create.mockResolvedValue({ id: 'colony-2' }); tx.colony.create.mockResolvedValue({ id: 'colony-2' });
tx.colonyLineage.create.mockResolvedValue({ id: 'lineage-1' });
tx.colony.update.mockResolvedValue({ tx.colony.update.mockResolvedValue({
...parentColony, ...parentColony,
apiaryId: 'apiary-2', apiaryId: 'apiary-2',
@@ -607,4 +772,56 @@ describe('ColoniesService', () => {
const [[query]] = tx.$queryRaw.mock.calls as [[{ sql: string }]]; const [[query]] = tx.$queryRaw.mock.calls as [[{ sql: string }]];
expect(query.sql).toContain('ANY(?) ORDER BY id FOR UPDATE'); expect(query.sql).toContain('ANY(?) ORDER BY id FOR UPDATE');
}); });
describe('a colony that ends through an edit', () => {
const living = {
...parentColony,
healthStatus: null,
currentQueenId: 'q1',
};
it.each(['SOLD', 'DECEASED'] as const)(
'update to %s releases the queen with the day of the change and records ColonyEnded via EDIT',
async (status) => {
prisma.colony.findUnique.mockResolvedValue(living);
tx.colony.update.mockResolvedValue({
...living,
status,
currentQueenId: null,
});
await service.update('colony-1', { status }, 'user-1');
const today = berlinToday();
expect(tx.queenColonyHistory.updateMany).toHaveBeenCalledWith({
where: { queenId: 'q1', colonyId: 'colony-1', endDate: null },
data: { endDate: new Date(`${today}T00:00:00.000Z`) },
});
expect(tx.colony.update.mock.calls[0]).toEqual([
{ where: { id: 'colony-1' }, data: { currentQueenId: null } },
]);
expect(events.recordAll).toHaveBeenCalledWith(tx, [
expect.objectContaining({ type: 'ColonyStatusChanged' }),
expect.objectContaining({
type: 'ColonyEnded',
payload: {
status,
endedOn: today,
releasedQueenId: 'q1',
via: 'EDIT',
},
}),
]);
},
);
it('releases no queen for an edit that keeps the colony alive', async () => {
prisma.colony.findUnique.mockResolvedValue(living);
tx.colony.update.mockResolvedValue({ ...living, status: 'WEAK' });
await service.update('colony-1', { status: 'WEAK' }, 'user-1');
expect(tx.queenColonyHistory.updateMany).not.toHaveBeenCalled();
});
});
}); });
@@ -8,15 +8,20 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service'; import { WebhooksService } from '../webhooks/webhooks.service';
import { DomainEventsService } from '../domain-events/domain-events.service'; import { DomainEventsService } from '../domain-events/domain-events.service';
import { Colony, ColonyStatus } from '../../generated/prisma'; import { Colony, ColonyStatus, Prisma } from '../../generated/prisma';
import { CreateColonyDto } from './dto/create-colony.dto'; import { CreateColonyDto } from './dto/create-colony.dto';
import { UpdateColonyDto } from './dto/update-colony.dto'; import { UpdateColonyDto } from './dto/update-colony.dto';
import { MoveColonyDto } from './dto/move-colony.dto'; import { MoveColonyDto } from './dto/move-colony.dto';
import { SplitColonyDto } from './dto/split-colony.dto'; import { SplitColonyDto } from './dto/split-colony.dto';
import { MergeColonyDto } from './dto/merge-colony.dto'; import { MergeColonyDto } from './dto/merge-colony.dto';
import { isLivingColonyStatus } from './colony-status'; import { releaseQueenIn } from './colony-lifecycle';
import {
isLivingColonyStatus,
TERMINAL_COLONY_STATUSES,
} from './colony-status';
import { lockRowsForUpdate } from '../prisma/row-lock'; import { lockRowsForUpdate } from '../prisma/row-lock';
import { colonyTransitionEvents, lockColony } from './colony-events'; import { colonyTransitionEvents, lockColony } from './colony-events';
import { berlinToday, dayToDate } from '../common/dates';
export interface ColonyFilter { export interface ColonyFilter {
status?: ColonyStatus; status?: ColonyStatus;
@@ -104,16 +109,40 @@ export class ColoniesService {
dto: UpdateColonyDto, dto: UpdateColonyDto,
actingUserId: string, actingUserId: string,
): Promise<Colony> { ): Promise<Colony> {
// Fail fast with 404; everything else is decided on the locked row. const { after } = await this.prisma.$transaction((tx) =>
await this.findById(id); this.updateIn(tx, id, dto, actingUserId),
);
return after;
}
const colony = await this.prisma.$transaction(async (tx) => { /**
* The core of update, for callers that already hold a transaction (an
* inspection handing values over to the colony card). Locks the colony row
* and reads "before" only afterwards (404 when missing); writes the apiary
* history, the status events and the audit entry with the change.
*/
async updateIn(
tx: Prisma.TransactionClient,
id: string,
dto: UpdateColonyDto,
actingUserId: string,
): Promise<{ before: Colony; after: Colony }> {
const before = await lockColony(tx, id); const before = await lockColony(tx, id);
const movesApiary = const movesApiary =
dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId; dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId;
if (movesApiary) { if (movesApiary) {
await this.assertApiaryActive(dto.apiaryId!); await this.assertApiaryActive(dto.apiaryId!);
} }
// An edit into an ended status ends the queen assignment like dissolve
// does, before the status is written (the pointer is read under the lock).
const today = berlinToday();
const endsColony =
dto.status !== undefined &&
TERMINAL_COLONY_STATUSES.includes(dto.status) &&
isLivingColonyStatus(before.status);
const releasedQueenId = endsColony
? await releaseQueenIn(tx, id, dayToDate(today))
: null;
const updated = await tx.colony.update({ const updated = await tx.colony.update({
where: { id }, where: { id },
data: { ...dto }, data: { ...dto },
@@ -133,7 +162,11 @@ export class ColoniesService {
} }
await this.events.recordAll( await this.events.recordAll(
tx, tx,
colonyTransitionEvents(before, updated, actingUserId), colonyTransitionEvents(before, updated, actingUserId, {
endedOn: today,
releasedQueenId,
via: 'EDIT',
}),
); );
await this.auditLog.record( await this.auditLog.record(
{ {
@@ -146,9 +179,7 @@ export class ColoniesService {
}, },
tx, tx,
); );
return updated; return { before, after: updated };
});
return colony;
} }
async move( async move(
@@ -194,9 +225,16 @@ export class ColoniesService {
dto: SplitColonyDto, dto: SplitColonyDto,
actingUserId: string, actingUserId: string,
): Promise<Colony> { ): Promise<Colony> {
const parent = await this.findById(id); await this.findById(id);
this.assertActive(parent); const today = berlinToday();
const splitOn = dto.date ?? today;
if (splitOn > today) {
throw new BadRequestException('Das Datum liegt in der Zukunft.');
}
const queenSource = dto.queenSource ?? null;
const child = await this.prisma.$transaction(async (tx) => { const child = await this.prisma.$transaction(async (tx) => {
const parent = await lockColony(tx, id);
this.assertActive(parent);
const created = await tx.colony.create({ const created = await tx.colony.create({
data: { data: {
code: dto.code, code: dto.code,
@@ -205,12 +243,13 @@ export class ColoniesService {
notes: dto.notes, notes: dto.notes,
}, },
}); });
await tx.colonyLineage.create({ const lineage = await tx.colonyLineage.create({
data: { data: {
type: 'SPLIT', type: 'SPLIT',
fromColonyId: parent.id, fromColonyId: parent.id,
toColonyId: created.id, toColonyId: created.id,
date: new Date(), date: dayToDate(splitOn),
queenSource,
notes: dto.notes, notes: dto.notes,
}, },
}); });
@@ -224,6 +263,18 @@ export class ColoniesService {
}, },
tx, tx,
); );
await this.events.record(tx, {
type: 'ColonySplit',
aggregateType: 'Colony',
aggregateId: created.id,
actorUserId: actingUserId,
payload: {
parentColonyId: parent.id,
lineageId: lineage.id,
queenSource,
splitOn,
},
});
return created; return created;
}); });
return child; return child;
@@ -246,6 +297,7 @@ export class ColoniesService {
'Jedes Volk darf nur einmal zur Vereinigung ausgewählt werden', 'Jedes Volk darf nur einmal zur Vereinigung ausgewählt werden',
); );
} }
const today = berlinToday();
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
// One statement in id order, the same order bulk lockRowsForShare // One statement in id order, the same order bulk lockRowsForShare
// uses: no deadlock with a concurrent bulk write. // uses: no deadlock with a concurrent bulk write.
@@ -266,28 +318,23 @@ export class ColoniesService {
// The survivor keeps its own queen; merged-away queens are simply // The survivor keeps its own queen; merged-away queens are simply
// released, not auto-transferred (which colony's queen "wins" a // released, not auto-transferred (which colony's queen "wins" a
// merge is a beekeeper decision, not one to guess here). // merge is a beekeeper decision, not one to guess here).
const releasedQueenId = await releaseQueenIn(
tx,
mergedColonyId,
dayToDate(today),
);
const mergedAway = await tx.colony.update({ const mergedAway = await tx.colony.update({
where: { id: mergedColonyId }, where: { id: mergedColonyId },
data: { data: { status: 'MERGED', deactivatedAt: new Date() },
status: 'MERGED',
deactivatedAt: new Date(),
currentQueenId: null,
},
}); });
await this.events.recordAll( await this.events.recordAll(
tx, tx,
colonyTransitionEvents(merged, mergedAway, actingUserId), colonyTransitionEvents(merged, mergedAway, actingUserId, {
endedOn: today,
releasedQueenId,
via: 'MERGE',
}),
); );
if (merged.currentQueenId) {
await tx.queenColonyHistory.updateMany({
where: {
queenId: merged.currentQueenId,
colonyId: mergedColonyId,
endDate: null,
},
data: { endDate: new Date() },
});
}
await tx.colonyLineage.create({ await tx.colonyLineage.create({
data: { data: {
type: 'MERGE', type: 'MERGE',
@@ -320,27 +367,19 @@ export class ColoniesService {
// Re-dissolving would overwrite deactivatedAt and the terminal status // Re-dissolving would overwrite deactivatedAt and the terminal status
// (e.g. MERGED -> DISSOLVED), losing when and how the colony really ended. // (e.g. MERGED -> DISSOLVED), losing when and how the colony really ended.
this.assertActive(before); this.assertActive(before);
const today = berlinToday();
const releasedQueenId = await releaseQueenIn(tx, id, dayToDate(today));
const updated = await tx.colony.update({ const updated = await tx.colony.update({
where: { id }, where: { id },
data: { data: { status: 'DISSOLVED', deactivatedAt: new Date() },
status: 'DISSOLVED',
deactivatedAt: new Date(),
currentQueenId: null,
},
}); });
if (before.currentQueenId) {
await tx.queenColonyHistory.updateMany({
where: {
queenId: before.currentQueenId,
colonyId: id,
endDate: null,
},
data: { endDate: new Date() },
});
}
await this.events.recordAll( await this.events.recordAll(
tx, tx,
colonyTransitionEvents(before, updated, actingUserId), colonyTransitionEvents(before, updated, actingUserId, {
endedOn: today,
releasedQueenId,
via: 'DISSOLVE',
}),
); );
await this.auditLog.record( await this.auditLog.record(
{ {
@@ -371,9 +410,15 @@ export class ColoniesService {
where: { id }, where: { id },
data: { status: 'ACTIVE', deactivatedAt: null }, data: { status: 'ACTIVE', deactivatedAt: null },
}); });
// The clean-up of the end is moot now (colony row locked first, then
// the suggestions: the order every writer uses).
await tx.suggestion.updateMany({
where: { colonyId: id, intent: 'CLEANUP', status: 'OPEN' },
data: { status: 'OBSOLETE', decidedAt: new Date() },
});
await this.events.recordAll( await this.events.recordAll(
tx, tx,
colonyTransitionEvents(before, updated, actingUserId), colonyTransitionEvents(before, updated, actingUserId, null),
); );
await this.auditLog.record( await this.auditLog.record(
{ {
@@ -0,0 +1,108 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import ts from 'typescript';
const SRC = join(__dirname, '..');
const TERMINAL = new Set(['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED']);
function sourceFiles(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const path = join(dir, name);
if (statSync(path).isDirectory()) {
return sourceFiles(path);
}
return path.endsWith('.ts') && !path.endsWith('.spec.ts') ? [path] : [];
});
}
interface Writer {
where: string;
releaseBefore: boolean;
}
/**
* Every function-like node that writes an ended colony status into a `data`
* object (`data: { status: 'SOLD' }`) or passes the DTO whole (`data: {
* ...dto }` in the colonies service, whose status may be ended), with
* whether a `releaseQueenIn(` call stands earlier in the same method.
*/
function writersIn(file: string): Writer[] {
const text = readFileSync(file, 'utf8');
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
const isColoniesService = file.endsWith('colonies.service.ts');
const writers: Writer[] = [];
const writesEndedStatus = (
data: ts.ObjectLiteralExpression,
method: ts.Node | undefined,
): boolean =>
data.properties.some(
(property) =>
(ts.isPropertyAssignment(property) &&
property.name.getText() === 'status' &&
ts.isStringLiteral(property.initializer) &&
TERMINAL.has(property.initializer.text)) ||
(isColoniesService &&
method !== undefined &&
ts.isMethodDeclaration(method) &&
method.name.getText() === 'updateIn' &&
ts.isSpreadAssignment(property) &&
property.expression.getText() === 'dto'),
);
const enclosingMethod = (node: ts.Node): ts.Node | undefined => {
for (let up = node.parent; up; up = up.parent) {
if (ts.isMethodDeclaration(up) || ts.isFunctionDeclaration(up)) {
return up;
}
}
return undefined;
};
const visit = (node: ts.Node): void => {
if (
ts.isPropertyAssignment(node) &&
node.name.getText() === 'data' &&
ts.isObjectLiteralExpression(node.initializer) &&
writesEndedStatus(node.initializer, enclosingMethod(node))
) {
const method = enclosingMethod(node);
const body = method?.getText() ?? '';
const release = body.indexOf('releaseQueenIn(');
writers.push({
where: `${file}:${source.getLineAndCharacterOfPosition(node.getStart()).line + 1}`,
releaseBefore:
method !== undefined &&
release !== -1 &&
method.getStart() + release < node.getStart(),
});
}
ts.forEachChild(node, visit);
};
visit(source);
return writers;
}
/**
* Guard: whoever lets a colony end must end the queen assignment in the
* same transaction. A new way to set DISSOLVED/SOLD/DECEASED/MERGED without
* releaseQueenIn leaves a dead colony holding the queen's unique pointer.
* Checked per method: the release comes before the write in the same method.
*/
describe('a colony that ends releases its queen', () => {
const writers = sourceFiles(SRC).flatMap(writersIn);
it('every write of an ended colony status is preceded by releaseQueenIn in the same method', () => {
expect(writers.filter((writer) => !writer.releaseBefore)).toEqual([]);
});
it('finds the four known ways (dissolve, merge, edit, winter check)', () => {
const places = writers.map((writer) => writer.where);
expect(
places.filter((p) => p.includes('colonies.service.ts')),
).toHaveLength(3);
expect(
places.filter((p) => p.includes('winter-checks.service.ts')),
).toHaveLength(1);
});
});
@@ -13,7 +13,7 @@ function colony(overrides: Partial<Colony> = {}): Colony {
describe('colonyTransitionEvents', () => { describe('colonyTransitionEvents', () => {
it('records nothing when neither status nor healthStatus changed', () => { it('records nothing when neither status nor healthStatus changed', () => {
expect( expect(
colonyTransitionEvents(colony(), colony({ notes: 'neu' }), 'u1'), colonyTransitionEvents(colony(), colony({ notes: 'neu' }), 'u1', null),
).toEqual([]); ).toEqual([]);
}); });
@@ -22,6 +22,7 @@ describe('colonyTransitionEvents', () => {
colony(), colony(),
colony({ healthStatus: 'CRITICAL' }), colony({ healthStatus: 'CRITICAL' }),
'u1', 'u1',
null,
); );
expect(events).toEqual([ expect(events).toEqual([
{ {
@@ -38,18 +39,24 @@ describe('colonyTransitionEvents', () => {
}); });
it.each(['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] as const)( it.each(['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] as const)(
'adds ColonyEnded { status: %s } on the transition from living to ended', 'ColonyEnded carries status %s, endedOn, releasedQueenId and via',
(status) => { (status) => {
const events = colonyTransitionEvents( const events = colonyTransitionEvents(
colony({ status: 'WEAK' }), colony({ status: 'WEAK' }),
colony({ status }), colony({ status }),
null, null,
{ endedOn: '2027-03-12', releasedQueenId: 'q1', via: 'WINTER_CHECK' },
); );
expect(events.map((event) => event.type)).toEqual([ expect(events.map((event) => event.type)).toEqual([
'ColonyStatusChanged', 'ColonyStatusChanged',
'ColonyEnded', 'ColonyEnded',
]); ]);
expect(events[1].payload).toEqual({ status }); expect(events[1].payload).toEqual({
status,
endedOn: '2027-03-12',
releasedQueenId: 'q1',
via: 'WINTER_CHECK',
});
}, },
); );
@@ -59,6 +66,7 @@ describe('colonyTransitionEvents', () => {
colony({ status: 'DISSOLVED' }), colony({ status: 'DISSOLVED' }),
colony({ status: 'DECEASED' }), colony({ status: 'DECEASED' }),
'u1', 'u1',
null,
).map((event) => event.type), ).map((event) => event.type),
).toEqual(['ColonyStatusChanged']); ).toEqual(['ColonyStatusChanged']);
expect( expect(
@@ -66,7 +74,19 @@ describe('colonyTransitionEvents', () => {
colony({ status: 'DISSOLVED' }), colony({ status: 'DISSOLVED' }),
colony({ status: 'ACTIVE' }), colony({ status: 'ACTIVE' }),
'u1', 'u1',
null,
).map((event) => event.type), ).map((event) => event.type),
).toEqual(['ColonyStatusChanged']); ).toEqual(['ColonyStatusChanged']);
}); });
it('refuses to build ColonyEnded without the context of the end', () => {
expect(() =>
colonyTransitionEvents(
colony({ status: 'ACTIVE' }),
colony({ status: 'SOLD' }),
'u1',
null,
),
).toThrow('ColonyEnded needs');
});
}); });
+14 -2
View File
@@ -5,6 +5,7 @@ import {
AnyNewDomainEvent, AnyNewDomainEvent,
ColonyStateSnapshot, ColonyStateSnapshot,
} from '../domain-events/domain-event-types'; } from '../domain-events/domain-event-types';
import type { ColonyEnd } from './colony-lifecycle';
import { isLivingColonyStatus } from './colony-status'; import { isLivingColonyStatus } from './colony-status';
type ColonyState = Pick<Colony, 'status' | 'healthStatus'>; type ColonyState = Pick<Colony, 'status' | 'healthStatus'>;
@@ -18,12 +19,15 @@ function snapshotOf(colony: ColonyState): ColonyStateSnapshot {
* ColonyStatusChanged as soon as status or healthStatus differs, plus * ColonyStatusChanged as soon as status or healthStatus differs, plus
* ColonyEnded when a living colony became an ended one. Empty when nothing * ColonyEnded when a living colony became an ended one. Empty when nothing
* the events care about changed. The caller records them in the same * the events care about changed. The caller records them in the same
* transaction as the colony update. * transaction as the colony update. `ended` is the context of the end
* (day, released queen, way) and is required exactly when a living colony
* became an ended one - the caller calls releaseQueenIn first.
*/ */
export function colonyTransitionEvents( export function colonyTransitionEvents(
before: Pick<Colony, 'id' | 'status' | 'healthStatus'>, before: Pick<Colony, 'id' | 'status' | 'healthStatus'>,
after: Pick<Colony, 'id' | 'status' | 'healthStatus'>, after: Pick<Colony, 'id' | 'status' | 'healthStatus'>,
actorUserId: string | null, actorUserId: string | null,
ended: ColonyEnd | null,
): AnyNewDomainEvent<'ColonyStatusChanged' | 'ColonyEnded'>[] { ): AnyNewDomainEvent<'ColonyStatusChanged' | 'ColonyEnded'>[] {
if ( if (
before.status === after.status && before.status === after.status &&
@@ -44,12 +48,20 @@ export function colonyTransitionEvents(
isLivingColonyStatus(before.status) && isLivingColonyStatus(before.status) &&
!isLivingColonyStatus(after.status) !isLivingColonyStatus(after.status)
) { ) {
if (ended === null) {
throw new Error('ColonyEnded needs the context of the end');
}
events.push({ events.push({
type: 'ColonyEnded', type: 'ColonyEnded',
aggregateType: 'Colony', aggregateType: 'Colony',
aggregateId: after.id, aggregateId: after.id,
actorUserId, actorUserId,
payload: { status: after.status }, payload: {
status: after.status,
endedOn: ended.endedOn,
releasedQueenId: ended.releasedQueenId,
via: ended.via,
},
}); });
} }
return events; return events;
@@ -0,0 +1,49 @@
import type { Prisma } from '../../generated/prisma';
import { releaseQueenIn } from './colony-lifecycle';
describe('releaseQueenIn', () => {
let tx: {
colony: { findUnique: jest.Mock; update: jest.Mock };
queenColonyHistory: { updateMany: jest.Mock };
};
const asTx = () => tx as unknown as Prisma.TransactionClient;
const endDate = new Date('2027-03-12T00:00:00.000Z');
beforeEach(() => {
tx = {
colony: { findUnique: jest.fn(), update: jest.fn() },
queenColonyHistory: { updateMany: jest.fn() },
};
});
it('ends the open history with the end date and clears currentQueenId', async () => {
tx.colony.findUnique.mockResolvedValue({ currentQueenId: 'queen-1' });
const released = await releaseQueenIn(asTx(), 'col-1', endDate);
expect(released).toBe('queen-1');
expect(tx.queenColonyHistory.updateMany).toHaveBeenCalledWith({
where: { queenId: 'queen-1', colonyId: 'col-1', endDate: null },
data: { endDate },
});
expect(tx.colony.update).toHaveBeenCalledWith({
where: { id: 'col-1' },
data: { currentQueenId: null },
});
});
it('returns null for a colony without a queen and writes nothing', async () => {
tx.colony.findUnique.mockResolvedValue({ currentQueenId: null });
await expect(releaseQueenIn(asTx(), 'col-1', endDate)).resolves.toBeNull();
expect(tx.queenColonyHistory.updateMany).not.toHaveBeenCalled();
expect(tx.colony.update).not.toHaveBeenCalled();
});
it('returns null for an unknown colony', async () => {
tx.colony.findUnique.mockResolvedValue(null);
await expect(releaseQueenIn(asTx(), 'gone', endDate)).resolves.toBeNull();
});
});
@@ -0,0 +1,48 @@
import type { Prisma } from '../../generated/prisma';
import { assertTransactionClient } from '../prisma/db-client';
/** The ways a colony can end; carried by ColonyEnded. */
export type ColonyEndVia = 'DISSOLVE' | 'MERGE' | 'EDIT' | 'WINTER_CHECK';
/** What ColonyEnded and its handlers need to know about the end. */
export interface ColonyEnd {
/** YYYY-MM-DD, the business day of the end - never read from the clock later. */
endedOn: string;
releasedQueenId: string | null;
via: ColonyEndVia;
}
/**
* Ends the queen assignment of a colony that ends: closes the open
* QueenColonyHistory with `endDate` and clears currentQueenId (a unique
* pointer, so a dead colony must not keep it). The queen's own status does
* not change. Resolves to the queen's id, or null when the colony had none.
*
* Every way a colony ends calls this in the transaction of the end, with
* the colony row locked: currentQueenId is read here, under that lock, and
* the callers build ColonyEnded from the result.
*/
export async function releaseQueenIn(
tx: Prisma.TransactionClient,
colonyId: string,
endDate: Date,
): Promise<string | null> {
assertTransactionClient(tx, 'releaseQueenIn');
const colony = await tx.colony.findUnique({
where: { id: colonyId },
select: { currentQueenId: true },
});
const queenId = colony?.currentQueenId ?? null;
if (queenId === null) {
return null;
}
await tx.queenColonyHistory.updateMany({
where: { queenId, colonyId, endDate: null },
data: { endDate },
});
await tx.colony.update({
where: { id: colonyId },
data: { currentQueenId: null },
});
return queenId;
}
@@ -0,0 +1,117 @@
import {
broodConditionFromPattern,
planColonyTakeover,
} from './colony-takeover';
const inspection = {
colonyStrength: 12 as number | null,
broodPattern: 4 as number | null,
feedStatus: 'GOOD' as 'GOOD' | null,
};
const allowed = {
canUpdateColony: true,
colonyEnded: false,
hasNewerInspection: false,
};
describe('colony takeover', () => {
it.each([
[5, 'EXCELLENT'],
[4, 'GOOD'],
[3, 'AVERAGE'],
[2, 'WEAK'],
[1, 'CRITICAL'],
[null, null],
])('brood pattern %p -> %p', (pattern, expected) => {
expect(broodConditionFromPattern(pattern)).toBe(expected);
});
it('takes over only requested fields that have a value', () => {
const result = planColonyTakeover(
{ ...inspection, feedStatus: null },
['occupiedFrames', 'feedStatus'],
allowed,
);
expect(result.patch).toEqual({ occupiedFrames: 12 });
expect(result.takeover).toEqual({
applied: ['occupiedFrames'],
skipped: [{ field: 'feedStatus', reason: 'NO_VALUE' }],
});
});
it('maps the brood pattern to the brood status', () => {
const result = planColonyTakeover(inspection, ['broodStatus'], allowed);
expect(result.patch).toEqual({ broodStatus: 'GOOD' });
});
it('takes nothing over when nothing is requested', () => {
const result = planColonyTakeover(inspection, [], allowed);
expect(result.patch).toEqual({});
expect(result.takeover).toEqual({ applied: [], skipped: [] });
});
it('skips everything with NO_PERMISSION when the caller may not update colonies', () => {
const result = planColonyTakeover(
inspection,
['occupiedFrames', 'broodStatus', 'feedStatus'],
{ ...allowed, canUpdateColony: false, hasNewerInspection: true },
);
expect(result.patch).toEqual({});
expect(result.takeover.applied).toEqual([]);
expect(result.takeover.skipped).toEqual([
{ field: 'occupiedFrames', reason: 'NO_PERMISSION' },
{ field: 'broodStatus', reason: 'NO_PERMISSION' },
{ field: 'feedStatus', reason: 'NO_PERMISSION' },
]);
});
it('skips everything with NEWER_INSPECTION when a later inspection exists', () => {
const result = planColonyTakeover(inspection, ['occupiedFrames'], {
...allowed,
hasNewerInspection: true,
});
expect(result.patch).toEqual({});
expect(result.takeover.skipped).toEqual([
{ field: 'occupiedFrames', reason: 'NEWER_INSPECTION' },
]);
});
it('skips every field with COLONY_ENDED when the colony no longer exists', () => {
const result = planColonyTakeover(
inspection,
['occupiedFrames', 'feedStatus'],
{ ...allowed, colonyEnded: true },
);
expect(result.patch).toEqual({});
expect(result.takeover.skipped).toEqual([
{ field: 'occupiedFrames', reason: 'COLONY_ENDED' },
{ field: 'feedStatus', reason: 'COLONY_ENDED' },
]);
});
it.each([45, -1])(
'skips OUT_OF_RANGE for the value %p outside the colony card range',
(value) => {
const result = planColonyTakeover(
{ ...inspection, colonyStrength: value },
['occupiedFrames'],
allowed,
);
expect(result.patch).toEqual({});
expect(result.takeover.skipped).toEqual([
{ field: 'occupiedFrames', reason: 'OUT_OF_RANGE' },
]);
},
);
it('accepts the bounds 0 and 40', () => {
for (const value of [0, 40]) {
const result = planColonyTakeover(
{ ...inspection, colonyStrength: value },
['occupiedFrames'],
allowed,
);
expect(result.patch).toEqual({ occupiedFrames: value });
}
});
});
@@ -0,0 +1,121 @@
import type {
Colony,
ColonyCondition,
Inspection,
} from '../../generated/prisma';
export const COLONY_TAKEOVER_FIELDS = [
'occupiedFrames',
'broodStatus',
'feedStatus',
] as const;
export type ColonyTakeoverField = (typeof COLONY_TAKEOVER_FIELDS)[number];
export type TakeoverSkipReason =
| 'NO_VALUE'
| 'NO_PERMISSION'
| 'NEWER_INSPECTION'
| 'COLONY_ENDED'
| 'OUT_OF_RANGE';
export interface ColonyTakeover {
applied: ColonyTakeoverField[];
skipped: { field: ColonyTakeoverField; reason: TakeoverSkipReason }[];
}
export const MAX_OCCUPIED_FRAMES = 40;
const CONDITION_BY_BROOD_PATTERN: Record<number, ColonyCondition> = {
5: 'EXCELLENT',
4: 'GOOD',
3: 'AVERAGE',
2: 'WEAK',
1: 'CRITICAL',
};
/** Brood pattern 1-5 as the colony card's brood status; the mapping is an assumption. */
export function broodConditionFromPattern(
pattern: number | null,
): ColonyCondition | null {
if (pattern === null) {
return null;
}
return CONDITION_BY_BROOD_PATTERN[pattern] ?? null;
}
/** Only values that exist: a takeover never writes null. */
type TakeoverPatch = {
[K in ColonyTakeoverField]?: NonNullable<Colony[K]>;
};
type TakeoverSource = Pick<
Inspection,
'colonyStrength' | 'broodPattern' | 'feedStatus'
>;
interface FieldPlan {
patch: TakeoverPatch | null;
skip: TakeoverSkipReason | null;
}
function planField(
field: ColonyTakeoverField,
inspection: TakeoverSource,
): FieldPlan {
switch (field) {
case 'occupiedFrames': {
const frames = inspection.colonyStrength;
if (frames === null) {
return { patch: null, skip: 'NO_VALUE' };
}
if (frames < 0 || frames > MAX_OCCUPIED_FRAMES) {
return { patch: null, skip: 'OUT_OF_RANGE' };
}
return { patch: { occupiedFrames: frames }, skip: null };
}
case 'broodStatus': {
const status = broodConditionFromPattern(inspection.broodPattern);
return status === null
? { patch: null, skip: 'NO_VALUE' }
: { patch: { broodStatus: status }, skip: null };
}
case 'feedStatus':
return inspection.feedStatus === null
? { patch: null, skip: 'NO_VALUE' }
: { patch: { feedStatus: inspection.feedStatus }, skip: null };
}
}
/**
* What a saved inspection hands over to the colony card. Values come from the
* stored inspection only. A missing permission, an ended colony and a newer
* inspection each skip every requested field: a newer inspection blocks all
* fields, not only the one it would overwrite (spec 7.9).
*/
export function planColonyTakeover(
inspection: TakeoverSource,
requested: readonly ColonyTakeoverField[],
ctx: {
canUpdateColony: boolean;
colonyEnded: boolean;
hasNewerInspection: boolean;
},
): { patch: TakeoverPatch; takeover: ColonyTakeover } {
const patch: TakeoverPatch = {};
const takeover: ColonyTakeover = { applied: [], skipped: [] };
for (const field of requested) {
const plan: FieldPlan = !ctx.canUpdateColony
? { patch: null, skip: 'NO_PERMISSION' }
: ctx.colonyEnded
? { patch: null, skip: 'COLONY_ENDED' }
: ctx.hasNewerInspection
? { patch: null, skip: 'NEWER_INSPECTION' }
: planField(field, inspection);
if (plan.skip !== null) {
takeover.skipped.push({ field, reason: plan.skip });
continue;
}
Object.assign(patch, plan.patch);
takeover.applied.push(field);
}
return { patch, takeover };
}
@@ -25,13 +25,29 @@ export class CreateColonyDto {
@IsString() @IsString()
originText?: string; originText?: string;
@ApiPropertyOptional({ minimum: 1, maximum: 10 }) @ApiPropertyOptional({
minimum: 1,
maximum: 10,
deprecated: true,
description: 'Volksstärke (alt, Skala 1–10)',
})
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@Min(1) @Min(1)
@Max(10) @Max(10)
strength?: number; strength?: number;
@ApiPropertyOptional({
minimum: 0,
maximum: 40,
description: 'Volksstärke in besetzten Waben',
})
@IsOptional()
@IsInt()
@Min(0)
@Max(40, { message: 'Höchstens 40 besetzte Waben.' })
occupiedFrames?: number;
@ApiPropertyOptional({ enum: ColonyCondition }) @ApiPropertyOptional({ enum: ColonyCondition })
@IsOptional() @IsOptional()
@IsEnum(ColonyCondition) @IsEnum(ColonyCondition)
@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString } from 'class-validator'; import { IsEnum, IsOptional, IsString, Matches } from 'class-validator';
import { ColonyType } from '../../../generated/prisma'; import { ColonyType, SplitQueenSource } from '../../../generated/prisma';
export class SplitColonyDto { export class SplitColonyDto {
@ApiProperty() @ApiProperty()
@@ -15,4 +15,16 @@ export class SplitColonyDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
notes?: string; notes?: string;
@ApiPropertyOptional({
description: 'Day of the split (YYYY-MM-DD), not after today.',
})
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'date must be YYYY-MM-DD' })
date?: string;
@ApiPropertyOptional({ enum: SplitQueenSource })
@IsOptional()
@IsEnum(SplitQueenSource)
queenSource?: SplitQueenSource;
} }
@@ -18,6 +18,7 @@ class UpdatableColonyFieldsDto extends PartialType(
'originText', 'originText',
'colonyType', 'colonyType',
'strength', 'strength',
'occupiedFrames',
'broodStatus', 'broodStatus',
'feedStatus', 'feedStatus',
'healthStatus', 'healthStatus',
@@ -38,15 +38,12 @@ describe('legacy after-commit paths', () => {
it('runAfterCommit is only used by the remaining legacy paths', () => { it('runAfterCommit is only used by the remaining legacy paths', () => {
expect(filesCalling('runAfterCommit(')).toEqual([ expect(filesCalling('runAfterCommit(')).toEqual([
'src/automation/automation.service.ts', 'src/automation/automation.service.ts',
'src/calendar-sync/calendar-sync.service.ts',
'src/webhooks/webhooks.service.ts', 'src/webhooks/webhooks.service.ts',
]); ]);
}); });
it('only the queen assignment still enqueues a calendar entry directly', () => { it('no module enqueues a calendar entry directly any more', () => {
expect(filesCalling('calendarSync.enqueue(')).toEqual([ expect(filesCalling('calendarSync.enqueue(')).toEqual([]);
'src/queens/queens.service.ts',
]);
}); });
it('only the modules without an event still dispatch webhooks directly', () => { it('only the modules without an event still dispatch webhooks directly', () => {
+100
View File
@@ -0,0 +1,100 @@
import {
addDays,
berlinDateTime,
berlinDayOf,
berlinToday,
dayToDate,
daysBetween,
} from './dates';
describe('dates', () => {
it('berlinToday at 2026-03-28T23:30Z is 2026-03-29', () => {
expect(berlinToday(new Date('2026-03-28T23:30:00Z'))).toBe('2026-03-29');
});
it('the 04:30 Berlin job time is the same calendar day', () => {
expect(berlinToday(new Date('2026-10-13T02:30:00Z'))).toBe('2026-10-13');
});
it('berlinToday defaults to the current instant', () => {
expect(berlinToday()).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it('berlinDayOf uses the Berlin calendar day, not the UTC one', () => {
expect(berlinDayOf(new Date('2026-12-31T23:30:00Z'))).toBe('2027-01-01');
expect(berlinDayOf(new Date('2026-06-30T21:59:00Z'))).toBe('2026-06-30');
expect(berlinDayOf(new Date('2026-06-30T22:00:00Z'))).toBe('2026-07-01');
});
it('berlinDateTime 2026-03-29 06:30 is 04:30Z on the day DST starts', () => {
expect(berlinDateTime('2026-03-29', '06:30').toISOString()).toBe(
'2026-03-29T04:30:00.000Z',
);
});
it('berlinDateTime 2026-10-25 06:30 is 05:30Z after DST ends', () => {
expect(berlinDateTime('2026-10-25', '06:30').toISOString()).toBe(
'2026-10-25T05:30:00.000Z',
);
});
it('berlinDateTime uses winter and summer offsets on ordinary days', () => {
expect(berlinDateTime('2026-01-15', '06:30').toISOString()).toBe(
'2026-01-15T05:30:00.000Z',
);
expect(berlinDateTime('2026-07-15', '06:30').toISOString()).toBe(
'2026-07-15T04:30:00.000Z',
);
});
it('berlinDateTime handles midnight and the evening before a DST change', () => {
expect(berlinDateTime('2026-03-29', '00:00').toISOString()).toBe(
'2026-03-28T23:00:00.000Z',
);
expect(berlinDateTime('2026-10-25', '00:00').toISOString()).toBe(
'2026-10-24T22:00:00.000Z',
);
expect(berlinDateTime('2026-03-28', '21:00').toISOString()).toBe(
'2026-03-28T20:00:00.000Z',
);
});
it('berlinDateTime rejects malformed input', () => {
expect(() => berlinDateTime('2026-3-29', '06:30')).toThrow();
expect(() => berlinDateTime('2026-03-29', '6:30')).toThrow();
expect(() => berlinDateTime('2026-03-29', '25:00')).toThrow();
});
it('dayToDate is midnight UTC', () => {
expect(dayToDate('2026-05-05').toISOString()).toBe(
'2026-05-05T00:00:00.000Z',
);
});
it('dayToDate rejects impossible days', () => {
expect(() => dayToDate('2026-02-30')).toThrow();
expect(() => dayToDate('2026-13-01')).toThrow();
expect(() => dayToDate('2026-04-31')).toThrow();
expect(dayToDate('2024-02-29').toISOString()).toBe(
'2024-02-29T00:00:00.000Z',
);
});
it('addDays crosses month ends', () => {
expect(addDays('2026-02-27', 2)).toBe('2026-03-01');
});
it('addDays goes backwards and over year ends', () => {
expect(addDays('2026-01-01', -1)).toBe('2025-12-31');
expect(addDays('2026-12-31', 1)).toBe('2027-01-01');
});
it('daysBetween counts calendar days', () => {
expect(daysBetween('2026-05-01', '2026-05-09')).toBe(8);
expect(daysBetween('2026-05-09', '2026-05-01')).toBe(-8);
});
it('daysBetween counts whole days across a DST change', () => {
expect(daysBetween('2026-03-28', '2026-03-30')).toBe(2);
});
});
+101
View File
@@ -0,0 +1,101 @@
const BERLIN_TIME_ZONE = 'Europe/Berlin';
const MS_PER_DAY = 86_400_000;
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const TIME_PATTERN = /^([01]\d|2[0-3]):([0-5]\d)$/;
// en-CA formats a date as YYYY-MM-DD.
const berlinDayFormat = new Intl.DateTimeFormat('en-CA', {
timeZone: BERLIN_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const berlinPartsFormat = new Intl.DateTimeFormat('en-GB', {
timeZone: BERLIN_TIME_ZONE,
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
/** The calendar day (YYYY-MM-DD) of an instant in Europe/Berlin. */
export function berlinDayOf(instant: Date): string {
return berlinDayFormat.format(instant);
}
/** Today's calendar day (YYYY-MM-DD) in Europe/Berlin. */
export function berlinToday(now: Date = new Date()): string {
return berlinDayOf(now);
}
/** A stored day (YYYY-MM-DD) as the DateTime used for dates: midnight UTC. */
export function dayToDate(day: string): Date {
if (!DAY_PATTERN.test(day)) {
throw new Error(`Invalid day: ${day}`);
}
const date = new Date(`${day}T00:00:00.000Z`);
// 2026-02-30 parses to March 2nd: only a day that round-trips exists.
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== day) {
throw new Error(`Invalid day: ${day}`);
}
return date;
}
function dayNumber(day: string): number {
return Math.round(dayToDate(day).getTime() / MS_PER_DAY);
}
/** Calendar arithmetic on YYYY-MM-DD strings; no time zone involved. */
export function addDays(day: string, days: number): string {
const result = new Date(dayToDate(day).getTime() + days * MS_PER_DAY);
return result.toISOString().slice(0, 10);
}
/** Whole calendar days from `from` to `to` (negative when `to` is earlier). */
export function daysBetween(from: string, to: string): number {
return dayNumber(to) - dayNumber(from);
}
/** Offset of Europe/Berlin to UTC at an instant, in milliseconds. */
function berlinOffsetMs(instant: Date): number {
const parts: Record<string, number> = {};
for (const part of berlinPartsFormat.formatToParts(instant)) {
if (part.type !== 'literal') {
parts[part.type] = Number(part.value);
}
}
const asUtc = Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second,
);
// The instant without its milliseconds: the formatter works in seconds.
return asUtc - Math.floor(instant.getTime() / 1000) * 1000;
}
/**
* The instant at which the Berlin wall clock shows `day` `hhmm`. The offset
* is taken at the target day itself (applied twice, so the day a clock change
* happens comes out right): 06:30 is 04:30Z in summer and 05:30Z in winter.
*/
export function berlinDateTime(day: string, hhmm: string): Date {
const time = TIME_PATTERN.exec(hhmm);
if (!time) {
throw new Error(`Invalid time: ${hhmm}`);
}
const wallClockAsUtc =
dayToDate(day).getTime() +
(Number(time[1]) * 60 + Number(time[2])) * 60_000;
let instant = new Date(
wallClockAsUtc - berlinOffsetMs(new Date(wallClockAsUtc)),
);
instant = new Date(wallClockAsUtc - berlinOffsetMs(instant));
return instant;
}
@@ -0,0 +1,56 @@
import {
FEED_CONDITION_LABELS_DE,
appendLegacyFeedNote,
feedConditionFromInput,
feedConditionFromLegacy,
} from './feed-vocabulary';
describe('feed vocabulary', () => {
it.each([
[' Ausreichend ', 'GOOD', null],
['niedrig', 'WEAK', null],
['KRITISCH', 'CRITICAL', null],
['', null, null],
[' ', null, null],
[null, null, null],
[undefined, null, null],
['halb voll', null, 'Futter (alt): halb voll'],
])('maps %p', (text, feedStatus, notesSuffix) => {
expect(feedConditionFromLegacy(text)).toEqual({ feedStatus, notesSuffix });
});
it('labels every condition in German', () => {
expect(FEED_CONDITION_LABELS_DE).toEqual({
EXCELLENT: 'Reichlich',
GOOD: 'Ausreichend',
AVERAGE: 'Mittel',
WEAK: 'Niedrig',
CRITICAL: 'Kritisch',
});
});
it.each([
['critical', 'CRITICAL', null],
[' EXCELLENT ', 'EXCELLENT', null],
['niedrig', 'WEAK', null],
['constructor', null, 'Futter (alt): constructor'],
['halb voll', null, 'Futter (alt): halb voll'],
])(
'accepts condition names or legacy text: %p',
(text, feedStatus, notesSuffix) => {
expect(feedConditionFromInput(text)).toEqual({ feedStatus, notesSuffix });
},
);
describe('appendLegacyFeedNote', () => {
it.each([
[null, null, null],
['x', null, 'x'],
[null, 'Futter (alt): a', 'Futter (alt): a'],
['', 'Futter (alt): a', 'Futter (alt): a'],
['x', 'Futter (alt): a', 'x\nFutter (alt): a'],
])('joins %p and %p', (notes, suffix, expected) => {
expect(appendLegacyFeedNote(notes, suffix)).toBe(expected);
});
});
});
@@ -0,0 +1,67 @@
import { ColonyCondition } from '../../generated/prisma';
/** German display labels for the one feed vocabulary (colony card and inspection). */
export const FEED_CONDITION_LABELS_DE: Record<ColonyCondition, string> = {
EXCELLENT: 'Reichlich',
GOOD: 'Ausreichend',
AVERAGE: 'Mittel',
WEAK: 'Niedrig',
CRITICAL: 'Kritisch',
};
export interface LegacyFeedMapping {
feedStatus: ColonyCondition | null;
/** 'Futter (alt): <Text>' for text that maps to no condition, else null. */
notesSuffix: string | null;
}
const LEGACY_WORDS: ReadonlyMap<string, ColonyCondition> = new Map([
['ausreichend', 'GOOD'],
['niedrig', 'WEAK'],
['kritisch', 'CRITICAL'],
]);
const LEGACY_NOTE_PREFIX = 'Futter (alt): ';
/**
* Maps the old free-text feed status (trimmed, case-insensitive) onto the
* enum. Empty text maps to nothing; unknown text is kept for the notes.
*/
export function feedConditionFromLegacy(
text: string | null | undefined,
): LegacyFeedMapping {
const trimmed = (text ?? '').trim();
if (trimmed === '') {
return { feedStatus: null, notesSuffix: null };
}
const mapped = LEGACY_WORDS.get(trimmed.toLowerCase());
if (mapped) {
return { feedStatus: mapped, notesSuffix: null };
}
return { feedStatus: null, notesSuffix: `${LEGACY_NOTE_PREFIX}${trimmed}` };
}
/**
* Like feedConditionFromLegacy, but a value that already is a condition name
* (any case) is taken as it is. For import paths that see both old and new data.
*/
export function feedConditionFromInput(
text: string | null | undefined,
): LegacyFeedMapping {
const upper = (text ?? '').trim().toUpperCase();
if (Object.hasOwn(FEED_CONDITION_LABELS_DE, upper)) {
return { feedStatus: upper as ColonyCondition, notesSuffix: null };
}
return feedConditionFromLegacy(text);
}
/** Appends the legacy suffix to the notes, separated by a newline. */
export function appendLegacyFeedNote(
notes: string | null,
suffix: string | null,
): string | null {
if (!suffix) {
return notes;
}
return notes ? `${notes}\n${suffix}` : suffix;
}
@@ -0,0 +1,24 @@
import { endSentence, formatDayDe } from './format-day';
describe('formatDayDe', () => {
it('formats a day without and with the year', () => {
expect(formatDayDe('2026-05-05')).toBe('05.05.');
expect(formatDayDe('2026-05-05', true)).toBe('05.05.2026');
});
});
describe('endSentence', () => {
it('adds the period when the text has none', () => {
expect(endSentence('Weiselzellen gefunden')).toBe('Weiselzellen gefunden.');
});
it('does not double the period a formatted day already ends with', () => {
expect(endSentence(`Ableger von V-1 am ${formatDayDe('2026-10-13')}`)).toBe(
'Ableger von V-1 am 13.10.',
);
});
it('keeps a text that already ends with a period', () => {
expect(endSentence('Fertig.')).toBe('Fertig.');
});
});
Loaded 100 of 477 files, more files were not shown because too many files have changed in this diff. Show more