Compare commits
22
Commits
No files matched your search
@@ -54,7 +54,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
|
||||||
type=sha,format=short
|
type=sha,format=short
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
|
||||||
type=sha,format=short
|
type=sha,format=short
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
name: Manifests
|
||||||
|
|
||||||
|
# Prueft deploy/k8s ohne Cluster-Zugriff: Overlay und jeden Rollout-Schritt
|
||||||
|
# rendern, gegen die Kubernetes-Schemas validieren und die Regeln testen
|
||||||
|
# (NetworkPolicies, Pod-Haertung, Ressourcen, CronJobs, Digests, Live-Selektoren).
|
||||||
|
#
|
||||||
|
# Bewusst kein pull_request-Trigger: der Runner laeuft im Cluster, und ein
|
||||||
|
# Fork-PR braechte eigenen Code (auch eine geaenderte Fassung dieser Datei,
|
||||||
|
# also ohne jeden if-Waechter) darauf zur Ausfuehrung. push setzt
|
||||||
|
# Schreibrechte am Repo voraus; jeder Branch-Push wird geprueft, damit auch
|
||||||
|
# Aenderungen vor dem Merge nach main durch die CI gehen.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths: ['deploy/k8s/**', '.gitea/workflows/manifests.yml']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
runs-on: docker-builder
|
||||||
|
env:
|
||||||
|
# Werkzeuge mit festen Versionen; jede Datei wird gegen ihre SHA-256 von
|
||||||
|
# der offiziellen Release-Seite geprueft, bevor sie laeuft:
|
||||||
|
# kubectl: https://dl.k8s.io/release/<v>/bin/linux/amd64/kubectl.sha256
|
||||||
|
# kubeconform: CHECKSUMS im GitHub-Release yannh/kubeconform
|
||||||
|
# yq: checksums (Spalte SHA-256) im GitHub-Release mikefarah/yq
|
||||||
|
# Ein Update aendert Version und Pruefsumme zusammen.
|
||||||
|
KUBECTL_VERSION: v1.36.5
|
||||||
|
KUBECTL_SHA256: 33bc88a24c3b09cf55bfd59e6ca977881b61fc3f25ab65bc80c65b430248648e
|
||||||
|
KUBECONFORM_VERSION: v0.8.0
|
||||||
|
KUBECONFORM_SHA256: 9bc2bffbf71f261128533edaf912153948b7ff238f9a531ae6d34466ec287883
|
||||||
|
YQ_VERSION: v4.53.6
|
||||||
|
YQ_SHA256: c5f056448f973ae7d39b5401949648a78f2dc1947d6a8eb65be60d5c504b9385
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Node einrichten
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: kubectl, kubeconform und yq laden
|
||||||
|
run: |
|
||||||
|
mkdir -p "$RUNNER_TEMP/bin"
|
||||||
|
cd "$RUNNER_TEMP"
|
||||||
|
curl -fsSLo kubectl "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl"
|
||||||
|
echo "${KUBECTL_SHA256} kubectl" | sha256sum -c -
|
||||||
|
curl -fsSLo kubeconform.tar.gz "https://github.com/yannh/kubeconform/releases/download/${KUBECONFORM_VERSION}/kubeconform-linux-amd64.tar.gz"
|
||||||
|
echo "${KUBECONFORM_SHA256} kubeconform.tar.gz" | sha256sum -c -
|
||||||
|
tar -xzf kubeconform.tar.gz kubeconform
|
||||||
|
curl -fsSLo yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64"
|
||||||
|
echo "${YQ_SHA256} yq" | sha256sum -c -
|
||||||
|
install -m 0755 kubectl kubeconform yq "$RUNNER_TEMP/bin/"
|
||||||
|
|
||||||
|
- name: Overlay rendern
|
||||||
|
run: |
|
||||||
|
"$RUNNER_TEMP/bin/kubectl" kustomize deploy/k8s/overlays/encry > "$RUNNER_TEMP/rendered.yaml"
|
||||||
|
"$RUNNER_TEMP/bin/yq" ea -o=json '[.]' "$RUNNER_TEMP/rendered.yaml" > "$RUNNER_TEMP/rendered.json"
|
||||||
|
|
||||||
|
- name: Rollout-Schritte rendern
|
||||||
|
# Runbook Schritte 1, 2, 3 und 5: Overlay mit den bis dahin
|
||||||
|
# freigegebenen Komponenten (siehe render-stages.mjs).
|
||||||
|
run: |
|
||||||
|
export KUBECTL="$RUNNER_TEMP/bin/kubectl" YQ="$RUNNER_TEMP/bin/yq"
|
||||||
|
node deploy/k8s/tests/render-stages.mjs "$RUNNER_TEMP/stages"
|
||||||
|
|
||||||
|
- name: Gegen Kubernetes-Schemas pruefen
|
||||||
|
run: |
|
||||||
|
"$RUNNER_TEMP/bin/kubeconform" -strict -summary "$RUNNER_TEMP/rendered.yaml" "$RUNNER_TEMP"/stages/*.yaml
|
||||||
|
|
||||||
|
- name: Regeln testen
|
||||||
|
run: |
|
||||||
|
export RENDERED_MANIFESTS="$RUNNER_TEMP/rendered.json" RENDERED_STAGES="$RUNNER_TEMP/stages/stages.json"
|
||||||
|
node --test deploy/k8s/tests/manifest-policy.test.mjs
|
||||||
+562
-23
@@ -27,6 +27,14 @@ 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–A10), „Die nächsten Schritte“, „Heute“ | Season, Suggestions |
|
||||||
|
| `SeasonModule` | Saisonabschnitt, Fälligkeit von Durchsicht und Varroa-Messung, Alter der Königin | – |
|
||||||
|
| `PhenologyModule` | Saisonzustand je Stand (GTS, Phase, Kalibrierung), Job-Schritt `phenology`, Meldungen (API, Neuberechnung, Handler), Abschnitt 11.11 | Season, Weather, Notifications |
|
||||||
|
| `WeatherHistoryModule` | Wetterquelle (`WeatherHistorySource`) und Rückholdienst für die Worker-Schleife `phenology-weather`, Abschnitt 11.11 | – |
|
||||||
|
| `TelegramModule` | `TelegramClient`, `TelegramSender` (Zustellung, nur Worker), Abschnitt 11.13 | Notifications |
|
||||||
|
| `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 +52,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/
|
||||||
@@ -51,10 +60,24 @@ app/
|
|||||||
│ ├── honey-batches/[id]/
|
│ ├── honey-batches/[id]/
|
||||||
│ ├── storage/
|
│ ├── storage/
|
||||||
│ ├── search/
|
│ ├── search/
|
||||||
│ └── settings/users/ nur Administrator
|
│ ├── offline/ Offline-Seiten (Start, apiary?id=, colony?id=)
|
||||||
|
│ ├── nav-config.ts einzige Quelle aller Navigationspunkte
|
||||||
|
│ └── settings/
|
||||||
|
│ ├── users-roles/ nur Administrator
|
||||||
|
│ └── system/ „Verwaltung > System“ (Jobs, Ereignisse, Laufrollen)
|
||||||
```
|
```
|
||||||
|
|
||||||
Zentrale Schnellaktion "+ Neu" (Abschnitt 22): permanent sichtbarer Floating-Button in der mobilen Bottom-Nav, öffnet Auswahl-Sheet (Durchsicht, Fütterung, Behandlung, Varroamessung, Aufgabe, Ernte, Volk, Königin) → direkt zum jeweiligen Schnellerfassungs-Formular, mobil möglichst 1-2 Taps bis zum ersten Eingabefeld.
|
Zentrale Schnellaktion "+ Neu" (Abschnitt 22): permanent sichtbarer Floating-Button in der mobilen Bottom-Nav, öffnet ein Auswahl-Sheet mit Icons und 56-px-Zeilen (auch am Desktop; `quick-add-sheet.tsx`, Einträge aus `quick-actions.ts`) (Durchsicht, Fütterung, Behandlung, Varroamessung, Aufgabe, Ernte, Volk, Königin) → direkt zum jeweiligen Schnellerfassungs-Formular, mobil möglichst 1-2 Taps bis zum ersten Eingabefeld.
|
||||||
|
|
||||||
|
**Navigation aus einer Quelle:** `app/(app)/nav-config.ts` ist die einzige Quelle für Seitenleiste, Bottom-Nav, „Mehr“-Sheet und „+ Neu“; der Berechtigungsfilter gilt überall gleich. Neue Seiten (auch „Verwaltung > System“) werden nur dort eingetragen. Auf allen `new`-Seiten führt ein Zurück-Link zur Liste, bei einem Entwurf mit Rückfrage. „Mehr“ und „+ Neu“ teilen sich die Sheet-Hülle `components/nav/nav-sheet.tsx`.
|
||||||
|
|
||||||
|
**Offline-Seiten:** Ohne Netz zeigen `/offline` (Start: geladene Stände), `/offline/apiary?id=…` und `/offline/colony?id=…` die Daten aus den Offline-Paketen (IndexedDB). QR- und NFC-Deep-Links (`/apiaries/<id>`, `/colonies/<id>`) weicht der Service Worker ohne Netz auf diese Seiten aus; der Weg zurück („Mit Netz öffnen“) übernimmt `from` nur bei gleicher Herkunft (same-origin). Formulare lesen Völker und Königinnen aus dem Paket, wenn die API nicht antwortet (`withPackFallback`, `networkMode: "always"`).
|
||||||
|
|
||||||
|
**API-Client und Service Worker.** `apiFetch` und `apiFetchFormData` nehmen die Option `refreshOn401: false`: Ein 401 wird dann weder erneuert noch wiederholt, die deutsche Meldung des Servers kommt unverändert an. Alle Nachweis-Aufrufe (Step-up, Abschnitt 11.13) setzen sie (Annahme 56). Der Service Worker legt `/api/v1/profile/telegram`, `/api/v1/profile/notification-settings` und `/api/v1/system` nie im Cache ab (`NEVER_CACHED_API_PATHS` in `public/sw-routes.js`; eine Änderung hebt `SW_ROUTES_VERSION` in `public/sw.js`).
|
||||||
|
|
||||||
|
**Stand-Seite und Standrunde:** `apiaries/[id]` zeigt die lebenden Völker mit Ampel und „Runde starten“; nach dem Speichern führt „Weiter mit …“ zum nächsten Volk. Der Rundenzustand liegt je Benutzer lokal und wird beim Abmelden gelöscht.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
@@ -94,45 +117,146 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
|||||||
2. **Treatment auf Einzelvolk statt "Völkergruppe"**: Abschnitt 4.11 erlaubt "Volk oder Völkergruppe". MVP modelliert `Treatment` pro Volk (`colonyId` Pflichtfeld); eine Gruppenbehandlung wird im MVP als mehrere Treatment-Datensätze mit gleicher `batchNumber`/Notiz erfasst statt eigener Gruppen-Entität. Spätere Migration kann eine `TreatmentGroup`-Entität nachziehen, falls das in der Praxis zu unhandlich ist.
|
2. **Treatment auf Einzelvolk statt "Völkergruppe"**: Abschnitt 4.11 erlaubt "Volk oder Völkergruppe". MVP modelliert `Treatment` pro Volk (`colonyId` Pflichtfeld); eine Gruppenbehandlung wird im MVP als mehrere Treatment-Datensätze mit gleicher `batchNumber`/Notiz erfasst statt eigener Gruppen-Entität. Spätere Migration kann eine `TreatmentGroup`-Entität nachziehen, falls das in der Praxis zu unhandlich ist.
|
||||||
3. **Lagerverwaltung MVP-Umfang**: "grundlegende Lagerverwaltung" (Abschnitt 26 Punkt 12) wird als generisches `StockItem` + `StockMovement` (Kategorie Honig/Verbrauchsmaterial/Imkereimaterial) umgesetzt. Das detaillierte Inventar mit Wartungsdaten (Abschnitt 4.22) bleibt v1.x/"SOLL", wie im Lastenheft vorgesehen.
|
3. **Lagerverwaltung MVP-Umfang**: "grundlegende Lagerverwaltung" (Abschnitt 26 Punkt 12) wird als generisches `StockItem` + `StockMovement` (Kategorie Honig/Verbrauchsmaterial/Imkereimaterial) umgesetzt. Das detaillierte Inventar mit Wartungsdaten (Abschnitt 4.22) bleibt v1.x/"SOLL", wie im Lastenheft vorgesehen.
|
||||||
4. **API-Client-Rolle im MVP**: kein vollwertiger OAuth2/Client-Credentials-Server. Ein API-Client ist ein `User` mit Rolle `API_CLIENT` und einem langlebigen Service-Refresh-Token; vollwertiges Scopes-/Client-Management ist SOLL/KANN.
|
4. **API-Client-Rolle im MVP**: kein vollwertiger OAuth2/Client-Credentials-Server. Ein API-Client ist ein `User` mit Rolle `API_CLIENT` und einem langlebigen Service-Refresh-Token; vollwertiges Scopes-/Client-Management ist SOLL/KANN.
|
||||||
5. **Backup-fähige Struktur**: kein eigenes Backup-Tooling im Code. Umgesetzt als: reproduzierbare Prisma-Migrationen (kein manuelles Schema-Drift), dokumentierte `pg_dump`/PITR-Strategie in diesem Dokument (Betrieb obliegt dem bestehenden Cluster/DBA), `/health`-Endpoint für Betriebsüberwachung.
|
5. **Backup-fähige Struktur**: reproduzierbare Prisma-Migrationen (kein manuelles Schema-Drift), anwendungsseitiges Backup (`BackupModule`, Abschnitt 8) und der CronJob `apicore-db-backup` mit `pg_dump` (Abschnitt 7), `/health`-Endpoint für Betriebsüberwachung.
|
||||||
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 (wie beim Webhooks-Modul) nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster mit minütlichem `@Cron`-Worker: 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. 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. Auch bei der Schleuderung warnt die App (`WITHDRAWAL_ACTIVE`, Abschnitt 11.12). Diese Warnung sieht jeder, der eine Schleuderung erfasst, auch ohne `treatments:read`: Lebensmittelsicherheit geht vor der Lese-Trennung.
|
||||||
|
|
||||||
11. **Idempotenz per Opt-in**: Anlege-Endpunkte, die Formulare und die Offline-Warteschlange wiederholen können (Durchsicht, Behandlung, Fütterung, Varroa, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung), 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 laufen alle Seiteneffekte nach dem Commit (`AutomationService.evaluate*`, `CalendarSyncService.enqueue`/`cancel`, `WebhooksService.dispatch`) best-effort über `runAfterCommit` (`src/common/after-commit.ts`) – sie loggen auf Error-Level und werfen nie. Was das Schreiben verhindern muss (z. B. `generateNumber`, Validierung), wirft weiter. Doppelte Automatik-Aufgaben verhindert eine Zeilensperre auf der Regel (`SELECT … FOR UPDATE`). Dauerhaft zugestellte Seiteneffekte bringt erst das Ereignis-Rückgrat (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`) und Lager (`product.stock_changed`); `batch.created` kommt seit T4b aus `HoneyBatchRecorded` (Abschnitt 10, Webhook-Zustellung), Schleuderungen und Honigchargen haben keine Post-Commit-Seiteneffekte mehr. 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. 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. 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.
|
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) 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); 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.
|
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 `BACKUP_ENCRYPTION_KEY` (sonst per HKDF aus `JWT_ACCESS_SECRET`). 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). Webhook-Secrets liegen verschlüsselt in der DB (Schlüssel aus `JWT_ACCESS_SECRET`) – für eine Wiederherstellung in einem anderen Deployment mit anderem `JWT_ACCESS_SECRET` 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, Pflanzenentwicklungs-Meldungen 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.
|
||||||
|
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.
|
||||||
|
**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.
|
||||||
|
24. **Offline-Standmodus**: „Stand laden“ legt ein Paket je Stand in IndexedDB ab (`GET /apiaries/:id/offline-pack`: lebende Völker, Königinnen, die letzten 3 Durchsichten je Volk, offene Aufgaben, je Volk `occupiedFrames` und die nächsten Schritte `nextSteps`, optional, Version 1). Es enthält weder Koordinaten noch Adresse des Standes; Königinnen, Durchsichten und Aufgaben fehlen ohne das jeweilige Leserecht (Benutzer-ID der Durchsichten wird weggelassen). Ein Paket gilt nach 24 h als veraltet (Alter immer sichtbar), wird nach 1 h automatisch erneuert und nach 14 Tagen oder nach 403/404 gelöscht. Lehnt der Server einen Eintrag ab (bei der Sammelerfassung mit `invalidColonyIds`), erzwingt das ein Neuladen der betroffenen Pakete (frühestens nach 5 min). Der Service Worker (`public/sw.js`, `sw-routes.js`) speichert Seiten nur zusammen mit ihren Assets und Daten nur als `application/json`; nie gespeichert werden Auth-Antworten, Anfrage-Header (kein Bearer-Token im Cache), die Antworten, die ein Paket liefert (eine Offline-Datenquelle mit sichtbarem Alter), `/api/v1/apiaries` und `/apiaries/:id` (Standort), `/export/` und `/backup/`. Navigation hat routenabhängige Zeitlimits: App-Hülle 4 s, Offline-Seiten zuerst aus dem Cache, Deep Links etwa 10 s, übrige Seiten Netzfehler bzw. erst ab 15 s. Alle API-Anfragen haben eine Gesamtfrist inklusive Antwortkörper (Standard 4 s; Schreibzugriffe ohne Idempotenz-Schlüssel, darunter Login, MFA und Refresh, 15 s; Sammelerfassung 15 s; Upload 30 s; Download 60 s).
|
||||||
|
25. **Offline-Sitzung**: Ohne Netz bleibt der Benutzer mit der zwischengespeicherten Identität (Name, Rechte) 7 Tage angemeldet, nie mit einem Token; der Sync bleibt gesperrt, bis ein Refresh gelingt. Ein Refresh gilt nur für dieselbe Benutzer-ID; das Token eines anderen Kontos löscht das Gerät (Pakete, Warteschlange, Caches). Nur 401/403 beenden die Sitzung, 429/502/503 und Netzfehler zählen als Netzfehler. Ein Abmelden ohne Netz merkt den Widerruf vor und holt ihn beim nächsten Start vor allem anderen nach. Jeder Warteschlangen-Eintrag hat einen Besitzer (`ownerUserId`); fremde Einträge werden nie gesendet. Beim Abmelden und Benutzerwechsel werden Pakete, Rundenzustand, Query-Cache, Foto-Übergabemarker und die Cache-Storage-Einträge des Service Workers gelöscht (`WIPE_CACHES` mit Generationszähler, damit eine laufende Anfrage danach nichts mehr schreibt). Beim Sync beendet 401 den Lauf, ohne Einträge zu markieren; 408/429/502/503/504 lassen Einträge wartend; 409 „wird bereits verarbeitet“ heißt „später erneut“.
|
||||||
|
26. **Fotos**: Aufnahmen werden im Browser per Canvas neu kodiert (längste Kante 2560 px, JPEG, ohne EXIF/XMP/GPS), auch im bestehenden Dokument-Upload. Vor dem Upload prüft der Client Metadaten und höchstens 10 MB; der Upload ist idempotent. Ein Foto wartet in IndexedDB auf die Server-ID seiner Durchsicht und wird danach angehängt. Ein Übergabemarker in `localStorage` (mit `createdAt`, höchstens 3 Tage alt, je Benutzer) verhindert, dass ein bereits gespeicherter Entwurf samt Fotos ein zweites Mal gesendet wird. Offen: die Prüfung mit `exiftool` auf einem echten Telefon (auch älteres iOS Safari).
|
||||||
|
27. **Sammelerfassung am Stand**: `POST /treatments|feedings|varroa/bulk` schreibt alles oder nichts in einer Transaktion (`@Idempotent()`). Die Völker werden sortiert mit `lockRowsForShare` (`FOR SHARE`, `src/prisma/row-lock.ts`) gesperrt, der Ablauf steckt im Helfer `runBulk`; das Ereignis je Volk trägt `bulkId`. Entscheidung K1: Die Chargennummer des Arzneimittels (`batchNumber`) wird einmal eingegeben und nie erzeugt (rechtlich relevantes Bestandsbuch); gruppiert wird über `bulkId`. Entscheidung K2: Varroa nimmt je Volk einen eigenen Eintrag, ein gemeinsamer Milbenwert wäre erfundene Messung. Unbekannte oder beendete Völker meldet die 400-Antwort als `invalidColonyIds`. Der Idempotenz-Schlüssel bindet Benutzer, Schlüssel und Pfad, nicht den Inhalt: Der Client nimmt darum je abweichender Absendung einen neuen Schlüssel.
|
||||||
|
28. **Skalen und Vorbelegung**: Alle Bewertungsskalen der Durchsicht zeigen „5 = gut“ (Gespeichertes bleibt unverändert). Die Schwarmstimmung ist gespeichert umgekehrt (1 = ruhig, 5 = stark) und wird nur in der Anzeige gedreht (`RatingScale direction="lower-is-better"`; die Beschriftungen nennen die gespeicherten Enden); keine Datenmigration. Waben stehen per Plus/Minus in `colonyStrength` und neu `broodFrames`, das Altfeld `broodAmount` nur im Bearbeiten-Dialog. `colonyStrength` zählt besetzte Waben, höchstens 40 (Annahme 34). Werte der letzten Durchsicht erscheinen als sichtbarer Hinweis je Feld mit „Letztes Mal … übernehmen“ und „Alles wie letztes Mal“, nie stillschweigend vorbelegt.
|
||||||
|
29. **Ampeln**: Die Varroa-Ampel kommt vom Server aus Bändern nach Methode, Monat und Volkstyp (Abschnitt 11.4); `VARROA_WARN_FRACTION` gibt es nicht mehr. Der Trend zeigt die Grenzen als Stufenlinien je Monat, Zeiträume 1/2/3/7 Tage oder „anders“. Die Ampel eines Volkes (`lib/colonies/status-light.ts`) nimmt den schlechtesten Befund: rot bei `QUEENLESS`, kritischem Gesundheits-, Futter- oder Brutzustand oder offener kritischer Meldung; gelb bei `WEAK`/`CONSPICUOUS`, schwachem Zustand, offener Warnung oder überfälliger Durchsicht. „Überfällig“ heißt `inspectionDueOn` überschritten („Durchsicht seit 3 Tagen fällig“, Abschnitt 11.3); das ersetzt die festen 14 Tage `INSPECTION_STALE_DAYS`. Ohne Fälligkeitstag (Winterruhe, alte Pakete) gibt es keinen Befund, und „Noch keine Durchsicht“ entfällt, weil die Fälligkeit auf die Anlage des Volkes zurückfällt. `openWarnings` und `openCritical` der Volksübersicht zählen offene Vorschläge der Priorität `HIGH` bzw. `URGENT`, die der Nutzer sehen darf (`tasks:read` und das Recht des Vorschlags), nicht mehr Benachrichtigungen. Die Farbe steht nie allein (Symbol und Wort); die Kontraste der Farbtöne prüft ein Test.
|
||||||
|
30. **Fehlversuchs-Sperre für API-Schlüssel, über alle Pods**: Ungültige Schlüssel (unbekannt, widerrufen, abgelaufen) zählen je Client-Netz – IPv4-Adresse bzw. IPv6-/64, wie der Throttler (`rateLimitSubject`). Nach 20 ungültigen Schlüsseln in einem Fenster von 15 min (ab dem ersten Fehlversuch) bekommt das Netz für weitere ungültige Schlüssel 15 min lang `429` (`src/api-keys/failed-attempt-limiter.ts`, Grenzen `API_KEY_FAILURE_LIMIT`). Ein gültiger Schlüssel wird davon nie abgewiesen: Erst kommt die Suche über den Hash, nur ein ungültiger Schlüssel wird gezählt oder gesperrt. Sonst könnte jeder, der eine gemeinsame Adresse (Cluster-Ausgang, Proxy) nutzt oder vortäuscht, ERPNext oder den Shop aussperren; bei 256 Zufallsbits je Schlüssel kostet das nichts. Falsch aufgebaute Tokens werden ohne Datenbankzugriff mit 401 abgewiesen und zählen nicht. Die Zähler liegen wie die des Throttlers in der UNLOGGED-Tabelle `ThrottlerHit` (über `PrismaThrottlerStorage`: eigener Throttler-Name `api-key-failures`, Schlüssel-Präfix `api-key-failures:`, eine Upsert-Anweisung, nur DB-Uhr `now()`) und gelten damit für alle api-Pods gemeinsam; kein zweiter Zähler im Prozessspeicher. Bei einem DB-Fehler schlägt die Anfrage fehl (fail-closed, wie der Throttler). Abgewiesene Schlüssel werden nur mit Präfix und IP protokolliert, das Erreichen der Grenze einmal je Fenster.
|
||||||
|
31. **Vorschläge, Anker und Dubletten**: Regel-, Ketten- und Autopilot-Vorschläge sind ein Modell mit den Arten `PLAN` und `DO` (Abschnitt 11.1). `dedupeKey = <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). Der 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. Ohne gespeicherte Zeile für das laufende Jahr gilt der reine Kalenderzustand (Verhalten von T4a); dort steht vor `SALWEIDE` noch `WINTER` des Vorjahres. Seit T4b kommen gemeldete Phasen und die Grünlandtemperatursumme (GTS, Wetter) dazu; sie wirken über `SeasonService.statesFor`, die Aufrufer bleiben gleich. Der Zustand eines Stands steht in `ApiaryPhenology (apiaryId, year)`; der Job-Schritt `phenology` und jede Meldung oder Rücknahme berechnen ihn neu (`PhenologyStateService.recomputeIn`, unter Zeilensperre auf `Apiary`). Die Phase ist das Maximum aus eigener Meldung (Meldungen für alle Stände zählen für jeden Stand), GTS-Schätzung und Kalender. Sie läuft im Jahr nur vorwärts, außer nach einer Korrektur oder Rücknahme einer Meldung (`allowBackward`, gesetzt nur vom Handler bei `PhenologyCorrected`; eine neue Meldung schiebt die Phase nie zurück). GTS = Summe der positiven Tagesmittel ab 1. Januar bis gestern, Januar × 0,5, Februar × 0,75, ab März × 1 (nach DWD). Annahme: 200 °C gelten als `SALWEIDE` (Standardschwelle `PHENOLOGY.gtsThresholds`). Weitere Schwellen werden nie erfunden, sondern als Median der GTS am ersten Meldetag der letzten bis zu 3 Vorjahre kalibriert (Standard `KIRSCHE` und `APFEL`); ein einzelnes Vorjahr genügt und ist dann der Median. Ohne Vorjahresmeldung gilt für diese Phasen der Kalender. Fehlen mehr als `maxGapShare` (10 %) der Tage, steht „Wetterdaten lückenhaft“; ist der letzte Wettertag älter als 3 Tage, „Wetterdaten veraltet“; ohne Koordinaten „Keine Koordinaten“. In allen drei Fällen wird die GTS nicht benutzt und der Kalender entscheidet. Vor der ersten Phase des Jahres ist die gespeicherte Phase leer (Quelle Kalender), der Abschnitt `WINTERRUHE`. Ist die gespeicherte Zeile älter als 2 Tage (Job ausgefallen), gilt wieder der Kalenderzustand. `springSince` ist nicht gespeichert, sondern genähert: im Abschnitt `FRUEHJAHR` der Abschnittsbeginn, später im Jahr das Kalenderdatum von `SALWEIDE` (höchstens der Abschnittsbeginn). Weicht der GTS-Tag von `SALWEIDE` stark vom Kalender ab, verschiebt sich die Frist „Erste Durchsicht im Jahr“ um diese Differenz. Die Rückfall-Daten für Schwaben und die 200-°C-Schwelle sind Annahmen und noch nicht an regionalen Daten geprüft. Die Wetterquelle beschreibt Abschnitt 11.11. Meldungen und Rücknahmen stoßen die Neuberechnung über Ereignisse an, Saisonwechsel erscheinen nur in der Morgenübersicht (Annahme 47).
|
||||||
|
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. In der App steuern die Morgenzeit (05:00–12:00), die Ruhezeit und die Umstellung Sommer-/Winterzeit nichts; die Übersicht entsteht immer um 04:30 mit dem Stand dieser Stunde. Sie wirken auf die Telegram-Zustellung (Annahme 49). Die Saison-Zeile der Übersicht nennt die Abschnittswechsel seit dem Zeitfenster der letzten Übersicht, nicht als Einzelmeldung (Annahme 47).
|
||||||
|
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.
|
||||||
|
42. **Wetterquelle Open-Meteo (Entscheidung des Inhabers)**: Die Pflanzenentwicklung nutzt die freie Open-Meteo-API (Forecast-Endpunkt für die letzten 90 Tage, Archiv für ältere Lücken). Die nicht-kommerziellen Bedingungen und das damit verbundene Lizenzrisiko hat der Inhaber bewusst übernommen. Es wird kein API-Schlüssel verwendet (`OPEN_METEO_API_KEY` bleibt leer). Die Quelle liegt hinter der Schnittstelle `WeatherHistorySource` (`src/weather/history`); ein Wechsel geht über `WEATHER_HISTORY_SOURCE` und einen neuen Adapter. Koordinaten werden auf 0,01° gerundet. URL und Schlüssel erscheinen nie in Logs oder Fehlermeldungen. Details: Abschnitt 11.11.
|
||||||
|
43. **Pflanzenentwicklung: Tabellen und Fremdschlüssel**: Abweichend vom Entwurf (Spec 4.2) verweisen `ApiaryPhenology.apiaryId` und `ApiaryWeatherDay.apiaryId` mit `ON DELETE CASCADE` auf `Apiary` (abgeleitete Daten und Cache, keine Waisen, ein Restore von Ständen wird nie blockiert). `PhenologyReport.apiaryId` verweist mit `ON DELETE RESTRICT` auf `Apiary`: Der Prisma-Standard für eine optionale Relation, `SetNull`, machte aus einer Meldung still eine Meldung „für alle Stände“. Die Migration `20261015100000_t4_phenology` vergibt `phenology:read`, `phenology:create` und `phenology:update` (Gruppe „Pflanzenentwicklung“) an alle Systemrollen; `read` geht an Rollen, die `colonies:read` halten, `create` und `update` an Rollen mit `inspections:create`, jeweils nur Nicht-Systemrollen. Die Migration legt die Rechte selbst an, weil der Katalog-Abgleich beim Start neue Rechte nur für Zeilen vergibt, die er selbst anlegt (`PermissionCatalogSyncService`).
|
||||||
|
44. **Bewegungsbuch der Honigcharge**: `HoneyBatch.amount` bleibt der laufende Rest in kg, `HoneyBatch.initialAmount` (kg) ist die Anfangsmenge. `BatchMovement.quantity` ist bei Honig eine vorzeichenbehaftete Menge in Gramm mit `honeyBatchId`. Invariante: `amount = initialAmount + Summe(quantity) / 1000`, auf das Gramm gerundet, nie unter 0, Toleranz 0,5 g. Eine Charge ohne `amount` und `initialAmount` wird nicht gebucht. Jede `BatchMovement` gehört zu genau einer Quelle aus Abfüllung, Herstellungscharge, Rohstoff-Charge und Honigcharge (CHECK `BatchMovement_exactly_one_batch`, `num_nonnulls(…) = 1`). Rückrechnung für Altbestand (Migration `20261015100100_t4_honey_ledger`, Import alter Bundles): `initialAmount = amount + Summe(Gramm der Abfüllungen) / 1000`, je Abfüllung eine Buchung `BOTTLING` mit `sourceBottlingBatchId`, Datum und Benutzer der Abfüllung (Grammwerte halb aufgerundet, `floor(x·1000 + 0,5)`). Frühere Handkorrekturen von `amount` stecken damit in `initialAmount`. Die Migration endet mit einer Prüfung (`RAISE NOTICE` mit Zählern, `RAISE EXCEPTION` bei verletzter Invariante, dann Rollback). Laufzeit: `HoneyBatchLedgerService` (`src/honey-batches/honey-batch-ledger.service.ts`) ist der einzige Schreiber von `HoneyBatch.amount` (Wächter `honey-batch-amount-writes.spec.ts`). Buchungen laufen in der Transaktion des Aufrufers unter `lockRowForUpdate('HoneyBatch')`: `bookIn` (vorzeichenbehaftete Gramm; `amount` wird aus `initialAmount + Summe(quantity) / 1000` auf das Gramm neu berechnet, nie unter 0), `setInitialIn` (erste Menge: `amount` und `initialAmount` zugleich, keine Buchung) und `clearIn` (Menge entfernen, nur ohne Buchungen, sonst 400 „Die Menge wird ueber das Bewegungsbuch gefuehrt. Bitte eine Korrektur buchen.“). Eine Abbuchung braucht genug Rest (Toleranz 0,5 g), sonst 400 (Abfüllung: „Abfuellmenge uebersteigt die verfuegbare Menge der Honigcharge“, sonst „Die Menge kann nicht unter 0 sinken.“). Gerundet wird überall gleich, `floor(x·1000 + 0,5)` Gramm, auch bei Mengen, die Nutzer eingeben (12,3456 kg werden 12,346 kg). Eine Abfüllung anlegen bucht `BOTTLING` negativ mit `sourceBottlingBatchId`, Ändern die Differenz (bei Chargenwechsel zurück auf die alte, von der neuen), Entfernen `BOTTLING` positiv mit der Notiz „Abfuellung entfernt“ (ohne Quelle: die Abfüllung wird danach gelöscht). `PATCH amount` bucht `CORRECTION` (neu minus aktuell; `initialAmount` bleibt, Startmenge und Summe der Buchungen weichen danach ab); der erste Wert einer Charge ohne Menge (`null` → Wert) setzt `initialAmount`. Eine Charge ohne `initialAmount` bucht nichts, die Abfüllung setzt trotzdem den Status `BOTTLED`. Eine Abfüllung, die dem Buch nie etwas entnommen hat (Heuristik `drewFromLedger`: keine `BOTTLING`-Buchung mit `honeyBatchId` und ihrer `sourceBottlingBatchId`, etwa weil die Charge beim Abfüllen noch keine Menge hatte), bucht bei Änderung und Entfernung nichts zurück; wird sie in eine andere Charge verschoben, entnimmt sie erst dort. Das folgt aus den Buchungen und ist kein gespeichertes Merkmal. Sperrreihenfolge je Pfad: Abfüllung → Honigcharge (zwei Chargen in einer Anweisung, aufsteigend; die Abfüllung sperrt die Charge schon vor dem Anlegen), Schleuderung → Honigcharge. API des Buchs: Abschnitt 11.12.
|
||||||
|
45. **Honig-Kette: Vorschläge, Warnungen, nie sperren**: Vorbelegungen (Menge, Honigsorte, Wassergehalt, Glasgröße, MHD) sind Vorschläge mit Übernehmen-Knopf, nie stillschweigend eingetragen. Plausibilitätswarnungen (`EXTRACTION_EXCEEDS_HARVEST`, `BATCHES_EXCEED_EXTRACTION`, Hinweis `HARVEST_NET_MISSING`) und die Wartezeitwarnung sperren nie (kein 4xx). Die Wartezeitwarnung `WITHDRAWAL_ACTIVE` bekommt jeder, der eine Schleuderung erfasst oder vorbelegt, auch ohne `treatments:read` (Lebensmittelsicherheit vor der Lese-Trennung). Die Vorbelegungs-Endpunkte hängen an den Anlege-Rechten (`extractions:create`, `honey-batches:create`): Sie liefern dieselben Daten wie der Anlege-Ablauf. Die Honigsorte kommt aus den Ernten, weil `Extraction` kein Sortenfeld hat. Abschnitt 11.12.
|
||||||
|
46. **Telegram: Token nur im Worker, Zustellung über Outbox**: Der Bot-Token (`TELEGRAM_BOT_TOKEN`) liegt nur in der Umgebung des Workers (optionales Secret `apicore-telegram`); der API-Pod kennt nur `TELEGRAM_BOT_USERNAME` für den Deep Link. Der Token erscheint nie in Logs oder Fehlern. Ein fehlerhaft geformter Token (oder in Produktion ein Platzhalter) lässt den Worker nicht abstürzen: Fehler-Log ohne Wert, Telegram bleibt aus. Gesendet wird nur aus Worker-Schleifen über `NotificationDelivery`, nie aus Anfragen, Handlern oder Jobs; die Zustellung läuft in der Schleife `telegram-send`, der Empfang in `telegram-updates` (Abschnitt 11.13). Der Sender nimmt genau ein Worker-Replikat an (`replicas: 1`): Chat-Abstand, Senderate und die Pausen nach 401 und 429 liegen im Speicher des Pods, mit mehreren Pods hätte jeder seine eigenen. Die SQL-Regel „eine Zustellung je Nutzer und Batch“ und Telegrams 429-Antworten schützen den Bot auch dann. Ohne `FRONTEND_ORIGIN` geht die Nachricht außerhalb der Entwicklung (`NODE_ENV` leer oder `development`) ohne Link hinaus (ein Warn-Log je Pod), nie mit einem localhost-Link. Abschnitt 11.13.
|
||||||
|
47. **Pflanzen-Meldungen, Neuberechnung und Saisonwechsel**: Rechte: `phenology:read` (Liste), `phenology:create` (Melden), `phenology:update` (Bearbeiten und Zurückziehen); `GET /phenology/state` braucht `apiaries:read` und enthält nie Koordinaten (Abschnitt 11.11). Das Datum muss ein echter Kalendertag sein und darf nicht nach dem Berliner Tag von heute liegen (400). Ein unbekannter Stand ergibt 404 (in der Transaktion geprüft, Stand vor Meldung in der Sperrreihenfolge); `apiaryId` leer (`""`) ergibt 400, `null` heißt „alle Stände“. Wer `phenology:update` hält, darf auch fremde Meldungen bearbeiten und zurückziehen (kleines Team, bewusst); eine zurückgezogene Meldung lässt sich nicht mehr bearbeiten (409), erneutes Zurückziehen ebenso (409). Eine Änderung ohne tatsächliche Abweichung schreibt weder Datensatz noch Audit-Eintrag noch Ereignis. `PhenologyReported` und `PhenologyCorrected` (auch beim Zurückziehen) entstehen in der Schreib-Transaktion. `PhenologyCorrected` trägt `previousApiaryId` (beim Zurückziehen gleich `apiaryId`): Eine Verschiebung von Stand A nach B berechnet nur A und B neu; ist eine Seite `null` (Meldung erweitert oder eingeengt), gilt „alle Stände“. Der Handler `phenology.recompute` rechnet für den späteren von Berliner Tag des Ereignisses (`occurredAt`) und heute, damit eine verzögerte Zustellung keinen älteren Tag (GTS-Stand, Abschnittsbeginn) zurückschreibt; bei einem oder zwei Ständen direkt, in Id-Reihenfolge. Gilt „alle Stände“, schreibt er je aktivem Stand ein internes Ereignis `PhenologyRecomputeRequested { apiaryId, allowBackward, day }` (kein Schlüssel), und `phenology.recompute-apiary` rechnet einen Stand je Zustellung (übersprungen, wenn er nicht mehr aktiv ist). So läuft eine Meldung für alle Stände nicht in einer einzigen Zustell-Transaktion. **Bewusste Abweichung von der Spec:** Saisonwechsel erscheinen nur in der Morgenübersicht (Abschnitt 11.9), nie als Einzelmeldung: Meldungen für alle Stände ergäben sonst Stände × Nutzer Meldungen. `SeasonPhaseChanged` bleibt für den Autopiloten.
|
||||||
|
48. **Autopilot A4–A10: Annahmen**: A4 (Sommerbehandlung planen) und A8 (Umweiseln planen) sind `PLAN`-Vorschläge ohne gespeicherten Plan: Übernehmen legt genau eine Aufgabe an, heute fällig (Spec 8.2). Der gespeicherte Saisonzustand kennt nur den Beginn des aktuellen Abschnitts. Im Abschnitt `HERBST` ist der Beginn von `SPAETSOMMER` daher genähert: der Kalendertag von `TRACHTENDE` (`PHENOLOGY.calendarFallback`), höchstens der Beginn von `HERBST` (`lateSummerStart`). Wurde die Phase viel früher oder später gemeldet als der Kalender sagt, zählt eine Fütterung zwischen beiden Tagen für A5 falsch (nur A5 betrifft das). Der genaue Weg ist eine Spalte `lateSummerSince` an `ApiaryPhenology` (Migration, späteres Schema-Fenster; im Code als TODO vermerkt). A6 ohne Durchsicht zählt Fütterungen seit dem späteren von Anlagetag des Volkes und heute minus 60 Tage; A7 nutzt nur eine Durchsicht ab `springSince` des laufenden Jahres (sonst ab 1. Januar). „Gemeldet am TT.MM.“ im Grund von A4 steht nur, wenn `reportedOn` vorliegt (bei gespeicherten Zeilen nie), sonst „Tracht vorbei (gemeldet)“, bei Schätzung oder Kalender „(geschätzt)“ bzw. „(Kalender)“. `syncApiaryIn` gleicht alle Völker eines Stands in einer Transaktion ab (etwa 10 Abfragen je Volk); für sehr große Stände wäre eine Aufteilung nötig. Ein Wächter (`src/suggestions/suggesting-triggers.spec.ts`) verlangt für jeden vorschlagenden Trigger eine Textvorlage und für jeden im Quelltext gefundenen `reasonCode` einen Registereintrag; ein neuer Trigger fällt durch, bis er eine Vorlage hat oder bewusst ausgenommen wird.
|
||||||
|
49. **Telegram-Zustellung: Zustellzeit, Zeitumstellung, Prüfung beim Senden**: Zeiten rechnen auf der Berliner Wanduhr (Europe/Berlin, `src/notifications/delivery-time.ts`). Verfügbar ab dem späteren von jetzt und der Kategorie-Zeit (`TASK_DUE`: eine Minute nach der Morgenzeit, `DIGEST`: die Morgenzeit, jeweils heute); liegt das Ergebnis in der Ruhezeit (Standard 21:00 bis 06:30, über Mitternacht, Ende ausgeschlossen; fehlende oder gleiche Grenzen = keine Ruhezeit), rückt es an deren Ende. Eine Uhrzeit, die im Herbst doppelt vorkommt (Doppelstunde 02:xx), gilt als das erste Vorkommen (Sommerzeit); eine Uhrzeit in der Frühjahrslücke (02:30 existiert nicht) als der Zeitpunkt eine Stunde später (03:30 Sommerzeit). Antworten des Bots (`templateCode`) umgehen Ruhezeit und Prüfungen. Beim Senden gilt der Stand dieses Moments, nicht der der Planung: Nutzer und Rolle aktiv, Telegram an, Verbindung, Kategorie, jedes Recht aus `requiredPermissions` (frisch gelesen, nicht aus dem 30-s-Cache) und Frische. `TASK_DUE` und `DIGEST` sind nur am selben Berliner Tag frisch, alle anderen höchstens 24 Stunden; eine `TASK_DUE`-Zustellung braucht die Aufgabe noch offen. Sonst `SKIPPED` mit Grund. Der Batch ist auf 120 s begrenzt; der Rest geht ohne Fehlversuch zurück. Abschnitt 11.13.
|
||||||
|
50. **Sperrreihenfolge: Stand vor Volk**: Die Karte `LOCKABLE_TABLES` (`LOCK_ORDER`, `src/prisma/row-lock.ts`) gilt für jede Transaktion, die Zeilen mehrerer Tabellen sperrt (Tabellen in dieser Reihenfolge, innerhalb einer Tabelle Id aufsteigend), mit einer Ausnahme (Entscheidung zu Task 27): `Apiary` wird vor `Colony` gesperrt. Der Abgleich je Stand (`AutopilotService.syncApiaryIn`) sperrt die Standzeile, liest danach die Völker, sperrt alle in einer Anweisung (`lockRowsForUpdate`, Id aufsteigend) und erst dann Vorschläge und Regeln; der Handler `autopilot.colony-sync` sperrt die Völker eines Ereignisses ebenso in einer Anweisung. Kein Pfad sperrt ein Volk und danach einen Stand (die Neuberechnung der Pflanzenentwicklung sperrt nur den Stand); ein künftiger Pfad Volk → Stand würde mit dem Abgleich je Stand verklemmen. Die Karte im Code führt `Apiary` weiterhin weiter hinten (Stand dieses Dokuments): Die Ausnahme steht als Regel hier, nicht in der Karte.
|
||||||
|
51. **Telegram: fehlerhaftes Update wird übersprungen (Annahme der Umsetzung)**: Ein Update, dessen Verarbeitung scheitert, wird bis zu 5 Mal oder höchstens 10 Minuten wiederholt (Zähler und erste Fehlzeit je `update_id` im Speicher des Pods; `MAX_UPDATE_ATTEMPTS`, `MAX_UPDATE_RETRY_MS`). Danach überspringt es eine eigene Transaktion: Offset weiter, `lastError` = „Telegram-Nachricht übersprungen“, eine Warnung ohne IDs und Text im Log, der Rest des Batches läuft weiter. Ohne diese Grenze würde ein dauerhaft scheiternder Befehl alle späteren Befehle (etwa jedes `/start`) sperren. Kosten bei falscher Annahme: ein verlorenes `/start`, der Nutzer erzeugt den Link neu. Eine verlorene Lease zählt nie als Fehlversuch, Verbindungsfehler ebenso wenig; die Zähler werden nach jedem Batch bereinigt. Abschnitt 11.13.
|
||||||
|
52. **Wassergehalt: Bestätigung gilt für den gemessenen Wert**: Die Bestätigung über der gesetzlichen Grenze (`acknowledgedWarnings: ['WATER_CONTENT_ABOVE_LEGAL']`) trägt zusätzlich `acknowledgedWaterContent` mit dem Wert aus der 409-Antwort. Sie gilt nur, wenn der unter der Sperre geprüfte Wert genau gleich ist; sonst folgt eine frische 409. Wer bestätigt hat, hat also diesen Wert gesehen, und eine zwischenzeitlich neue Messung entwertet die Bestätigung (Lebensmittelsicherheit; Kosten bei falscher Annahme: ein zusätzliches Feld). Streng größer als die Grenze löst die Bestätigung aus, genau 20,0 % warnt nur. Abschnitt 11.12.
|
||||||
|
53. **Vorbelegungs-Hinweise: Speichern bleibt erlaubt**: Laden die Hinweise einer Vorbelegung (Warnungen, etwa die Wartezeit) nicht, sperrt die Oberfläche das Speichern nicht; eine dauerhafte Zeile weist darauf hin, das Backend prüft beim Speichern. Kosten bei falscher Annahme: Ein Nutzer speichert, ohne die Wartezeitwarnung gesehen zu haben; die Antwort des Speicherns trägt die Warnung trotzdem. Abschnitt 11.12.
|
||||||
|
54. **Telegram: `LINKED` ohne Antwortgrenze**: Die Grenze von 3 Antworten je Chat in 10 Minuten gilt für `HELP`, `LINK_INVALID` und `STOPPED`, nicht für `LINKED`. `LINKED` braucht ein gültiges Einmal-Token des angemeldeten Nutzers, ein Fremder kann es nicht auslösen. Abschnitt 11.13.
|
||||||
|
55. **Pflanzenmeldung: doppelte Meldung beim erneuten Öffnen des Blatts**: Jede Meldung des Blatts „Entwicklung melden“ bekommt einen eigenen Schlüssel. Öffnet jemand das Blatt erneut und meldet dieselbe Phase noch einmal, entsteht eine zweite Meldung (auch aus der Offline-Warteschlange). Das ist bewusst hingenommen: Die Auswertung nimmt den frühesten Meldetag der Phase, die Zeile lässt sich zurückziehen (Kosten bei falscher Annahme: eine Zeile mehr). Abschnitt 11.11.
|
||||||
|
56. **Step-up: ein 401 wird nie mit Refresh wiederholt**: Bei Aufrufen mit Nachweis (Telegram verbinden, Backup-Import, Passwort ändern, Konto deaktivieren, MFA bestätigen und abschalten) bedeutet ein 401 „Nachweis falsch“. Der Client erneuert dort das Token nicht und wiederholt nicht (`refreshOn401: false`), weil ein zweiter Versuch den Sperrzähler verdoppeln würde. Ist die Sitzung wirklich abgelaufen, erscheint statt Stille der deutsche Hinweis „Sitzung abgelaufen. Bitte die Seite neu laden und erneut anmelden.“ (der Client kennt den Ablauf des Tokens nicht und erneuert nicht vorab). Kosten bei falscher Annahme: Eine abgelaufene Sitzung zeigt bei einem Step-up einmal einen Fehler statt einer stillen Erneuerung. Abschnitt 11.13.
|
||||||
|
57. **Abfüllung: Produktwechsel setzt Artikelwerte zurück**: Wechselt das Produkt, verwirft die Maske von Hand geänderte Glasgröße und MHD und nimmt die Werte des neuen Artikels; ein Datumswechsel lässt eigene Eingaben stehen. Sonst bliebe die Glasgröße eines anderen Artikels unbemerkt im Formular. Kosten bei falscher Annahme: Wer erst tippt und dann das Produkt wählt, muss neu eintragen. Abschnitt 11.12.
|
||||||
|
58. **„Alle fehlgeschlagenen erneut versuchen“: die Zahl kann zu niedrig sein**: Die API liefert keine Gesamtzahl der `FAILED`-Zeilen, die Rückfrage nennt daher nur „Mindestens N“ (größerer Wert aus `failed24h` und den `FAILED`-Zeilen der Liste) und immer die Grenze „höchstens 100, neueste zuerst“. Kosten bei falscher Annahme: Es gehen mehr (bis 100) Nachrichten erneut hinaus als angezeigt. Abschnitt 10, System-Seite.
|
||||||
|
|
||||||
## 7. Kubernetes
|
## 7. Kubernetes
|
||||||
|
|
||||||
`deploy/k8s/` enthält je Projekt: `deployment.yaml`, `service.yaml`, `ingress.yaml`, sowie `configmap.example.yaml` und `secret.example.yaml` (Platzhalter, keine echten Werte). Backend exponiert `GET /health` (liveness) und `GET /health/ready` (readiness, prüft DB-Verbindung). Beide Projekte je ein schlankes Multi-Stage-Dockerfile (Build-Stage mit vollem `node_modules`, Runtime-Stage mit `npm ci --omit=dev` bzw. Next.js `output: 'standalone'`).
|
`deploy/k8s/` ist Kustomize und die Quelle der Wahrheit für den Cluster (Spec `docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md` §6):
|
||||||
|
|
||||||
`deploy/k8s/postgres/` enthält das PostgreSQL-`StatefulSet` (ein Replica, `volumeClaimTemplates` für persistente Daten, `postgres:18-alpine`), den zugehörigen headless `Service` (`apicore-postgres`, DNS-Name fürs Backend) sowie `secret.example.yaml` für `POSTGRES_USER`/`POSTGRES_PASSWORD`/`POSTGRES_DB`. `deploy/k8s/backend/secret.example.yaml`s `DATABASE_URL` zeigt auf diesen Service.
|
- `base/`: clusterneutral, ohne Namespace, Hosts und Digests. `backend/` (Deployment `apicore-backend`, Service, PVC `apicore-data`), `frontend/` (Deployment, Service), `postgres/` (StatefulSet, headless Service `apicore-postgres`, CronJob `apicore-db-backup` mit `pg_dump`). Die Vorlagen `configmap.example.yaml` und `secret.example.yaml` stehen nicht unter `resources`; Secrets bleiben außerhalb des Repos.
|
||||||
|
- Selektoren und Service-Ports entsprechen dem Cluster (ein Test hält sie fest): Backend und Frontend `app: apicore-backend|apicore-frontend`, Postgres `app.kubernetes.io/name: apicore-postgres` (Pod zusätzlich `encry.de/tier: database`, Service-Port `postgres`). Bis 2026-10-13 wich das Repo bei Postgres ab (`app: apicore-postgres`, ohne Härtung, `volumeClaimTemplates`). Seit 2026-10-13 ist `base/postgres` der Live-Stand: StatefulSet mit Härtung (uid/gid 70, nur lesbares Root-Dateisystem, begrenzte `emptyDir`s für Socket, `/tmp` und `/dev/shm`, kein Service-Account-Token, `enableServiceLinks: false`), Probes, Ressourcen und Affinität wie live, Service und das statisch gebundene PVC `apicore-postgres-data` (PV `pv-apicore-postgres`). Das PV und sein SMB-Secret gehören zur Cluster-Infrastruktur und liegen nicht im Repo. Ein Test verhindert, dass das Repo die Härtung abschwächt; Selektor und Volumes sind unveränderlich, darum Postgres-Änderungen immer zuerst per `kubectl diff` prüfen.
|
||||||
|
- `components/`, je Rollout-Schritt einschaltbar. Das Auskommentieren im Overlay ist nur lokal für `kubectl diff`/`apply` gedacht und wird nicht committet.
|
||||||
|
- `network-policies`: Default-Deny und die Freigaben aus der Tabelle unten. Setzt selbst das Label `db-client: "true"` an der api, damit Schritt 2 sie nicht von Postgres abschneidet.
|
||||||
|
- `pod-hardening`: Frontend mit `readOnlyRootFilesystem`, `emptyDir` auf `/tmp` und `/app/.next/cache`, ohne Service-Account-Token, Ressourcen laut Spec §7.
|
||||||
|
- `backend-roles`: `APICORE_ROLE=api` samt initContainern `migrate` und `seed` im Deployment `apicore-backend`, Deployment `apicore-worker`, CronJobs `apicore-job-<name>`. Das neue Backend-Image (CMD nur `node dist/main.js`) geht nur zusammen mit dieser Komponente live. Ohne `APICORE_ROLE` bricht es den Start in Produktion ab, und ohne die initContainer liefe keine Migration.
|
||||||
|
- `api-hpa`: HPA `apicore-backend` (`autoscaling/v2`, 1–2 Pods bei 70 % CPU); entfernt `replicas` aus dem Deployment.
|
||||||
|
- `overlays/encry/`: Namespace `apicore`, ConfigMaps, Ingresses (Traefik, Middleware `apicore-cloudflare-only@kubernetescrd`, cert-manager `letsencrypt-prod` mit HTTP-01), Image-Digests. Hosts, ConfigMap-Werte und Digests sind bis zum Rollout Platzhalter (`example.com`, `sha256:000…`).
|
||||||
|
- `tests/`: `manifest-policy.test.mjs` prüft das gerenderte Overlay (NetworkPolicies nach Kubernetes-Semantik, Pod-Härtung, Ressourcen, CronJobs, Migration, Digests, Live-Selektoren) und jeden Rollout-Schritt (DNS, Ingress, api → Postgres und alle DB-Clients bleiben erreichbar). `render-stages.mjs` rendert die Schritte 1, 2, 3 und 5 aus dem Overlay. Die CI (`.gitea/workflows/manifests.yml`, nur `push` und `workflow_dispatch`, kein `pull_request`, weil der Runner im Cluster läuft) rendert mit `kubectl kustomize`, validiert mit `kubeconform -strict` und führt den Test aus; kubectl, kubeconform und yq mit festen Versionen und SHA-256.
|
||||||
|
- **Telegram-Secret.** `apicore-telegram` (Schlüssel `TELEGRAM_BOT_TOKEN`) bindet nur `apicore-worker` ein: als einzelne `secretKeyRef`-Variable mit `optional: true` am Haupt-Container, ohne `envFrom`, ohne Volume, nicht im initContainer. Die api bekommt das Secret nie. Fehlt es, startet der Worker normal, nur die Telegram-Schleifen laufen nicht. `TELEGRAM_BOT_USERNAME` steht in der ConfigMap `apicore-backend-config` (api und Worker lesen sie per `envFrom`; leerer Platzhalter, bis der Bot angelegt ist); das Token steht nie in einer ConfigMap. `manifest-policy.test.mjs` prüft für den Zielstand und jede Rollout-Stufe mit Worker: genau ein Worker-Pod, genau eine Token-Variable ohne Klartextwert, kein anderer Pod mit `TELEGRAM_BOT_TOKEN`. Einrichtung bei BotFather: je Umgebung ein eigener Bot, Gruppen aus (`/setjoingroups` → Disable), kein Webhook (der Worker ruft `getUpdates`). Der Worker läuft mit `replicas: 1`, weil der Versand das annimmt (Annahme 46); Claims und Empfangs-Lease bleiben auch mit mehr Pods korrekt.
|
||||||
|
|
||||||
|
**Rollen-Deployments** (ein Image, Rolle per `APICORE_ROLE`):
|
||||||
|
|
||||||
|
| Objekt | Rolle | Start | Ports | Pods | CPU Request/Limit | RAM Request/Limit |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Deployment `apicore-backend` | `api` | Image-CMD `node dist/main.js` | 3001 (Service, Ingress) | 1, mit HPA 1–2 | 50m/500m | 192Mi/512Mi |
|
||||||
|
| Deployment `apicore-worker` | `worker` | `node dist/worker.js` | 3002 nur `/health` für die Probes; kein Service, kein Ingress | 1 | 20m/200m | 128Mi/256Mi |
|
||||||
|
| CronJobs `apicore-job-<name>` | `job` | `node dist/cli.js <name>` | keine | je Lauf ein Pod | 50m/500m | 192Mi/512Mi |
|
||||||
|
| Deployment `apicore-frontend` | – | `node server.js` | 3000 | 1 | 30m/300m | 128Mi/256Mi |
|
||||||
|
|
||||||
|
initContainer haben die Ressourcen der CronJob-Pods. Alle App-Pods laufen ohne Service-Account-Token, mit nur lesbarem Root-Dateisystem und `emptyDir` auf `/tmp` (`HOME=/tmp`), als uid 1000, mit `seccompProfile: RuntimeDefault` und ohne Capabilities. api und worker rollen mit `maxSurge: 1`, `maxUnavailable: 0` aus.
|
||||||
|
|
||||||
|
**CronJobs:** `timeZone: Europe/Berlin`, `concurrencyPolicy: Forbid`, `startingDeadlineSeconds: 600`, je 3 erfolgreiche und fehlgeschlagene Jobs, `ttlSecondsAfterFinished: 86400`, `restartPolicy: Never`.
|
||||||
|
|
||||||
|
| CronJob | Zeitplan | backoffLimit | activeDeadlineSeconds |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `apicore-job-cleanup` | `0 4 * * *` | 1 | 1800 |
|
||||||
|
| `apicore-job-daily-evaluation` | `30 4 * * *` | 1 | 1800 |
|
||||||
|
| `apicore-job-task-templates` | `0 5 * * *` | 1 | 1800 |
|
||||||
|
| `apicore-job-app-backup` | `0 * * * *`, prüft selbst, ob fällig; `apicore-data` auf `/data` | 0 | 3600 |
|
||||||
|
| `apicore-job-rekey`, `apicore-job-rekey-dry-run` | `suspend: true`, nur von Hand (`kubectl create job --from=cronjob/…`) | 0 | 1800 |
|
||||||
|
|
||||||
|
**NetworkPolicies:** Default-Deny für Ingress und Egress im Namespace. Kubelet-Probes erlaubt Kubernetes immer.
|
||||||
|
|
||||||
|
| Pod (Selektor) | Ingress | Egress |
|
||||||
|
|---|---|---|
|
||||||
|
| alle | – | DNS: UDP und TCP 53 zu `k8s-app: kube-dns` in `kube-system` |
|
||||||
|
| frontend (`app: apicore-frontend`) | Traefik (`app.kubernetes.io/name: rke2-traefik` in `kube-system`) auf 3000 | `app: apicore-backend` auf 3001 |
|
||||||
|
| api (`app: apicore-backend`) | Traefik und frontend auf 3001 | Postgres 5432; TCP 443 zu öffentlichen Adressen |
|
||||||
|
| worker (`app: apicore-worker`) | keiner | Postgres 5432; TCP 443 zu öffentlichen Adressen |
|
||||||
|
| Jobs (`app in (apicore-job, apicore-db-backup)`) | keiner | Postgres 5432 |
|
||||||
|
| postgres (`app.kubernetes.io/name: apicore-postgres`) | nur Pods mit `db-client: "true"` auf 5432 | keiner außer DNS |
|
||||||
|
| cert-manager-Solver (`acme.cert-manager.io/http01-solver: "true"`) | Traefik auf 8089 (HTTP-01-Erneuerung) | keiner außer DNS |
|
||||||
|
|
||||||
|
„Öffentlich“ heißt `0.0.0.0/0` ohne `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `100.64.0.0/10`, `169.254.0.0/16`, `127.0.0.0/8` und `::/0` ohne `fc00::/7`, `fe80::/10`, `::1/128`. Canal kennt keine FQDN-Regeln; den SSRF-Schutz für Webhook-Ziele behält die App (`webhook-url-guard.ts`). `db-client: "true"` tragen nur api, worker, die App-Jobs und `apicore-db-backup`.
|
||||||
|
|
||||||
|
**Übergang:** Im Namespace liegen außerdem drei Policies aus dem Repo `rke2-cluster` (Label `encry.de/managed-by: cluster-hardening`): `default-deny-ingress`, `allow-from-traefik` und `allow-same-namespace`. NetworkPolicies addieren sich; bis diese drei in Runbook Schritt 2 dort entfernt werden, gelten sie neben den neuen.
|
||||||
|
- `allow-same-namespace` öffnet die Ingress-Seite jedes Pods für alle Pods im Namespace. Postgres nimmt dann auf der Ingress-Seite Verbindungen von jedem Pod an, nicht nur von `db-client`. Weiter sperrt nur die Egress-Seite: Jeder Pod im Namespace unterliegt dem neuen Default-Deny-Egress, das Frontend kommt also trotzdem nicht an Postgres. Die Absicherung hat dann eine statt zwei Schichten.
|
||||||
|
- `allow-from-traefik` lässt Traefik jeden Pod auf jedem Port erreichen, auch Postgres 5432 und den Worker auf 3002. Die Regel „Traefik nur auf 3000 und 3001“ gilt erst danach; ausnutzbar ist das nur über eine Ingress-Route.
|
||||||
|
- `default-deny-ingress` ist neben dem neuen `default-deny` überflüssig, schwächt aber nichts.
|
||||||
|
|
||||||
|
**Images:** Das Base-Image `node:22-alpine` ist in beiden Dockerfiles per Digest gepinnt; ein Update ist ein bewusster Commit mit neuem Digest. Die CI taggt nur noch `sha-<commit>` und Versionen, kein `latest`. Das Overlay setzt die Digests der gebauten Images. Bis Schritt 3 bleibt beim Backend der Digest des laufenden Images.
|
||||||
|
|
||||||
|
**Rollout:** nur nach dem Runbook im Plan `docs/superpowers/plans/2026-10-13-plattform-rueckgrat.md`, jeder Schritt mit Freigabe des Inhabers: 0 Digests, 1 Overlay auf den Ist-Stand (Diff leer), 2 NetworkPolicies, 3 Rollen und Pod-Härtung, 4 Schlüssel trennen, 5 HPA.
|
||||||
|
|
||||||
## 8. Backup & Restore
|
## 8. Backup & Restore
|
||||||
|
|
||||||
**Schema-Migrationen (Reproduzierbarkeit):** Ausschließlich `prisma/migrations/` als Quelle der Wahrheit. Lokal/Entwicklung: `npx prisma migrate dev`. Gegen jede Produktions- oder Staging-Datenbank ausschließlich `npx prisma migrate deploy` (idempotent, wendet nur fehlende Migrationen an, kein Schema-Drift). `prisma db push` wird an keiner Stelle im Code, in Skripten oder im Dockerfile verwendet und ist für Produktions-/Staging-Umgebungen untersagt. Der Backend-Container führt `migrate deploy` und danach `npm run seed` selbst beim Start aus, bevor der Server Verbindungen annimmt (Dockerfile-`CMD`: `npm run migrate:deploy && npm run seed && node dist/main.js`) – kein separater Kubernetes-`Job`. Bei `replicas: 2` starten mehrere Pods potenziell gleichzeitig; das ist unproblematisch, da Prisma für `migrate deploy` einen datenbankseitigen Advisory-Lock nutzt und `prisma/seed.ts` per Unique-Constraint auf `User.email` idempotent ist (verlierender Pod bekommt einen `P2002`-Fehler, den `seed.ts` abfängt und als No-op behandelt statt den Container abstürzen zu lassen) – eine Instanz wendet die Migrationen/den Seed an, die anderen finden danach nichts mehr zu tun. `deploy/k8s/backend/deployment.yaml` hat dafür eine `startupProbe` mit großzügigem `failureThreshold`, damit liveness/readiness den Container nicht neu starten, während die (beim allerersten Deploy ggf. länger laufende) Migration noch durchläuft.
|
**Schema-Migrationen (Reproduzierbarkeit):** Ausschließlich `prisma/migrations/` als Quelle der Wahrheit. Lokal/Entwicklung: `npx prisma migrate dev`. Gegen jede Produktions- oder Staging-Datenbank ausschließlich `npx prisma migrate deploy` (idempotent, wendet nur fehlende Migrationen an, kein Schema-Drift). `prisma db push` wird an keiner Stelle im Code, in Skripten oder im Dockerfile verwendet und ist für Produktions-/Staging-Umgebungen untersagt.
|
||||||
|
|
||||||
|
Die Migration läuft nicht mehr im Container-`CMD` (das ist nur noch `node dist/main.js`). Sie läuft als initContainer `migrate` (`node node_modules/prisma/build/index.js migrate deploy`, `HOME=/tmp`, `CHECKPOINT_DISABLE=1`) in den Pods von `api` und `worker` (`deploy/k8s/components/backend-roles`).
|
||||||
|
|
||||||
|
Starten mehrere dieser initContainer gleichzeitig, serialisiert sie der datenbankseitige Advisory-Lock von Prisma. Ein Lauf, der den Lock nicht binnen 10 s bekommt, bricht ab. Kubelet startet den initContainer dann neu, und der nächste Lauf findet nichts mehr zu tun. Das ist harmlos.
|
||||||
|
|
||||||
|
Der Seed (`node dist/seed.js`) läuft als zweiter initContainer `seed` nur im `api`-Pod. Er ist per Unique-Constraint auf `User.email` idempotent: Ein verlierender Pod bekommt `P2002`, `seed.ts` behandelt das als No-op.
|
||||||
|
|
||||||
|
Scheitert die Migration, scheitert der initContainer und der neue Pod startet nicht. Wegen `maxSurge: 1` und `maxUnavailable: 0` läuft der alte Pod weiter. Die Hauptprozesse starten ohne Prisma-CLI. Die `startupProbe` der api (bis 5 Minuten) bleibt für das alte Image, das die Migration noch im `CMD` ausführt.
|
||||||
|
|
||||||
**Erster Admin-Zugang:** `prisma/seed.ts` legt beim allerersten Start (leere `User`-Tabelle) einen Administrator an (`SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD`, Default `[email protected]`/`ChangeMe123!`) und gibt URL (`FRONTEND_ORIGIN`), Benutzername und Passwort einmalig im Container-Log aus. Existiert der User schon (auch deaktiviert), passiert nichts – kein erneutes Log, kein erneutes Anlegen, damit spätere Passwortänderungen nicht durch veraltete Logs verwirrend überschrieben wirken und Zugangsdaten nicht bei jedem Neustart in die Logs wandern. Ersetzen: neuen Admin über `/settings/users` anlegen, dann den alten deaktivieren (Löschen gibt es nicht, siehe "Historie statt Löschen") – die Lockout-Sperre in `UsersService.assertNotLastSystemRoleUser` verhindert ohnehin, den letzten aktiven Admin zu deaktivieren.
|
**Erster Admin-Zugang:** `prisma/seed.ts` legt beim allerersten Start (leere `User`-Tabelle) einen Administrator an (`SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD`, Default `[email protected]`/`ChangeMe123!`) und gibt URL (`FRONTEND_ORIGIN`), Benutzername und Passwort einmalig im Container-Log aus. Existiert der User schon (auch deaktiviert), passiert nichts – kein erneutes Log, kein erneutes Anlegen, damit spätere Passwortänderungen nicht durch veraltete Logs verwirrend überschrieben wirken und Zugangsdaten nicht bei jedem Neustart in die Logs wandern. Ersetzen: neuen Admin über `/settings/users` anlegen, dann den alten deaktivieren (Löschen gibt es nicht, siehe "Historie statt Löschen") – die Lockout-Sperre in `UsersService.assertNotLastSystemRoleUser` verhindert ohnehin, den letzten aktiven Admin zu deaktivieren.
|
||||||
|
|
||||||
**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) und IdempotencyRecord (Annahme 11); 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`. Seit T4b zusätzlich `PhenologyReport` (eigene Beobachtungen; Import nach `winterCheck`, Benutzer-IDs wie bei anderen Pflicht-Benutzern umgesetzt; nur aus versiegelten Dateien). `ApiaryPhenology` (abgeleiteter Saisonzustand, der Job `daily-evaluation` berechnet ihn neu) und `ApiaryWeatherDay` (Wetter-Cache, der Worker holt ihn neu) sind ausgenommen. Alte Bundles, deren `HoneyBatch`-Zeilen kein `initialAmount` tragen, werden beim Import wie in Migration `t4_honey_ledger` zurückgerechnet (Annahme 44, `src/backup/legacy-honey-ledger.ts`): `initialAmount` wird gesetzt; die `BOTTLING`-Buchungen mit der stabilen ID `legacy-bottling:<Abfüllung>` entstehen nur aus versiegelten Dateien, nach dem Abschnitt `batchMovement`, und werden bei einem erneuten Import nie überschrieben. Eine unversiegelte Datei setzt nur `initialAmount` bei Chargen, die es noch nicht gibt. Nach dem Honig-Teil des Imports prüft eine SQL-Abfrage die Invariante der importierten Chargen (`honeyLedgerViolations`) in der Import-Transaktion: bei Treffern Warnung im Log mit den IDs und `imported.honeyLedgerViolations = <Anzahl>` (Schlüssel nur bei > 0); der Import scheitert deshalb nicht. 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 Telegram-Tabellen werden nie exportiert: `TelegramLink` (Chat-Bindung an den Bot dieses Deployments; Nutzer verknüpfen nach einem Restore neu, wie bei ApiKey), `TelegramLinkToken` (Einmal-Tokens, nur Minuten gültig), `NotificationDelivery` (Zustell-Warteschlange; ein Restore schickte alte Nachrichten erneut) und `TelegramPollState` (Update-Offset und Abfrage-Lease dieses Deployments). 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:** `BackupSchedulerService` registriert einen `@nestjs/schedule`-Cron-Job, der die Bundle-Datei periodisch auf `BACKUP_STORAGE_PATH` schreibt und alte Stände nach `BACKUP_RETENTION_COUNT` rotiert. Pfad/Zeitplan/Aufbewahrung sind sowohl per Env-Variable als auch über die GUI (Verwaltung > Backup) konfigurierbar – eine gesetzte Env-Variable hat dabei immer Vorrang vor dem GUI-Wert. Bei `replicas: 2` verhindert ein Datei-Mtime-Guard (kein DB-Lock nötig), dass beide Pods im selben Tick doppelt sichern; ohne gesetzten Pfad bleibt der Zeitplan inaktiv, manueller Export/Import über die UI funktioniert davon unabhängig immer.
|
- **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.
|
||||||
|
|
||||||
Ein rohes `pg_dump`/PITR-Backup auf Infrastrukturebene bleibt als zusätzliche, komplementäre Absicherung für den Katastrophenfall (z. B. korrupte Datenbankdateien, nicht nur fachliche Daten) sinnvoll, ist aber nicht mehr der primäre, hier dokumentierte Mechanismus – die in-App-Lösung deckt den fachlichen Anwendungsfall "Daten sichern/übertragen" bereits vollständig ab.
|
Ein rohes `pg_dump`/PITR-Backup auf Infrastrukturebene bleibt als zusätzliche, komplementäre Absicherung für den Katastrophenfall (z. B. korrupte Datenbankdateien, nicht nur fachliche Daten) sinnvoll, ist aber (CronJob `apicore-db-backup`, Abschnitt 7) nicht der primäre, hier dokumentierte Mechanismus – die in-App-Lösung deckt den fachlichen Anwendungsfall "Daten sichern/übertragen" bereits vollständig ab.
|
||||||
|
|
||||||
## 9. API-Sicherheit (Stand Oktober 2026)
|
## 9. API-Sicherheit (Stand Oktober 2026)
|
||||||
|
|
||||||
@@ -143,15 +267,430 @@ Grundlage: OWASP API Security Top 10 (2023) und OWASP ASVS Level 2. Umsetzung un
|
|||||||
| Authentifizierung | JWT mit festem Algorithmus, Issuer und Audience je Zweck; MFA (TOTP) mit Replay-Schutz; Kontosperre 15 min nach 5 Fehlversuchen; gleiche Antwort für unbekannte/gesperrte Konten; bcrypt auf jedem Pfad |
|
| Authentifizierung | JWT mit festem Algorithmus, Issuer und Audience je Zweck; MFA (TOTP) mit Replay-Schutz; Kontosperre 15 min nach 5 Fehlversuchen; gleiche Antwort für unbekannte/gesperrte Konten; bcrypt auf jedem Pfad |
|
||||||
| Passwörter | mind. 12 Zeichen, max. 72 Byte, Abgleich mit Have I Been Pwned (k-Anonymität, abschaltbar per `PASSWORD_BREACH_CHECK=false`) |
|
| Passwörter | mind. 12 Zeichen, max. 72 Byte, Abgleich mit Have I Been Pwned (k-Anonymität, abschaltbar per `PASSWORD_BREACH_CHECK=false`) |
|
||||||
| Sitzungen | Refresh-Rotation atomar, Diebstahlerkennung, max. 7 Tage; Einmal-Tokens für SSO-Abschluss und MFA-Schritt |
|
| Sitzungen | Refresh-Rotation atomar, Diebstahlerkennung, max. 7 Tage; Einmal-Tokens für SSO-Abschluss und MFA-Schritt |
|
||||||
| Step-up | Backup-Import, MFA ein/aus verlangen aktuelles Passwort (+ TOTP) |
|
| Step-up | Backup-Import, MFA ein/aus und Telegram verbinden verlangen aktuelles Passwort (+ TOTP); das Verbinden ist auf 10 pro Minute gedrosselt |
|
||||||
| Autorisierung | fail-closed `PermissionsGuard`; keine Rechteausweitung über Benutzer-/Rollenpflege; Suche nur über lesbare Objektarten |
|
| Autorisierung | fail-closed `PermissionsGuard`; keine Rechteausweitung über Benutzer-/Rollenpflege; Suche nur über lesbare Objektarten |
|
||||||
| Maschinen-Zugriff | API-Schlüssel nur als Hash (Suche über den Hash, kein Vergleich von Geheimnissen), Scope-Allowlist (`shop-sync:read`, `shop-sync:write`, `erp-inventory:read`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben; nach 20 ungültigen Schlüsseln in 15 min je IP (IPv6: je /64) `429` für weitere ungültige – gültige Schlüssel nie, falsch aufgebaute Tokens zählen nicht; Warnung beim Start, wenn `TRUST_PROXY` in Produktion fehlt; Logs nur mit Schlüssel-Präfix |
|
| Maschinen-Zugriff | API-Schlüssel nur als Hash (Suche über den Hash, kein Vergleich von Geheimnissen), Scope-Allowlist (`shop-sync:read`, `shop-sync:write`, `erp-inventory:read`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben. Je neuem Schlüssel genau ein Recht: `ApiKeysService.create` lehnt mehrere Rechte mit 400 ab (Lesen und Buchen damit immer getrennt); bereits ausgestellte Schlüssel mit mehreren Rechten bleiben gültig und werden in der Oberfläche mit Hinweis markiert; `erp-inventory:read` zählt als Lesen. Nach 20 ungültigen Schlüsseln in 15 min je IP (IPv6: je /64) `429` für weitere ungültige – gültige Schlüssel nie, falsch aufgebaute Tokens zählen nicht; Zähler in `ThrottlerHit`, über alle Pods geteilt (Annahme 30); Warnung beim Start, wenn `TRUST_PROXY` in Produktion fehlt; Logs nur mit Schlüssel-Präfix |
|
||||||
| Ausgehende Aufrufe | Webhooks: Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, 5 s Timeout, Signatur über Zeitstempel+ID+Body |
|
| Ausgehende Aufrufe | Webhooks: nie aus der Anfrage heraus gesendet, sondern über die Zustelltabelle `WebhookDelivery` (Warteschlange und Zustellprotokoll), die der Worker abarbeitet; Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, keine Weiterleitungen, 5 s Leerlauf-Timeout und 10 s Frist je Versuch (danach wird die Verbindung abgebrochen); nur eine `2xx`-Antwort gilt als zugestellt, sonst Wiederholung nach der Outbox-Regel (30 s bis 6 h, nach 8 Versuchen `FAILED`); stabile Delivery-ID (`X-ApiCore-Delivery` = `WebhookDelivery.id`, mit gleichem Body über alle Wiederholungen); Signatur über Zeitstempel+ID+Body; Antwortkörper wird nie gelesen, `lastError` nur über `loggableError()` |
|
||||||
| Transport/Antworten | `Cache-Control: no-store` (außer öffentliche Chargen-Seite), CSP `default-src 'none'`, `frame-ancestors 'none'`, keine ETags; Produktion startet nur mit https-`FRONTEND_ORIGIN` und sicheren Cookies |
|
| Wetter und Telegram | Wetter (Open-Meteo) nur aus der Worker-Schleife `phenology-weather` und der Wetter-Anzeige, über `src/weather/`: nur https-URLs aus der Konfiguration, keine Weiterleitungen (`redirect: 'error'`), 8 s Frist, Antwort höchstens 1 000 000 Byte, Fehlertexte fest (kein URL, keine Ursache, kein Schlüssel). Telegram (`api.telegram.org`) nur aus Worker-Schleifen über `TelegramClient` (`src/telegram/`): Klartext ohne `parse_mode`, Linkvorschau aus, Text höchstens 600 Zeichen ohne Steuer- und Richtungszeichen, keine Weiterleitungen, Antwort höchstens 256 kB; der Token steht nie in Logs oder Fehlern (`TelegramApiError` trägt Methode, Status, bereinigte Beschreibung, `retry_after` 1–3600 s, keine URL). Wächter `src/common/no-network-outside-loops.spec.ts`: Wetter-Hosts nur in `src/weather/`, `api.telegram.org` nur in `src/telegram/`, `fetch(` und Netzwerkpakete nur in den erlaubten Dateien (Graph-Client, Passwort-Prüfung, Webhook-Zustellung, Worker-Health, Wetter, Telegram), nie in Handlern oder Jobs; `handler-contract.spec.ts` verbietet den Import von `telegram-client` in Handlern |
|
||||||
| Rate-Limits | Throttler vor der Authentifizierung (100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über `TRUST_PROXY` |
|
| Transport/Antworten | Alle API-Antworten tragen `Cache-Control: no-store` und `Pragma: no-cache`; einzige Ausnahme ist die erfolgreiche (200) öffentliche Herkunftsantwort `GET /public/batches/:lot` mit `Cache-Control: public, max-age=300, s-maxage=3600` (ohne Pragma, am Cloudflare-Edge cachebar), Fehlerantworten bleiben `no-store`; `Cache-Control: no-store` (außer öffentliche Chargen-Seite), CSP `default-src 'none'`, `frame-ancestors 'none'`, keine ETags; Produktion startet nur mit https-`FRONTEND_ORIGIN` und sicheren Cookies |
|
||||||
|
| Rate-Limits | Throttler vor der Authentifizierung, Zähler in der UNLOGGED-Tabelle `ThrottlerHit` und damit über alle Pods geteilt (eine Upsert-Anweisung, ausschließlich DB-Uhr `now()`; 100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über `TRUST_PROXY` (IPv6 je /64); bei DB-Fehler schlägt die Anfrage fehl (fail-closed) – nur die Liveness `GET /health` ist ausgenommen, damit ein DB-Ausfall nicht alle Pods neu startet; abgelaufene Zeilen räumt der Job `cleanup` auf |
|
||||||
| Daten | Backups verschlüsselt und signiert, Dateien 0600; CSV-Export ohne Formel-Injection; Logs ohne Query-Strings |
|
| Daten | Backups verschlüsselt und signiert, Dateien 0600; CSV-Export ohne Formel-Injection; Logs ohne Query-Strings |
|
||||||
|
|
||||||
|
**Umgebungsvariablen seit T4b/T4c** (Vorlagen in `.env.example`, `configmap.example.yaml`, `secret.example.yaml`; alle optional):
|
||||||
|
|
||||||
|
| Variable | Wo | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| `WEATHER_HISTORY_SOURCE` | ConfigMap, API und Worker | Wetterquelle, Standard `open-meteo`; eine unbekannte Quelle stoppt den Start (Meldung nennt nur den Namen der Variable) |
|
||||||
|
| `OPEN_METEO_FORECAST_URL`, `OPEN_METEO_ARCHIVE_URL` | ConfigMap | Endpunkte, Standard die öffentlichen Open-Meteo-URLs; nur `https:` |
|
||||||
|
| `OPEN_METEO_API_KEY` | Secret | Bleibt leer (Annahme 42); falls gesetzt, wird er als `apikey` gesendet |
|
||||||
|
| `WEATHER_USER_AGENT_CONTACT` | ConfigMap | Kontakt für den Header `User-Agent: ApiCore/<Version> (+<Kontakt>)`; ohne Wert `(self-hosted beekeeping app)`; kein persönlicher Standard, auf druckbares ASCII reduziert |
|
||||||
|
| `TELEGRAM_BOT_USERNAME` | ConfigMap, API und Worker | Benutzername des Bots (ohne `@`, 5–32 Zeichen, `^[A-Za-z][A-Za-z0-9_]{4,31}$`), erzeugt den Deep Link; ungültig = nicht gesetzt, mit Warnung in jeder Rolle |
|
||||||
|
| `TELEGRAM_BOT_TOKEN` | Secret `apicore-telegram`, nur Worker | Bot-Token von BotFather. Der API-Pod darf das Secret nicht mounten. Fehlt er, ist Telegram aus; ein Benutzername ohne Token oder umgekehrt warnt im Log |
|
||||||
|
|
||||||
|
Die Manifeste binden das Secret nur am Worker ein (Abschnitt 7). Ohne Token laufen die Worker-Schleifen `telegram-send` und `telegram-updates` nicht (Abschnitt 10).
|
||||||
|
|
||||||
**Bekannte Restpunkte (bewusst nicht umgesetzt):**
|
**Bekannte Restpunkte (bewusst nicht umgesetzt):**
|
||||||
|
|
||||||
- Rate-Limits zählen je Pod im Speicher; bei 2 Replikas verdoppelt sich das Limit. Brute-Force-Schutz hängt nicht daran, sondern an der Kontosperre in der DB. Ein gemeinsamer Speicher (Redis) wäre der nächste Schritt, falls Lastspitzen ein Thema werden.
|
|
||||||
- Eine E-Mail-Änderung im eigenen Profil meldet „bereits vergeben“, wenn die Adresse existiert. Das erfordert eine Anmeldung plus aktuelles Passwort; eine generische Meldung würde Nutzer nur verwirren.
|
- Eine E-Mail-Änderung im eigenen Profil meldet „bereits vergeben“, wenn die Adresse existiert. Das erfordert eine Anmeldung plus aktuelles Passwort; eine generische Meldung würde Nutzer nur verwirren.
|
||||||
|
|
||||||
|
## 10. Ereignis-Rückgrat und Laufrollen
|
||||||
|
|
||||||
|
### Ereignisse
|
||||||
|
|
||||||
|
**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). `availableAt` ist `timestamp(3)`: Ein per SQL geschriebenes `now()` wird auf die Millisekunde gerundet und kann bis 0,5 ms hinter dem `now()` der nächsten Anweisung liegen. Darum vergleicht der Claim gegen `CAST(now() AS timestamp(3))`, sonst fände ein sofortiger Claim frisch fällig gesetzte Zeilen gelegentlich nicht (Regressionstest `test/outbox-claims.e2e-spec.ts`).
|
||||||
|
|
||||||
|
**Delivery-Sperre.** Je Ereignis laufen die Handler nacheinander in fester Reihenfolge (Handler-ID nach Codepunkt), jeder in einer eigenen Transaktion mit 10 s Timeout:
|
||||||
|
1. `DomainEventDelivery` (eventId, handler) anlegen, falls es sie noch nicht gibt (`skipDuplicates`).
|
||||||
|
2. Die Zeile `FOR UPDATE` sperren. Ist sie `DONE`, wird der Handler übersprungen.
|
||||||
|
3. Den Handler mit dem Transaktions-Client aufrufen; die Korrelations-ID steht im `domainEventContext`.
|
||||||
|
4. Die Delivery auf `DONE` setzen, `attempts + 1`.
|
||||||
|
|
||||||
|
Die Wirkung des Handlers und `DONE` werden gemeinsam committet. Die Exklusivität je (Ereignis, Handler) kommt allein von dieser `FOR UPDATE`-Sperre der Delivery-Zeile, nicht vom Claim: Ein Ereignis kann nach einer Stale-Freigabe von einem zweiten Worker übernommen werden, während der erste noch läuft. Der zweite Worker wartet dann auf die Sperre und findet danach `DONE`. So entsteht jede Wirkung höchstens einmal je (Ereignis, Handler), auch bei einem Retry oder einem wieder freigegebenen Ereignis. Scheitert ein Handler, rollt seine Transaktion zurück. Außerhalb davon zählt die Delivery `attempts + 1` und speichert `lastError`; die übrigen Handler laufen trotzdem.
|
||||||
|
|
||||||
|
**Backoff und `FAILED`.** Sind alle Deliveries `DONE` (oder hat der Typ keinen Handler), wird das Ereignis `DONE` (`processedAt = now()`). Sonst gilt `attempts + 1`, `lastError = "<handlerId>: <Meldung>"` (erster gescheiterter Handler) und `availableAt = now() +` Wartezeit: 30 s, 2 min, 10 min, 1 h, ab dem 5. Fehlversuch 6 h. Beim 8. Fehlversuch wird das Ereignis `FAILED`, ebenso seine offenen Deliveries. Erledigte Handler laufen beim Wiederholen nicht erneut. Der Abschluss ist ein Compare-and-Set auf `id` + `claimedAt`: Wer seinen Claim verloren hat, ändert nichts.
|
||||||
|
|
||||||
|
**Liegengebliebene Claims.** `releaseStaleClaims()` läuft in jedem Worker-Takt vor `drain()`. Es setzt Ereignisse, deren `claimedAt` älter als 5 min ist, sofort wieder auf `PENDING`, mit `attempts + 1` und `lastError = 'claim expired (worker restarted?)'`; ab 8 Versuchen werden sie `FAILED`, und ihre offenen Deliveries ebenfalls. So kann ein Ereignis, das seinen Worker abstürzen lässt, nicht endlos kreisen.
|
||||||
|
|
||||||
|
**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`. `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` (`InspectionRecorded`: auswerten und offene Vorschläge nachführen; `InspectionCorrected`: nur nachführen, nie neu vorschlagen) und `automation.varroa-threshold` (`VarroaMeasured` und `VarroaCorrected`; eine korrigierte Zählung kann die Schwelle überschreiten, und der offene Vorschlag derselben Messung wird aufgefrischt oder aufgelöst, Abschnitt 11.4), den Autopilot über `autopilot.colony-sync` (Abschnitt 11.5), Kalender über `calendar-sync.inspection` (beide Durchsicht-Ereignisse), Webhooks `inspection.created` und `varroa.measured` über `webhooks.forward`. Ein Handler wertet den Datensatz so aus, wie er bei der Zustellung in der Datenbank steht.
|
||||||
|
|
||||||
|
**Behandlungen, Fütterungen und Ernten.** Anlegen schreibt Datensatz, Audit-Eintrag und `TreatmentRecorded`, `FeedingRecorded` bzw. `HarvestRecorded` in einer Transaktion, Ändern `TreatmentCorrected`, `FeedingCorrected` bzw. `HarvestCorrected`. Kalendereintrag (`calendar-sync.*`), Webhook (`treatment.created`, `feeding.created`, `harvest.created`) und Folgekontrolle (`automation.treatment-follow-up`, `automation.feeding-follow-up`) entstehen ausschließlich in den Handlern; Korrekturen lösen nur den Kalender-Handler aus, keine Folgekontrolle und keinen Webhook. Die Fälligkeit der Folgekontrolle gilt ab dem Datum der Maßnahme. `update` sperrt die Zeile und liest „vorher“ danach über `tx`; die Behandlung sperrt beim Anlegen das Volk und liest es über `tx`, bei Ernten läuft die Gewichtsprüfung unter der Sperre.
|
||||||
|
|
||||||
|
**Völker.** `ColoniesService` und `WinterChecksService` schreiben Audit, Datenänderung und Ereignis in einer Transaktion. Ändert sich `status` oder `healthStatus` (update, merge je vereinigtem Volk, dissolve, reactivate, Auswinterungsverlust mit `markColonyDeceased`), entsteht `ColonyStatusChanged { from, to }`; beim Übergang von lebend nach beendet (`DISSOLVED`, `SOLD`, `DECEASED`, `MERGED`) zusätzlich `ColonyEnded { status, endedOn, releasedQueenId, via }` (Abschnitt 11.7). `automation.colony-health` hört auf `ColonyStatusChanged`. Die Operationen sperren das Volk (`lockColony`: Sperre, dann Neulesen über `tx`) und prüfen darunter, ob es noch lebt; `merge` sperrt alle vereinigten Völker mit einer Anweisung (`lockRowsForUpdate`). `colony.created` bleibt im Altpfad `WebhooksService.dispatch` (Annahme 11). Das Teilen schreibt `ColonySplit`, die Königin-Zuordnung `QueenAssigned` in ihrer Transaktion (Handler-Übersicht unten); Kalender, Webhook und Ketten folgen aus diesen Ereignissen.
|
||||||
|
|
||||||
|
**Bestand und Abfüllung.** Bestandsbuchungen kündigen sich über `StockChanged { bottlingBatchIds, productIds, rawMaterialKinds }` an. Das Ereignis entsteht in derselben Transaktion wie Bewegung, Audit-Eintrag und Datenänderung, über `StockService.recordChange(tx, change, actingUserId, aggregate)`. Eine leere Änderung erzeugt kein Ereignis, jede ID und jede Sorte steht nur einmal darin.
|
||||||
|
- Entnahme und Inventur hängen das Ereignis an die Bewegung (`BatchMovement`). Bei einer Rohstoff-Charge steht deren Sorte in `rawMaterialKinds`.
|
||||||
|
- Jede angenommene Shop-Buchung (`ShopStockRequest`, `actorUserId = null`) schreibt ihr eigenes Ereignis. Abgelehnte und wiederholte Anfragen schreiben keines.
|
||||||
|
- Abfüllungen schreiben beim Anlegen `BatchBottled { honeyBatchId }`, bei Korrektur und Löschung `BottlingCorrected { honeyBatchId, removed }`, jeweils zusammen mit `StockChanged` in einem `recordAll`, also mit einer Korrelations-ID.
|
||||||
|
- Herstellungschargen schreiben `StockChanged` mit dem Artikel und den verbrauchten bzw. beim Löschen zurückgegebenen Rohstoff-Sorten.
|
||||||
|
|
||||||
|
Daraus folgen die Webhooks `batch.updated` und `stock.changed` (`webhooks.forward`; reine Rohstoff-Änderungen gehen nicht an den Shop) sowie die Rohstoff-Mindestmenge (`automation.raw-material-low`, je Sorte über `remainingByRawMaterialKind`). Korrekturen und Löschungen von Abfüllung, Herstellungscharge und Rohstoff-Charge sperren die Zeile (`lockRowForUpdate`) und lesen „vorher“ erst danach über `tx`; Mengenprüfung und Bewegungs-Check vor dem Löschen laufen ebenfalls unter dieser Sperre. Herstellungschargen sperren zuerst den Artikel (`StockService.lock`), dann die Charge: dieselbe Reihenfolge wie ein Shop-Verkauf, der den Artikel hält, während seine Bewegung die Charge referenziert. Reines Anlegen oder Ändern einer Rohstoff-Charge schreibt kein `StockChanged`; die Mindestmenge wird erst bei der nächsten Entnahme, Korrektur oder Verbrauchsbuchung dieser Sorte geprüft.
|
||||||
|
|
||||||
|
**Sammelerfassung am Stand.** `POST /treatments/bulk`, `/feedings/bulk` und `/varroa-measurements/bulk` legen in einer Transaktion (`runBulk`, `src/common/bulk/run-bulk.ts`) je Volk einen eigenen Datensatz an: höchstens `BULK_MAX_COLONIES` = 100 Völker, Timeout `BULK_TRANSACTION_TIMEOUT_MS` = 15 s. Es gilt alles oder nichts: `assertLivingColonies` sperrt die Völker per `lockRowsForShare` (`SELECT … FOR SHARE`, aufsteigend nach id) und liest sie neu. Ist ein Volk unbekannt oder beendet, wird nichts gespeichert, und die Antwort 400 nennt die Völker in `invalidColonyIds`; der Client kann ohne sie erneut senden. Jeder Datensatz bekommt seinen eigenen Audit-Eintrag und sein eigenes `TreatmentRecorded`, `FeedingRecorded` bzw. `VarroaMeasured`; diese Ereignisse tragen optional `bulkId` (`RecordedForColony`), bei Einzelanlage fehlt das Feld. Kalender, Folgekontrolle, Varroa-Schwelle und Webhooks folgen daher je Volk unverändert aus den Ereignissen. Einzel- und Sammelanlage nutzen dieselbe Datenaufbereitung; bei Behandlungen kommt der Stand je Volk aus der gesperrten Zeile (Bestandsbuch). Die Chargennummer ist die Arzneimittel-Charge der Packung: einmal eingegeben, für alle Datensätze gleich, nie erzeugt. Die Gruppierung leistet `bulkId`. Varroa nimmt `entries` mit Milbenzahl je Volk (keine gemeinsame Milbenzahl), die Tagesrate wird je Eintrag wie bei der Einzelmessung abgeleitet. Die Einzel-Endpunkte akzeptieren weiter beendete Völker, nur die Sammelerfassung prüft auf lebende.
|
||||||
|
|
||||||
|
**Zeilensperren.** `src/prisma/row-lock.ts` bietet `lockRowForUpdate` (eine Zeile, liefert, ob sie existiert), `lockRowsForUpdate` und `lockRowsForShare` (mehrere Zeilen mit einer Anweisung, aufsteigend nach id, damit sich überlappende Mengen nicht gegenseitig blockieren). Sie laufen nur mit einem Transaktions-Client, Tabellennamen kommen aus einer festen Liste (nie aus Eingaben). Muster für jedes Ändern: sperren, dann „vorher“ über `tx` neu lesen, nie die Vorab-Lesung verwenden. Die Reihenfolge der Sperr-Karte (`LOCK_ORDER`) gilt für alle Tabellen mit einer Ausnahme: `Apiary` kommt vor `Colony` (Annahme 50).
|
||||||
|
|
||||||
|
**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`).
|
||||||
|
|
||||||
|
**Ereignisse der Honig-Kette und der Pflanzenentwicklung (T4b).** Sieben Typen kommen in den Katalog (`domain-event-types.ts`), Aggregattypen `Extraction`, `HoneyBatch`, `PhenologyReport` und `Apiary` dazu; die Lieferung der Meldungs-API und der Autopilot-Regeln A4–A10 ergänzt `PhenologyRecomputeRequested` und `WinterCheckRecorded` (Aggregattyp `WinterCheck`):
|
||||||
|
- `ExtractionRecorded` und `ExtractionCorrected` (Aggregat `Extraction`, Payload `harvestIds`, bei der Korrektur nach der Änderung) schreibt `ExtractionsService` in der Transaktion des Schreibens, zusammen mit dem Audit-Eintrag.
|
||||||
|
- `HoneyBatchRecorded` und `HoneyBatchCorrected` (Aggregat `HoneyBatch`, Payload `extractionId`) schreibt `HoneyBatchesService` ebenso. `update` sperrt zuerst die Zeile und liest „vorher“ danach (Abschnitt 11.12).
|
||||||
|
- `PhenologyReported` und `PhenologyCorrected` (Aggregat `PhenologyReport`) schreibt `PhenologyService` mit Datensatz und Audit-Eintrag in einer Transaktion: Anlegen → `PhenologyReported`, Bearbeiten und Zurückziehen → `PhenologyCorrected` mit `previousApiaryId` (Annahme 47, Abschnitt 11.11). Ändert ein Bearbeiten nichts, entsteht kein Ereignis.
|
||||||
|
- `SeasonPhaseChanged` (Aggregat `Apiary`, Payload `apiaryId`, `phase`, `segment`, `previousPhase`, `previousSegment`, `source`) schreibt `PhenologyStateService`, wenn sich Phase oder Abschnitt eines Stands ändern: höchstens eines je Stand und Tag über `dailyDedupeKey`. Beim ersten Zustand eines Jahres wird gegen den Kalenderzustand verglichen; ein unveränderter Zustand erzeugt kein Ereignis. Der Handler `autopilot.apiary-sync` hört darauf; eine Einzelmeldung je Saisonwechsel gibt es bewusst nicht (Annahme 47).
|
||||||
|
- `PhenologyRecomputeRequested { apiaryId, allowBackward, day }` (Aggregat `Apiary`, ohne Schlüssel) schreibt der Handler `phenology.recompute` je aktivem Stand, wenn eine Meldung für alle Stände gilt; `phenology.recompute-apiary` rechnet damit einen Stand je Zustellung.
|
||||||
|
- `WinterCheckRecorded { colonyId }` (Aggregat `WinterCheck`) schreiben `WinterChecksService.create` und `update` in ihrer Transaktion (ein Verschieben auf ein anderes Volk meldet auch das alte); `autopilot.colony-sync` löst damit A9/A10 sofort auf.
|
||||||
|
`Apiary`, `PhenologyReport`, `Extraction` und `HoneyBatch` stehen am Ende der Sperr-Karte (`LOCKABLE_TABLES`, Schlüssel `id`); `NotificationDelivery` steht ganz zuletzt (Abschnitt 11.13).
|
||||||
|
|
||||||
|
**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` | 20 Typen: `InspectionRecorded/Corrected`, `VarroaMeasured/Corrected`, `TreatmentRecorded/Corrected`, `FeedingRecorded/Corrected`, `HarvestRecorded/Corrected`, `ColonyStatusChanged`, `ColonySplit`, `QueenAssigned`, `TaskCompleted`, `TaskUpdated`, `WithdrawalEnded`, `QueenAged`, `InspectionOverdue`, `VarroaMeasurementDue`, `WinterCheckRecorded` | Betrifft ein Ereignis mehrere Völker, sperrt der Handler sie zuerst in einer Anweisung (`lockRowsForUpdate`, Id aufsteigend); dann ruft er `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, bei `WinterCheckRecorded` das Volk der Prüfung (A9/A10 lösen sich sofort auf); Aufgaben-Ereignisse nur mit `relatedType = COLONY` (Abschnitt 11.5). |
|
||||||
|
| `autopilot.apiary-sync` | `SeasonPhaseChanged` | Gleicht alle lebenden Völker des Stands ab (`AutopilotService.syncApiaryIn(tx, apiaryId, berlinToday())`): Stand sperren, Völker lesen, alle in einer Anweisung sperren (Id aufsteigend), Regeln und Saison einmal lesen, dann je Volk `syncColonyIn`. Ergänzt `autopilot.colony-sync`; es entsteht höchstens ein Ereignis je Stand und Tag, spätere Wechsel fängt der tägliche Schritt `autopilot` auf (Annahmen 48, 50). |
|
||||||
|
| `phenology.recompute` | `PhenologyReported`, `PhenologyCorrected` | Berechnet den Saisonzustand der betroffenen Stände in der Zustell-Transaktion neu (`PhenologyStateService.recomputeIn`, am späteren von Ereignistag und heute); bei `PhenologyCorrected` darf die Phase zurückfallen (`allowBackward`). Betroffen: Stand und, bei einer Korrektur, `previousApiaryId`; gilt eine Seite für alle Stände, schreibt er je aktivem Stand ein `PhenologyRecomputeRequested`. Fehler gehen als Wiederholung durch (Annahme 47). |
|
||||||
|
| `phenology.recompute-apiary` | `PhenologyRecomputeRequested` | Rechnet genau einen Stand neu; ein nicht mehr aktiver oder gelöschter Stand wird übersprungen. |
|
||||||
|
| `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. |
|
||||||
|
| `withdrawal.extraction-check` | `ExtractionRecorded`, `ExtractionCorrected` | Prüft die Ernten der Schleuderung gegen die Wartezeitfenster (`conflictsForExtraction`) und meldet mit demselben Schlüssel wie Ernte und Abfüllung (`withdrawal-conflict:<Ernte>:<Volk>`): Ein Konflikt wird einmal gemeldet, egal welche Stufe ihn zuerst sieht (Abschnitt 11.8, 11.12). |
|
||||||
|
| `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`, `HoneyBatchRecorded` | `queen.replaced`, `batch.created` (siehe „Webhook-Zustellung“). `HoneyBatchCorrected` sendet nichts. |
|
||||||
|
|
||||||
|
**Worker-Schleife `phenology-weather`** (T4b). Takt 3 600 000 ms, kein Weckkanal (keine Outbox); der erste Durchlauf folgt 10 s nach dem Start des Workers (`WEATHER_FIRST_TICK_DELAY_MS`). Ein Takt ruft `WeatherHistoryService.syncOnce(berlinToday(), signal)` auf und holt die fehlenden Tagesmittel der aktiven Stände mit Koordinaten (Ablauf und Regeln: Abschnitt 11.11). Die Schleife schreibt nur `ApiaryWeatherDay`: kein Ereignis, keine Meldung. Die Loops liegen in einer Karte nach Namen; `WorkerLoop.channel` ist optional. Zu den LISTEN-Kanälen kommt `notification_delivery` (Abschnitt 11.13). Der Test `test/worker-runtime.e2e-spec.ts` ersetzt `WEATHER_HISTORY_SOURCE` durch eine Attrappe, weil die Schleife sonst für jeden Stand der geteilten Test-DB den echten Dienst aufriefe.
|
||||||
|
|
||||||
|
**Zustellplanung.** Kein Handler: `NotificationsService.write` plant nach dem Einfügen der Meldungen (dieselbe Transaktion; ohne Aufrufer-Transaktion öffnet es eine eigene, damit Meldungen und Zustellungen zusammen bestehen) über den `NotificationDeliveryPlanner` je neu eingefügter Meldung höchstens eine `NotificationDelivery` für Telegram und weckt den Worker per `pg_notify('notification_delivery', '')`, nur wenn Zeilen entstanden sind. Eine wiederholte Meldung (gleicher `dedupeKey`) plant nichts. Regeln: Annahme 49, Abschnitt 11.13.
|
||||||
|
|
||||||
|
**Worker-Schleife `telegram-send`.** Takt 5 000 ms und auf `notification_delivery`; sie ist nur registriert, wenn `TELEGRAM_BOT_TOKEN` gültig gesetzt ist (`TelegramClient.isConfigured()`). Ein Takt ruft `TelegramSender.releaseStaleClaims()` und dann `drain(stopping.signal)` auf. Mit `telegram-updates` ist sie der einzige Netzzugriff zur Bot-API. Rechte kommen aus dem globalen `JobPermissionsModule` des Workers, `TelegramModule` importiert kein `PermissionsModule`.
|
||||||
|
|
||||||
|
**Worker-Schleife `telegram-updates`.** Takt 30 000 ms (die Wartezeit ohne Lease, nach 409 und nach Fehlern), kein Weckkanal; wie `telegram-send` nur mit gültigem `TELEGRAM_BOT_TOKEN` registriert. Ein Takt ruft `TelegramUpdatesService.pollOnce(stopping.signal)`; nach `'polled'` wird die Schleife sofort wieder geweckt, das Stop-Signal bricht einen laufenden Abruf ab. Störungen stehen nur in `TelegramPollState.lastError` (Verwaltung > System) und im Log; sie machen den Worker nicht ungesund. Ablauf und Regeln: Abschnitt 11.13.
|
||||||
|
|
||||||
|
### Webhook-Zustellung
|
||||||
|
|
||||||
|
**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`, `QueenAssigned` → `queen.replaced`, `HoneyBatchRecorded` → `batch.created`. `data` ist wie bisher der Datensatz, gelesen über `tx`: die Ernte mit ihren Völkern, bei `batch.created` und `batch.updated` die Honigcharge (bei `batch.created` mit `honeyBatchInclude`, `src/honey-batches/honey-batch-include.ts`, sodass die Form von `GET /honey-batches/:id` gleich bleibt; `HoneyBatchCorrected` sendet nichts), 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`, `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.
|
||||||
|
|
||||||
|
**Ergebnis.** Nur eine `2xx`-Antwort gilt als zugestellt: `DELIVERED`, `deliveredAt = now()`. Jede andere Antwort, ein Netzwerkfehler und ein pausiertes Abonnement (`subscription inactive`, ohne zu senden) sind Fehlversuche nach derselben Regel wie bei Ereignissen: `attempts + 1`, Wartezeit 30 s, 2 min, 10 min, 1 h, danach 6 h, beim 8. Fehlversuch `FAILED`. Ein Abonnement, das innerhalb dieses Fensters wieder aktiviert wird, bekommt seine Zustellungen also noch. `lastStatusCode` hält den Status der letzten Antwort (ohne Antwort `null`), `lastError` einen festen Text (`Receiver answered HTTP <status>`) oder die Meldung aus `loggableError()`; Antwortkörper, Secret und Payload stehen nie darin. Status-Code und Abschluss werden in einer Transaktion gesetzt, beide als Compare-and-Set auf `id` + `claimedAt`. Beim Herunterfahren holt `drain()` keine weitere Runde; die laufende wird zu Ende zugestellt.
|
||||||
|
|
||||||
|
**Zustellprotokoll.** `GET /webhooks/:id/deliveries?limit=1..100` (Standard 50, Recht `webhooks:read`) liefert die neuesten Zustellungen eines Abonnements ohne Body: `id`, `event`, `status`, `attempts`, `lastStatusCode`, `lastError`, `createdAt`, `deliveredAt`.
|
||||||
|
|
||||||
|
**Tests.** e2e-Tests rufen `deliverWebhooks(app, [subscriptionId])` aus `test/utils/webhooks.ts` auf. Das stellt nur die Zustellungen dieser Abonnements zu.
|
||||||
|
|
||||||
|
### Jobs
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**`cleanup`** (täglich 04:00). Alle Altersvergleiche laufen in SQL gegen `now()`; `PROCESSING`-Einträge bleiben immer stehen, `PENDING` ebenso, mit der einen Ausnahme der Telegram-Zustellungen unten.
|
||||||
|
- Läufe, die seit mehr als 6 h `RUNNING` sind (`JOB_RUN_STALE_HOURS`, weit über jeder `activeDeadlineSeconds`), werden `FAILED` mit `error: 'aborted (pod ended without result)'`; je betroffenem Job geht die übliche Benachrichtigung raus, damit ein Job, der jedes Mal abgeschossen wird, nicht still scheitert.
|
||||||
|
- Idempotenz-Einträge nach 90 Tagen und verbrauchte Tokens 1 h nach ihrem Ablauf (`IdempotencyCleanupService`; die Stunde Puffer deckt Uhrabweichungen der api-Pods ab, die den Ablauf mit ihrer eigenen Uhr prüfen).
|
||||||
|
- `ThrottlerHit`-Zeilen, deren Fenster abgelaufen ist und die keine aktive Sperre tragen (`"expiresAt" < now()` und `"blockedUntil"` leer oder `< now()`); eine noch laufende Sperre bleibt so erhalten.
|
||||||
|
- `DONE`-Ereignisse und `DELIVERED`-Zustellungen nach 30 Tagen (`occurredAt` bzw. `createdAt`), `FAILED`-Einträge beider Tabellen nach 90 Tagen; die Deliveries eines Ereignisses gehen per `ON DELETE CASCADE` mit. 30 Tage liegen bewusst über dem Nachholfenster der täglichen Auswertung, sonst würde ein gelöschter `dedupeKey` ein Vorkommen erneut erzeugen.
|
||||||
|
- `JobRun`-Zeilen nach 90 Tagen.
|
||||||
|
- `NotificationDelivery` (nach `createdAt`, auch Antworten des Bots ohne Nutzer): `DELIVERED` und `SKIPPED` nach 30 Tagen, `FAILED` nach 90 Tagen, `PENDING` nach 7 Tagen (`PENDING_DELIVERY_MAX_AGE_DAYS`; eine so alte Zustellung ist hängen geblieben, sie wird mit Warn-Log samt Zahl gelöscht). `TelegramLinkToken`, die seit mehr als 24 h abgelaufen oder verbraucht sind.
|
||||||
|
|
||||||
|
Das Ergebnis zählt jede Kategorie (`idempotencyRecords`, `consumedTokens`, `throttlerHits`, `domainEvents.done`, `domainEvents.failed`, `webhookDeliveries.delivered`, `webhookDeliveries.failed`, `jobRuns.aborted`, `jobRuns.deleted`, `notificationDeliveries.done`, `notificationDeliveries.failed`, `notificationDeliveries.pending`, `telegramLinkTokens`).
|
||||||
|
|
||||||
|
**`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) 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` (`PhenologyStep`, Abschnitt 11.11) berechnet den Saisonzustand `ApiaryPhenology` für jeden aktiven Stand, je Stand eine Transaktion. Zähler `phenologyApiaries`, `seasonChanges`; ein gescheiterter Stand hält die anderen nicht auf (`JobPartialFailure`, Lauf `FAILED` mit Zählern). Er steht vor `time-events`, damit alle späteren Schritte den frischen Zustand sehen. Ein scheiternder Schritt hält die übrigen nicht auf: Der Lauf endet `FAILED` mit allen Zählern (`JobPartialFailure`, `JobRun.result` bleibt erhalten), `error` nennt Schritt und Ursache (`Daily evaluation failed for: time-events: TaskDue`, bei Völkern `autopilot: 2 colonies`).
|
||||||
|
- **`time-events`** (`src/jobs/steps/time-events.step.ts`) erzeugt die zeitbasierten Ereignisse. Regeln und Werte stehen in `src/jobs/daily-evaluation.rules.ts`, eine Zeile je Typ in `DAILY_EVALUATION_RULES`; jede Regel nennt je Vorkommen das Ereignis und den Tag des fachlichen Ereignisses. Je Typ eine Transaktion; scheitert eine Regel, laufen die übrigen weiter, und der Schritt wirft `JobPartialFailure` mit den Zählern der anderen. Das Ergebnis zählt die neuen Ereignisse je Typ.
|
||||||
|
- `WithdrawalEnded` (Aggregat `Treatment`, Payload `colonyId`, `endedOn`): Ende des Wartezeitfensters über den Fenster-Helfer (Berliner Tag, Abschnitt 11.8), nur mit Wartezeit. Die SQL-Kandidatenabfrage nimmt einen Tag Rand auf jeder Seite, der Helfer entscheidet.
|
||||||
|
- `TaskDue` (`Task`, `dueOn`): `dueDate` offener Aufgaben (`OPEN`, `IN_PROGRESS`).
|
||||||
|
- `QueenAged` (`Queen`, `hatchYear`, `colonyId`): jeden Jobtag für lebende Völker, deren aktuelle Königin `ACTIVE` und gealtert ist (`isQueenAged`, Annahme 37); Schlüssel `QueenAged:<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`, optional `kind`, `anchor`): am Fälligkeitstag aus `varroaMeasurementDue` (Abschnitt 11.3).
|
||||||
|
- `InspectionOverdue` (`Colony`, `dueOn`, `lastInspectedOn`, optional `firstOfYear`): am Tag nach `inspectionDueOn`; `dueOn` im Payload ist der echte Fälligkeitstag, nicht der Ereignistag (Abschnitt 11.3). Eine feste Märzmeldung entfällt.
|
||||||
|
- Die Ereignisse sind Tatsachen: `active` einer Regel (`INSPECTION_INTERVAL`, `SEASON_AUTOPILOT`) unterdrückt nur Vorschläge, nicht das Ereignis. Es entsteht ein Ereignis je Fälligkeitstag; ein Wechsel des Abschnitts-Intervalls ergibt einen neuen Fälligkeitstag und damit ein zweites Ereignis.
|
||||||
|
- Aufgezeichnet wird jedes Vorkommen, dessen Tag im Nachholfenster `[heute − 7, heute]` liegt (`CATCH_UP_DAYS`), mit `dedupeKey = "<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` und, nur wenn mindestens eine Übersicht geschrieben wurde, `digestWindowEnd` (Beginn des Schritts, ISO-Zeit; Zeitfenster der Saison-Zeile, Abschnitt 11.9); ein Wiederholungslauf am selben Tag schreibt 0 und merkt kein Fensterende.
|
||||||
|
|
||||||
|
**`app-backup`** (stündlich) prüft, ob ein Backup fällig ist, und schreibt es dann: siehe Abschnitt 8, „Automatischer Zeitplan“.
|
||||||
|
|
||||||
|
**`rekey`** (nur von Hand, CronJobs `apicore-job-rekey` und `apicore-job-rekey-dry-run` mit `suspend: true`): siehe Annahme 22.
|
||||||
|
|
||||||
|
**Tests.** `test/jobs.e2e-spec.ts` ruft `runCli([...])` mit dem echten `JobsModule` auf, wie `node dist/cli.js`. `test/daily-evaluation.e2e-spec.ts` ruft `TimeEventsStep.run(Tag)` mit Tagen in den Jahren 2001 und 2002 auf: Der Schritt wertet die ganze geteilte Test-DB aus, so bleiben die Ereignisse paralleler e2e-Dateien unberührt (und es entstehen keine `JobRun`-Zeilen, die `jobs.e2e-spec.ts` zählt). `test/rekey.e2e-spec.ts` setzt nur `*_PREVIOUS`, weil der Job jede Zeile der geteilten Test-DB besucht und die anderen e2e-Prozesse mit denselben aktuellen Schlüsseln weiterlesen müssen.
|
||||||
|
|
||||||
|
### System-Seite
|
||||||
|
|
||||||
|
Die Seite „Verwaltung > System“ (Rechte `system:read` zum Ansehen, `system:manage` zum Wiederholen; nur Systemrollen) zeigt je Job den letzten Lauf (`JobRun`), die Warteschlange der Domänenereignisse (offen, in Arbeit, fehlgeschlagen, Alter des ältesten offenen Eintrags nach DB-Uhr) und die offenen und fehlgeschlagenen Webhook-Zustellungen. Routen: `GET /system/overview`, `GET /system/job-runs?job=&limit=1..100` (Standard 50), `GET /system/domain-events/failed`, `POST /system/domain-events/:id/retry` (204), `GET /system/webhook-deliveries/failed`, `POST /system/webhook-deliveries/:id/retry` (204). Wiederholen ist nur für `FAILED` möglich (409 sonst, 404 unbekannt): Versuche und `availableAt` werden in SQL (`now()`) zurückgesetzt, fehlgeschlagene Handler-Zustellungen eines Ereignisses wieder `PENDING`, ein Audit-Eintrag (UPDATE) geschrieben und der Worker per `pg_notify` geweckt. Die Ansichten enthalten nie Payload, Webhook-Body, Secrets oder vollständige URLs (nur den Host); `lastError` steht wie gespeichert (`loggableError()`). API-Schlüssel erreichen `/system` nie (Scope-Allowlist).
|
||||||
|
|
||||||
|
**Telegram-Karte** (nur mit `system:manage`; Betrieb des Bots: Abschnitt 11.13). `GET /system/notification-deliveries?status=` liefert `telegram` und `deliveries`. `telegram.botNameSet` heißt nur, dass der Bot-Name gesetzt ist: Ob der Versand läuft, weiß die api nicht, weil sie das Token nie hat; er läuft nur im Worker, wenn das Secret vorhanden ist. `receiver`: `leaseOwner` (nur bei laufender Lease), `lastPollAgeSeconds` (DB-Uhr), `lastError`. `sender`: `pending`, `oldestPendingAgeSeconds` (längste Überfälligkeit; wegen der Ruhezeit Wartende zählen nicht), `failed24h` (in den letzten 24 h angelegt und fehlgeschlagen), `lastErrors` (letzte 5, mit `createdAt` = angelegt am). `deliveries`: die neuesten 100 (Status, Kategorie, Vorlage, Versuche, `availableAt`, Statuscode, Fehler, angelegt/zugestellt), nie Chat-ID, Nutzer oder Text. `POST /system/notification-deliveries/:id/retry` (204): nur `FAILED` (409 sonst, 404 unbekannt); setzt `PENDING`, Versuche 0, `availableAt = now()`, schreibt ein Audit und weckt den Worker. `POST /system/notification-deliveries/retry-failed` (200 `{ retried }`): dasselbe für die neuesten `FAILED`, höchstens 100 je Aufruf (bei mehr erneut aufrufen), unter Zeilensperre, ein Audit je Zeile.
|
||||||
|
|
||||||
|
**Telegram-Karte (Oberfläche).** Die Karte erscheint nur mit `system:manage`, weil `GET /system/notification-deliveries` dieses Recht verlangt (die Seite selbst braucht `system:read`). Sie zeigt Bot (`botNameSet`, Name, Hinweis, dass der Versand nur im Worker läuft), Empfang (Lease oder „Kein Empfänger aktiv“, Alter des letzten Abrufs, `lastError` im Wortlaut des Servers), Versand (wartend, ältestes Wartendes mit Warnung ab 600 Sekunden, `FAILED` der letzten 24 Stunden, letzte fünf Fehler mit HTTP-Code) und die neuesten 100 Zustellungen (Status immer mit Wort). Liegt der letzte Abruf länger als 120 Sekunden zurück, erscheint auch mit Lease die feste Warnung „Kein Abruf seit über 2 Minuten“ (`role="status"`), das Alter steht getrennt daneben. „Erneut versuchen“ gibt es nur an `FAILED`-Zeilen; „Alle fehlgeschlagenen erneut versuchen“ fragt per `ConfirmDialog` nach („Mindestens N sind fehlgeschlagen.“, immer „Es werden höchstens 100 gesendet, neueste zuerst.“; Annahme 58). Offline sind die Knöpfe mit sichtbarem Grund „Nur mit Verbindung“ gesperrt. Die Ereignisnamen der Warteschlange stehen in `DOMAIN_EVENT_LABELS`.
|
||||||
|
|
||||||
|
## 11. Logik-Ketten und Saison-Autopilot (T4a bis T4c)
|
||||||
|
|
||||||
|
Regeln, Ketten und der Autopilot schreiben Vorschläge statt Aufgaben; ein Mensch übernimmt oder verwirft. Alles hängt am Ereignis-Rückgrat (Abschnitt 10, Handler-Übersicht) und am Job `daily-evaluation`. Entwurf: Spec `docs/superpowers/specs/2026-10-13-t4-logik-ketten-design.md`. T4b und T4c ergänzen Pflanzenentwicklung und Wetter (11.11), Honig-Kette und Bewegungsbuch (11.12) sowie Telegram (11.13). Noch offen sind die Bestätigung beim Wassergehalt (Regel `HONEY_WATER_CONTENT`) und das Verknüpfen und Abfragen von Telegram (Task 36).
|
||||||
|
|
||||||
|
### 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; Ausnahme: `Apiary` vor `Colony`, Annahme 50).
|
||||||
|
|
||||||
|
| 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`). Der Abschnitt entsteht aus der gespeicherten Zeile `ApiaryPhenology` des laufenden Jahres (Phase aus Meldung, GTS und Kalender, Annahme 36; ein nicht aufsteigender `calendarFallback` fällt auf den Standard). Ohne Zeile oder bei einer Zeile, die älter als 2 Tage ist, gilt der reine Kalenderzustand. `springSince` ist der Tag, an dem `SALWEIDE` dieses Jahres erreicht ist (genähert, siehe Annahme 36), 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“). Wechselt ein Stand seine Saison (`SeasonPhaseChanged`), gleicht `autopilot.apiary-sync` alle seine lebenden Völker ab (`syncApiaryIn`, Sperrreihenfolge Stand, dann alle Völker in einer Anweisung: Annahme 50). Eine Winterprüfung (`WinterCheckRecorded`) gleicht das betroffene Volk sofort ab, sodass A9/A10 ohne Warten auf den Tagesjob verschwinden.
|
||||||
|
|
||||||
|
**Regeln A1–A10** (`src/autopilot/autopilot-rules.ts`, reine Funktion `evaluateAutopilot`, Annahme 48):
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
| A4 | `SUMMER_TREATMENT` (`PLAN`) | Abschnitt `SPAETSOMMER`, keine Behandlung seit Abschnittsbeginn (`SUMMER_TREATMENT_DUE`) | Jahr | heute; `HIGH` |
|
||||||
|
| A5 | `FEED` (`DO`) | `SPAETSOMMER` oder `HERBST`, keine Fütterung seit Beginn von `SPAETSOMMER` (`AUTUMN_FEED_MISSING`); in `HERBST` gilt der Kalendertag von `TRACHTENDE`, höchstens der Beginn von `HERBST` (Annahme 48) | Jahr | heute |
|
||||||
|
| A6 | `FEED` (`DO`) | nicht `WINTERRUHE`; Futter der letzten Durchsicht `WEAK` oder `CRITICAL`, sonst Volkskarte `CRITICAL` (`FEED_LOW`, Beleg `source` `INSPECTION` oder `COLONY_CARD`); keine Fütterung seit der Durchsicht (ohne Durchsicht: seit dem späteren von Anlagetag und heute − 60 Tage) | ID der Durchsicht oder `none` | heute |
|
||||||
|
| A7 | `HONEY_SUPER` (`DO`) | Phase ab `KIRSCHE` bis vor `LINDE` (Reihenfolge `PHENOLOGY_PHASES`), `colonyStrength` einer Durchsicht ab `springSince` des Jahres (sonst ab 1. Januar) ≥ `honeySuperMinFrames` (`HONEY_SUPER_CHECK`) | Jahr | heute |
|
||||||
|
| A8 | `QUEEN_REPLACE` (`PLAN`) | `SCHWARMZEIT` oder `SOMMER`, Königin gealtert (`isQueenAged`, `queenMaxAgeYears`; `QUEEN_OLD`) | `<Königin>-<Jahr>` | heute |
|
||||||
|
| A9 | `WINTER_CHECK` (`DO`) | `HERBST`, kein `WinterCheck` `AUTUMN` mit `winterYear` = Jahr (`WINTER_CHECK_AUTUMN_MISSING`) | `<Jahr>-AUTUMN` | heute |
|
||||||
|
| A10 | `WINTER_CHECK` (`DO`) | `FRUEHJAHR`, Einwinterung des Vorwinters vorhanden, `SPRING` fehlt (`WINTER_CHECK_SPRING_MISSING`) | `<Jahr−1>-SPRING` | heute |
|
||||||
|
|
||||||
|
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; alle Regeln sind einzeln über `config.rules` schaltbar. Der Grund von A4 und A5 nennt die Quelle der Phase aus `SeasonState.phaseSource` (gemeldet, geschätzt, Kalender).
|
||||||
|
|
||||||
|
**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 sowie seit T4b bei der Schleuderung (Handler `withdrawal.extraction-check`, Abschnitt 11.12).
|
||||||
|
- **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 Schleuderung, 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` schreibt dieser Endpunkt nie (nur der Telegram-Sender setzt es auf aus, Abschnitt 11.13). 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`, Saison-Zeile, siehe unten). 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`.
|
||||||
|
|
||||||
|
**Saison-Zeile.** Saisonwechsel kommen nur hier vor, nie als Einzelmeldung (bewusste Abweichung von der Spec, Annahme 47). Die Zeile entsteht aus den `SeasonPhaseChanged`-Ereignissen mit `occurredAt` im Fenster `[von, bis)`: `bis` ist der Beginn des Schritts, `von` das `digestWindowEnd` des jüngsten abgeschlossenen `daily-evaluation`-Laufs (`SUCCEEDED` oder `FAILED`, unter den letzten 10), der eines gemerkt hat, sonst `bis` − 24 Stunden. Der nächste Lauf beginnt damit, wo der vorige aufhörte; jeder Wechsel steht genau einmal da, auch einer, den eine Meldung tagsüber nach 04:30 ausgelöst hat. Ein Lauf, der keine Übersicht schrieb (Wiederholung am selben Tag, kein Inhalt), merkt kein Fensterende, sodass das nächste Fenster weiter zurückreicht. Je Stand werden die Ereignisse zusammengezogen (erster vorheriger bis letzter Abschnitt); reine Phasenwechsel, der erste Zustand eines Jahres (kein vorheriger Abschnitt) und Wechsel ohne Nettoänderung ergeben keine Zeile, ebenso Stände ohne lebende Völker. Ein Wechsel vorwärts lautet „Stand {Name}: {Abschnitt} beginnt. Durchsicht alle {n} Tage.“, ein Rückschritt (zurückgezogene Meldung; `HERBST` → `WINTERRUHE` zählt als vorwärts) neutral „Stand {Name}: wieder {Abschnitt}.“. Bekannte Ränder: Ein manueller Wiederholungslauf am selben Tag schreibt nichts, ein Ereignis, das nach `bis` committet, aber davor gestempelt ist (eine über 04:30 offene Transaktion), kann fehlen; beides ist selten und hingenommen.
|
||||||
|
|
||||||
|
**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“.
|
||||||
|
|
||||||
|
### 11.11 Pflanzenentwicklung und Wetter
|
||||||
|
|
||||||
|
Stand: Schema, Wetterquelle, Berechnung, Job-Schritt, die Meldungs-API (Melden, Bearbeiten, Zurückziehen mit Neuberechnung) und die Oberfläche zum Melden (Offline-Warteschlange eingeschlossen) sind fertig.
|
||||||
|
|
||||||
|
**Tabellen** (Migration `20261015100000_t4_phenology`, Annahme 43). `PhenologyReport` hält eine gemeldete Phase (Stand oder alle Stände, Datum, Rücknahme per `revokedAt`; Index `(apiaryId, phase, date)`). `ApiaryPhenology (apiaryId, year)` ist der abgeleitete Saisonzustand. `ApiaryWeatherDay` ist der Wetter-Cache (Tagesmittel, `source` `forecast` oder `archive`, `fetchedAt`). Rechte `phenology:read`, `phenology:create`, `phenology:update` (Gruppe „Pflanzenentwicklung“). Backup: Abschnitt 8.
|
||||||
|
|
||||||
|
**Wetterquelle.** Open-Meteo, Annahme 42. Die Schnittstelle `WeatherHistorySource` (`src/weather/history/`) kapselt den Adapter `OpenMeteoHistorySource`. Das Tagesmittel ist `temperature_2m_mean` mit `timezone=Europe/Berlin`, also der Mittelwert der Stundenwerte des Berliner Kalendertags, auch an den Tagen der Zeitumstellung mit 23 und 25 Stunden. Die letzten 90 Tage kommen vom Forecast-Endpunkt (`past_days=92`, `forecast_days=1`), ältere Lücken vom Archiv (`start_date`, `end_date`). Das Fenster bestimmt der Dienst, nicht der Adapter. Werte außerhalb −50…50 °C und Nicht-Zahlen werden `null`. Jeder Fehler ist ein `WeatherSourceError` mit festem Text; er enthält weder URL noch Schlüssel noch Ursache. Konfiguration: Abschnitt 9. Jede Anfrage trägt `User-Agent: ApiCore/<Version> (+<WEATHER_USER_AGENT_CONTACT>)`, auch die Wetter-Anzeige der API (`WeatherService`, die dieselbe Konfiguration und Rundung nutzt).
|
||||||
|
|
||||||
|
**Schleife `phenology-weather`** (Abschnitt 10; `WeatherHistoryService.syncOnce`):
|
||||||
|
- Takt stündlich, erster Durchlauf 10 s nach dem Start des Workers.
|
||||||
|
- Aktive Stände mit Koordinaten, gruppiert nach dem auf 0,01° gerundeten Punkt: Stände am selben Punkt teilen eine Anfrage.
|
||||||
|
- Fehlend heißt: keine Zeile vom 1. Januar bis gestern (Berliner Tag), oder `meanTempC` ist in den letzten 90 Tagen `null` (ältere `null`-Werte werden nicht erneut geholt). Ein Stand, der nach dem 1. Januar angelegt wird, bekommt die Tage ab 1. Januar unabhängig vom Anlagedatum. Frühere Jahre für die Kalibrierung werden nicht nachgeholt; die Aufbewahrung beträgt 4 Jahre.
|
||||||
|
- Beide Bereiche eines Punkts werden zuerst geholt, dann in Blöcken von höchstens 200 Upserts je Transaktion geschrieben (`WEATHER_WRITE_CHUNK_SIZE`). Ein Fehler schreibt für den Punkt nichts, wird ohne URL geloggt und im nächsten Takt wiederholt.
|
||||||
|
- Abstand 300 ms zwischen den Punkten. Bei HTTP 429 endet der Durchlauf, und bis Retry-After (Sekunden oder HTTP-Datum, höchstens 24 h) wird auch über Takte hinweg nichts angefragt. Ein 503 mit Retry-After wirkt ebenso, ein 503 ohne ist ein gewöhnlicher Fehler. Drei fehlgeschlagene Punkte hintereinander beenden den Durchlauf (`endedEarly`); ein Erfolg setzt die Zählung zurück.
|
||||||
|
- Korrektur: Eine `forecast`-Zeile, die älter als das 90-Tage-Fenster ist, gilt als fehlend und wird durch den Archivwert ersetzt, höchstens einmal in 7 Tagen (`FORECAST_RECHECK_MS`, nach `fetchedAt`). Hat das Archiv keinen Wert, bleibt der gespeicherte stehen und nur `fetchedAt` wird aktualisiert; ein `null` aus dem Archiv überschreibt nie einen gespeicherten Wert.
|
||||||
|
- Je Durchlauf ein Log mit `points`, `daysWritten`, `failedPoints`, `endedEarly`.
|
||||||
|
- Der Job `cleanup` löscht `ApiaryWeatherDay` vor dem 1. Januar von (Berliner Jahr − 4); Ergebniszähler `weatherDays`.
|
||||||
|
|
||||||
|
**Berechnung.** Annahme 36 beschreibt Phase, GTS, Kalibrierung und Rückfall. Der Job-Schritt `phenology` (Abschnitt 10) und künftig jede Meldung oder Rücknahme rufen `PhenologyStateService.recomputeIn`. Die Funktion sperrt zuerst die Zeile `Apiary` und liest dann die gespeicherte Zeile, lädt die Meldungen der letzten 3 Jahre (Filter in SQL: eigener Stand oder alle Stände, nicht zurückgezogen) und das Wetter des Jahres sowie früherer Jahre mit Kalibrierungsmeldung, kalibriert die Schwellen und schreibt `ApiaryPhenology` per Upsert. Die Kalibrierung eines Vorjahres nimmt die GTS bis zum Vortag des ersten Meldetags, also das, was der Morgen jenes Tages gezeigt hätte. `recomputeAll` liest Regel und Meldungen einmal vorab und rechnet dann je aktivem Stand in einer eigenen Transaktion; ein gescheiterter Stand wird gezählt und geloggt, der Lauf geht weiter. Eine nach dem Vorab-Lesen zurückgezogene Meldung sieht nur die Neuberechnung dieser Meldung selbst. `ApiaryPhenology` speichert keine Notiz, kein `reportedOn` und kein `springSince`; `seasonStateFromRow` (`src/phenology/phenology-row.ts`) leitet sie ab (Notiz aus den Koordinaten, `reportedOn` leer). Die gespeicherte GTS ist nur gesetzt, wenn sie nutzbar war. Beim ersten Zustand eines Jahres wird gegen den Kalenderzustand verglichen, damit ein Rollout mitten in der Saison kein falsches `SeasonPhaseChanged` erzeugt. Der Handler bei `PhenologyCorrected` ruft `recomputeIn` mit `allowBackward: true` auf.
|
||||||
|
|
||||||
|
**Meldungen** (`PhenologyModule`, Annahme 47). Routen: `GET /phenology/state` (`apiaries:read`; je aktivem Stand Zustand ohne Koordinaten, `reportedOn` nur bei Quelle `REPORT` der früheste Meldetag der geltenden Phase), `GET /phenology/reports?year=` (`phenology:read`, Standard laufendes Jahr, auch zurückgezogene mit `revokedAt`, Stand- und Nutzername), `POST /phenology/reports` (`phenology:create`, `@Idempotent()`; Phase, Datum `JJJJ-MM-TT`, `apiaryId` oder `null` für alle, Notiz höchstens 500 Zeichen), `PATCH /phenology/reports/:id` (`phenology:update`; `null` auf Phase oder Datum: 400, auf Stand oder Notiz: leert), `POST /phenology/reports/:id/revoke` (`phenology:update`, `@Idempotent()`, schon zurückgezogen: 409). Datum in der Zukunft: 400 „Das Datum liegt in der Zukunft.“, ungültiger Tag: 400 „Das Datum ist ungültig.“, unbekannter Stand: 404 „Stand nicht gefunden.“, Bearbeiten einer zurückgezogenen Meldung: 409 „Die Meldung wurde schon zurückgezogen.“. Jede Änderung ist eine Transaktion aus Sperre auf die Meldung (`lockRowForUpdate`, danach „vorher“ neu lesen), Schreiben, Audit-Eintrag mit Vorher-Wert und Ereignis; Handler `phenology.recompute` und `phenology.recompute-apiary` (Abschnitt 10) berechnen danach die Stände neu. Saisonwechsel erscheinen in der Morgenübersicht (Abschnitt 11.9).
|
||||||
|
|
||||||
|
**Oberfläche** (Seite eines Standes). Der Kopf `ApiarySeason` zeigt Saisonabschnitt (`SeasonChip`), Phase und Quelle („gemeldet am TT.MM.“, „geschätzt“, „Kalender“) und bei schwacher Wetterbasis einen Hinweis. Das Intervall im Chip („alle 7 Tage“) stammt aus `GET /autopilot/today` (`inspectionDue[].intervalDays`) und fehlt für Stände ohne fällige Durchsicht oder ohne Recht; ein Feld `intervalDays` an `GET /phenology/state` würde es exakt machen (offen, Nachfolgearbeit). Der Knopf „Pflanze blüht“ (`phenology:create`) öffnet das Blatt „Entwicklung melden“ (`PhenologyReportSheet`): acht Zeilen (Icon und Wort, 56 px), ein Tipp meldet die Phase für heute (Berliner Tag), vorgewählt „Für alle Stände“, wahlweise „Nur <Stand>“. Jede Phase und jeder Bereich ist eine eigene Meldung mit eigenem `Idempotency-Key`. Ein Serverfehler bleibt im Blatt sichtbar; während einer Meldung oder Rücknahme lässt es sich nicht schließen. Darunter stehen die Meldungen des Jahres (`phenology:read`; eigener Stand und „alle Stände“, zurückgezogene durchgestrichen) mit „Zurückziehen“ (`phenology:update`, nur online, sonst „Nur mit Verbindung“); das Zurückziehen einer Meldung für alle Stände fragt über den `ConfirmDialog` nach. Offline landet die Meldung im IndexedDB-Speicher `pending-phenology-reports` (`apicore-offline`, Version 4; gleiche Form wie die übrigen Einzel-Warteschlangen mit Schlüssel und Besitzer; Anzeige „Pflanzenmeldung“). Der Sync sendet ihn mit den anderen Warteschlangen (`POST /phenology/reports`, originaler Schlüssel; abgelehnte Einträge bleiben mit der Antwort des Servers stehen). Danach lösen Offline-Band und Warteschlangen-Sheet ein globales `invalidateQueries()` aus, sodass Saisonzustand und Meldungsliste neu laden. Eine Meldung für einen früheren Tag bietet die Oberfläche nicht an (nur die API per PATCH). Dass ein erneutes Öffnen des Blatts dieselbe Phase doppelt melden kann, ist Annahme 55.
|
||||||
|
|
||||||
|
### 11.12 Honig-Kette und Bewegungsbuch
|
||||||
|
|
||||||
|
Stand: Ereignisse, Vorbelegung, Warnungen, Wartezeit bei der Schleuderung, der Webhook, die Laufzeit-Buchungen des Bewegungsbuchs und die Qualitätsprüfung der Abfüllung (API) sind fertig, ebenso die Oberfläche von Schleuderung, Honigcharge und Bewegungsbuch sowie die Abfüllungsmaske mit Artikelwerten und der Wassergehalt-Bestätigung (409).
|
||||||
|
|
||||||
|
**Ereignisse.** `ExtractionRecorded`/`ExtractionCorrected` und `HoneyBatchRecorded`/`HoneyBatchCorrected` entstehen in der Schreib-Transaktion mit dem Audit-Eintrag (Abschnitt 10). Der Webhook `batch.created` kommt über `webhooks.forward` aus `HoneyBatchRecorded`; `HoneyBatchCorrected` sendet nichts, wie vorher beim Ändern.
|
||||||
|
|
||||||
|
**Sperren.** `ExtractionsService.update` und `HoneyBatchesService.update` sperren die Zeile zuerst und lesen „vorher“ danach. Reihenfolge Schleuderung vor Honigcharge: `create` einer Charge sperrt die Schleuderung vor dem Schreiben (`lockRowForUpdate`). `update` liest die Schleuderungs-ID vorab, sperrt die Schleuderungen (bisherige und neue, `lockRowsForUpdate`), dann die Charge und liest sie neu; ist die Charge inzwischen zu einer anderen Schleuderung gewandert, antwortet die Anfrage 409. Die Mengenprüfung der Chargen einer Schleuderung läuft so unter der Schleuderungs-Sperre.
|
||||||
|
|
||||||
|
**Vorbelegung** (nur Vorschläge, Annahme 45; die Antwort-Klassen tragen in Swagger die Beschreibung „Vorschlag, keine Messung – nie stillschweigend übernehmen“):
|
||||||
|
- `GET /extractions/prefill?harvestIds=a,b` (Recht `extractions:create`, 1–50 verschiedene IDs, Komma-Liste oder wiederholter Parameter; vor `:id` deklariert): `amountKg` = Summe der Nettogewichte, nur wenn alle bekannt sind; `honeyType` nur, wenn alle Ernten dieselbe nicht leere Sorte haben; `sources` mit Berliner Tag; Hinweis `HARVEST_NET_MISSING`; Warnung `WITHDRAWAL_ACTIVE`. Unbekannte IDs: 400 mit derselben Meldung wie beim Anlegen.
|
||||||
|
- `GET /honey-batches/prefill?extractionId=` (Recht `honey-batches:create`, vor `:id`): `remainingKg` = Menge der Schleuderung minus Summe der vorhandenen Chargen (nie unter 0, `null` ohne Menge der Schleuderung), `honeyType` der Ernten, `waterContent` der Schleuderung, Warnungen. Unbekannte Schleuderung: 404.
|
||||||
|
- Beide Routen hängen an den Anlege-Rechten, weil sie dieselben Daten liefern wie der Anlege-Ablauf.
|
||||||
|
|
||||||
|
**Plausibilitätswarnungen** (`src/honey-batches/honey-plausibility.ts`; Antwort `warnings` bei `POST` und `PATCH`, nie 4xx; Toleranz 0,5 g; Zahlen deutsch formatiert):
|
||||||
|
- `EXTRACTION_EXCEEDS_HARVEST` (WARNUNG): Menge der Schleuderung größer als die Summe der Nettogewichte; nur wenn alle Nettogewichte bekannt sind.
|
||||||
|
- `HARVEST_NET_MISSING` (HINWEIS): mindestens eine Ernte ohne Nettogewicht, ein Vergleich ist nicht möglich.
|
||||||
|
- `BATCHES_EXCEED_EXTRACTION` (WARNUNG): Summe der Anfangsmengen der Chargen größer als die Menge der Schleuderung. Eine Charge ohne `initialAmount` zählt nicht (auch nicht für den Rest der Vorbelegung); der frühere Rückfall auf `amount` ist entfernt.
|
||||||
|
- `WITHDRAWAL_ACTIVE` bei der Schleuderung: Der Handler `withdrawal.extraction-check` und die Antwort prüfen über `WithdrawalCheckService.conflictsForExtraction`. Die Warnung sieht jeder, der eine Schleuderung erfasst, auch ohne `treatments:read`. Die Meldung nutzt denselben Schlüssel wie Ernte und Abfüllung (Abschnitt 11.8), ein Konflikt wird also nur einmal gemeldet.
|
||||||
|
|
||||||
|
**Bewegungsbuch.** Annahme 44. Schema und Rückrechnung: Migration `20261015100100_t4_honey_ledger`, `BatchMovement.honeyBatchId` (Fremdschlüssel `Restrict`, Index) und `sourceBottlingBatchId` (Fremdschlüssel `SetNull`, Index). Die Invariante `amount = initialAmount + Summe(quantity) / 1000` prüfen die Migration und der Import alter Bundles (Abschnitt 8) mit einer SQL-Abfrage (`INVARIANT_SQL` im Test-Helfer `test/utils/honey-ledger.ts`). Haltbarkeit: `Product.shelfLifeMonths` (Ganzzahl, optional; Migration `20261015100200_t4_product_shelf_life`). Laufzeit-Buchungen: Annahme 44.
|
||||||
|
|
||||||
|
**API des Buchs.** `GET /honey-batches/:id/movements` (`honey-batches:read`; 404 für eine unbekannte Charge) antwortet `{ items, hasMore }` mit höchstens 200 Buchungen, neueste zuerst (`id`, `type`, `quantityGrams`, `reason`, `notes`, `sourceBottlingBatchId`, `createdAt`, `userId`). `POST /honey-batches/:id/movements` (`honey-batches:update`, `@Idempotent()`) nimmt `{ type: 'WITHDRAWAL' | 'CORRECTION', deltaKg, reason?, notes? }` (`deltaKg` endlich, ungleich 0, Betrag höchstens 100 000; Notiz höchstens 500 Zeichen) und antwortet mit der Buchung. Eine `WITHDRAWAL` braucht einen Grund („Bitte einen Grund angeben.“) und eine negative Menge („Eine Entnahme braucht eine negative Menge.“); bei einer `CORRECTION` ist ein Grund erlaubt und wird gespeichert. Eine Charge ohne erfasste Menge ergibt 400 „Die Menge dieser Honigcharge ist nicht erfasst. Bitte zuerst die Menge eintragen.“; ein widersprüchlicher Bestand (`initialAmount` gesetzt, `amount` leer) 409. Die Buchung schreibt Audit `HoneyBatch UPDATE` und `HoneyBatchCorrected`. Die Oberfläche zeigt die Startmenge getrennt von der Liste der Buchungen und liest `.items`.
|
||||||
|
|
||||||
|
**Antwort-Form.** Antworten von `POST/PATCH /extractions` und `/honey-batches` tragen `warnings`. Eine Bestätigung (`acknowledgedWarnings`, 409) gibt es hier nicht; sie gehört zur Abfüllung (nächster Absatz).
|
||||||
|
|
||||||
|
**Qualität vor der Abfüllung** (Annahmen 52 und 53; `src/common/quality/water-content.ts`, `src/bottling/bottling-defaults.ts`). Der Wassergehalt der Honigcharge wird gegen die Regel `HONEY_WATER_CONTENT` geprüft (Standard: Warnschwelle 18 % nach D.I.B. „Echter Deutscher Honig“, Bestätigungsgrenze 20 % nach HonigV Anlage 2; je Honigsorte überschreibbar, etwa `{ "Heidehonig": { confirmAbovePercent: 23 } }`, Sorte ohne Beachtung der Groß-/Kleinschreibung, sonst exakt; ein Eintrag darf nur eine der beiden Grenzen setzen). Es zählt die neueste `HoneyMeasurement` (nach `measuredAt`), sonst `HoneyBatch.waterContent`.
|
||||||
|
- `WATER_CONTENT_HIGH` (WARNUNG): über der Warnschwelle. `WATER_CONTENT_MISSING` (HINWEIS): kein Wert. `WATER_CONTENT_ABOVE_LEGAL` (BESTÄTIGEN): streng über der Bestätigungsgrenze; genau 20,0 % warnt nur mit `WATER_CONTENT_HIGH`. `JAR_SIZE_DIFFERS` (HINWEIS): die gesendete Glasgröße weicht von `Product.packagingSize` ab.
|
||||||
|
- `POST /bottling-batches` antwortet bei `WATER_CONTENT_ABOVE_LEGAL` mit 409 `{ statusCode, code: 'CONFIRMATION_REQUIRED', message, waterContent, warnings }`, solange die Bestätigung fehlt. `warnings` enthält die Qualitätsbefunde der Anfrage (Wassergehalt und Glasgröße), nicht die Wartezeitwarnungen. Die Prüfung läuft in der Transaktion nach der Sperre der Honigcharge und vor dem Schreiben: Bei 409 entstehen weder Abfüllung noch Buchung, Ereignis oder Audit-Eintrag; eine zwischenzeitlich erfasste Messung zählt.
|
||||||
|
- Die bestätigte Wiederholung sendet denselben Body plus `acknowledgedWarnings: ['WATER_CONTENT_ABOVE_LEGAL']` (jeder andere Inhalt: 400) und `acknowledgedWaterContent` (Zahl 0–100) mit dem `waterContent` der 409-Antwort. Die Bestätigung gilt nur, wenn der unter der Sperre geprüfte Wert genau diesem Wert entspricht; sonst (neue Messung, Code ohne Wert, anderer Wert) folgt eine frische 409 mit dem neuen Wert. Der Idempotenz-Schlüssel wird bei Fehlern freigegeben, die Wiederholung darf also denselben `Idempotency-Key` nutzen; eine weitere Anfrage mit diesem Schlüssel nach dem 201 gibt die gespeicherte Antwort wieder.
|
||||||
|
- Die 201-Antwort trägt zuerst die Qualitätsbefunde (auch den bestätigten `WATER_CONTENT_ABOVE_LEGAL`), dann die Wartezeitwarnungen. `PATCH /bottling-batches/:id` und die Messungs-Routen der Honigcharge (`POST` und `PATCH …/measurements`, Antwort mit `warnings`) liefern nie 409. Das `PATCH` meldet Qualitätsbefunde nur, wenn sich Honigcharge oder Glasgröße ändern.
|
||||||
|
- Vorbelegung `GET /bottling-batches/prefill?honeyBatchId=&productId=&date=` (`bottling:create`, vor `:id` deklariert): `{ jarSize, jarSizeSource: 'PRODUCT' | null, bestBeforeDate, shelfLifeMonths, waterContent, warnings }`. Die Glasgröße kommt vom Artikel (`packagingSize`), das MHD aus `Product.shelfLifeMonths` (1–60, beim Anlegen und Ändern des Artikels setzbar, `null` löscht): Abfülltag plus Monate, gekürzt auf das Monatsende (31.01. + 1 Monat = 28.02.); ein Zeitstempel zählt als sein Berliner Tag, `date` ist standardmäßig heute. `honeyBatchId` fehlt: 400; unbekannte Charge oder Artikel: 404; Artikel nicht `BOTTLED_HONEY`: 400. Beim Anlegen gewinnt ein gesendeter Wert, sonst der Artikelwert; fehlt beides, antwortet die API mit 400 („Glasgröße und MHD fehlen. …“, „Ohne Haltbarkeit am Artikel bitte ein MHD eintragen.“, „Ohne Packungsgröße am Artikel bitte eine Glasgröße eintragen.“). Unmögliche Tage (30.02.) in `date`, `bestBeforeDate` und der Vorbelegung ergeben 400 „Das Datum ist ungültig. …“.
|
||||||
|
|
||||||
|
**Oberfläche der Honig-Kette.** Vorbelegungen heißen „Vorschlag aus …“ („aus 2 Ernten“, „aus den Ernten: Raps“, „aus der Schleuderung (Rest)“) und werden nur durch einen Knopf übernommen. Laden die Hinweise nicht, zeigt `PrefillStatus` (feste `aria-live`-Zeile) „Hinweise (z. B. Wartezeit) konnten nicht geladen werden.“ mit „Erneut versuchen“; Speichern bleibt erlaubt, das Backend prüft (Annahme 53). `lib/honey-batches/errors.ts` (`describeSaveError`, `saveErrorText`) ist der einzige Ort, der einen fehlgeschlagenen Speichervorgang liest (400-`issues` je Feld, Nest-`message[]` durch deutschen Text ersetzt, 409-Meldung durchgereicht, Netzfehler). Das Anlegen einer Honigcharge bietet „Wassergehalt (%)“ mit dem Vorschlag aus der Schleuderung. Die Detailseite hat die Karte „Bewegungen“ (Buchungen mit Link zur Abfüllung, Grund und Korrektur mit Vorzeichen; bei `hasMore` der Hinweis „Ältere Buchungen werden nicht angezeigt“). „Entnahme“ (nur `WITHDRAWAL`, Grund Pflicht und nicht vorgewählt, Menge mindestens 1 g und höchstens der Rest) und „Menge korrigieren“ (Ausgangswert beim Öffnen gesperrt, Differenz sichtbar, bei 0 „Keine Abweichung“, Grund optional, Buchung als „Korrektur“ beschriftet) gibt es mit `honey-batches:update` und erfasster Menge. Beide Auslöser sind offline gesperrt („Nur mit Verbindung“) und buchen mit eigenem Schlüssel je Buchung. Alle Abfragen der Detailseite liegen unter dem Schlüsselpräfix `["honey-batches"]`, das beide Dialoge ungültig machen. Die Bestätigung beim Wassergehalt (409) gehört zur Abfüllungsmaske (nächster Absatz), nicht zu diesen Dialogen.
|
||||||
|
|
||||||
|
**Abfüllungsmaske** (`/bottling/new`, Annahme 57). Sie fragt `GET /bottling-batches/prefill` je Honigcharge, Produkt und Datum ab; die letzte Antwort bleibt beim Nachladen stehen, zählt für Beschriftung, Differenz-Hinweise und Wassergehalt aber nur für genau diese Charge, dieses Produkt und diesen Tag. Glasgröße und MHD stammen als Stammdaten vom Artikel (keine Beobachtung) und nennen ihre Herkunft („aus Artikel: 24 Monate“, „aus Artikel: 250 g“, „aus Entwurf“, bei fehlgeschlagenem Abruf „nicht aus Artikel“). Eine eigene Eingabe gewinnt, bis sich das Produkt ändert (dann werden die Eingaben zurückgesetzt); ein Datumswechsel lässt sie stehen. Ein wiederhergestellter Entwurf behält die Kennzeichnung getippter Werte. Weichen Glasgröße oder MHD vom Artikel ab, erscheint der Hinweis schon vor dem Speichern („Glasgröße weicht vom Artikel ab (Artikel: 250 g).“). Wassergehalt der Charge und Server-Hinweise stehen in einem dauerhaften Statusbereich (Symbol plus Wort); `PrefillStatus` meldet Laden und Fehler mit „Erneut versuchen“, und „Abfüllung anlegen“ ist gesperrt, solange die Artikelwerte laden („Artikelwerte werden geladen …“). Die Abfüllung wird nicht offline eingereiht: Offline ist der Knopf mit sichtbarem Grund „Nur mit Verbindung“ gesperrt.
|
||||||
|
|
||||||
|
Antwortet das Anlegen mit 409 `CONFIRMATION_REQUIRED`, öffnet ein Dialog mit Fokusfalle den gemessenen Wert („Wassergehalt 20,5 % über der gesetzlichen Grenze. Trotzdem abfüllen?“). „Trotzdem abfüllen“ (genau eine Anfrage, auch bei Doppelklick) sendet denselben Body mit `acknowledgedWarnings` und `acknowledgedWaterContent` unter demselben `Idempotency-Key`; eine frische 409 mit neuem Wert fragt erneut. „Abbrechen“ schreibt nichts, behält das Formular, gibt den Fokus zurück und erneuert den Schlüssel. Der Schlüssel wird außerdem erneuert, sobald der Body vom zuletzt gesendeten abweicht (auch durch eine geänderte Vorbelegung), und nach einem Erfolg. Fehler beim Speichern liest allein `describeSaveError`.
|
||||||
|
|
||||||
|
**Haltbarkeit und Gewicht am Artikel.** Das Artikelformular (neu und bearbeiten) hat „Haltbarkeit (Monate)“ (ganze Monate 1–60, leerbar) und „Gewicht pro Glas (g)“ (`packagingSize`, ganze Gramm 1–10000). Im Bearbeiten-Dialog wird das Gewicht nur geprüft, wenn es geändert wurde; ein alter Dezimalwert wird nie umgerechnet. Die Prüfung von `packagingSize` im Backend (`@IsInt`, 1–10000) ist ein Folgepunkt, zuvor wird die Produktionsdatenlage geprüft (Rollout-Schritt mit Freigabe).
|
||||||
|
|
||||||
|
### 11.13 Telegram
|
||||||
|
|
||||||
|
Stand: Schema, Client, Prüfung beim Start, Planung, Senden, Verbinden, Empfangen, die Betriebsansicht und die Oberfläche (Profil-Karte, Step-up, Systemkarte) sind fertig (`TelegramModule` importiert `WorkerRuntimeModule` und `WorkerModule`). `NotificationPreference.telegramEnabled` schaltet das Verbinden ein, das Trennen (und der Sender bei 403) aus (Abschnitt 11.9).
|
||||||
|
|
||||||
|
**Tabellen** (Migration `20261015110000_t4_telegram`). Enums `NotificationChannel` und `NotificationDeliveryStatus`. `TelegramLink` (Chat-Bindung eines Nutzers; eindeutig je Chat), `TelegramLinkToken` (Einmal-Token zum Verknüpfen), `NotificationDelivery` (Zustell-Warteschlange), `TelegramPollState` (genau eine Zeile `singleton`, per CHECK `id = 'singleton'`). Alle Fremdschlüssel auf `User` laufen mit `ON DELETE CASCADE`, ebenso `Notification.deliveries`. Keine dieser Tabellen ist Teil des Backups (Abschnitt 8).
|
||||||
|
|
||||||
|
**`NotificationDelivery`.** Eindeutig je `(notificationId, channel)`, Index auf `notificationId`. Der CHECK `NotificationDelivery_target_check` verlangt entweder Meldung und Nutzer oder Vorlage und Chat. Zustellungen mit Vorlage haben `chatId` und kein `userId`; sie entfernt die Nutzer-Kaskade nicht. Ihre Aufräumregeln nach Alter stehen im Job `cleanup` (Abschnitt 10).
|
||||||
|
|
||||||
|
**Dritte Outbox.** `NotificationDelivery` nutzt `src/common/outbox/outbox-claims.ts` wie Ereignisse, Webhooks und Kalender: `claimBatch`, `settleSuccess`, `settleFailure`, `releaseStaleClaims`. Neu sind `deferClaim` (zurück auf `PENDING` nach einer Wartezeit, Versuche unverändert, Wartezeit höchstens 24 h) und `settleSkipped` (`SKIPPED` mit Grund, höchstens 200 Zeichen). Beide sind Compare-and-Set auf `id` + `claimedAt` und nutzen die DB-Uhr. Weckkanal `notification_delivery` (`worker-wakeup.ts`); `NotificationDelivery` steht in der Sperr-Karte ganz zuletzt.
|
||||||
|
|
||||||
|
**`TelegramClient`** (`src/telegram/`; Konfiguration per Token `TELEGRAM_CONFIG`, bereitgestellt von `TelegramModule`). Aufrufe `getUpdates` (`allowed_updates ["message"]`; Updates ohne `message.chat` werden übersprungen) und `sendMessage`. Sicherheit (Annahme 46, Abschnitt 9):
|
||||||
|
- Der Token steht nie in Logs, Fehlern oder `lastError`. `TelegramApiError` trägt Methode, Status, bereinigte Beschreibung und `retry_after`; `describeTelegramFailure` folgt `cause` höchstens 5 Ebenen tief und gibt nie die Meldung zurück.
|
||||||
|
- Nur HTTPS zu `api.telegram.org`; `redirect: 'error'`, eine Weiterleitung ergibt „network error“ ohne URL und Token; Antwortkörper werden gestreamt und bei 256 kB abgebrochen.
|
||||||
|
- `retry_after` wird auf 1–3600 s begrenzt (Nicht-Ganzzahlen und Texte werden `null`).
|
||||||
|
- Der Text wird bereinigt (`sanitizeTelegramText`): Steuerzeichen und die Richtungs- und Nullbreitenzeichen U+200B–200F, U+202A–202E, U+2066–2069 entfernt, höchstens 600 Zeichen nach Codepunkten (kein halbes Surrogatpaar) mit Auslassungspunkten; Klartext, kein `parse_mode`.
|
||||||
|
- Ein fehlerhaft geformter Token macht den Client unbrauchbar („bot token not configured“), ohne dass er ruft.
|
||||||
|
- `assertTelegramToken(role, env, logger?)` (`src/runtime/startup-checks.ts`) prüft nur die Rollen `worker` und `all`. Ein fehlender Token ist in Ordnung. Ein fehlerhafter (in Produktion auch ein Platzhalter wie `change-me…`) wirft nie: Fehler-Log ohne Wert, Telegram bleibt aus. Ein Benutzername ohne Token oder umgekehrt warnt nur im Log.
|
||||||
|
- `TELEGRAM_BOT_USERNAME` verliert ein führendes `@` und muss `^[A-Za-z][A-Za-z0-9_]{4,31}$` erfüllen, sonst gilt er als nicht gesetzt (`telegramBotAvailable()` falsch) mit Warnung.
|
||||||
|
- Der Wächter `no-network-outside-loops.spec.ts` hält fest, dass das Netzwerk nur in den erlaubten Dateien und nur aus Schleifen genutzt wird (Abschnitt 9).
|
||||||
|
|
||||||
|
**Planung.** `NotificationDeliveryPlanner.planIn(db, notifications)` (Abschnitt 10, „Zustellplanung“) liest in einer Abfrage die Einstellungen (`telegramEnabled`, Verbindung, `telegramCategories`) und die DB-Uhr, legt per `createMany({ skipDuplicates })` (eindeutig je Meldung und Kanal) die Zustellungen an und weckt den Worker nur, wenn Zeilen entstanden. Standardkategorien `TASK_DUE` und `DIGEST`; `SUGGESTION`, `WITHDRAWAL`, `SEASON` und `SYSTEM` schaltet der Nutzer zu. Der Text wird nicht gespeichert, sondern beim Senden aus der Meldung gerendert. Die Zustellzeit (`availableAt`) folgt Annahme 49 (Morgenzeit, Ruhezeit, Zeitumstellung).
|
||||||
|
|
||||||
|
**Texte** (`src/telegram/telegram-texts.ts`). `TELEGRAM_TEMPLATES` hält die festen Texte, `renderTelegramText` baut daraus den Klartext: ohne Details nur „Neue Meldung in ApiCore.“ und Link, Standardlink `/dashboard`, ein Schrägstrich am Ende von `FRONTEND_ORIGIN` wird toleriert. Höchstens 600 Zeichen nach Codepunkten; die Meldung wird mit Auslassungspunkten gekürzt, der Link bleibt ganz, Steuer- und unsichtbare Zeichen entfallen. Ohne `FRONTEND_ORIGIN` entfällt der Link (Annahme 46).
|
||||||
|
|
||||||
|
**Senden** (`TelegramSender`, Schleife `telegram-send`, Abschnitt 10). Ein Durchlauf übernimmt höchstens 25 Zustellungen (`claimBatch`, älteste zuerst). Je Batch geht höchstens eine Zustellung je Nutzer bzw. Chat hinaus, die übrigen kommen 1,1 s später (`deferClaim`); ein Speicher je Chat (10 s) hält auch aufeinanderfolgende Durchläufe und Bot-Antworten auseinander, und zwischen zwei Sendungen liegen mindestens 40 ms (25 pro Sekunde). `drain` wartet nach einer Runde mit zurückgehaltenen Nachrichten 1,1 s, damit ein Schwall für eine Person nicht auf den nächsten 5-s-Takt wartet; ein Batch ist auf 120 s begrenzt (`TELEGRAM_BATCH_BUDGET_MS`), danach gehen die übrigen ohne Fehlversuch zurück, ebenso beim Herunterfahren. Vor jeder Sendung prüft eine SQL-Abfrage mit frisch gelesenen Rechten (`PermissionResolverService.getFreshPermissions`) die Gründe aus Annahme 49 in dieser Reihenfolge: `user inactive`, `telegram disabled`, `not linked`, `category off`, `permission revoked`, `stale`, `task already done`, dazu `notification gone` und `unknown template`; ein Treffer ergibt `SKIPPED` mit Grund. Antworten des Bots (`templateCode`) überspringen alle Prüfungen. Die Frische rechnet in SQL (Berliner Tag bzw. 24 Stunden gegen `now()`).
|
||||||
|
|
||||||
|
**Antworten von Telegram.**
|
||||||
|
- 200: `DELIVERED`.
|
||||||
|
- 429: `deferClaim` um `retry_after` (ohne Angabe 5 s), kein Fehlversuch; der Sender pausiert für den ganzen Pod bis dahin (`pausedUntil`, der Rest des Batches geht zurück).
|
||||||
|
- 403 und 400 „chat not found“ (Bot blockiert, Chat gelöscht): Bei einer Nutzer-Zustellung sperrt eine Transaktion `NotificationPreference` (`lockRowForUpdate`), entfernt über den gemeinsamen Helfer `removeLinkIn` (siehe „Befehle“) die `TelegramLink` mit `userId` und `chatId`, schaltet nur dann `telegramEnabled` aus und schreibt die Meldung „Telegram-Zustellung beendet. Der Bot wurde blockiert oder der Chat gelöscht.“ (`SYSTEM`, HINWEIS), wenn die Verbindung noch auf diesen Chat zeigt; die Zustellung wird `SKIPPED` („chat unavailable“). Antworten des Bots werden nur `SKIPPED`.
|
||||||
|
- 401 (Token ungültig): `deferClaim` um 15 min, der Sender pausiert 15 min (monotone Uhr, kein Claim), und `system:manage` bekommt einmal je Berliner Tag „Telegram: Bot-Token ungültig.“ (Schlüsselpräfix `telegram-token-invalid:<Tag>`). Scheitert die Meldung, wird das nur geloggt.
|
||||||
|
- Alles andere (5xx, Zeitüberschreitung, Netzfehler, sonstige 4xx, unerwartete Ausnahmen): `settleFailure` mit `loggableError(...).message`, Wiederholung nach der Outbox-Regel (30 s bis 6 h, nach 8 Versuchen `FAILED`).
|
||||||
|
|
||||||
|
**Verbinden.** `POST /profile/telegram/link-token` (jeder angemeldete Nutzer; Step-up mit aktuellem Passwort und bei MFA TOTP; 10 pro Minute; 201; nicht `@Idempotent`, weil die Antwort ein Geheimnis enthält; `Cache-Control: no-store`) antwortet `{ deepLink, expiresAt }` mit `https://t.me/<Bot>?start=<Token>`. Ohne `TELEGRAM_BOT_USERNAME` ergibt es 409 „Telegram ist nicht eingerichtet.“; das Bot-Token kennt die api nie. Das Token besteht aus 32 Zufallsbytes (Base64url, 43 Zeichen), gespeichert wird nur der SHA-256. Es gilt 10 Minuten (Ablauf nach DB-Uhr) und einmal; ein neues entwertet ältere offene Token desselben Nutzers. Der Job `cleanup` löscht Token, die seit mehr als 24 h abgelaufen oder verbraucht sind. `POST /profile/telegram/unlink` (204) trennt.
|
||||||
|
|
||||||
|
**Empfangen** (`TelegramUpdatesService.pollOnce`, Schleife `telegram-updates`, Abschnitt 10). Long Polling (`getUpdates`, `timeout=25`, `allowed_updates ['message']`), kein öffentlicher Eingang, nur private Chats. Genau ein Empfänger: eine Lease in `TelegramPollState` (60 s, nur DB-Uhr, jede Runde verlängert); wer sie nicht hat, wartet 30 s, ebenso nach 409 und nach Fehlern. Das setzt ein einzelnes Worker-Replikat voraus (Abschnitt 7). Jedes Update läuft in einer eigenen Transaktion, in der zuerst der Offset (`update_id + 1`) per Compare-and-Set auf Lease-Inhaber und alten Stand weitergeschoben wird; geht die Lease verloren, bricht die Transaktion samt Wirkung ab, jedes Update wirkt genau einmal. Nicht auswertbare Updates (etwa ohne `message.chat`) schiebt der Offset ohne Wirkung weiter, auch am Ende eines Batches (`getUpdates` liefert `{ updates, highestUpdateId }`). Ein Update, dessen Wirkung immer scheitert, wird nach 5 Versuchen oder 10 Minuten übersprungen (Annahme 51). Verbindungsfehler (Datenbank oder Netz nicht erreichbar, auch `TelegramApiError` ohne Status) werden gespeichert, zählen aber nie; die Zähler werden nach jedem Batch bereinigt (Einträge hinter dem Offset und ältere als das Fenster). Grenzen und Uhr kommen über das optionale Token `TELEGRAM_UPDATES_OPTIONS` (`{ attempts, windowMs, now }`) in den Dienst. Eine Antwort mit Einträgen, aber ohne `update_id`, gilt als Fehler (`lastError` „getUpdates answer without update ids“, Offset bleibt, 30 s Wartezeit). Auf 429 wartet der Empfang bis `retry_after` (ohne Angabe 30 s, höchstens 1 Stunde), ohne DB und Telegram anzufassen. Beim Beenden bricht das Stop-Signal den Abruf ab; `WorkerRuntime.onModuleDestroy` ruft nach dem Stopp der Schleifen `releaseLease()` auf (ohne gehaltene Lease ohne Wirkung, auch bei einem Stopp während der 30-s-Wartezeit), der nächste Empfänger kommt sofort zum Zug. `lastPollAt` steht nach dem Abruf; `lastError` (fester Text plus Klassenname, nie IDs oder Nachrichtentext) wird erst nach einem Batch ohne Fehler und ohne Überspringen gelöscht.
|
||||||
|
|
||||||
|
**Befehle.** `/start <Token>` (auch `/start@Bot <Token>`) verbraucht das Token per Compare-and-Set (nur offen und nicht abgelaufen), legt die Präferenz an, sperrt `NotificationPreference` aller betroffenen Nutzer in einer Anweisung (aufsteigend) und liest Verbindung und Präferenz erst danach (dieselbe Sperre wie der 403-Pfad des Senders). Es ersetzt eine frühere Verbindung desselben Nutzers (`linkedAt` beginnt neu), löst die Verbindung eines anderen Nutzers mit demselben Chat (Meldung „Deine Telegram-Verbindung wurde getrennt, weil der Chat mit einem anderen Konto verbunden wurde.“), schaltet Telegram ein und schreibt die Meldung „Telegram verbunden mit „{Vorname}“ am TT.MM.JJJJ. Warst du das nicht? In den Einstellungen trennen.“ (deckt einen mitgelesenen Link auf; Vorname bereinigt, höchstens 64 Zeichen). Ist Nutzer oder Rolle inaktiv, oder das Token ungültig, abgelaufen oder benutzt, antwortet der Bot `LINK_INVALID` ohne Hinweis auf Konten. `/stop` und „Trennen“ in der App entfernen die Verbindung und senden `STOPPED`. `/hilfe`, `/help`, `/start` ohne Token und anderer Text ergeben `HELP`. Antworten des Bots werden als `NotificationDelivery` mit `templateCode` und `chatId` eingereiht (kein Netzaufruf in der Transaktion) und vom Sender verschickt. Höchstens 3 Antworten je Chat in 10 Minuten (Zähler im Speicher, gezählt erst nach dem Commit); `LINKED` ist ausgenommen (Annahme 54). Jede Beendigung einer Verbindung (App, `/stop`, Chat zieht zu einem anderen Konto um, Bot blockiert oder Chat gelöscht im Sender) läuft über denselben Helfer `removeLinkIn` (`src/telegram/telegram-link-removal.ts`): Verbindung löschen (bei Bedarf nur, wenn sie noch auf diesen Chat zeigt), `telegramEnabled = false` und die wartenden Telegram-Zustellungen des Nutzers (`PENDING`, `PROCESSING`) in derselben Transaktion auf `SKIPPED` mit `claimedAt` leer und Grund `unlinked`, `chat unavailable` oder `relinked`. Wird die Verbindung eines Nutzers auf einen anderen Chat umgestellt, verfallen die wartenden Zustellungen ebenfalls (`relinked`); derselbe Chat noch einmal ändert nichts. Alte Nachrichten erreichen so nie einen später verbundenen Chat. Datenschutz: Vorname, Chat-ID und Token stehen nie in Logs; die Chat-ID verlässt den Server nicht (auch die Einstellungs-Antwort enthält sie nicht). Je Umgebung ein eigener Bot, weil ein Bot nur einen Empfänger haben darf.
|
||||||
|
|
||||||
|
**Betrieb.** Die Karte „Telegram“ unter Verwaltung > System (Abschnitt 10) zeigt Empfänger (Lease, Alter des letzten Abrufs, `lastError`) und Sender (wartend, überfällig, fehlgeschlagen in 24 h, letzte Fehler) und erlaubt das Wiederholen fehlgeschlagener Zustellungen. Die Einstellungsansicht (`GET /profile/notification-settings`) nennt das Feld `telegram.botNameSet` (Bot-Name gesetzt; das Token kennt die api nicht), derselbe Begriff wie am System-Endpunkt. Verdrahtung des Secrets und Einrichtung des Bots: Abschnitt 7.
|
||||||
|
|
||||||
|
**Oberfläche: Profil > Telegram** (`TelegramLinkCard`). Der Zustand steht in einer Statuszeile (`role="status"`): „Telegram ist nicht eingerichtet.“ (ohne `botNameSet`, dann ohne Knopf), „Nicht verbunden“ oder „Verbunden mit „{Vorname}“ seit {TT.MM.JJJJ}“. „Telegram verbinden“ öffnet den `StepUpDialog`; danach erscheint der große Knopf „In Telegram öffnen“ (`target="_blank"`, `rel="noopener noreferrer"`) mit „Gültig bis {HH:MM}“ (Berliner Zeit), und der Fokus springt auf diesen Knopf; nach „Trennen“ (`ConfirmDialog`) springt er auf „Telegram verbinden“. Der Deep Link ist ein Geheimnis: Er wird nur gezeigt, wenn er mit `https://t.me/` beginnt (sonst „Der Telegram-Link ist ungültig. …“), liegt nur im Zustand der Komponente (nie im React-Query-Cache, in `localStorage`/IndexedDB oder im Log) und verschwindet bei Ablauf oder sobald der Status „verbunden“ meldet. Solange ein Link gültig und noch nicht verbunden ist, lädt die Karte den Status alle 5 Sekunden neu. Verbundene Nutzer wählen die sechs Kategorien (`ToggleChip`, während des Speicherns und offline aus) und den Schalter „Mit Details“ mit dem Hinweis „Nachrichten laufen über Server von Telegram und sind dort nicht Ende-zu-Ende verschlüsselt.“; jede Änderung geht einzeln per `PATCH`. Verbinden, Trennen und Speichern sind offline gesperrt („Nur mit Verbindung“).
|
||||||
|
|
||||||
|
**Step-up** (`src/components/auth/step-up-dialog.tsx`, geteilt mit dem Backup-Import; `pendingLabel`, beim Import „Wird eingespielt…“). Der Dialog fragt das aktuelle Passwort und bei MFA den 6-stelligen Code; `onConfirm(proof)` darf werfen, dann bleibt er offen, leert die Felder, fokussiert das Passwortfeld und zeigt den deutschen Grund in einer dauerhaften Live-Region. Während der Ausführung sind die Eingaben `readOnly`, Esc, Hintergrund und Schließen-Knopf wirkungslos. Die Felder leben nur, solange der Dialog offen ist. Ein fehlgeschlagener Import erscheint im Dialog statt als Toast. Alle Step-up-Aufrufe senden mit `refreshOn401: false` (Annahme 56): `createTelegramLinkToken`, `importBackup`, `changePassword`, `deactivateAccount`, `confirmMfaEnrollment` und `disableMfa`. `stepUpErrorText` (`src/lib/auth/step-up-error.ts`, auch im Passwortformular des Profils) liefert: 429 „Zu viele Versuche. Bitte in einer Minute noch einmal.“, ein 401 ohne eigene Meldung „Sitzung abgelaufen. Bitte die Seite neu laden und erneut anmelden.“, deutsche Server-Meldungen unverändert. Der Client kennt den Ablauf des Tokens nicht und erneuert vor Step-up-Aufrufen nicht vorab.
|
||||||
|
|
||||||
|
Logs nennen nur Zustellungs-IDs, nie Token, Chat-ID oder Text.
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
# A stale build info makes `nest build` emit only changed files into the
|
||||||
|
# freshly deleted dist (tsconfig.json: incremental).
|
||||||
|
*.tsbuildinfo
|
||||||
generated
|
generated
|
||||||
coverage
|
coverage
|
||||||
.devdb
|
.devdb
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
DATABASE_URL="postgresql://user:password@localhost:5432/apicore?schema=public"
|
DATABASE_URL="postgresql://user:password@localhost:5432/apicore?schema=public"
|
||||||
|
|
||||||
# Mindestens 32 Zeichen (server-seitig erzwungen, siehe main.ts) - signiert
|
# Mindestens 32 Zeichen (server-seitig erzwungen, siehe
|
||||||
# jedes Access-/MFA-/SSO-Token UND verschluesselt gespeicherte TOTP-Secrets.
|
# src/runtime/startup-checks.ts) - signiert jedes Access-/MFA-/SSO-Token.
|
||||||
|
# TOTP- und Webhook-Secrets aus der Zeit vor den eigenen Schluesseln (unten)
|
||||||
|
# sind noch damit verschluesselt: erst nach dem rekey-Job rotieren.
|
||||||
# Erzeugen z.B. mit: openssl rand -hex 32
|
# Erzeugen z.B. mit: openssl rand -hex 32
|
||||||
JWT_ACCESS_SECRET="change-me-to-a-random-value-at-least-32-characters-long"
|
JWT_ACCESS_SECRET="change-me-to-a-random-value-at-least-32-characters-long"
|
||||||
JWT_REFRESH_SECRET="change-me-refresh-secret"
|
JWT_REFRESH_SECRET="change-me-refresh-secret"
|
||||||
@@ -22,7 +24,7 @@ FRONTEND_ORIGIN="http://localhost:3000"
|
|||||||
# trusted (client IP for rate limits and API-key IP allowlists). "1" for the
|
# trusted (client IP for rate limits and API-key IP allowlists). "1" for the
|
||||||
# single K8s ingress; a list such as "loopback, 10.0.0.0/8" also works.
|
# single K8s ingress; a list such as "loopback, 10.0.0.0/8" also works.
|
||||||
# Behind Cloudflare + Traefik use "loopback, <pod CIDR>, <Cloudflare ranges>"
|
# Behind Cloudflare + Traefik use "loopback, <pod CIDR>, <Cloudflare ranges>"
|
||||||
# (see deploy/k8s/backend/configmap.example.yaml); "1" would yield the
|
# (see deploy/k8s/base/backend/configmap.example.yaml); "1" would yield the
|
||||||
# Cloudflare edge IP that many clients share.
|
# Cloudflare edge IP that many clients share.
|
||||||
TRUST_PROXY=1
|
TRUST_PROXY=1
|
||||||
|
|
||||||
@@ -54,11 +56,25 @@ BACKUP_CRON_SCHEDULE="0 3 * * *"
|
|||||||
# Wie viele der neuesten Backup-Dateien behalten werden, aeltere werden nach
|
# Wie viele der neuesten Backup-Dateien behalten werden, aeltere werden nach
|
||||||
# jedem Lauf geloescht (Standard: 14).
|
# jedem Lauf geloescht (Standard: 14).
|
||||||
BACKUP_RETENTION_COUNT=14
|
BACKUP_RETENTION_COUNT=14
|
||||||
# Backups are encrypted (AES-256-GCM). Without this key they are sealed with a
|
|
||||||
# key derived from JWT_ACCESS_SECRET and restore only into this deployment.
|
# Encryption keys, one per purpose (AES-256-GCM, key derived per purpose with
|
||||||
# Set the same value on another deployment to restore there. At least 32
|
# HKDF): TOTP secrets, webhook signing secrets, backup files. Required in
|
||||||
# characters, e.g. "openssl rand -hex 32". Keep it outside the backup storage.
|
# production: at least 32 characters each, all three different from each
|
||||||
|
# other and from JWT_ACCESS_SECRET/JWT_REFRESH_SECRET, e.g.
|
||||||
|
# "openssl rand -hex 32". Outside production a missing key falls back to one
|
||||||
|
# derived from JWT_ACCESS_SECRET.
|
||||||
|
# Rotation: move the old value to <NAME>_PREVIOUS (still opens), set a new
|
||||||
|
# <NAME>, run the rekey job, then drop <NAME>_PREVIOUS. Backup files are not
|
||||||
|
# resealed: keep BACKUP_ENCRYPTION_KEY_PREVIOUS while older files must restore.
|
||||||
|
# Set the same BACKUP_ENCRYPTION_KEY on another deployment to restore its
|
||||||
|
# backups there (MFA_/WEBHOOK_ENCRYPTION_KEY too, to keep MFA and webhook
|
||||||
|
# secrets). Keep the backup key outside the backup storage.
|
||||||
|
# MFA_ENCRYPTION_KEY=""
|
||||||
|
# WEBHOOK_ENCRYPTION_KEY=""
|
||||||
# BACKUP_ENCRYPTION_KEY=""
|
# BACKUP_ENCRYPTION_KEY=""
|
||||||
|
# MFA_ENCRYPTION_KEY_PREVIOUS=""
|
||||||
|
# WEBHOOK_ENCRYPTION_KEY_PREVIOUS=""
|
||||||
|
# BACKUP_ENCRYPTION_KEY_PREVIOUS=""
|
||||||
|
|
||||||
# Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert
|
# Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert
|
||||||
# (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start).
|
# (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start).
|
||||||
@@ -84,3 +100,39 @@ OIDC_REDIRECT_URI="http://localhost:3001/api/v1/auth/sso/callback"
|
|||||||
# gesamte API-Schema ungeschuetzt (kein JwtAuthGuard) preisgeben wuerde.
|
# gesamte API-Schema ungeschuetzt (kein JwtAuthGuard) preisgeben wuerde.
|
||||||
# Nur fuer lokale Entwicklung/Debugging explizit aktivieren.
|
# Nur fuer lokale Entwicklung/Debugging explizit aktivieren.
|
||||||
ENABLE_SWAGGER=false
|
ENABLE_SWAGGER=false
|
||||||
|
|
||||||
|
# Laufrolle des Prozesses: api (nur HTTP), worker, job (CLI) oder all (API +
|
||||||
|
# Worker in einem Prozess, nur fuer lokale Entwicklung und Tests). Ohne Wert
|
||||||
|
# gilt der Standard des Einstiegspunkts (main: all, worker: worker, cli: job).
|
||||||
|
# In Produktion Pflicht und "all" ist dort verboten.
|
||||||
|
# APICORE_ROLE=api
|
||||||
|
# Port des Health-Endpunkts des Worker-Prozesses.
|
||||||
|
# WORKER_HEALTH_PORT=3002
|
||||||
|
|
||||||
|
# Wetterquelle fuer die Pflanzenentwicklung (Gradtagszahl) und die
|
||||||
|
# Wetteranzeige. Einzige Quelle ist die freie Open-Meteo-API (bewusst
|
||||||
|
# uebernommenes Lizenzrisiko, nicht-kommerzielle Bedingungen); der Adapter
|
||||||
|
# (src/weather/history) erlaubt einen Wechsel. Nur https:-URLs; ein
|
||||||
|
# unbekannter Quellenname oder eine http:-URL stoppt den Start. Der Schluessel
|
||||||
|
# ist optional, gehoert in ein Secret und steht nie in Logs oder Fehlermeldungen.
|
||||||
|
# WEATHER_HISTORY_SOURCE=open-meteo
|
||||||
|
# OPEN_METEO_FORECAST_URL=https://api.open-meteo.com/v1/forecast
|
||||||
|
# OPEN_METEO_ARCHIVE_URL=https://archive-api.open-meteo.com/v1/archive
|
||||||
|
# OPEN_METEO_API_KEY=
|
||||||
|
# Optional: Kontakt (E-Mail oder URL) fuer den User-Agent-Header der
|
||||||
|
# Wetteranfragen ("ApiCore/<Version> (+<Kontakt>)"); ohne Wert
|
||||||
|
# "ApiCore/<Version> (self-hosted beekeeping app)".
|
||||||
|
# WEATHER_USER_AGENT_CONTACT=
|
||||||
|
|
||||||
|
# Telegram-Erinnerungen (optional, T4c). TELEGRAM_BOT_USERNAME (ohne @, 5-32
|
||||||
|
# Zeichen: Buchstaben, Ziffern, Unterstrich) steht in API und Worker und
|
||||||
|
# erzeugt den Deep Link. TELEGRAM_BOT_TOKEN (BotFather) gehoert NUR dem Worker,
|
||||||
|
# im Cluster in das eigene Secret apicore-telegram; der API-Pod darf es nicht
|
||||||
|
# einbinden. Ein fehlerhaftes Token stoppt nichts: Telegram bleibt aus, der
|
||||||
|
# Worker loggt einen Fehler (nie den Wert). Bei BotFather Gruppen ausschalten
|
||||||
|
# (/setjoingroups) und keinen Webhook setzen.
|
||||||
|
# Je Umgebung ein eigener Bot. Im Cluster steht der Name in der ConfigMap
|
||||||
|
# (TELEGRAM_BOT_USERNAME, leer = Telegram in der App nicht eingerichtet); den
|
||||||
|
# Zustand zeigt Verwaltung > System (Telegram-Karte, nur system:manage).
|
||||||
|
# TELEGRAM_BOT_USERNAME=
|
||||||
|
# TELEGRAM_BOT_TOKEN=
|
||||||
@@ -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/
|
||||||
+24
-10
@@ -1,6 +1,10 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
FROM node:22-alpine AS builder
|
# Base-Image per Digest gepinnt (node:22-alpine, Multi-Arch-Index vom
|
||||||
|
# 2026-09-23): ein Rebuild zieht nie ungeprueft ein neues Image. Ein Update
|
||||||
|
# ist ein bewusster Commit mit neuem Digest, ermittelt per
|
||||||
|
# docker buildx imagetools inspect node:22-alpine
|
||||||
|
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
@@ -14,16 +18,16 @@ RUN npx prisma generate
|
|||||||
RUN npx eslint "{src,test}/**/*.ts" && npm run typecheck && npm test -- --ci
|
RUN npx eslint "{src,test}/**/*.ts" && npm run typecheck && npm test -- --ci
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:22-alpine AS runtime
|
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS runtime
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY --from=builder /app/generated ./generated
|
COPY --from=builder /app/generated ./generated
|
||||||
# schema.prisma, migrations/ und prisma.config.ts: noetig fuer
|
# schema.prisma, migrations/ und prisma.config.ts: noetig fuer den
|
||||||
# `npm run migrate:deploy`, das der Container beim Start selbst ausfuehrt
|
# initContainer `migrate` (siehe Kommentar ueber CMD unten).
|
||||||
# (siehe CMD unten). seed.ts selbst wird NICHT zur Laufzeit ausgefuehrt -
|
# seed.ts selbst wird NICHT zur Laufzeit ausgefuehrt -
|
||||||
# `npm run build` kompiliert es vorab zu dist/seed.js (siehe
|
# `npm run build` kompiliert es vorab zu dist/seed.js (siehe
|
||||||
# tsconfig.seed.json), das schon in ./dist enthalten ist. Frueher lief
|
# tsconfig.seed.json), das schon in ./dist enthalten ist. Frueher lief
|
||||||
# `ts-node prisma/seed.ts` direkt im Container: der TypeScript-Compiler
|
# `ts-node prisma/seed.ts` direkt im Container: der TypeScript-Compiler
|
||||||
@@ -31,9 +35,19 @@ COPY --from=builder /app/generated ./generated
|
|||||||
# fuehrte zu sporadischen OOM-Crashes beim Start.
|
# fuehrte zu sporadischen OOM-Crashes beim Start.
|
||||||
COPY --from=builder /app/prisma ./prisma
|
COPY --from=builder /app/prisma ./prisma
|
||||||
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
|
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
|
||||||
EXPOSE 3001
|
# 3001: HTTP der api-Rolle. 3002: /health der worker-Rolle.
|
||||||
|
EXPOSE 3001 3002
|
||||||
USER node
|
USER node
|
||||||
# migrate deploy ist idempotent und nutzt einen DB-seitigen Advisory-Lock,
|
# Migration und Seed laufen nicht mehr hier, sondern als initContainer im
|
||||||
# seed ist idempotent per Unique-Constraint auf User.email (siehe seed.ts) -
|
# Pod (deploy/k8s/components/backend-roles): `migrate` in api und worker
|
||||||
# beides unproblematisch bei mehreren gleichzeitig startenden Pod-Replicas.
|
# (`node node_modules/prisma/build/index.js migrate deploy`, idempotent mit
|
||||||
CMD ["sh", "-c", "npm run migrate:deploy && npm run seed && node dist/main.js"]
|
# DB-seitigem Advisory-Lock), `seed` nur in der api (`node dist/seed.js`,
|
||||||
|
# idempotent per Unique-Constraint auf User.email). Scheitert die Migration,
|
||||||
|
# startet der neue Pod nicht und der alte laeuft weiter. Der Hauptprozess
|
||||||
|
# startet ohne Prisma-CLI; worker und Jobs ueberschreiben das Kommando
|
||||||
|
# (dist/worker.js, dist/cli.js <job>), die Rolle waehlt APICORE_ROLE.
|
||||||
|
# Dieses Image darf darum nur zusammen mit components/backend-roles live
|
||||||
|
# gehen (Runbook Schritt 3). Fehlt APICORE_ROLE, bricht der Start in
|
||||||
|
# Produktion absichtlich ab. APICORE_ROLE nicht von Hand in ein Deployment
|
||||||
|
# ohne die initContainer setzen: dann liefe keine Migration.
|
||||||
|
CMD ["node", "dist/main.js"]
|
||||||
Generated
+21
-35
@@ -15,7 +15,6 @@
|
|||||||
"@nestjs/jwt": "^11.0.2",
|
"@nestjs/jwt": "^11.0.2",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/schedule": "^12.0.1",
|
|
||||||
"@nestjs/swagger": "^11.4.7",
|
"@nestjs/swagger": "^11.4.7",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@prisma/adapter-pg": "^7.9.1",
|
"@prisma/adapter-pg": "^7.9.1",
|
||||||
@@ -24,12 +23,14 @@
|
|||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cron": "^4.4.0",
|
||||||
"helmet": "^8.3.0",
|
"helmet": "^8.3.0",
|
||||||
"openid-client": "^6.8.4",
|
"openid-client": "^6.8.4",
|
||||||
"otplib": "^13.5.0",
|
"otplib": "^13.5.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"pdfkit": "^0.19.1",
|
"pdfkit": "^0.19.1",
|
||||||
|
"pg": "^8.23.1",
|
||||||
"prisma": "^7.9.1",
|
"prisma": "^7.9.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/pdfkit": "^0.17.6",
|
"@types/pdfkit": "^0.17.6",
|
||||||
|
"@types/pg": "^8.23.1",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/supertest": "^7.0.0",
|
"@types/supertest": "^7.0.0",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
@@ -2478,22 +2480,6 @@
|
|||||||
"@nestjs/core": "^11.0.0"
|
"@nestjs/core": "^11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nestjs/schedule": {
|
|
||||||
"version": "12.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-12.0.1.tgz",
|
|
||||||
"integrity": "sha512-v0z/li9L8oxpvuIDAGm5uOLaZ8KzsfZRzgWgppvs5vClneSinZP5IFl3C1F1DCeTCkNLiGN9g/oLUvzg8x++RQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"cron": "4.4.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.19.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@nestjs/common": "^11.0.0 || ^12.0.0",
|
|
||||||
"@nestjs/core": "^11.0.0 || ^12.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nestjs/schematics": {
|
"node_modules/@nestjs/schematics": {
|
||||||
"version": "11.1.0",
|
"version": "11.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz",
|
||||||
@@ -3601,9 +3587,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/luxon": {
|
"node_modules/@types/luxon": {
|
||||||
"version": "3.7.5",
|
"version": "3.7.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.6.tgz",
|
||||||
"integrity": "sha512-jJ41Q4z6ZVO260MNDdHfW7+7a5iMiX8Mr6ZJHcmgrvhZha6dz5704o/lF2kKl6URjH6ivEL97w9xS/MgpJEphg==",
|
"integrity": "sha512-6KSjliQAXK8ZLgFQO4B7iE4Rpf/B3rItzCFuXezBY/XIAGxOdLd7vRNK9/StRPwiS/yJsVbB7HKpW46B8tcEuQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/methods": {
|
"node_modules/@types/methods": {
|
||||||
@@ -10087,14 +10073,14 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pg": {
|
"node_modules/pg": {
|
||||||
"version": "8.23.0",
|
"version": "8.23.1",
|
||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
|
||||||
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
|
"integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.14.0",
|
"pg-connection-string": "^2.14.1",
|
||||||
"pg-pool": "^3.14.0",
|
"pg-pool": "^3.14.0",
|
||||||
"pg-protocol": "^1.16.0",
|
"pg-protocol": "^1.16.1",
|
||||||
"pg-types": "2.2.0",
|
"pg-types": "2.2.0",
|
||||||
"pgpass": "1.0.5"
|
"pgpass": "1.0.5"
|
||||||
},
|
},
|
||||||
@@ -10102,7 +10088,7 @@
|
|||||||
"node": ">= 16.0.0"
|
"node": ">= 16.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"pg-cloudflare": "^1.4.0"
|
"pg-cloudflare": "^1.4.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"pg-native": ">=3.0.1"
|
"pg-native": ">=3.0.1"
|
||||||
@@ -10114,16 +10100,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pg-cloudflare": {
|
"node_modules/pg-cloudflare": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
|
||||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
"integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/pg-connection-string": {
|
"node_modules/pg-connection-string": {
|
||||||
"version": "2.14.0",
|
"version": "2.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
|
||||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
"integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pg-int8": {
|
"node_modules/pg-int8": {
|
||||||
@@ -10145,9 +10131,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pg-protocol": {
|
"node_modules/pg-protocol": {
|
||||||
"version": "1.16.0",
|
"version": "1.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
|
||||||
"integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
|
"integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pg-types": {
|
"node_modules/pg-types": {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "node dist/main",
|
"start:prod": "node dist/main",
|
||||||
|
"worker": "node dist/worker.js",
|
||||||
|
"job": "node dist/cli.js",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
@@ -29,7 +31,6 @@
|
|||||||
"@nestjs/jwt": "^11.0.2",
|
"@nestjs/jwt": "^11.0.2",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/schedule": "^12.0.1",
|
|
||||||
"@nestjs/swagger": "^11.4.7",
|
"@nestjs/swagger": "^11.4.7",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@prisma/adapter-pg": "^7.9.1",
|
"@prisma/adapter-pg": "^7.9.1",
|
||||||
@@ -38,12 +39,14 @@
|
|||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cron": "^4.4.0",
|
||||||
"helmet": "^8.3.0",
|
"helmet": "^8.3.0",
|
||||||
"openid-client": "^6.8.4",
|
"openid-client": "^6.8.4",
|
||||||
"otplib": "^13.5.0",
|
"otplib": "^13.5.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"pdfkit": "^0.19.1",
|
"pdfkit": "^0.19.1",
|
||||||
|
"pg": "^8.23.1",
|
||||||
"prisma": "^7.9.1",
|
"prisma": "^7.9.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
@@ -63,6 +66,7 @@
|
|||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/pdfkit": "^0.17.6",
|
"@types/pdfkit": "^0.17.6",
|
||||||
|
"@types/pg": "^8.23.1",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/supertest": "^7.0.0",
|
"@types/supertest": "^7.0.0",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
@@ -92,7 +96,7 @@
|
|||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose|@nestjs/schedule|cron)/)"
|
"node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose|cron)/)"
|
||||||
],
|
],
|
||||||
"collectCoverageFrom": [
|
"collectCoverageFrom": [
|
||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
-- Platform backbone: domain-event outbox (DomainEvent + per-handler
|
||||||
|
-- DomainEventDelivery), webhook delivery queue, cron job run history and
|
||||||
|
-- throttler counters, plus two small columns (Apiary.publicLocationLabel,
|
||||||
|
-- SiteSettings.originCountry).
|
||||||
|
-- ThrottlerHit is UNLOGGED on purpose: no WAL, emptied after a crash - only
|
||||||
|
-- rate-limit counters get lost. DomainEvent.dedupeKey is a nullable UNIQUE
|
||||||
|
-- (not a partial index) so the schema and the database cannot drift apart.
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "DomainEventStatus" AS ENUM ('PENDING', 'PROCESSING', 'DONE', 'FAILED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "DomainEventDeliveryStatus" AS ENUM ('PENDING', 'DONE', 'FAILED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "WebhookDeliveryStatus" AS ENUM ('PENDING', 'PROCESSING', 'DELIVERED', 'FAILED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "JobRunStatus" AS ENUM ('RUNNING', 'SUCCEEDED', 'FAILED');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Apiary" ADD COLUMN "publicLocationLabel" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "SiteSettings" ADD COLUMN "originCountry" TEXT NOT NULL DEFAULT 'Deutschland';
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DomainEvent" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"aggregateType" TEXT NOT NULL,
|
||||||
|
"aggregateId" TEXT NOT NULL,
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
"actorUserId" TEXT,
|
||||||
|
"correlationId" TEXT NOT NULL,
|
||||||
|
"dedupeKey" TEXT,
|
||||||
|
"occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"status" "DomainEventStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"claimedAt" TIMESTAMP(3),
|
||||||
|
"processedAt" TIMESTAMP(3),
|
||||||
|
"lastError" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "DomainEvent_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DomainEventDelivery" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"eventId" TEXT NOT NULL,
|
||||||
|
"handler" TEXT NOT NULL,
|
||||||
|
"status" "DomainEventDeliveryStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"lastError" TEXT,
|
||||||
|
"completedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "DomainEventDelivery_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "WebhookDelivery" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"subscriptionId" TEXT NOT NULL,
|
||||||
|
"event" TEXT NOT NULL,
|
||||||
|
"body" TEXT NOT NULL,
|
||||||
|
"status" "WebhookDeliveryStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"claimedAt" TIMESTAMP(3),
|
||||||
|
"lastStatusCode" INTEGER,
|
||||||
|
"lastError" TEXT,
|
||||||
|
"deliveredAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "WebhookDelivery_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "JobRun" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"job" TEXT NOT NULL,
|
||||||
|
"status" "JobRunStatus" NOT NULL DEFAULT 'RUNNING',
|
||||||
|
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"finishedAt" TIMESTAMP(3),
|
||||||
|
"result" JSONB,
|
||||||
|
"error" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "JobRun_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE UNLOGGED TABLE "ThrottlerHit" (
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"throttlerName" TEXT NOT NULL,
|
||||||
|
"hits" INTEGER NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"blockedUntil" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "ThrottlerHit_pkey" PRIMARY KEY ("key","throttlerName")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "DomainEvent_dedupeKey_key" ON "DomainEvent"("dedupeKey");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DomainEvent_status_availableAt_idx" ON "DomainEvent"("status", "availableAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DomainEvent_correlationId_idx" ON "DomainEvent"("correlationId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DomainEvent_aggregateType_aggregateId_idx" ON "DomainEvent"("aggregateType", "aggregateId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "DomainEventDelivery_eventId_handler_key" ON "DomainEventDelivery"("eventId", "handler");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WebhookDelivery_status_availableAt_idx" ON "WebhookDelivery"("status", "availableAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WebhookDelivery_subscriptionId_createdAt_idx" ON "WebhookDelivery"("subscriptionId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "JobRun_job_startedAt_idx" ON "JobRun"("job", "startedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "JobRun_status_idx" ON "JobRun"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ThrottlerHit_expiresAt_idx" ON "ThrottlerHit"("expiresAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DomainEventDelivery" ADD CONSTRAINT "DomainEventDelivery_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "DomainEvent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "WebhookDelivery" ADD CONSTRAINT "WebhookDelivery_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "WebhookSubscription"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "Inspection" ADD COLUMN "broodFrames" INTEGER;
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
-- ERP-Export /bewegungen sortiert und blättert nach (createdAt, id).
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "BatchMovement_createdAt_id_idx" ON "BatchMovement"("createdAt", "id");
|
||||||
@@ -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);
|
||||||
+39
@@ -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;
|
||||||
+16
@@ -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;
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
-- T4 migration 4/n: plant development (phenology) and the season segment.
|
||||||
|
-- PhenologyReport holds the beekeeper's own reports; ApiaryPhenology and
|
||||||
|
-- ApiaryWeatherDay are derived/cached state written only by the
|
||||||
|
-- daily-evaluation step and the weather loop.
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PhenologyPhase" AS ENUM ('SALWEIDE', 'KIRSCHE', 'APFEL', 'HOLUNDER', 'LINDE', 'TRACHTENDE', 'HERBST', 'WINTER');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SeasonSegment" AS ENUM ('WINTERRUHE', 'FRUEHJAHR', 'SCHWARMZEIT', 'SOMMER', 'SPAETSOMMER', 'HERBST');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PhaseSource" AS ENUM ('REPORT', 'GTS', 'CALENDAR');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PhenologyReport" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"apiaryId" TEXT,
|
||||||
|
"phase" "PhenologyPhase" NOT NULL,
|
||||||
|
"date" TIMESTAMP(3) NOT NULL,
|
||||||
|
"notes" TEXT,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"revokedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "PhenologyReport_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ApiaryPhenology" (
|
||||||
|
"apiaryId" TEXT NOT NULL,
|
||||||
|
"year" INTEGER NOT NULL,
|
||||||
|
"gts" DOUBLE PRECISION,
|
||||||
|
"gtsThroughDay" TIMESTAMP(3),
|
||||||
|
"missingDays" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"estimatedPhase" "PhenologyPhase",
|
||||||
|
"reportedPhase" "PhenologyPhase",
|
||||||
|
"effectivePhase" "PhenologyPhase",
|
||||||
|
"phaseSource" "PhaseSource",
|
||||||
|
"segment" "SeasonSegment" NOT NULL,
|
||||||
|
"segmentSince" TIMESTAMP(3) NOT NULL,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ApiaryPhenology_pkey" PRIMARY KEY ("apiaryId","year")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ApiaryWeatherDay" (
|
||||||
|
"apiaryId" TEXT NOT NULL,
|
||||||
|
"day" TIMESTAMP(3) NOT NULL,
|
||||||
|
"meanTempC" DOUBLE PRECISION,
|
||||||
|
"source" TEXT NOT NULL,
|
||||||
|
"fetchedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ApiaryWeatherDay_pkey" PRIMARY KEY ("apiaryId","day")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PhenologyReport_date_idx" ON "PhenologyReport"("date");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PhenologyReport_apiaryId_phase_date_idx" ON "PhenologyReport"("apiaryId", "phase", "date");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PhenologyReport" ADD CONSTRAINT "PhenologyReport_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PhenologyReport" ADD CONSTRAINT "PhenologyReport_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ApiaryPhenology" ADD CONSTRAINT "ApiaryPhenology_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ApiaryWeatherDay" ADD CONSTRAINT "ApiaryWeatherDay_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Permissions: the boot-time catalog sync grants new permissions to system
|
||||||
|
-- roles only for rows it creates itself, and these rows exist before the
|
||||||
|
-- first boot. So system roles (ADMIN: full access) get all three here; this
|
||||||
|
-- mirrors what PermissionCatalogSyncService does for permissions it creates.
|
||||||
|
-- Phenology reports are everyday beekeeping records, so non-system roles get
|
||||||
|
-- them like winter-checks (assumption 16): read with colonies:read,
|
||||||
|
-- create/update with inspections:create.
|
||||||
|
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'phenology:read', 'phenology', 'read', 'Ansehen', 'Pflanzenentwicklung', 860),
|
||||||
|
(gen_random_uuid()::text, 'phenology:create', 'phenology', 'create', 'Melden', 'Pflanzenentwicklung', 861),
|
||||||
|
(gen_random_uuid()::text, 'phenology:update', 'phenology', 'update', 'Bearbeiten', 'Pflanzenentwicklung', 862)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||||
|
SELECT r.id, p.id, now()
|
||||||
|
FROM "Role" r, "Permission" p
|
||||||
|
WHERE r."isSystem" = true AND p.resource = 'phenology'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||||
|
SELECT DISTINCT rp."roleId", p.id, now()
|
||||||
|
FROM "RolePermission" rp
|
||||||
|
JOIN "Role" r ON r.id = rp."roleId" AND r."isSystem" = false
|
||||||
|
JOIN "Permission" have ON have.id = rp."permissionId"
|
||||||
|
JOIN "Permission" p ON p.resource = 'phenology'
|
||||||
|
WHERE (have.key = 'colonies:read' AND p.action = 'read')
|
||||||
|
OR (have.key = 'inspections:create' AND p.action IN ('create', 'update'))
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
-- T4 migration 6/n: ledger of the honey batch. HoneyBatch.amount (kg) stays
|
||||||
|
-- the running rest; initialAmount (kg) is the starting quantity, and
|
||||||
|
-- amount = initialAmount + sum(BatchMovement.quantity) / 1000
|
||||||
|
-- (quantity in grams, signed). Every bottling is booked as a BOTTLING
|
||||||
|
-- movement. Earlier hand corrections of amount are folded into initialAmount.
|
||||||
|
--
|
||||||
|
-- Invariant check afterwards (must return 0 rows):
|
||||||
|
-- SELECT hb."id" FROM "HoneyBatch" hb
|
||||||
|
-- LEFT JOIN (SELECT "honeyBatchId", sum("quantity") g FROM "BatchMovement"
|
||||||
|
-- WHERE "honeyBatchId" IS NOT NULL GROUP BY 1) m ON m."honeyBatchId" = hb."id"
|
||||||
|
-- WHERE hb."initialAmount" IS NOT NULL AND hb."amount" IS NOT NULL
|
||||||
|
-- AND abs(hb."amount" - (hb."initialAmount" + coalesce(m.g, 0) / 1000.0)) > 0.0005;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "BatchMovement" ADD COLUMN "honeyBatchId" TEXT,
|
||||||
|
ADD COLUMN "sourceBottlingBatchId" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "HoneyBatch" ADD COLUMN "initialAmount" DOUBLE PRECISION;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "BatchMovement_honeyBatchId_idx" ON "BatchMovement"("honeyBatchId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "BatchMovement_sourceBottlingBatchId_idx" ON "BatchMovement"("sourceBottlingBatchId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_honeyBatchId_fkey" FOREIGN KEY ("honeyBatchId") REFERENCES "HoneyBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_sourceBottlingBatchId_fkey" FOREIGN KEY ("sourceBottlingBatchId") REFERENCES "BottlingBatch"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- A movement now belongs to exactly one of four batch kinds (name kept from
|
||||||
|
-- 20261005100000_raw_materials_production).
|
||||||
|
ALTER TABLE "BatchMovement" DROP CONSTRAINT "BatchMovement_exactly_one_batch";
|
||||||
|
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_exactly_one_batch"
|
||||||
|
CHECK (num_nonnulls("bottlingBatchId", "productionBatchId", "rawMaterialLotId", "honeyBatchId") = 1);
|
||||||
|
|
||||||
|
-- Recalculation, only for batches with a recorded amount (a batch without
|
||||||
|
-- one is not booked, as before).
|
||||||
|
-- 1. initialAmount = amount + the bottlings rounded to the gram exactly like
|
||||||
|
-- the bookings below (kg), so the invariant holds without a rounding gap.
|
||||||
|
UPDATE "HoneyBatch" hb
|
||||||
|
SET "initialAmount" = hb."amount" + coalesce((
|
||||||
|
SELECT sum(floor(b."totalAmount" * 1000 + 0.5)) FROM "BottlingBatch" b WHERE b."honeyBatchId" = hb."id"
|
||||||
|
), 0) / 1000.0
|
||||||
|
WHERE hb."amount" IS NOT NULL;
|
||||||
|
|
||||||
|
-- 2. One BOTTLING movement per bottling, in grams, dated like the bottling.
|
||||||
|
-- floor(x + 0.5) rounds half up like the application (Math.round), whereas
|
||||||
|
-- round(double precision) may round half to even.
|
||||||
|
INSERT INTO "BatchMovement" ("id", "honeyBatchId", "sourceBottlingBatchId", "type", "quantity", "notes", "userId", "createdAt")
|
||||||
|
SELECT gen_random_uuid()::text, b."honeyBatchId", b."id", 'BOTTLING',
|
||||||
|
-(floor(b."totalAmount" * 1000 + 0.5))::integer,
|
||||||
|
'Übernahme aus Abfüllung (T4)', b."userId", b."createdAt"
|
||||||
|
FROM "BottlingBatch" b
|
||||||
|
JOIN "HoneyBatch" hb ON hb."id" = b."honeyBatchId"
|
||||||
|
WHERE hb."amount" IS NOT NULL;
|
||||||
|
|
||||||
|
-- Report and self-check: the counts go to the migration log; a broken
|
||||||
|
-- invariant aborts the whole migration (the file runs in one transaction).
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
batches integer;
|
||||||
|
bookings integer;
|
||||||
|
skipped integer;
|
||||||
|
broken integer;
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO batches FROM "HoneyBatch" WHERE "initialAmount" IS NOT NULL;
|
||||||
|
SELECT count(*) INTO bookings FROM "BatchMovement" WHERE "honeyBatchId" IS NOT NULL AND "type" = 'BOTTLING';
|
||||||
|
SELECT count(*) INTO skipped FROM "BottlingBatch" b
|
||||||
|
JOIN "HoneyBatch" hb ON hb."id" = b."honeyBatchId" WHERE hb."amount" IS NULL;
|
||||||
|
RAISE NOTICE 'honey ledger: % batches with initialAmount, % bookings created, % bottlings skipped (batch amount is NULL)',
|
||||||
|
batches, bookings, skipped;
|
||||||
|
|
||||||
|
SELECT count(*) INTO broken FROM "HoneyBatch" hb
|
||||||
|
LEFT JOIN (SELECT "honeyBatchId", sum("quantity") AS g FROM "BatchMovement"
|
||||||
|
WHERE "honeyBatchId" IS NOT NULL GROUP BY 1) m ON m."honeyBatchId" = hb."id"
|
||||||
|
WHERE hb."initialAmount" IS NOT NULL AND hb."amount" IS NOT NULL
|
||||||
|
AND abs(hb."amount" - (hb."initialAmount" + coalesce(m.g, 0) / 1000.0)) > 0.0005;
|
||||||
|
IF broken > 0 THEN
|
||||||
|
RAISE EXCEPTION 'honey ledger invariant broken for % batches', broken;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- T4 migration 7/n (part shelf life): Product.shelfLifeMonths, the shelf life
|
||||||
|
-- in months, used to pre-fill the best-before date. No default value.
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Product" ADD COLUMN "shelfLifeMonths" INTEGER;
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
-- T4 migration 11/n (part Telegram and delivery): chat binding, single-use
|
||||||
|
-- link tokens, the delivery outbox and the receiver's poll state. All four
|
||||||
|
-- tables are excluded from the backup (MODELS_EXCLUDED_FROM_BACKUP).
|
||||||
|
-- NotificationDelivery carries either a notification for a user or a bot
|
||||||
|
-- system reply for a chat, never both (CHECK below), and at most one
|
||||||
|
-- delivery per notification and channel. TelegramPollState holds exactly one
|
||||||
|
-- row (CHECK below), created here.
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "NotificationChannel" AS ENUM ('TELEGRAM');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "NotificationDeliveryStatus" AS ENUM ('PENDING', 'PROCESSING', 'DELIVERED', 'SKIPPED', 'FAILED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TelegramLink" (
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"chatId" BIGINT NOT NULL,
|
||||||
|
"telegramFirstName" TEXT,
|
||||||
|
"linkedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "TelegramLink_pkey" PRIMARY KEY ("userId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TelegramLinkToken" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"tokenHash" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"consumedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "TelegramLinkToken_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "NotificationDelivery" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"channel" "NotificationChannel" NOT NULL,
|
||||||
|
"userId" TEXT,
|
||||||
|
"chatId" BIGINT,
|
||||||
|
"notificationId" TEXT,
|
||||||
|
"templateCode" TEXT,
|
||||||
|
"status" "NotificationDeliveryStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"claimedAt" TIMESTAMP(3),
|
||||||
|
"lastStatusCode" INTEGER,
|
||||||
|
"lastError" TEXT,
|
||||||
|
"deliveredAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "NotificationDelivery_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TelegramPollState" (
|
||||||
|
"id" TEXT NOT NULL DEFAULT 'singleton',
|
||||||
|
"updateOffset" BIGINT NOT NULL DEFAULT 0,
|
||||||
|
"leaseOwner" TEXT,
|
||||||
|
"leaseUntil" TIMESTAMP(3),
|
||||||
|
"lastPollAt" TIMESTAMP(3),
|
||||||
|
"lastError" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "TelegramPollState_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TelegramLink_chatId_key" ON "TelegramLink"("chatId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TelegramLinkToken_tokenHash_key" ON "TelegramLinkToken"("tokenHash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TelegramLinkToken_userId_idx" ON "TelegramLinkToken"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NotificationDelivery_status_availableAt_idx" ON "NotificationDelivery"("status", "availableAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NotificationDelivery_userId_createdAt_idx" ON "NotificationDelivery"("userId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NotificationDelivery_notificationId_idx" ON "NotificationDelivery"("notificationId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "NotificationDelivery_notificationId_channel_key" ON "NotificationDelivery"("notificationId", "channel");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TelegramLink" ADD CONSTRAINT "TelegramLink_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TelegramLinkToken" ADD CONSTRAINT "TelegramLinkToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "NotificationDelivery" ADD CONSTRAINT "NotificationDelivery_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "NotificationDelivery" ADD CONSTRAINT "NotificationDelivery_notificationId_fkey" FOREIGN KEY ("notificationId") REFERENCES "Notification"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- A delivery is either a notification for a user (rendered at send time) or a
|
||||||
|
-- system reply for a chat (rendered from templateCode).
|
||||||
|
ALTER TABLE "NotificationDelivery" ADD CONSTRAINT "NotificationDelivery_target_check" CHECK (
|
||||||
|
("notificationId" IS NOT NULL AND "userId" IS NOT NULL AND "templateCode" IS NULL AND "chatId" IS NULL)
|
||||||
|
OR ("notificationId" IS NULL AND "templateCode" IS NOT NULL AND "chatId" IS NOT NULL)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The receiver's single state row.
|
||||||
|
ALTER TABLE "TelegramPollState" ADD CONSTRAINT "TelegramPollState_singleton_check" CHECK ("id" = 'singleton');
|
||||||
|
INSERT INTO "TelegramPollState" ("id") VALUES ('singleton');
|
||||||
@@ -111,10 +111,12 @@ model User {
|
|||||||
feedings Feeding[]
|
feedings Feeding[]
|
||||||
harvests Harvest[]
|
harvests Harvest[]
|
||||||
extractions Extraction[]
|
extractions Extraction[]
|
||||||
|
phenologyReports PhenologyReport[]
|
||||||
honeyMeasurements HoneyMeasurement[]
|
honeyMeasurements HoneyMeasurement[]
|
||||||
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 +126,10 @@ model User {
|
|||||||
batchMovements BatchMovement[]
|
batchMovements BatchMovement[]
|
||||||
rawMaterialLots RawMaterialLot[]
|
rawMaterialLots RawMaterialLot[]
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
|
notificationPreference NotificationPreference?
|
||||||
|
telegramLink TelegramLink?
|
||||||
|
telegramLinkTokens TelegramLinkToken[]
|
||||||
|
notificationDeliveries NotificationDelivery[]
|
||||||
queenEvaluations QueenEvaluation[]
|
queenEvaluations QueenEvaluation[]
|
||||||
webhookSubscriptions WebhookSubscription[]
|
webhookSubscriptions WebhookSubscription[]
|
||||||
documents Document[]
|
documents Document[]
|
||||||
@@ -230,6 +236,8 @@ model Apiary {
|
|||||||
latitude Float?
|
latitude Float?
|
||||||
longitude Float?
|
longitude Float?
|
||||||
address String?
|
address String?
|
||||||
|
// Coarse, public-safe place name for storefront/shop output (never the exact address).
|
||||||
|
publicLocationLabel String?
|
||||||
status ApiaryStatus @default(ACTIVE)
|
status ApiaryStatus @default(ACTIVE)
|
||||||
notes String?
|
notes String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -241,6 +249,9 @@ model Apiary {
|
|||||||
harvests Harvest[]
|
harvests Harvest[]
|
||||||
treatments Treatment[]
|
treatments Treatment[]
|
||||||
rawMaterialLots RawMaterialLot[]
|
rawMaterialLots RawMaterialLot[]
|
||||||
|
phenologyReports PhenologyReport[]
|
||||||
|
phenologyStates ApiaryPhenology[]
|
||||||
|
weatherDays ApiaryWeatherDay[]
|
||||||
|
|
||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
@@ -288,6 +299,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?
|
||||||
@@ -310,6 +322,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])
|
||||||
@@ -338,6 +351,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
|
||||||
@@ -346,6 +365,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())
|
||||||
|
|
||||||
@@ -454,6 +474,7 @@ model Inspection {
|
|||||||
weather String?
|
weather String?
|
||||||
temperature Float?
|
temperature Float?
|
||||||
colonyStrength Int?
|
colonyStrength Int?
|
||||||
|
broodFrames Int?
|
||||||
broodAmount String?
|
broodAmount String?
|
||||||
broodPattern Int?
|
broodPattern Int?
|
||||||
openBroodPresent Boolean?
|
openBroodPresent Boolean?
|
||||||
@@ -464,7 +485,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?
|
||||||
@@ -526,12 +547,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 {
|
||||||
@@ -541,7 +649,7 @@ enum TaskTemplateRecurrenceType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wiederkehrende Aufgaben-Vorlagen – erzeugen automatisch eine neue Task,
|
// Wiederkehrende Aufgaben-Vorlagen – erzeugen automatisch eine neue Task,
|
||||||
// sobald nextDueDate erreicht ist (siehe TasksService.generateDueTasks).
|
// sobald nextDueDate erreicht ist (siehe TasksService.generateFromTemplates).
|
||||||
model TaskTemplate {
|
model TaskTemplate {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
title String
|
title String
|
||||||
@@ -586,6 +694,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?
|
||||||
@@ -826,6 +935,9 @@ model HoneyBatch {
|
|||||||
extraction Extraction @relation(fields: [extractionId], references: [id])
|
extraction Extraction @relation(fields: [extractionId], references: [id])
|
||||||
honeyType String?
|
honeyType String?
|
||||||
amount Float?
|
amount Float?
|
||||||
|
// Ausgangsmenge in kg; amount = initialAmount + Summe der Buchungen / 1000.
|
||||||
|
// null = Menge nicht erfasst, dann wird nicht gebucht.
|
||||||
|
initialAmount Float?
|
||||||
waterContent Float?
|
waterContent Float?
|
||||||
storageContainerId String?
|
storageContainerId String?
|
||||||
storageContainer StorageContainer? @relation(fields: [storageContainerId], references: [id])
|
storageContainer StorageContainer? @relation(fields: [storageContainerId], references: [id])
|
||||||
@@ -837,6 +949,7 @@ model HoneyBatch {
|
|||||||
bottlingBatches BottlingBatch[]
|
bottlingBatches BottlingBatch[]
|
||||||
relocations HoneyBatchRelocation[]
|
relocations HoneyBatchRelocation[]
|
||||||
containerAllocations HoneyBatchContainerAllocation[]
|
containerAllocations HoneyBatchContainerAllocation[]
|
||||||
|
movements BatchMovement[]
|
||||||
|
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([extractionId])
|
@@index([extractionId])
|
||||||
@@ -1010,6 +1123,8 @@ model Product {
|
|||||||
packagingSize Float?
|
packagingSize Float?
|
||||||
weight Float?
|
weight Float?
|
||||||
salesUnit String?
|
salesUnit String?
|
||||||
|
// Haltbarkeit in Monaten, kein Standardwert (MHD-Vorbelegung bei der Abfuellung).
|
||||||
|
shelfLifeMonths Int?
|
||||||
status ProductStatus @default(ACTIVE)
|
status ProductStatus @default(ACTIVE)
|
||||||
kind ProductKind @default(BOTTLED_HONEY)
|
kind ProductKind @default(BOTTLED_HONEY)
|
||||||
shopCategory ShopCategory @default(HONIG)
|
shopCategory ShopCategory @default(HONIG)
|
||||||
@@ -1073,6 +1188,7 @@ model BottlingBatch {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
movements BatchMovement[]
|
movements BatchMovement[]
|
||||||
|
causedMovements BatchMovement[] @relation("BatchMovementSourceBottling")
|
||||||
|
|
||||||
@@index([honeyBatchId])
|
@@index([honeyBatchId])
|
||||||
@@index([productId])
|
@@index([productId])
|
||||||
@@ -1169,6 +1285,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 {
|
||||||
@@ -1191,6 +1309,12 @@ model BatchMovement {
|
|||||||
productionBatch ProductionBatch? @relation(fields: [productionBatchId], references: [id], onDelete: Restrict)
|
productionBatch ProductionBatch? @relation(fields: [productionBatchId], references: [id], onDelete: Restrict)
|
||||||
rawMaterialLotId String?
|
rawMaterialLotId String?
|
||||||
rawMaterialLot RawMaterialLot? @relation(fields: [rawMaterialLotId], references: [id], onDelete: Restrict)
|
rawMaterialLot RawMaterialLot? @relation(fields: [rawMaterialLotId], references: [id], onDelete: Restrict)
|
||||||
|
// Honig: Bewegungsbuch der Honigcharge, quantity dann in Gramm.
|
||||||
|
honeyBatchId String?
|
||||||
|
honeyBatch HoneyBatch? @relation(fields: [honeyBatchId], references: [id], onDelete: Restrict)
|
||||||
|
// Rueckverweis auf die verursachende Abfuellung (BOTTLING).
|
||||||
|
sourceBottlingBatchId String?
|
||||||
|
sourceBottlingBatch BottlingBatch? @relation("BatchMovementSourceBottling", fields: [sourceBottlingBatchId], references: [id], onDelete: SetNull)
|
||||||
type BatchMovementType
|
type BatchMovementType
|
||||||
// Mit Vorzeichen: minus = raus, plus = rein.
|
// Mit Vorzeichen: minus = raus, plus = rein.
|
||||||
quantity Int
|
quantity Int
|
||||||
@@ -1206,7 +1330,11 @@ model BatchMovement {
|
|||||||
@@index([bottlingBatchId])
|
@@index([bottlingBatchId])
|
||||||
@@index([productionBatchId])
|
@@index([productionBatchId])
|
||||||
@@index([rawMaterialLotId])
|
@@index([rawMaterialLotId])
|
||||||
|
@@index([honeyBatchId])
|
||||||
|
@@index([sourceBottlingBatchId])
|
||||||
@@index([shopRequestId])
|
@@index([shopRequestId])
|
||||||
|
// ERP-Export /bewegungen: sortiert und blättert nach (createdAt, id).
|
||||||
|
@@index([createdAt, id])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ShopStockRequestKind {
|
enum ShopStockRequestKind {
|
||||||
@@ -1270,21 +1398,125 @@ 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())
|
||||||
|
deliveries NotificationDelivery[]
|
||||||
|
|
||||||
@@index([userId, read])
|
@@index([userId, read])
|
||||||
@@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
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NotificationChannel {
|
||||||
|
TELEGRAM
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NotificationDeliveryStatus {
|
||||||
|
PENDING
|
||||||
|
PROCESSING
|
||||||
|
DELIVERED
|
||||||
|
SKIPPED
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat binding of a user to this deployment's Telegram bot (T4c). One private
|
||||||
|
// chat belongs to exactly one user.
|
||||||
|
model TelegramLink {
|
||||||
|
userId String @id
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
chatId BigInt @unique
|
||||||
|
telegramFirstName String?
|
||||||
|
linkedAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-use link token, valid for 10 minutes; only the SHA-256 is stored.
|
||||||
|
model TelegramLinkToken {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
tokenHash String @unique
|
||||||
|
expiresAt DateTime
|
||||||
|
consumedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outbox like WebhookDelivery. The text is not stored: the sender renders it
|
||||||
|
// from the Notification or the templateCode. CHECK (migration): exactly one of
|
||||||
|
// (notificationId with userId) or (templateCode with chatId).
|
||||||
|
model NotificationDelivery {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
channel NotificationChannel
|
||||||
|
userId String?
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
chatId BigInt?
|
||||||
|
notificationId String?
|
||||||
|
notification Notification? @relation(fields: [notificationId], references: [id], onDelete: Cascade)
|
||||||
|
templateCode String?
|
||||||
|
status NotificationDeliveryStatus @default(PENDING)
|
||||||
|
attempts Int @default(0)
|
||||||
|
availableAt DateTime @default(now())
|
||||||
|
claimedAt DateTime?
|
||||||
|
lastStatusCode Int?
|
||||||
|
lastError String?
|
||||||
|
deliveredAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([notificationId, channel])
|
||||||
|
@@index([status, availableAt])
|
||||||
|
@@index([userId, createdAt])
|
||||||
|
@@index([notificationId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// One row: update offset and poll lease of the Telegram receiver.
|
||||||
|
model TelegramPollState {
|
||||||
|
id String @id @default("singleton")
|
||||||
|
updateOffset BigInt @default(0)
|
||||||
|
leaseOwner String?
|
||||||
|
leaseUntil DateTime?
|
||||||
|
lastPollAt DateTime?
|
||||||
|
lastError String?
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Webhooks (Abschnitt 27) – v1.x Modul 28
|
// Webhooks (Abschnitt 27) – v1.x Modul 28
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1300,6 +1532,8 @@ model WebhookSubscription {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
deliveries WebhookDelivery[]
|
||||||
|
|
||||||
@@index([active])
|
@@index([active])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1351,6 +1585,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 {
|
||||||
@@ -1403,6 +1646,8 @@ model SiteSettings {
|
|||||||
// part of any public page.
|
// part of any public page.
|
||||||
holderName String?
|
holderName String?
|
||||||
holderAddress String?
|
holderAddress String?
|
||||||
|
// Country of origin printed on labels and shop output.
|
||||||
|
originCountry String @default("Deutschland")
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1437,6 +1682,7 @@ enum CalendarSyncSourceType {
|
|||||||
FEEDING
|
FEEDING
|
||||||
HARVEST
|
HARVEST
|
||||||
QUEEN_ASSIGNMENT
|
QUEEN_ASSIGNMENT
|
||||||
|
WITHDRAWAL
|
||||||
}
|
}
|
||||||
|
|
||||||
enum CalendarSyncStatus {
|
enum CalendarSyncStatus {
|
||||||
@@ -1456,6 +1702,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1521,3 +1768,201 @@ model CalendarSyncOutboxEntry {
|
|||||||
@@unique([sourceType, sourceId])
|
@@unique([sourceType, sourceId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plattform-Rückgrat: Domain-Events (Outbox), Webhook-Zustellung, Job-Läufe,
|
||||||
|
// Throttler-Zähler. Alle fünf Modelle sind vom Backup ausgenommen.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
enum DomainEventStatus {
|
||||||
|
PENDING
|
||||||
|
PROCESSING
|
||||||
|
DONE
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
model DomainEvent {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
type String
|
||||||
|
aggregateType String
|
||||||
|
aggregateId String
|
||||||
|
payload Json
|
||||||
|
actorUserId String?
|
||||||
|
correlationId String
|
||||||
|
// Nullable @unique instead of a partial index: avoids the migration drift
|
||||||
|
// described in the backbone spec (section 3.5).
|
||||||
|
dedupeKey String? @unique
|
||||||
|
occurredAt DateTime @default(now())
|
||||||
|
status DomainEventStatus @default(PENDING)
|
||||||
|
attempts Int @default(0)
|
||||||
|
availableAt DateTime @default(now())
|
||||||
|
claimedAt DateTime?
|
||||||
|
processedAt DateTime?
|
||||||
|
lastError String?
|
||||||
|
|
||||||
|
deliveries DomainEventDelivery[]
|
||||||
|
|
||||||
|
@@index([status, availableAt])
|
||||||
|
@@index([correlationId])
|
||||||
|
@@index([aggregateType, aggregateId])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DomainEventDeliveryStatus {
|
||||||
|
PENDING
|
||||||
|
DONE
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
model DomainEventDelivery {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
eventId String
|
||||||
|
event DomainEvent @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||||
|
handler String
|
||||||
|
status DomainEventDeliveryStatus @default(PENDING)
|
||||||
|
attempts Int @default(0)
|
||||||
|
lastError String?
|
||||||
|
completedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([eventId, handler])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WebhookDeliveryStatus {
|
||||||
|
PENDING
|
||||||
|
PROCESSING
|
||||||
|
DELIVERED
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
model WebhookDelivery {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
subscriptionId String
|
||||||
|
subscription WebhookSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
|
||||||
|
event String
|
||||||
|
body String
|
||||||
|
status WebhookDeliveryStatus @default(PENDING)
|
||||||
|
attempts Int @default(0)
|
||||||
|
availableAt DateTime @default(now())
|
||||||
|
claimedAt DateTime?
|
||||||
|
lastStatusCode Int?
|
||||||
|
lastError String?
|
||||||
|
deliveredAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([status, availableAt])
|
||||||
|
@@index([subscriptionId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum JobRunStatus {
|
||||||
|
RUNNING
|
||||||
|
SUCCEEDED
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
model JobRun {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
job String
|
||||||
|
status JobRunStatus @default(RUNNING)
|
||||||
|
startedAt DateTime @default(now())
|
||||||
|
finishedAt DateTime?
|
||||||
|
result Json?
|
||||||
|
error String?
|
||||||
|
|
||||||
|
@@index([job, startedAt])
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
||||||
|
// UNLOGGED in the migration: no WAL, emptied after a crash - only counters get lost.
|
||||||
|
model ThrottlerHit {
|
||||||
|
key String
|
||||||
|
throttlerName String
|
||||||
|
hits Int
|
||||||
|
expiresAt DateTime
|
||||||
|
blockedUntil DateTime?
|
||||||
|
|
||||||
|
@@id([key, throttlerName])
|
||||||
|
@@index([expiresAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pflanzenentwicklung und Saisonabschnitt (T4b, Spec 4.2)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
enum PhenologyPhase {
|
||||||
|
SALWEIDE
|
||||||
|
KIRSCHE
|
||||||
|
APFEL
|
||||||
|
HOLUNDER
|
||||||
|
LINDE
|
||||||
|
TRACHTENDE
|
||||||
|
HERBST
|
||||||
|
WINTER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SeasonSegment {
|
||||||
|
WINTERRUHE
|
||||||
|
FRUEHJAHR
|
||||||
|
SCHWARMZEIT
|
||||||
|
SOMMER
|
||||||
|
SPAETSOMMER
|
||||||
|
HERBST
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PhaseSource {
|
||||||
|
REPORT
|
||||||
|
GTS
|
||||||
|
CALENDAR
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eigene Meldung per Antippen; zurueckgezogen statt geloescht.
|
||||||
|
model PhenologyReport {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
// null = alle Staende
|
||||||
|
apiaryId String?
|
||||||
|
apiary Apiary? @relation(fields: [apiaryId], references: [id], onDelete: Restrict)
|
||||||
|
phase PhenologyPhase
|
||||||
|
date DateTime
|
||||||
|
notes String?
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
revokedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([date])
|
||||||
|
@@index([apiaryId, phase, date])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abgeleiteter Zustand, schreibt nur der Job/Handler (nicht im Backup).
|
||||||
|
model ApiaryPhenology {
|
||||||
|
apiaryId String
|
||||||
|
apiary Apiary @relation(fields: [apiaryId], references: [id], onDelete: Cascade)
|
||||||
|
year Int
|
||||||
|
gts Float?
|
||||||
|
gtsThroughDay DateTime?
|
||||||
|
missingDays Int @default(0)
|
||||||
|
estimatedPhase PhenologyPhase?
|
||||||
|
reportedPhase PhenologyPhase?
|
||||||
|
effectivePhase PhenologyPhase?
|
||||||
|
phaseSource PhaseSource?
|
||||||
|
segment SeasonSegment
|
||||||
|
segmentSince DateTime
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@id([apiaryId, year])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tagesmittel je Stand, schreibt nur die Worker-Schleife (nicht im Backup).
|
||||||
|
model ApiaryWeatherDay {
|
||||||
|
apiaryId String
|
||||||
|
apiary Apiary @relation(fields: [apiaryId], references: [id], onDelete: Cascade)
|
||||||
|
// Kalendertag Europe/Berlin
|
||||||
|
day DateTime
|
||||||
|
meanTempC Float?
|
||||||
|
// 'forecast' | 'archive'
|
||||||
|
source String
|
||||||
|
fetchedAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@id([apiaryId, day])
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { WorkerRuntimeModule } from './worker/worker-runtime.module';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role "all": the API plus the worker loops in one process, for local
|
||||||
|
* development and tests. Production refuses it (runtime/role.ts) and runs
|
||||||
|
* api and worker as separate pods. No worker health server here - the
|
||||||
|
* API's /health endpoints serve the process.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [AppModule, WorkerRuntimeModule],
|
||||||
|
})
|
||||||
|
export class AllInOneModule {}
|
||||||
@@ -19,6 +19,24 @@ export const API_KEY_SCOPES = [
|
|||||||
|
|
||||||
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
|
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scopes that book (change data). Every other scope only reads. A new key
|
||||||
|
* never mixes both kinds: a leaked read key cannot book, a leaked booking
|
||||||
|
* key cannot read (ARCHITECTURE.md, Annahme 14).
|
||||||
|
*/
|
||||||
|
export const API_KEY_WRITE_SCOPES = [
|
||||||
|
'shop-sync:write',
|
||||||
|
] as const satisfies readonly ApiKeyScope[];
|
||||||
|
|
||||||
export function isApiKeyScope(value: string): value is ApiKeyScope {
|
export function isApiKeyScope(value: string): value is ApiKeyScope {
|
||||||
return (API_KEY_SCOPES as readonly string[]).includes(value);
|
return (API_KEY_SCOPES as readonly string[]).includes(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isWriteScope(scope: string): boolean {
|
||||||
|
return (API_KEY_WRITE_SCOPES as readonly string[]).includes(scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if the scopes hold at least one reading and one booking right. */
|
||||||
|
export function combinesReadAndWrite(scopes: readonly string[]): boolean {
|
||||||
|
return scopes.some(isWriteScope) && scopes.some((s) => !isWriteScope(s));
|
||||||
|
}
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { ThrottlerStorageModule } from '../common/throttler/throttler-storage.module';
|
||||||
import { ApiKeysController } from './api-keys.controller';
|
import { ApiKeysController } from './api-keys.controller';
|
||||||
import { ApiKeysService } from './api-keys.service';
|
import { ApiKeysService } from './api-keys.service';
|
||||||
|
import { FailedAttemptLimiter } from './failed-attempt-limiter';
|
||||||
|
|
||||||
// Global: the app-wide auth guards need ApiKeysService to recognise keys.
|
// Global: the app-wide auth guards need ApiKeysService to recognise keys.
|
||||||
|
// ThrottlerStorageModule: the failure counters share the rate-limit table,
|
||||||
|
// so the limit holds across all api pods.
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [ThrottlerStorageModule],
|
||||||
controllers: [ApiKeysController],
|
controllers: [ApiKeysController],
|
||||||
providers: [ApiKeysService],
|
providers: [ApiKeysService, FailedAttemptLimiter],
|
||||||
exports: [ApiKeysService],
|
exports: [ApiKeysService],
|
||||||
})
|
})
|
||||||
export class ApiKeysModule {}
|
export class ApiKeysModule {}
|
||||||
@@ -1,13 +1,19 @@
|
|||||||
import { ForbiddenException, HttpException, Logger } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
HttpException,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
|
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
|
||||||
|
import { ApiKeysService, extractApiKeyToken } from './api-keys.service';
|
||||||
import {
|
import {
|
||||||
API_KEY_FAILURE_LIMIT,
|
API_KEY_FAILURE_LIMIT,
|
||||||
ApiKeysService,
|
FailedAttemptLimiter,
|
||||||
extractApiKeyToken,
|
} from './failed-attempt-limiter';
|
||||||
} from './api-keys.service';
|
|
||||||
|
|
||||||
// Synthetischer Schlüssel im echten Format - kein echter Zugang.
|
// Synthetischer Schlüssel im echten Format - kein echter Zugang.
|
||||||
const TOKEN = `ack_abcd1234_${'A'.repeat(43)}`;
|
const TOKEN = `ack_abcd1234_${'A'.repeat(43)}`;
|
||||||
@@ -31,6 +37,38 @@ function storedKey(overrides: Record<string, unknown> = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stand-in for the shared counter table with the same counting rule as
|
||||||
|
* PrismaThrottlerStorage within one window: count up to the limit, the hit
|
||||||
|
* after it blocks, a blocked source keeps its count. Time and the database
|
||||||
|
* itself are covered by the e2e tests.
|
||||||
|
*/
|
||||||
|
function inMemoryStorage(): PrismaThrottlerStorage {
|
||||||
|
const rows = new Map<string, { hits: number; blocked: boolean }>();
|
||||||
|
return {
|
||||||
|
increment: (
|
||||||
|
key: string,
|
||||||
|
ttl: number,
|
||||||
|
limit: number,
|
||||||
|
blockDuration: number,
|
||||||
|
throttlerName: string,
|
||||||
|
) => {
|
||||||
|
const id = `${throttlerName}|${key}`;
|
||||||
|
const row = rows.get(id) ?? { hits: 0, blocked: false };
|
||||||
|
const next = row.blocked
|
||||||
|
? row
|
||||||
|
: { hits: row.hits + 1, blocked: row.hits + 1 > limit };
|
||||||
|
rows.set(id, next);
|
||||||
|
return Promise.resolve({
|
||||||
|
totalHits: next.hits,
|
||||||
|
timeToExpire: ttl / 1000,
|
||||||
|
isBlocked: next.blocked,
|
||||||
|
timeToBlockExpire: next.blocked ? blockDuration / 1000 : 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
} as unknown as PrismaThrottlerStorage;
|
||||||
|
}
|
||||||
|
|
||||||
describe('ApiKeysService.authenticate', () => {
|
describe('ApiKeysService.authenticate', () => {
|
||||||
let prisma: { apiKey: { findUnique: jest.Mock; update: jest.Mock } };
|
let prisma: { apiKey: { findUnique: jest.Mock; update: jest.Mock } };
|
||||||
let service: ApiKeysService;
|
let service: ApiKeysService;
|
||||||
@@ -47,6 +85,7 @@ describe('ApiKeysService.authenticate', () => {
|
|||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
{ record: jest.fn() } as unknown as AuditLogService,
|
{ record: jest.fn() } as unknown as AuditLogService,
|
||||||
{ getPermissions: jest.fn() } as unknown as PermissionResolverService,
|
{ getPermissions: jest.fn() } as unknown as PermissionResolverService,
|
||||||
|
new FailedAttemptLimiter(inMemoryStorage()),
|
||||||
);
|
);
|
||||||
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
||||||
});
|
});
|
||||||
@@ -166,6 +205,18 @@ describe('ApiKeysService.authenticate', () => {
|
|||||||
).resolves.toBe(null);
|
).resolves.toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('meldet das Erreichen der Grenze einmal im Log', async () => {
|
||||||
|
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
|
||||||
|
await expect(
|
||||||
|
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
|
||||||
|
).rejects.toMatchObject({ status: 429 });
|
||||||
|
|
||||||
|
const limitMessages = warn.mock.calls.filter(([message]) =>
|
||||||
|
String(message).includes('get 429'),
|
||||||
|
);
|
||||||
|
expect(limitMessages).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('weist einen gültigen Schlüssel nie wegen der Sperre ab - sonst könnte jeder ERPNext oder den Shop aussperren', async () => {
|
it('weist einen gültigen Schlüssel nie wegen der Sperre ab - sonst könnte jeder ERPNext oder den Shop aussperren', async () => {
|
||||||
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
|
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
|
||||||
await expect(
|
await expect(
|
||||||
@@ -210,6 +261,101 @@ describe('ApiKeysService.authenticate', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ApiKeysService.create', () => {
|
||||||
|
let service: ApiKeysService;
|
||||||
|
let prisma: { apiKey: { create: jest.Mock } };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma = {
|
||||||
|
apiKey: {
|
||||||
|
create: jest.fn((args: { data: Record<string, unknown> }) =>
|
||||||
|
Promise.resolve({ id: 'key-1', keyHash: 'h', ...args.data }),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const permissionResolver = {
|
||||||
|
getPermissions: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
new Set(['shop-sync:read', 'shop-sync:write', 'erp-inventory:read']),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
service = new ApiKeysService(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
{ record: jest.fn() } as unknown as AuditLogService,
|
||||||
|
permissionResolver as unknown as PermissionResolverService,
|
||||||
|
new FailedAttemptLimiter(inMemoryStorage()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a new key with both shop scopes', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create(
|
||||||
|
{
|
||||||
|
name: 'Shop',
|
||||||
|
scopes: ['shop-sync:read', 'shop-sync:write'],
|
||||||
|
},
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
).rejects.toThrow(
|
||||||
|
new BadRequestException(
|
||||||
|
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(prisma.apiKey.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses the read-only ERP scope together with booking', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create(
|
||||||
|
{
|
||||||
|
name: 'ERPNext',
|
||||||
|
scopes: ['erp-inventory:read', 'shop-sync:write'],
|
||||||
|
},
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
).rejects.toThrow(
|
||||||
|
new BadRequestException(
|
||||||
|
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(prisma.apiKey.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a new key with more than one scope, even if all of them read', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create(
|
||||||
|
{ name: 'Alles', scopes: ['shop-sync:read', 'erp-inventory:read'] },
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
).rejects.toThrow(
|
||||||
|
new BadRequestException(
|
||||||
|
'Je Schlüssel nur eine Berechtigung - bitte getrennte Schlüssel anlegen',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(prisma.apiKey.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats the same scope given twice as one', async () => {
|
||||||
|
const created = await service.create(
|
||||||
|
{ name: 'ERPNext', scopes: ['erp-inventory:read', 'erp-inventory:read'] },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
expect(created.apiKey.scopes).toEqual(['erp-inventory:read']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['shop-sync:read', 'shop-sync:write', 'erp-inventory:read'])(
|
||||||
|
'still creates a key with only %s',
|
||||||
|
async (scope) => {
|
||||||
|
const created = await service.create(
|
||||||
|
{ name: 'Shop', scopes: [scope] },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
expect(created.apiKey.scopes).toEqual([scope]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
describe('extractApiKeyToken', () => {
|
describe('extractApiKeyToken', () => {
|
||||||
it('erkennt nur Bearer-Schlüssel im ApiCore-Format', () => {
|
it('erkennt nur Bearer-Schlüssel im ApiCore-Format', () => {
|
||||||
expect(extractApiKeyToken(`Bearer ${TOKEN}`)).toBe(TOKEN);
|
expect(extractApiKeyToken(`Bearer ${TOKEN}`)).toBe(TOKEN);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
@@ -11,11 +12,18 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { ApiKey } from '../../generated/prisma';
|
import { ApiKey } from '../../generated/prisma';
|
||||||
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
||||||
import { ApiKeyScope, isApiKeyScope } from './api-key-scopes';
|
import {
|
||||||
|
ApiKeyScope,
|
||||||
|
combinesReadAndWrite,
|
||||||
|
isApiKeyScope,
|
||||||
|
} from './api-key-scopes';
|
||||||
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
|
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
|
||||||
import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
|
import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
|
||||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
import { FailedAttemptLimiter } from './failed-attempt-limiter';
|
import {
|
||||||
|
API_KEY_FAILURE_LIMIT,
|
||||||
|
FailedAttemptLimiter,
|
||||||
|
} from './failed-attempt-limiter';
|
||||||
import { rateLimitSubject } from '../common/throttler/rate-limit-subject';
|
import { rateLimitSubject } from '../common/throttler/rate-limit-subject';
|
||||||
|
|
||||||
function normalizeAllowlist(entries: readonly string[] | undefined): string[] {
|
function normalizeAllowlist(entries: readonly string[] | undefined): string[] {
|
||||||
@@ -38,21 +46,6 @@ const LAST_USED_RESOLUTION_MS = 5 * 60 * 1000;
|
|||||||
// revealing its secret part.
|
// revealing its secret part.
|
||||||
const LOGGABLE_PREFIX_LENGTH = 12;
|
const LOGGABLE_PREFIX_LENGTH = 12;
|
||||||
|
|
||||||
/**
|
|
||||||
* Unknown, revoked or expired keys per client network before that network
|
|
||||||
* gets 429 for further invalid keys for the rest of the window - the same
|
|
||||||
* budget as the shop's ERP export. It slows down guessing and a
|
|
||||||
* misconfigured client; a valid key is never refused by it. Otherwise
|
|
||||||
* anyone able to send requests from (or forge) a shared address - the
|
|
||||||
* cluster egress, a proxy - could lock ERPNext or the shop out, and a key
|
|
||||||
* holds 256 random bits, so letting it through costs nothing.
|
|
||||||
*/
|
|
||||||
export const API_KEY_FAILURE_LIMIT = {
|
|
||||||
maxFailures: 20,
|
|
||||||
windowMs: 15 * 60 * 1000,
|
|
||||||
maxTrackedSources: 10_000,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type RejectReason = 'unknown' | 'revoked' | 'expired';
|
type RejectReason = 'unknown' | 'revoked' | 'expired';
|
||||||
|
|
||||||
/** What a request authenticated by an API key may do - it is not a user. */
|
/** What a request authenticated by an API key may do - it is not a user. */
|
||||||
@@ -91,12 +84,13 @@ function toSummary(apiKey: ApiKey): ApiKeySummary {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApiKeysService {
|
export class ApiKeysService {
|
||||||
private readonly logger = new Logger(ApiKeysService.name);
|
private readonly logger = new Logger(ApiKeysService.name);
|
||||||
private readonly failures = new FailedAttemptLimiter(API_KEY_FAILURE_LIMIT);
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
private readonly permissionResolver: PermissionResolverService,
|
private readonly permissionResolver: PermissionResolverService,
|
||||||
|
// Counters shared by all api pods (ThrottlerHit), not process memory.
|
||||||
|
private readonly failures: FailedAttemptLimiter,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(): Promise<ApiKeySummary[]> {
|
async findAll(): Promise<ApiKeySummary[]> {
|
||||||
@@ -110,6 +104,19 @@ export class ApiKeysService {
|
|||||||
dto: CreateApiKeyDto,
|
dto: CreateApiKeyDto,
|
||||||
actingUserId: string,
|
actingUserId: string,
|
||||||
): Promise<CreatedApiKey> {
|
): Promise<CreatedApiKey> {
|
||||||
|
// One scope per new key: a leaked read key cannot book, a leaked booking
|
||||||
|
// key cannot read, and each connected system gets its own key.
|
||||||
|
// Existing multi-scope keys stay valid (ARCHITECTURE.md, Annahme 14).
|
||||||
|
if (combinesReadAndWrite(dto.scopes)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (new Set(dto.scopes).size > 1) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Je Schlüssel nur eine Berechtigung - bitte getrennte Schlüssel anlegen',
|
||||||
|
);
|
||||||
|
}
|
||||||
// Nobody hands a machine more than they may do themselves.
|
// Nobody hands a machine more than they may do themselves.
|
||||||
const callerPermissions =
|
const callerPermissions =
|
||||||
await this.permissionResolver.getPermissions(actingUserId);
|
await this.permissionResolver.getPermissions(actingUserId);
|
||||||
@@ -224,20 +231,21 @@ export class ApiKeysService {
|
|||||||
if (reason === null && apiKey) {
|
if (reason === null && apiKey) {
|
||||||
return this.accept(apiKey, clientIp, now);
|
return this.accept(apiKey, clientIp, now);
|
||||||
}
|
}
|
||||||
this.recordRejection(token, reason ?? 'unknown', clientIp);
|
await this.recordRejection(token, reason ?? 'unknown', clientIp);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Counts an invalid key; 429 once the client network is over the limit. */
|
/** Counts an invalid key; 429 once the client network is over the limit. */
|
||||||
private recordRejection(
|
private async recordRejection(
|
||||||
token: string,
|
token: string,
|
||||||
reason: RejectReason,
|
reason: RejectReason,
|
||||||
clientIp: string | undefined,
|
clientIp: string | undefined,
|
||||||
): void {
|
): Promise<void> {
|
||||||
// Counted per client network like the throttler (IPv6 /64), so rotating
|
// Counted per client network like the throttler (IPv6 /64), so rotating
|
||||||
// addresses inside one's own prefix does not reset the budget.
|
// addresses inside one's own prefix does not reset the budget.
|
||||||
const subject = clientIp ? rateLimitSubject(clientIp) : 'unknown';
|
const subject = clientIp ? rateLimitSubject(clientIp) : 'unknown';
|
||||||
if (this.failures.isBlocked(subject)) {
|
const verdict = await this.failures.recordFailure(subject);
|
||||||
|
if (verdict.blocked) {
|
||||||
// Same body shape as the built-in 4xx exceptions (JSON object, not a
|
// Same body shape as the built-in 4xx exceptions (JSON object, not a
|
||||||
// bare string), so clients can read `message` uniformly.
|
// bare string), so clients can read `message` uniformly.
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
@@ -254,9 +262,9 @@ export class ApiKeysService {
|
|||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`API key rejected (${reason}): ${token.slice(0, LOGGABLE_PREFIX_LENGTH)} from ${clientIp ?? 'unknown'}`,
|
`API key rejected (${reason}): ${token.slice(0, LOGGABLE_PREFIX_LENGTH)} from ${clientIp ?? 'unknown'}`,
|
||||||
);
|
);
|
||||||
if (this.failures.recordFailure(subject)) {
|
if (verdict.limitReached) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Invalid API keys from ${subject} refused for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes after ${API_KEY_FAILURE_LIMIT.maxFailures} failures`,
|
`Invalid API keys from ${subject} reached ${API_KEY_FAILURE_LIMIT.maxFailures} failures - further invalid keys get 429 for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,112 +1,91 @@
|
|||||||
import { FailedAttemptLimiter } from './failed-attempt-limiter';
|
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
|
||||||
|
import {
|
||||||
|
API_KEY_FAILURE_LIMIT,
|
||||||
|
API_KEY_FAILURE_THROTTLER,
|
||||||
|
FailedAttemptLimiter,
|
||||||
|
} from './failed-attempt-limiter';
|
||||||
|
|
||||||
describe('FailedAttemptLimiter', () => {
|
describe('FailedAttemptLimiter', () => {
|
||||||
let now: number;
|
const increment = jest.fn();
|
||||||
let limiter: FailedAttemptLimiter;
|
const limiter = new FailedAttemptLimiter({
|
||||||
|
increment,
|
||||||
|
} as unknown as PrismaThrottlerStorage);
|
||||||
|
|
||||||
|
function record(totalHits: number, isBlocked: boolean) {
|
||||||
|
return {
|
||||||
|
totalHits,
|
||||||
|
timeToExpire: 600,
|
||||||
|
isBlocked,
|
||||||
|
timeToBlockExpire: isBlocked ? 900 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
now = 1_000_000;
|
increment.mockReset();
|
||||||
limiter = new FailedAttemptLimiter({
|
increment.mockResolvedValue(record(1, false));
|
||||||
maxFailures: 3,
|
});
|
||||||
windowMs: 60_000,
|
|
||||||
maxTrackedSources: 100,
|
it('zählt in der gemeinsamen Zähler-Tabelle, getrennt vom Anfrage-Limit', async () => {
|
||||||
clock: () => now,
|
await limiter.recordFailure('203.0.113.7');
|
||||||
|
|
||||||
|
// Ein Aufruf = eine Upsert-Anweisung mit DB-Uhr (PrismaThrottlerStorage),
|
||||||
|
// unter eigenem Throttler-Namen und eigenem Schlüssel-Präfix.
|
||||||
|
expect(increment).toHaveBeenCalledTimes(1);
|
||||||
|
expect(increment).toHaveBeenCalledWith(
|
||||||
|
`${API_KEY_FAILURE_THROTTLER}:203.0.113.7`,
|
||||||
|
API_KEY_FAILURE_LIMIT.windowMs,
|
||||||
|
API_KEY_FAILURE_LIMIT.maxFailures,
|
||||||
|
API_KEY_FAILURE_LIMIT.windowMs,
|
||||||
|
API_KEY_FAILURE_THROTTLER,
|
||||||
|
);
|
||||||
|
expect(API_KEY_FAILURE_THROTTLER).toBe('api-key-failures');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('behält die vereinbarten Grenzen: 20 Fehlversuche in 15 Minuten', () => {
|
||||||
|
expect(API_KEY_FAILURE_LIMIT).toEqual({
|
||||||
|
maxFailures: 20,
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sperrt eine Quelle erst, wenn die Obergrenze erreicht ist', () => {
|
it('sperrt nicht unterhalb der Grenze', async () => {
|
||||||
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
|
increment.mockResolvedValueOnce(
|
||||||
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
|
record(API_KEY_FAILURE_LIMIT.maxFailures - 1, false),
|
||||||
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
|
);
|
||||||
|
|
||||||
// Der Fehlversuch, mit dem die Grenze erreicht wird, meldet die Sperre.
|
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
|
||||||
expect(limiter.recordFailure('203.0.113.7')).toBe(true);
|
blocked: false,
|
||||||
expect(limiter.isBlocked('203.0.113.7')).toBe(true);
|
limitReached: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('meldet die Sperre nur einmal, nicht bei jedem weiteren Versuch', () => {
|
it('meldet genau den Fehlversuch, mit dem die Grenze erreicht ist', async () => {
|
||||||
limiter.recordFailure('203.0.113.7');
|
increment.mockResolvedValueOnce(
|
||||||
limiter.recordFailure('203.0.113.7');
|
record(API_KEY_FAILURE_LIMIT.maxFailures, false),
|
||||||
expect(limiter.recordFailure('203.0.113.7')).toBe(true);
|
);
|
||||||
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
|
|
||||||
|
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
|
||||||
|
blocked: false,
|
||||||
|
limitReached: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('trennt Quellen voneinander', () => {
|
it('sperrt, sobald die Datenbank die Quelle als gesperrt meldet', async () => {
|
||||||
for (let i = 0; i < 3; i++) limiter.recordFailure('203.0.113.7');
|
increment.mockResolvedValueOnce(
|
||||||
expect(limiter.isBlocked('203.0.113.7')).toBe(true);
|
record(API_KEY_FAILURE_LIMIT.maxFailures + 1, true),
|
||||||
expect(limiter.isBlocked('198.51.100.1')).toBe(false);
|
);
|
||||||
|
|
||||||
|
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
|
||||||
|
blocked: true,
|
||||||
|
limitReached: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gibt die Quelle nach Ablauf des Fensters wieder frei', () => {
|
it('gibt Datenbankfehler weiter, statt still durchzulassen', async () => {
|
||||||
for (let i = 0; i < 3; i++) limiter.recordFailure('203.0.113.7');
|
increment.mockRejectedValueOnce(new Error('db down'));
|
||||||
now += 60_001;
|
|
||||||
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
|
|
||||||
// Neues Fenster: der nächste Fehlversuch zählt wieder ab eins.
|
|
||||||
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
|
|
||||||
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('vergisst alte Quellen, statt unbegrenzt Speicher zu belegen', () => {
|
await expect(limiter.recordFailure('203.0.113.7')).rejects.toThrow(
|
||||||
const small = new FailedAttemptLimiter({
|
'db down',
|
||||||
maxFailures: 1,
|
);
|
||||||
windowMs: 60_000,
|
|
||||||
maxTrackedSources: 2,
|
|
||||||
clock: () => now,
|
|
||||||
});
|
|
||||||
small.recordFailure('a');
|
|
||||||
small.recordFailure('b');
|
|
||||||
now += 60_001;
|
|
||||||
// Beim dritten Eintrag wird aufgeräumt: a und b sind abgelaufen.
|
|
||||||
small.recordFailure('c');
|
|
||||||
expect(small.trackedSources()).toBe(1);
|
|
||||||
expect(small.isBlocked('c')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('verdrängt bei voller Liste den ältesten Eintrag, auch wenn er noch läuft', () => {
|
|
||||||
const small = new FailedAttemptLimiter({
|
|
||||||
maxFailures: 5,
|
|
||||||
windowMs: 60_000,
|
|
||||||
maxTrackedSources: 2,
|
|
||||||
clock: () => now,
|
|
||||||
});
|
|
||||||
small.recordFailure('a');
|
|
||||||
small.recordFailure('b');
|
|
||||||
small.recordFailure('c');
|
|
||||||
expect(small.trackedSources()).toBe(2);
|
|
||||||
});
|
|
||||||
it('verdrängt bei voller Liste zuerst nicht gesperrte Quellen', () => {
|
|
||||||
const small = new FailedAttemptLimiter({
|
|
||||||
maxFailures: 2,
|
|
||||||
windowMs: 60_000,
|
|
||||||
maxTrackedSources: 2,
|
|
||||||
clock: () => now,
|
|
||||||
});
|
|
||||||
small.recordFailure('angreifer');
|
|
||||||
small.recordFailure('angreifer'); // gesperrt, ältester Eintrag
|
|
||||||
small.recordFailure('harmlos'); // ein Fehlversuch, nicht gesperrt
|
|
||||||
small.recordFailure('neu');
|
|
||||||
|
|
||||||
expect(small.trackedSources()).toBe(2);
|
|
||||||
// Die Sperre bleibt, obwohl sie der älteste Eintrag ist.
|
|
||||||
expect(small.isBlocked('angreifer')).toBe(true);
|
|
||||||
// Verdrängt wurde der nicht gesperrte Eintrag.
|
|
||||||
expect(small.recordFailure('harmlos')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('verdrängt gesperrte Quellen erst, wenn alle gesperrt sind (älteste zuerst)', () => {
|
|
||||||
const small = new FailedAttemptLimiter({
|
|
||||||
maxFailures: 1,
|
|
||||||
windowMs: 60_000,
|
|
||||||
maxTrackedSources: 2,
|
|
||||||
clock: () => now,
|
|
||||||
});
|
|
||||||
small.recordFailure('a');
|
|
||||||
small.recordFailure('b');
|
|
||||||
small.recordFailure('c');
|
|
||||||
|
|
||||||
expect(small.trackedSources()).toBe(2);
|
|
||||||
expect(small.isBlocked('a')).toBe(false);
|
|
||||||
expect(small.isBlocked('b')).toBe(true);
|
|
||||||
expect(small.isBlocked('c')).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,94 +1,57 @@
|
|||||||
export interface FailedAttemptLimiterOptions {
|
import { Injectable } from '@nestjs/common';
|
||||||
/** Ab so vielen Fehlversuchen im Fenster ist die Quelle gesperrt. */
|
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
|
||||||
maxFailures: number;
|
|
||||||
/** Länge des Fensters in Millisekunden, gezählt ab dem ersten Fehlversuch. */
|
|
||||||
windowMs: number;
|
|
||||||
/** Obergrenze gemerkter Quellen - schützt den Speicher bei verteilten Angriffen. */
|
|
||||||
maxTrackedSources: number;
|
|
||||||
clock?: () => number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Window {
|
/**
|
||||||
failures: number;
|
* Unknown, revoked or expired keys per client network before that network
|
||||||
startedAt: number;
|
* gets 429 for further invalid keys - the same budget the ERP export
|
||||||
|
* contract names (docs/erp-inventar-export.md). It slows down guessing and a
|
||||||
|
* misconfigured client; a valid key is never refused by it. Otherwise
|
||||||
|
* anyone able to send requests from (or forge) a shared address - the
|
||||||
|
* cluster egress, a proxy - could lock ERPNext or the shop out, and a key
|
||||||
|
* holds 256 random bits, so letting it through costs nothing.
|
||||||
|
*/
|
||||||
|
export const API_KEY_FAILURE_LIMIT = {
|
||||||
|
maxFailures: 20,
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throttler name and key prefix of these counters in "ThrottlerHit", so
|
||||||
|
* they never mix with the request rate limits stored in the same table.
|
||||||
|
*/
|
||||||
|
export const API_KEY_FAILURE_THROTTLER = 'api-key-failures';
|
||||||
|
|
||||||
|
export interface FailedAttemptVerdict {
|
||||||
|
/** The source is over the limit: refuse the request with 429. */
|
||||||
|
blocked: boolean;
|
||||||
|
/** This failure is the one that reached the limit (once per window). */
|
||||||
|
limitReached: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zählt fehlgeschlagene Anmeldungen je Quelle (Client-IP) in einem festen
|
* Counts invalid API keys per source (client network) in the shared,
|
||||||
* Zeitfenster und sperrt die Quelle, sobald die Obergrenze erreicht ist.
|
* database-backed rate-limit storage, so the limit holds across all api
|
||||||
*
|
* pods. One upsert per failure, every time comparison on the database
|
||||||
* Bewusst im Speicher des Prozesses, wie der globale Throttler: ApiCore läuft
|
* clock (PrismaThrottlerStorage). The window starts with the first failure;
|
||||||
* mit einem Backend-Pod. Nach einem Neustart beginnt die Zählung von vorn -
|
* the failure after the limit blocks the source for one window length.
|
||||||
* das ist für eine Bremse gegen Durchprobieren ausreichend.
|
* A database error propagates: the request fails instead of passing.
|
||||||
*/
|
*/
|
||||||
|
@Injectable()
|
||||||
export class FailedAttemptLimiter {
|
export class FailedAttemptLimiter {
|
||||||
private readonly windows = new Map<string, Window>();
|
constructor(private readonly storage: PrismaThrottlerStorage) {}
|
||||||
private readonly clock: () => number;
|
|
||||||
|
|
||||||
constructor(private readonly options: FailedAttemptLimiterOptions) {
|
async recordFailure(source: string): Promise<FailedAttemptVerdict> {
|
||||||
this.clock = options.clock ?? Date.now;
|
const { maxFailures, windowMs } = API_KEY_FAILURE_LIMIT;
|
||||||
}
|
const record = await this.storage.increment(
|
||||||
|
`${API_KEY_FAILURE_THROTTLER}:${source}`,
|
||||||
isBlocked(source: string): boolean {
|
windowMs,
|
||||||
const window = this.activeWindow(source);
|
maxFailures,
|
||||||
return window !== undefined && window.failures >= this.options.maxFailures;
|
windowMs,
|
||||||
}
|
API_KEY_FAILURE_THROTTLER,
|
||||||
|
);
|
||||||
/** Zählt einen Fehlversuch; true genau dann, wenn er die Sperre auslöst. */
|
return {
|
||||||
recordFailure(source: string): boolean {
|
blocked: record.isBlocked,
|
||||||
const now = this.clock();
|
limitReached: !record.isBlocked && record.totalHits === maxFailures,
|
||||||
const current = this.activeWindow(source);
|
};
|
||||||
if (!current) {
|
|
||||||
this.makeRoom(now);
|
|
||||||
}
|
|
||||||
const failures = (current?.failures ?? 0) + 1;
|
|
||||||
this.windows.set(source, {
|
|
||||||
failures,
|
|
||||||
startedAt: current?.startedAt ?? now,
|
|
||||||
});
|
|
||||||
return failures === this.options.maxFailures;
|
|
||||||
}
|
|
||||||
|
|
||||||
trackedSources(): number {
|
|
||||||
return this.windows.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
private activeWindow(source: string): Window | undefined {
|
|
||||||
const window = this.windows.get(source);
|
|
||||||
if (window && this.clock() - window.startedAt > this.options.windowMs) {
|
|
||||||
this.windows.delete(source);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return window;
|
|
||||||
}
|
|
||||||
|
|
||||||
private makeRoom(now: number): void {
|
|
||||||
if (this.windows.size < this.options.maxTrackedSources) return;
|
|
||||||
for (const [source, window] of this.windows) {
|
|
||||||
if (now - window.startedAt > this.options.windowMs) {
|
|
||||||
this.windows.delete(source);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Immer noch voll: lieber eine Zählung verlieren als unbegrenzt wachsen.
|
|
||||||
// Zuerst den ältesten nicht gesperrten Eintrag verdrängen - sonst könnte
|
|
||||||
// ein Angreifer mit vielen Adressen seine eigene Sperre hinausschieben.
|
|
||||||
// Nur wenn alle gesperrt sind, geht der älteste gesperrte (die Map behält
|
|
||||||
// die Einfügereihenfolge).
|
|
||||||
if (this.windows.size >= this.options.maxTrackedSources) {
|
|
||||||
const victim =
|
|
||||||
this.oldestWhere(
|
|
||||||
(window) => window.failures < this.options.maxFailures,
|
|
||||||
) ?? this.oldestWhere(() => true);
|
|
||||||
if (victim !== undefined) this.windows.delete(victim);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private oldestWhere(
|
|
||||||
matches: (window: Window) => boolean,
|
|
||||||
): string | undefined {
|
|
||||||
for (const [source, window] of this.windows) {
|
|
||||||
if (matches(window)) return source;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,12 +15,16 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
|||||||
import { CreateApiaryDto } from './dto/create-apiary.dto';
|
import { CreateApiaryDto } from './dto/create-apiary.dto';
|
||||||
import { UpdateApiaryDto } from './dto/update-apiary.dto';
|
import { UpdateApiaryDto } from './dto/update-apiary.dto';
|
||||||
import { ApiariesService } from './apiaries.service';
|
import { ApiariesService } from './apiaries.service';
|
||||||
|
import { ApiaryFieldService } from './apiary-field.service';
|
||||||
|
|
||||||
@ApiTags('apiaries')
|
@ApiTags('apiaries')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('apiaries')
|
@Controller('apiaries')
|
||||||
export class ApiariesController {
|
export class ApiariesController {
|
||||||
constructor(private readonly apiariesService: ApiariesService) {}
|
constructor(
|
||||||
|
private readonly apiariesService: ApiariesService,
|
||||||
|
private readonly apiaryFieldService: ApiaryFieldService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@RequirePermission('apiaries:read')
|
@RequirePermission('apiaries:read')
|
||||||
@Get()
|
@Get()
|
||||||
@@ -31,6 +35,24 @@ export class ApiariesController {
|
|||||||
return this.apiariesService.findAll({ status, location });
|
return this.apiariesService.findAll({ status, location });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequirePermission('colonies:read')
|
||||||
|
@Get(':id/colony-overview')
|
||||||
|
colonyOverview(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.apiaryFieldService.colonyOverview(id, actor.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('colonies:read')
|
||||||
|
@Get(':id/offline-pack')
|
||||||
|
offlinePack(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.apiaryFieldService.offlinePack(id, actor.id);
|
||||||
|
}
|
||||||
|
|
||||||
@RequirePermission('apiaries:read')
|
@RequirePermission('apiaries:read')
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
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';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [AutopilotModule],
|
||||||
controllers: [ApiariesController],
|
controllers: [ApiariesController],
|
||||||
providers: [ApiariesService],
|
providers: [ApiariesService, ApiaryFieldService],
|
||||||
exports: [ApiariesService],
|
exports: [ApiariesService],
|
||||||
})
|
})
|
||||||
export class ApiariesModule {}
|
export class ApiariesModule {}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
|
import { ApiaryFieldService } from './apiary-field.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.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', () => {
|
||||||
|
let service: ApiaryFieldService;
|
||||||
|
let prisma: {
|
||||||
|
apiary: { findUnique: jest.Mock };
|
||||||
|
colony: { findMany: jest.Mock };
|
||||||
|
inspection: { groupBy: jest.Mock; findMany: jest.Mock };
|
||||||
|
$queryRaw: jest.Mock;
|
||||||
|
queen: { findMany: jest.Mock };
|
||||||
|
task: { findMany: 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) => ({
|
||||||
|
id,
|
||||||
|
code,
|
||||||
|
apiaryId: 'apiary-1',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
|
hiveType: null,
|
||||||
|
strength: 5,
|
||||||
|
occupiedFrames: 12,
|
||||||
|
broodStatus: null,
|
||||||
|
feedStatus: null,
|
||||||
|
healthStatus: null,
|
||||||
|
currentQueenId: queenId,
|
||||||
|
notes: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const grant = (...keys: string[]) =>
|
||||||
|
permissionResolver.getPermissions.mockResolvedValue(new Set(keys));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma = {
|
||||||
|
apiary: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue({
|
||||||
|
id: 'apiary-1',
|
||||||
|
name: 'Heimstand',
|
||||||
|
location: 'Garten',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
colony: {
|
||||||
|
findMany: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
|
colonyRow('c10', 'V-10', 'q1'),
|
||||||
|
colonyRow('c2', 'V-2', null),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
inspection: {
|
||||||
|
groupBy: jest.fn().mockResolvedValue([]),
|
||||||
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||||
|
queen: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
task: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
};
|
||||||
|
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(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
permissionResolver as unknown as PermissionResolverService,
|
||||||
|
nextSteps as unknown as NextStepsService,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundException for an unknown apiary', async () => {
|
||||||
|
grant('colonies:read');
|
||||||
|
prisma.apiary.findUnique.mockResolvedValue(null);
|
||||||
|
await expect(service.colonyOverview('x', 'u1')).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts living colonies by code with numeric collation', async () => {
|
||||||
|
grant('colonies:read');
|
||||||
|
const overview = await service.colonyOverview('apiary-1', 'u1');
|
||||||
|
expect(overview.rows.map((row) => row.code)).toEqual(['V-2', 'V-10']);
|
||||||
|
expect(prisma.colony.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({ apiaryId: 'apiary-1' }) as unknown,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves out inspections, queens and tasks without their read permission', async () => {
|
||||||
|
grant('colonies:read');
|
||||||
|
const pack = await service.offlinePack('apiary-1', 'u1');
|
||||||
|
expect(pack.inspections).toBeNull();
|
||||||
|
expect(pack.queens).toBeNull();
|
||||||
|
expect(pack.tasks).toBeNull();
|
||||||
|
expect(pack.overview.inspectionsVisible).toBe(false);
|
||||||
|
expect(pack.overview.rows.every((r) => r.lastInspectionDate === null)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(prisma.inspection.groupBy).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.inspection.findMany).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.queen.findMany).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.task.findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills the permitted sections and the last inspection date', async () => {
|
||||||
|
grant('colonies:read', 'inspections:read', 'queens:read', 'tasks:read');
|
||||||
|
const date = new Date('2026-05-01T10:00:00.000Z');
|
||||||
|
prisma.inspection.groupBy.mockResolvedValue([
|
||||||
|
{ colonyId: 'c2', _max: { date } },
|
||||||
|
]);
|
||||||
|
prisma.queen.findMany.mockResolvedValue([{ id: 'q1', code: 'K-1' }]);
|
||||||
|
prisma.task.findMany.mockResolvedValue([{ id: 't1', title: 'Futter' }]);
|
||||||
|
|
||||||
|
const pack = await service.offlinePack('apiary-1', 'u1');
|
||||||
|
|
||||||
|
expect(pack.overview.inspectionsVisible).toBe(true);
|
||||||
|
expect(pack.overview.rows[0].lastInspectionDate).toBe(date.toISOString());
|
||||||
|
expect(pack.overview.rows[1].lastInspectionDate).toBeNull();
|
||||||
|
expect(pack.queens).toEqual([{ id: 'q1', code: 'K-1' }]);
|
||||||
|
expect(prisma.queen.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: { id: { in: ['q1'] } } }),
|
||||||
|
);
|
||||||
|
expect(pack.tasks).toEqual([{ id: 't1', title: 'Futter' }]);
|
||||||
|
expect(prisma.task.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: {
|
||||||
|
status: { in: ['OPEN', 'IN_PROGRESS'] },
|
||||||
|
OR: [
|
||||||
|
{ relatedType: 'APIARY', relatedId: 'apiary-1' },
|
||||||
|
{ relatedType: 'COLONY', relatedId: { in: ['c2', 'c10'] } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps at most three inspections per colony, newest first', async () => {
|
||||||
|
grant('colonies:read', 'inspections:read');
|
||||||
|
prisma.inspection.findMany.mockResolvedValue([{ id: 'i1' }]);
|
||||||
|
|
||||||
|
const pack = await service.offlinePack('apiary-1', 'u1');
|
||||||
|
|
||||||
|
expect(prisma.inspection.findMany).toHaveBeenCalledTimes(2);
|
||||||
|
expect(prisma.inspection.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { colonyId: 'c2' },
|
||||||
|
take: 3,
|
||||||
|
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }],
|
||||||
|
omit: { userId: true },
|
||||||
|
});
|
||||||
|
expect(pack.inspections).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never returns coordinates, address, apiary notes or inspection userId', async () => {
|
||||||
|
grant('colonies:read', 'inspections:read');
|
||||||
|
const pack = await service.offlinePack('apiary-1', 'u1');
|
||||||
|
|
||||||
|
expect(Object.keys(pack.apiary).sort()).toEqual([
|
||||||
|
'id',
|
||||||
|
'location',
|
||||||
|
'name',
|
||||||
|
'status',
|
||||||
|
]);
|
||||||
|
const apiarySelect = (
|
||||||
|
prisma.apiary.findUnique.mock.calls[0] as [{ select: object }]
|
||||||
|
)[0].select;
|
||||||
|
expect(apiarySelect).not.toHaveProperty('latitude');
|
||||||
|
expect(apiarySelect).not.toHaveProperty('longitude');
|
||||||
|
expect(apiarySelect).not.toHaveProperty('address');
|
||||||
|
expect(apiarySelect).not.toHaveProperty('notes');
|
||||||
|
const inspectionArgs = (
|
||||||
|
prisma.inspection.findMany.mock.calls[0] as [{ omit: object }]
|
||||||
|
)[0];
|
||||||
|
expect(inspectionArgs.omit).toEqual({ userId: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes the warning counts from the open suggestions the caller may see', async () => {
|
||||||
|
grant('colonies:read', 'tasks:read');
|
||||||
|
nextSteps.warningCounts.mockResolvedValue(
|
||||||
|
new Map([['c2', { warnings: 2, critical: 1 }]]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const overview = await service.colonyOverview('apiary-1', 'u1');
|
||||||
|
|
||||||
|
expect(nextSteps.warningCounts).toHaveBeenCalledWith(
|
||||||
|
['c2', 'c10'],
|
||||||
|
'u1',
|
||||||
|
new Set(['colonies:read', 'tasks:read']),
|
||||||
|
);
|
||||||
|
expect(overview.rows[0]).toMatchObject({
|
||||||
|
code: 'V-2',
|
||||||
|
openWarnings: 2,
|
||||||
|
openCritical: 1,
|
||||||
|
});
|
||||||
|
expect(overview.rows[1]).toMatchObject({
|
||||||
|
openWarnings: 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
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 { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import type {
|
||||||
|
ColonyOverview,
|
||||||
|
ColonyOverviewRow,
|
||||||
|
OfflinePackV1,
|
||||||
|
} from './apiary-field.types';
|
||||||
|
|
||||||
|
const INSPECTIONS_PER_COLONY = 3;
|
||||||
|
|
||||||
|
type PackColony = OfflinePackV1['colonies'][number];
|
||||||
|
|
||||||
|
interface FieldData {
|
||||||
|
apiary: OfflinePackV1['apiary'];
|
||||||
|
colonies: PackColony[];
|
||||||
|
permissions: ReadonlySet<string>;
|
||||||
|
overview: ColonyOverview;
|
||||||
|
nextSteps: ReadonlyMap<string, ColonyNextSteps>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read models for work at the apiary: the colony traffic-light overview and
|
||||||
|
* the offline pack the PWA caches before the beekeeper loses reception.
|
||||||
|
* Sections beyond the colonies are only included for callers who may read them.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ApiaryFieldService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissionResolver: PermissionResolverService,
|
||||||
|
private readonly nextSteps: NextStepsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async colonyOverview(
|
||||||
|
apiaryId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<ColonyOverview> {
|
||||||
|
return (await this.load(apiaryId, userId)).overview;
|
||||||
|
}
|
||||||
|
|
||||||
|
async offlinePack(apiaryId: string, userId: string): Promise<OfflinePackV1> {
|
||||||
|
const { apiary, colonies, permissions, overview, nextSteps } =
|
||||||
|
await this.load(apiaryId, userId);
|
||||||
|
const [queens, inspections, tasks] = await Promise.all([
|
||||||
|
permissions.has('queens:read') ? this.loadQueens(colonies) : null,
|
||||||
|
permissions.has('inspections:read')
|
||||||
|
? this.loadInspections(colonies)
|
||||||
|
: null,
|
||||||
|
permissions.has('tasks:read') ? this.loadTasks(apiaryId, colonies) : null,
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
apiary,
|
||||||
|
overview,
|
||||||
|
colonies,
|
||||||
|
queens,
|
||||||
|
inspections,
|
||||||
|
tasks,
|
||||||
|
nextSteps: Object.fromEntries(
|
||||||
|
colonies.map((colony) => [
|
||||||
|
colony.id,
|
||||||
|
nextSteps.get(colony.id)?.steps ?? [],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(apiaryId: string, userId: string): Promise<FieldData> {
|
||||||
|
// Explicit select: coordinates, address and notes of an apiary are
|
||||||
|
// theft-sensitive and must never reach the field payloads.
|
||||||
|
const apiary = await this.prisma.apiary.findUnique({
|
||||||
|
where: { id: apiaryId },
|
||||||
|
select: { id: true, name: true, location: true, status: true },
|
||||||
|
});
|
||||||
|
if (!apiary) {
|
||||||
|
throw new NotFoundException('Apiary not found');
|
||||||
|
}
|
||||||
|
const permissions = await this.permissionResolver.getPermissions(userId);
|
||||||
|
const found = await this.prisma.colony.findMany({
|
||||||
|
where: { ...LIVING_COLONY_WHERE, apiaryId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
apiaryId: true,
|
||||||
|
status: true,
|
||||||
|
colonyType: true,
|
||||||
|
hiveType: true,
|
||||||
|
strength: true,
|
||||||
|
occupiedFrames: true,
|
||||||
|
broodStatus: true,
|
||||||
|
feedStatus: true,
|
||||||
|
healthStatus: true,
|
||||||
|
currentQueenId: true,
|
||||||
|
notes: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const colonies = [...found].sort((a, b) =>
|
||||||
|
a.code.localeCompare(b.code, 'de', { numeric: true }),
|
||||||
|
);
|
||||||
|
const inspectionsVisible = permissions.has('inspections:read');
|
||||||
|
const colonyIds = colonies.map((colony) => colony.id);
|
||||||
|
const [lastInspections, warnings, nextSteps] = await Promise.all([
|
||||||
|
inspectionsVisible ? this.lastInspectionDates(colonies) : null,
|
||||||
|
this.nextSteps.warningCounts(colonyIds, userId, permissions),
|
||||||
|
this.nextSteps.forColonies(colonyIds, userId, undefined, permissions),
|
||||||
|
]);
|
||||||
|
const rows = colonies.map((colony): ColonyOverviewRow => ({
|
||||||
|
id: colony.id,
|
||||||
|
code: colony.code,
|
||||||
|
status: colony.status,
|
||||||
|
strength: colony.strength,
|
||||||
|
occupiedFrames: colony.occupiedFrames,
|
||||||
|
broodStatus: colony.broodStatus,
|
||||||
|
feedStatus: colony.feedStatus,
|
||||||
|
healthStatus: colony.healthStatus,
|
||||||
|
lastInspectionDate: lastInspections?.get(colony.id) ?? null,
|
||||||
|
inspectionDueOn: nextSteps.get(colony.id)?.inspectionDueOn ?? null,
|
||||||
|
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 {
|
||||||
|
apiary,
|
||||||
|
colonies,
|
||||||
|
permissions,
|
||||||
|
overview: { apiaryId, inspectionsVisible, rows },
|
||||||
|
nextSteps,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lastInspectionDates(
|
||||||
|
colonies: PackColony[],
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
const groups = await this.prisma.inspection.groupBy({
|
||||||
|
by: ['colonyId'],
|
||||||
|
where: { colonyId: { in: colonies.map((colony) => colony.id) } },
|
||||||
|
_max: { date: true },
|
||||||
|
});
|
||||||
|
const dates = new Map<string, string>();
|
||||||
|
for (const group of groups) {
|
||||||
|
if (group._max.date) {
|
||||||
|
dates.set(group.colonyId, group._max.date.toISOString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadQueens(colonies: PackColony[]): Promise<OfflinePackV1['queens']> {
|
||||||
|
const queenIds = colonies.flatMap((colony) =>
|
||||||
|
colony.currentQueenId ? [colony.currentQueenId] : [],
|
||||||
|
);
|
||||||
|
return this.prisma.queen.findMany({
|
||||||
|
where: { id: { in: queenIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
hatchYear: true,
|
||||||
|
breed: true,
|
||||||
|
markingColor: true,
|
||||||
|
markingNumber: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadInspections(
|
||||||
|
colonies: PackColony[],
|
||||||
|
): Promise<OfflinePackV1['inspections']> {
|
||||||
|
const perColony = await Promise.all(
|
||||||
|
colonies.map((colony) =>
|
||||||
|
this.prisma.inspection.findMany({
|
||||||
|
where: { colonyId: colony.id },
|
||||||
|
take: INSPECTIONS_PER_COLONY,
|
||||||
|
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }],
|
||||||
|
omit: { userId: true },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return perColony.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadTasks(
|
||||||
|
apiaryId: string,
|
||||||
|
colonies: PackColony[],
|
||||||
|
): Promise<OfflinePackV1['tasks']> {
|
||||||
|
return this.prisma.task.findMany({
|
||||||
|
where: {
|
||||||
|
status: { in: ['OPEN', 'IN_PROGRESS'] },
|
||||||
|
OR: [
|
||||||
|
{ relatedType: 'APIARY', relatedId: apiaryId },
|
||||||
|
{
|
||||||
|
relatedType: 'COLONY',
|
||||||
|
relatedId: { in: colonies.map((colony) => colony.id) },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
dueDate: true,
|
||||||
|
priority: true,
|
||||||
|
status: true,
|
||||||
|
relatedType: true,
|
||||||
|
relatedId: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ dueDate: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type {
|
||||||
|
ApiaryStatus,
|
||||||
|
Colony,
|
||||||
|
ColonyCondition,
|
||||||
|
ColonyStatus,
|
||||||
|
Inspection,
|
||||||
|
Queen,
|
||||||
|
Task,
|
||||||
|
} 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. */
|
||||||
|
export interface ColonyOverviewRow {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
status: ColonyStatus;
|
||||||
|
strength: number | null;
|
||||||
|
occupiedFrames: number | null;
|
||||||
|
broodStatus: ColonyCondition | null;
|
||||||
|
feedStatus: ColonyCondition | null;
|
||||||
|
healthStatus: ColonyCondition | null;
|
||||||
|
/** ISO timestamp; always null when the caller may not read inspections. */
|
||||||
|
lastInspectionDate: string | null;
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
/** Open suggestions of priority URGENT the caller may see. */
|
||||||
|
openCritical: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColonyOverview {
|
||||||
|
apiaryId: string;
|
||||||
|
inspectionsVisible: boolean;
|
||||||
|
rows: ColonyOverviewRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the offline apiary mode needs. It deliberately carries no
|
||||||
|
* coordinates, address or apiary notes (apiary locations are theft-sensitive)
|
||||||
|
* and no inspection userId. Sections the caller has no read permission for
|
||||||
|
* are null.
|
||||||
|
*/
|
||||||
|
export interface OfflinePackV1 {
|
||||||
|
version: 1;
|
||||||
|
generatedAt: string;
|
||||||
|
apiary: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
location: string | null;
|
||||||
|
status: ApiaryStatus;
|
||||||
|
};
|
||||||
|
overview: ColonyOverview;
|
||||||
|
colonies: Pick<
|
||||||
|
Colony,
|
||||||
|
| 'id'
|
||||||
|
| 'code'
|
||||||
|
| 'apiaryId'
|
||||||
|
| 'status'
|
||||||
|
| 'colonyType'
|
||||||
|
| 'hiveType'
|
||||||
|
| 'strength'
|
||||||
|
| 'occupiedFrames'
|
||||||
|
| 'broodStatus'
|
||||||
|
| 'feedStatus'
|
||||||
|
| 'healthStatus'
|
||||||
|
| 'currentQueenId'
|
||||||
|
| 'notes'
|
||||||
|
>[];
|
||||||
|
queens:
|
||||||
|
| Pick<
|
||||||
|
Queen,
|
||||||
|
| 'id'
|
||||||
|
| 'code'
|
||||||
|
| 'hatchYear'
|
||||||
|
| 'breed'
|
||||||
|
| 'markingColor'
|
||||||
|
| 'markingNumber'
|
||||||
|
| 'status'
|
||||||
|
>[]
|
||||||
|
| null;
|
||||||
|
inspections: Omit<Inspection, 'userId'>[] | null;
|
||||||
|
tasks:
|
||||||
|
| Pick<
|
||||||
|
Task,
|
||||||
|
| 'id'
|
||||||
|
| 'title'
|
||||||
|
| 'description'
|
||||||
|
| 'dueDate'
|
||||||
|
| 'priority'
|
||||||
|
| 'status'
|
||||||
|
| 'relatedType'
|
||||||
|
| 'relatedId'
|
||||||
|
>[]
|
||||||
|
| null;
|
||||||
|
/**
|
||||||
|
* The next steps per colony id (what `GET /colonies/:id/next-steps`
|
||||||
|
* returns as `steps`). Absent in packs cached before T4.
|
||||||
|
*/
|
||||||
|
nextSteps?: Record<string, NextStep[]>;
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger';
|
import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsLatitude, IsLongitude, IsOptional, IsString } from 'class-validator';
|
import {
|
||||||
|
IsLatitude,
|
||||||
|
IsLongitude,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class CreateApiaryDto {
|
export class CreateApiaryDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -35,4 +41,13 @@ export class CreateApiaryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Ort für die öffentliche Herkunftsseite, z. B. "Nordhessen". Nie die genaue Adresse.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
publicLocationLabel?: string;
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,7 @@ import { noStoreMiddleware } from './common/middleware/no-store.middleware';
|
|||||||
* Production runs client -> Cloudflare -> Traefik -> backend, so the hop
|
* Production runs client -> Cloudflare -> Traefik -> backend, so the hop
|
||||||
* count is not stable; TRUST_PROXY is an Express list there ("loopback,
|
* count is not stable; TRUST_PROXY is an Express list there ("loopback,
|
||||||
* 10.42.0.0/16, <Cloudflare IPv4+IPv6 ranges>", see
|
* 10.42.0.0/16, <Cloudflare IPv4+IPv6 ranges>", see
|
||||||
* deploy/k8s/backend/configmap.example.yaml). A plain number ("1") is a hop
|
* deploy/k8s/base/backend/configmap.example.yaml). A plain number ("1") is a hop
|
||||||
* count and stays the default when the variable is unset.
|
* count and stays the default when the variable is unset.
|
||||||
*/
|
*/
|
||||||
export function trustProxySetting(): number | string {
|
export function trustProxySetting(): number | string {
|
||||||
@@ -58,7 +58,7 @@ export function configureApp(app: NestExpressApplication): void {
|
|||||||
// proxy, so rate limits, API-key failure limits and IP allowlists act
|
// proxy, so rate limits, API-key failure limits and IP allowlists act
|
||||||
// on a shared address instead of the client.
|
// on a shared address instead of the client.
|
||||||
bootstrapLogger.warn(
|
bootstrapLogger.warn(
|
||||||
'TRUST_PROXY is not set in production - client IPs for rate limits and API-key allowlists are likely the proxy address; set it as in deploy/k8s/backend/configmap.example.yaml',
|
'TRUST_PROXY is not set in production - client IPs for rate limits and API-key allowlists are likely the proxy address; set it as in deploy/k8s/base/backend/configmap.example.yaml',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// No ETags: they let caches and proxies revalidate (and keep) personal
|
// No ETags: they let caches and proxies revalidate (and keep) personal
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { ConfigModule } from '@nestjs/config';
|
|||||||
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
import { ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerModule } from '@nestjs/throttler';
|
||||||
import { ClientIpThrottlerGuard } from './common/throttler/client-ip-throttler.guard';
|
import { ClientIpThrottlerGuard } from './common/throttler/client-ip-throttler.guard';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { PrismaThrottlerStorage } from './common/throttler/prisma-throttler.storage';
|
||||||
|
import { ThrottlerStorageModule } from './common/throttler/throttler-storage.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { AuditModule } from './audit/audit.module';
|
import { AuditModule } from './audit/audit.module';
|
||||||
|
import { DomainEventsModule } from './domain-events/domain-events.module';
|
||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { RolesModule } from './roles/roles.module';
|
import { RolesModule } from './roles/roles.module';
|
||||||
@@ -26,6 +28,8 @@ 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 { PhenologyModule } from './phenology/phenology.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';
|
||||||
@@ -51,20 +55,31 @@ 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 { 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';
|
||||||
import { IdempotencyInterceptor } from './common/idempotency/idempotency.interceptor';
|
import { IdempotencyInterceptor } from './common/idempotency/idempotency.interceptor';
|
||||||
import { IdempotencyCleanupService } from './common/idempotency/idempotency-cleanup.service';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
ScheduleModule.forRoot(),
|
ThrottlerModule.forRootAsync({
|
||||||
ThrottlerModule.forRoot([{ name: 'default', ttl: 60000, limit: 100 }]),
|
imports: [ThrottlerStorageModule],
|
||||||
|
inject: [PrismaThrottlerStorage],
|
||||||
|
useFactory: (storage: PrismaThrottlerStorage) => ({
|
||||||
|
throttlers: [{ name: 'default', ttl: 60000, limit: 100 }],
|
||||||
|
storage,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PermissionsModule,
|
PermissionsModule,
|
||||||
AuditModule,
|
AuditModule,
|
||||||
|
DomainEventsModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
@@ -97,6 +112,12 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
|
|||||||
WebhooksModule,
|
WebhooksModule,
|
||||||
DocumentsModule,
|
DocumentsModule,
|
||||||
AutomationModule,
|
AutomationModule,
|
||||||
|
SuggestionsModule,
|
||||||
|
CleanupModule,
|
||||||
|
AutopilotModule,
|
||||||
|
PhenologyModule,
|
||||||
|
WithdrawalModule,
|
||||||
|
RemindersModule,
|
||||||
LabelTemplatesModule,
|
LabelTemplatesModule,
|
||||||
ImportModule,
|
ImportModule,
|
||||||
PublicModule,
|
PublicModule,
|
||||||
@@ -110,6 +131,7 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
|
|||||||
ShopSyncModule,
|
ShopSyncModule,
|
||||||
ErpExportModule,
|
ErpExportModule,
|
||||||
ApiKeysModule,
|
ApiKeysModule,
|
||||||
|
SystemModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -119,7 +141,6 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
|
|||||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||||
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
||||||
{ provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
|
{ provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
|
||||||
IdempotencyCleanupService,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
@@ -43,6 +43,18 @@ describe('AuditLogService', () => {
|
|||||||
expect(createArg.data.userId).toBe('user-1');
|
expect(createArg.data.userId).toBe('user-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('writes through the transaction client when one is given', async () => {
|
||||||
|
const tx = { auditLog: { create: jest.fn() } };
|
||||||
|
|
||||||
|
await service.record(
|
||||||
|
{ entityType: 'Inspection', entityId: 'i1', action: 'CREATE' },
|
||||||
|
tx as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tx.auditLog.create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('findAll filters by entity type and id', async () => {
|
it('findAll filters by entity type and id', async () => {
|
||||||
await service.findAll({ entityType: 'Colony', entityId: 'colony-1' });
|
await service.findAll({ entityType: 'Colony', entityId: 'colony-1' });
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { DbClient } from '../prisma/db-client';
|
||||||
import { AuditAction, AuditLog, Prisma } from '../../generated/prisma';
|
import { AuditAction, AuditLog, Prisma } from '../../generated/prisma';
|
||||||
|
|
||||||
interface RecordAuditLogInput {
|
export interface RecordAuditLogInput {
|
||||||
entityType: string;
|
entityType: string;
|
||||||
entityId: string;
|
entityId: string;
|
||||||
action: AuditAction;
|
action: AuditAction;
|
||||||
@@ -20,8 +21,17 @@ export interface AuditLogFilter {
|
|||||||
export class AuditLogService {
|
export class AuditLogService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async record(input: RecordAuditLogInput): Promise<void> {
|
/**
|
||||||
await this.prisma.auditLog.create({
|
* With `tx` the entry commits or rolls back together with the change it
|
||||||
|
* describes - no audited change without its entry, and no entry for a
|
||||||
|
* change that never happened.
|
||||||
|
*/
|
||||||
|
async record(
|
||||||
|
input: RecordAuditLogInput,
|
||||||
|
tx?: Prisma.TransactionClient,
|
||||||
|
): Promise<void> {
|
||||||
|
const db: DbClient = tx ?? this.prisma;
|
||||||
|
await db.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
entityType: input.entityType,
|
entityType: input.entityType,
|
||||||
entityId: input.entityId,
|
entityId: input.entityId,
|
||||||
|
|||||||
@@ -1,37 +1,114 @@
|
|||||||
import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util';
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { KEY_ID_PATTERN } from '../../common/crypto/key-ring';
|
||||||
|
import {
|
||||||
|
decryptMfaSecret,
|
||||||
|
encryptMfaSecret,
|
||||||
|
mfaSecretNeedsRekey,
|
||||||
|
} from './mfa-crypto.util';
|
||||||
|
|
||||||
|
// Written by the code before the key ring (AES key sha256(JWT_ACCESS_SECRET),
|
||||||
|
// no key id) with JWT_ACCESS_SECRET = 'x' * 40.
|
||||||
|
const LEGACY_SECRET =
|
||||||
|
'zJD2lAy1b/wslLyQ.hn8scDeaxIADXlRFtcGIyg==.3+5qbYM52lKKN/VXpInUOQ==';
|
||||||
|
const PLAINTEXT = 'JBSWY3DPEHPK3PXP';
|
||||||
|
|
||||||
describe('mfa-crypto.util', () => {
|
describe('mfa-crypto.util', () => {
|
||||||
const originalSecret = process.env.JWT_ACCESS_SECRET;
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret';
|
process.env.JWT_ACCESS_SECRET = 'x'.repeat(40);
|
||||||
|
process.env.MFA_ENCRYPTION_KEY = 'm'.repeat(40);
|
||||||
|
delete process.env.MFA_ENCRYPTION_KEY_PREVIOUS;
|
||||||
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
process.env.JWT_ACCESS_SECRET = originalSecret;
|
process.env = { ...originalEnv };
|
||||||
|
jest.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('round-trips a TOTP secret through encrypt/decrypt', () => {
|
it('round-trips a TOTP secret and prefixes the key id', () => {
|
||||||
const plaintext = 'JBSWY3DPEHPK3PXP';
|
const ciphertext = encryptMfaSecret(PLAINTEXT);
|
||||||
const ciphertext = encryptMfaSecret(plaintext);
|
|
||||||
expect(ciphertext).not.toContain(plaintext);
|
expect(ciphertext).toMatch(KEY_ID_PATTERN);
|
||||||
expect(decryptMfaSecret(ciphertext)).toBe(plaintext);
|
expect(ciphertext).not.toContain(PLAINTEXT);
|
||||||
|
expect(decryptMfaSecret(ciphertext)).toBe(PLAINTEXT);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('produces a different ciphertext every time (random IV)', () => {
|
it('produces a different ciphertext every time (random IV)', () => {
|
||||||
const plaintext = 'JBSWY3DPEHPK3PXP';
|
expect(encryptMfaSecret(PLAINTEXT)).not.toBe(encryptMfaSecret(PLAINTEXT));
|
||||||
expect(encryptMfaSecret(plaintext)).not.toBe(encryptMfaSecret(plaintext));
|
});
|
||||||
|
|
||||||
|
it('no longer depends on JWT_ACCESS_SECRET', () => {
|
||||||
|
const ciphertext = encryptMfaSecret(PLAINTEXT);
|
||||||
|
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
|
||||||
|
|
||||||
|
expect(decryptMfaSecret(ciphertext)).toBe(PLAINTEXT);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails to decrypt with the wrong key', () => {
|
it('fails to decrypt with the wrong key', () => {
|
||||||
const ciphertext = encryptMfaSecret('JBSWY3DPEHPK3PXP');
|
const ciphertext = encryptMfaSecret(PLAINTEXT);
|
||||||
process.env.JWT_ACCESS_SECRET = 'a-different-secret';
|
process.env.MFA_ENCRYPTION_KEY = 'n'.repeat(40);
|
||||||
expect(() => decryptMfaSecret(ciphertext)).toThrow();
|
|
||||||
|
expect(() => decryptMfaSecret(ciphertext)).toThrow('unknown key id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens a secret sealed with MFA_ENCRYPTION_KEY_PREVIOUS and flags it for rekey', () => {
|
||||||
|
const ciphertext = encryptMfaSecret(PLAINTEXT);
|
||||||
|
process.env.MFA_ENCRYPTION_KEY_PREVIOUS = process.env.MFA_ENCRYPTION_KEY;
|
||||||
|
process.env.MFA_ENCRYPTION_KEY = 'n'.repeat(40);
|
||||||
|
|
||||||
|
expect(decryptMfaSecret(ciphertext)).toBe(PLAINTEXT);
|
||||||
|
expect(mfaSecretNeedsRekey(ciphertext)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not flag a secret sealed with the current key', () => {
|
||||||
|
expect(mfaSecretNeedsRekey(encryptMfaSecret(PLAINTEXT))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still opens a legacy MFA secret encrypted with the JWT-derived key', () => {
|
||||||
|
expect(decryptMfaSecret(LEGACY_SECRET)).toBe(PLAINTEXT);
|
||||||
|
expect(mfaSecretNeedsRekey(LEGACY_SECRET)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed on a legacy secret once JWT_ACCESS_SECRET changed', () => {
|
||||||
|
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
|
||||||
|
|
||||||
|
expect(() => decryptMfaSecret(LEGACY_SECRET)).toThrow(
|
||||||
|
'ciphertext failed authentication',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never retries a k…: value that fails authentication with the legacy key', () => {
|
||||||
|
const warn = jest.spyOn(Logger.prototype, 'warn');
|
||||||
|
const currentKeyId = encryptMfaSecret(PLAINTEXT).split(':')[0];
|
||||||
|
// A valid legacy ciphertext behind the current key id: only a retry with
|
||||||
|
// sha256(JWT_ACCESS_SECRET) could open it.
|
||||||
|
const disguised = `${currentKeyId}:${LEGACY_SECRET}`;
|
||||||
|
|
||||||
|
expect(() => decryptMfaSecret(disguised)).toThrow(
|
||||||
|
'ciphertext failed authentication',
|
||||||
|
);
|
||||||
|
expect(() => decryptMfaSecret(`k00000000:${LEGACY_SECRET}`)).toThrow(
|
||||||
|
'unknown key id',
|
||||||
|
);
|
||||||
|
expect(warn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails on a tampered ciphertext', () => {
|
||||||
|
const [keyId, body] = encryptMfaSecret(PLAINTEXT).split(':');
|
||||||
|
const [iv, tag, data] = body.split('.');
|
||||||
|
const bytes = Buffer.from(data, 'base64');
|
||||||
|
bytes[0] ^= 0xff;
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
decryptMfaSecret(`${keyId}:${iv}.${tag}.${bytes.toString('base64')}`),
|
||||||
|
).toThrow('ciphertext failed authentication');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a malformed ciphertext', () => {
|
it('rejects a malformed ciphertext', () => {
|
||||||
expect(() => decryptMfaSecret('not-a-valid-ciphertext')).toThrow(
|
expect(() => decryptMfaSecret('not-a-valid-ciphertext')).toThrow(
|
||||||
'Invalid MFA secret ciphertext',
|
'malformed ciphertext',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,53 +1,31 @@
|
|||||||
import {
|
import {
|
||||||
createCipheriv,
|
KEY_ID_PATTERN,
|
||||||
createDecipheriv,
|
isSealedWithCurrentKey,
|
||||||
createHash,
|
loadKeyRing,
|
||||||
randomBytes,
|
openLegacyCiphertext,
|
||||||
} from 'node:crypto';
|
openWithRing,
|
||||||
|
sealWithRing,
|
||||||
const ALGORITHM = 'aes-256-gcm';
|
} from '../../common/crypto/key-ring';
|
||||||
const IV_BYTES = 12;
|
|
||||||
|
|
||||||
function getKey(): Buffer {
|
|
||||||
const secret = process.env.JWT_ACCESS_SECRET;
|
|
||||||
if (!secret) {
|
|
||||||
throw new Error('JWT_ACCESS_SECRET is not set');
|
|
||||||
}
|
|
||||||
return createHash('sha256').update(secret).digest();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TOTP secrets must be reversible (unlike passwords/refresh tokens, which
|
* TOTP secrets must be reversible (unlike passwords/refresh tokens, which
|
||||||
* are one-way hashed) so the server can compute the expected code on every
|
* are one-way hashed) so the server can compute the expected code on every
|
||||||
* login. AES-256-GCM at rest, keyed off JWT_ACCESS_SECRET, keeps the secret
|
* login. They are sealed with the MFA key ring (MFA_ENCRYPTION_KEY, see
|
||||||
* out of the DB in plaintext without introducing a new secret to manage.
|
* common/crypto/key-ring.ts). Secrets stored before the key ring have no key
|
||||||
|
* id and were encrypted with sha256(JWT_ACCESS_SECRET); they stay readable
|
||||||
|
* until the rekey job reseals them.
|
||||||
*/
|
*/
|
||||||
export function encryptMfaSecret(plaintext: string): string {
|
export function encryptMfaSecret(plaintext: string): string {
|
||||||
const iv = randomBytes(IV_BYTES);
|
return sealWithRing(loadKeyRing('mfa'), plaintext);
|
||||||
const cipher = createCipheriv(ALGORITHM, getKey(), iv);
|
|
||||||
const encrypted = Buffer.concat([
|
|
||||||
cipher.update(plaintext, 'utf8'),
|
|
||||||
cipher.final(),
|
|
||||||
]);
|
|
||||||
const authTag = cipher.getAuthTag();
|
|
||||||
return [iv, authTag, encrypted]
|
|
||||||
.map((buffer) => buffer.toString('base64'))
|
|
||||||
.join('.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decryptMfaSecret(ciphertext: string): string {
|
export function decryptMfaSecret(stored: string): string {
|
||||||
const [ivB64, authTagB64, dataB64] = ciphertext.split('.');
|
return KEY_ID_PATTERN.test(stored)
|
||||||
if (!ivB64 || !authTagB64 || !dataB64) {
|
? openWithRing(loadKeyRing('mfa'), stored).plaintext
|
||||||
throw new Error('Invalid MFA secret ciphertext');
|
: openLegacyCiphertext('mfa', stored);
|
||||||
}
|
}
|
||||||
const decipher = createDecipheriv(
|
|
||||||
ALGORITHM,
|
/** True for secrets the rekey job should reseal with the current key. */
|
||||||
getKey(),
|
export function mfaSecretNeedsRekey(stored: string): boolean {
|
||||||
Buffer.from(ivB64, 'base64'),
|
return !isSealedWithCurrentKey(loadKeyRing('mfa'), stored);
|
||||||
);
|
|
||||||
decipher.setAuthTag(Buffer.from(authTagB64, 'base64'));
|
|
||||||
return Buffer.concat([
|
|
||||||
decipher.update(Buffer.from(dataB64, 'base64')),
|
|
||||||
decipher.final(),
|
|
||||||
]).toString('utf8');
|
|
||||||
}
|
}
|
||||||
@@ -123,6 +123,18 @@ export const PERMISSION_CATALOG = [
|
|||||||
],
|
],
|
||||||
850,
|
850,
|
||||||
),
|
),
|
||||||
|
// Seeded with role grants by the t4_phenology migration (read with
|
||||||
|
// colonies:read, create/update with inspections:create).
|
||||||
|
...group(
|
||||||
|
'Pflanzenentwicklung',
|
||||||
|
'phenology',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['create', 'Melden'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
],
|
||||||
|
860,
|
||||||
|
),
|
||||||
...group(
|
...group(
|
||||||
'Behandlungen',
|
'Behandlungen',
|
||||||
'treatments',
|
'treatments',
|
||||||
@@ -361,6 +373,15 @@ export const PERMISSION_CATALOG = [
|
|||||||
[['manage', 'Anlegen, ansehen und widerrufen']],
|
[['manage', 'Anlegen, ansehen und widerrufen']],
|
||||||
3400,
|
3400,
|
||||||
),
|
),
|
||||||
|
...group(
|
||||||
|
'System',
|
||||||
|
'system',
|
||||||
|
[
|
||||||
|
['read', 'Hintergrundjobs und Warteschlangen ansehen'],
|
||||||
|
['manage', 'Fehlgeschlagene Ereignisse erneut anstoßen'],
|
||||||
|
],
|
||||||
|
3500,
|
||||||
|
),
|
||||||
// Like shop-sync: a narrow read-only allowlist for one machine consumer
|
// Like shop-sync: a narrow read-only allowlist for one machine consumer
|
||||||
// (ERPNext's nightly inventory mirror), separate from products:read etc.,
|
// (ERPNext's nightly inventory mirror), separate from products:read etc.,
|
||||||
// which return descriptions and full records.
|
// which return descriptions and full records.
|
||||||
@@ -368,7 +389,7 @@ export const PERMISSION_CATALOG = [
|
|||||||
'ERP-Anbindung',
|
'ERP-Anbindung',
|
||||||
'erp-inventory',
|
'erp-inventory',
|
||||||
[['read', 'Inventar-Spiegel für ERPNext lesen']],
|
[['read', 'Inventar-Spiegel für ERPNext lesen']],
|
||||||
3500,
|
3600,
|
||||||
),
|
),
|
||||||
] as const satisfies readonly PermissionCatalogEntry[];
|
] as const satisfies readonly PermissionCatalogEntry[];
|
||||||
|
|
||||||
|
|||||||
@@ -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 {}
|
||||||
@@ -94,6 +94,59 @@ describe('PermissionResolverService', () => {
|
|||||||
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
|
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getFreshPermissions', () => {
|
||||||
|
it('the permission check reads fresh, not the 30 s cache', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValueOnce(
|
||||||
|
activeUserWithPermissions('tasks:read'),
|
||||||
|
);
|
||||||
|
await resolver.getPermissions('user-1');
|
||||||
|
// The right is revoked on another replica: this process has no
|
||||||
|
// invalidateUser call, the cache still holds the old set.
|
||||||
|
prisma.user.findUnique.mockResolvedValueOnce(
|
||||||
|
activeUserWithPermissions('colonies:read'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const fresh = await resolver.getFreshPermissions('user-1');
|
||||||
|
|
||||||
|
expect([...fresh]).toEqual(['colonies:read']);
|
||||||
|
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills the cache for getPermissions', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue(
|
||||||
|
activeUserWithPermissions('tasks:read'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await resolver.getFreshPermissions('user-1');
|
||||||
|
const cached = await resolver.getPermissions('user-1');
|
||||||
|
|
||||||
|
expect([...cached]).toEqual(['tasks:read']);
|
||||||
|
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is empty for an inactive user, an inactive role and a missing user', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValueOnce({
|
||||||
|
isActive: false,
|
||||||
|
role: {
|
||||||
|
status: 'ACTIVE',
|
||||||
|
permissions: [{ permission: { key: 'tasks:read' } }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
prisma.user.findUnique.mockResolvedValueOnce({
|
||||||
|
isActive: true,
|
||||||
|
role: {
|
||||||
|
status: 'INACTIVE',
|
||||||
|
permissions: [{ permission: { key: 'tasks:read' } }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
prisma.user.findUnique.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
expect((await resolver.getFreshPermissions('u1')).size).toBe(0);
|
||||||
|
expect((await resolver.getFreshPermissions('u2')).size).toBe(0);
|
||||||
|
expect((await resolver.getFreshPermissions('u3')).size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('isTokenValid', () => {
|
describe('isTokenValid', () => {
|
||||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,19 @@ export class PermissionResolverService {
|
|||||||
return entry.permissions;
|
return entry.permissions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The permissions read fresh (one primary-key lookup), never from the
|
||||||
|
* cache, and stored as the cache entry. For a process that does not hear
|
||||||
|
* invalidateUser of the API pods (the worker): a right revoked there must
|
||||||
|
* stop mattering at once, not after the 30 s cache window. Empty for an
|
||||||
|
* inactive user or role.
|
||||||
|
*/
|
||||||
|
async getFreshPermissions(userId: string): Promise<ReadonlySet<string>> {
|
||||||
|
const entry = await this.loadEntry(userId);
|
||||||
|
this.cache.set(userId, entry);
|
||||||
|
return entry.permissions;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Always read fresh (one primary-key lookup) and stored as the cache
|
* Always read fresh (one primary-key lookup) and stored as the cache
|
||||||
* entry the PermissionsGuard then uses: a deactivation, role change or
|
* entry the PermissionsGuard then uses: a deactivation, role change or
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import { Prisma } from '../../generated/prisma';
|
||||||
|
import type {
|
||||||
|
AnyDomainEventEnvelope,
|
||||||
|
DomainEventEnvelope,
|
||||||
|
DomainEventType,
|
||||||
|
} from '../domain-events/domain-event-types';
|
||||||
|
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
|
||||||
|
import type { StockService } from '../stock/stock.service';
|
||||||
|
import { AutomationEventHandlers } from './automation.handlers';
|
||||||
|
import type { AutomationService } from './automation.service';
|
||||||
|
|
||||||
|
type RecordModel =
|
||||||
|
'inspection' | 'varroaMeasurement' | 'treatment' | 'feeding' | 'colony';
|
||||||
|
|
||||||
|
const RECORD_MODELS: readonly RecordModel[] = [
|
||||||
|
'inspection',
|
||||||
|
'varroaMeasurement',
|
||||||
|
'treatment',
|
||||||
|
'feeding',
|
||||||
|
'colony',
|
||||||
|
];
|
||||||
|
|
||||||
|
function envelope<T extends DomainEventType>(
|
||||||
|
type: T,
|
||||||
|
aggregateId: string,
|
||||||
|
payload: DomainEventEnvelope<T>['payload'],
|
||||||
|
): AnyDomainEventEnvelope<T> {
|
||||||
|
return {
|
||||||
|
id: 'e1',
|
||||||
|
type,
|
||||||
|
aggregateType: 'Inspection',
|
||||||
|
aggregateId,
|
||||||
|
payload,
|
||||||
|
actorUserId: 'u1',
|
||||||
|
correlationId: 'corr-1',
|
||||||
|
occurredAt: new Date('2026-10-13T08:00:00.000Z'),
|
||||||
|
attempts: 0,
|
||||||
|
} as unknown as AnyDomainEventEnvelope<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AutomationEventHandlers', () => {
|
||||||
|
let tx: Record<RecordModel, { findUnique: jest.Mock }>;
|
||||||
|
let automation: Record<
|
||||||
|
| 'evaluateInspectionIn'
|
||||||
|
| 'evaluateInspectionCorrectionIn'
|
||||||
|
| 'evaluateVarroaMeasurementIn'
|
||||||
|
| 'evaluateTreatmentIn'
|
||||||
|
| 'evaluateFeedingIn'
|
||||||
|
| 'evaluateColonyHealthIn'
|
||||||
|
| 'evaluateRawMaterialLowIn',
|
||||||
|
jest.Mock
|
||||||
|
>;
|
||||||
|
let stock: { remainingByRawMaterialKind: jest.Mock };
|
||||||
|
let handlers: AutomationEventHandlers;
|
||||||
|
|
||||||
|
const asTx = () => tx as unknown as Prisma.TransactionClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tx = Object.fromEntries(
|
||||||
|
RECORD_MODELS.map((model) => [
|
||||||
|
model,
|
||||||
|
{ findUnique: jest.fn().mockResolvedValue(null) },
|
||||||
|
]),
|
||||||
|
) as Record<RecordModel, { findUnique: jest.Mock }>;
|
||||||
|
automation = {
|
||||||
|
evaluateInspectionIn: jest.fn().mockResolvedValue(undefined),
|
||||||
|
evaluateInspectionCorrectionIn: jest.fn().mockResolvedValue(undefined),
|
||||||
|
evaluateVarroaMeasurementIn: jest.fn().mockResolvedValue(undefined),
|
||||||
|
evaluateTreatmentIn: jest.fn().mockResolvedValue(undefined),
|
||||||
|
evaluateFeedingIn: jest.fn().mockResolvedValue(undefined),
|
||||||
|
evaluateColonyHealthIn: jest.fn().mockResolvedValue(undefined),
|
||||||
|
evaluateRawMaterialLowIn: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
stock = { remainingByRawMaterialKind: jest.fn() };
|
||||||
|
handlers = new AutomationEventHandlers(
|
||||||
|
automation as unknown as AutomationService,
|
||||||
|
stock as unknown as StockService,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers the stable handler ids and event types', () => {
|
||||||
|
const registered = (method: keyof AutomationEventHandlers) =>
|
||||||
|
domainEventHandlerMetadata(
|
||||||
|
Reflect.get(AutomationEventHandlers.prototype, method),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(registered('inspectionRules')).toEqual([
|
||||||
|
{
|
||||||
|
types: ['InspectionRecorded', 'InspectionCorrected'],
|
||||||
|
handlerId: 'automation.inspection-rules',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(registered('varroaThreshold')).toEqual([
|
||||||
|
{
|
||||||
|
types: ['VarroaMeasured', 'VarroaCorrected'],
|
||||||
|
handlerId: 'automation.varroa-threshold',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(registered('treatmentFollowUp')).toEqual([
|
||||||
|
{
|
||||||
|
types: ['TreatmentRecorded'],
|
||||||
|
handlerId: 'automation.treatment-follow-up',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(registered('feedingFollowUp')).toEqual([
|
||||||
|
{ types: ['FeedingRecorded'], handlerId: 'automation.feeding-follow-up' },
|
||||||
|
]);
|
||||||
|
expect(registered('colonyHealth')).toEqual([
|
||||||
|
{ types: ['ColonyStatusChanged'], handlerId: 'automation.colony-health' },
|
||||||
|
]);
|
||||||
|
expect(registered('rawMaterialLow')).toEqual([
|
||||||
|
{ types: ['StockChanged'], handlerId: 'automation.raw-material-low' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('record handlers', () => {
|
||||||
|
const cases = [
|
||||||
|
[
|
||||||
|
'inspectionRules',
|
||||||
|
'InspectionRecorded',
|
||||||
|
'inspection',
|
||||||
|
'evaluateInspectionIn',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'varroaThreshold',
|
||||||
|
'VarroaMeasured',
|
||||||
|
'varroaMeasurement',
|
||||||
|
'evaluateVarroaMeasurementIn',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'varroaThreshold',
|
||||||
|
'VarroaCorrected',
|
||||||
|
'varroaMeasurement',
|
||||||
|
'evaluateVarroaMeasurementIn',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'treatmentFollowUp',
|
||||||
|
'TreatmentRecorded',
|
||||||
|
'treatment',
|
||||||
|
'evaluateTreatmentIn',
|
||||||
|
],
|
||||||
|
['feedingFollowUp', 'FeedingRecorded', 'feeding', 'evaluateFeedingIn'],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const run = (
|
||||||
|
method: (typeof cases)[number][0],
|
||||||
|
type: (typeof cases)[number][1],
|
||||||
|
) =>
|
||||||
|
handlers[method](
|
||||||
|
envelope(type, 'record-1', { colonyId: 'colony-1' }) as never,
|
||||||
|
asTx(),
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(cases)(
|
||||||
|
'handlers load the record via tx and evaluate it: %s on %s',
|
||||||
|
async (method, type, model, core) => {
|
||||||
|
const record = { id: 'record-1', colonyId: 'colony-1' };
|
||||||
|
tx[model].findUnique.mockResolvedValueOnce(record);
|
||||||
|
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
|
||||||
|
|
||||||
|
await run(method, type);
|
||||||
|
|
||||||
|
expect(tx[model].findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'record-1' },
|
||||||
|
});
|
||||||
|
expect(tx.colony.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'colony-1' },
|
||||||
|
select: { status: true },
|
||||||
|
});
|
||||||
|
expect(automation[core]).toHaveBeenCalledWith(tx, record);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(cases)(
|
||||||
|
'handlers load the record via tx and skip a missing one: %s on %s',
|
||||||
|
async (method, type, model, core) => {
|
||||||
|
await run(method, type);
|
||||||
|
|
||||||
|
expect(tx[model].findUnique).toHaveBeenCalledTimes(1);
|
||||||
|
expect(automation[core]).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
tx.inspection.findUnique.mockResolvedValueOnce({
|
||||||
|
id: 'record-1',
|
||||||
|
colonyId: 'colony-1',
|
||||||
|
});
|
||||||
|
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
|
||||||
|
automation.evaluateInspectionIn.mockRejectedValue(
|
||||||
|
new Error('notifications down'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
run('inspectionRules', 'InspectionRecorded'),
|
||||||
|
).rejects.toThrow('notifications down');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('colony-health', () => {
|
||||||
|
const from = { status: 'ACTIVE', healthStatus: 'AVERAGE' } as const;
|
||||||
|
const to = { status: 'ACTIVE', healthStatus: 'CRITICAL' } as const;
|
||||||
|
const event = () =>
|
||||||
|
envelope('ColonyStatusChanged', 'colony-1', { from, to });
|
||||||
|
|
||||||
|
it('hands the transition and the event to evaluateColonyHealthIn', async () => {
|
||||||
|
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
|
||||||
|
|
||||||
|
await handlers.colonyHealth(event(), asTx());
|
||||||
|
|
||||||
|
expect(tx.colony.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'colony-1' },
|
||||||
|
select: { status: true },
|
||||||
|
});
|
||||||
|
// 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. The
|
||||||
|
// event is the anchor of the suggestion.
|
||||||
|
expect(automation.evaluateColonyHealthIn).toHaveBeenCalledWith(
|
||||||
|
tx,
|
||||||
|
'colony-1',
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
{ id: 'e1', occurredAt: new Date('2026-10-13T08:00:00.000Z') },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a colony that no longer exists', async () => {
|
||||||
|
await handlers.colonyHealth(event(), asTx());
|
||||||
|
|
||||||
|
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', () => {
|
||||||
|
const stockChanged = (rawMaterialKinds: ('BEESWAX' | 'PROPOLIS')[]) =>
|
||||||
|
envelope('StockChanged', 'movement-1', {
|
||||||
|
bottlingBatchIds: [],
|
||||||
|
productIds: [],
|
||||||
|
rawMaterialKinds,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('evaluates every touched kind once with what is left of it', async () => {
|
||||||
|
stock.remainingByRawMaterialKind.mockImplementation((kind: string) =>
|
||||||
|
Promise.resolve(kind === 'BEESWAX' ? 400 : 75),
|
||||||
|
);
|
||||||
|
|
||||||
|
await handlers.rawMaterialLow(
|
||||||
|
stockChanged(['BEESWAX', 'PROPOLIS', 'BEESWAX']),
|
||||||
|
asTx(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(stock.remainingByRawMaterialKind).toHaveBeenCalledTimes(2);
|
||||||
|
expect(automation.evaluateRawMaterialLowIn.mock.calls).toEqual([
|
||||||
|
[tx, 'BEESWAX', 400],
|
||||||
|
[tx, 'PROPOLIS', 75],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing for a change without raw material', async () => {
|
||||||
|
await handlers.rawMaterialLow(stockChanged([]), asTx());
|
||||||
|
|
||||||
|
expect(stock.remainingByRawMaterialKind).not.toHaveBeenCalled();
|
||||||
|
expect(automation.evaluateRawMaterialLowIn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
|
||||||
|
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
|
||||||
|
import { isLivingColonyStatus } from '../colonies/colony-status';
|
||||||
|
import { Prisma } from '../../generated/prisma';
|
||||||
|
import { StockService } from '../stock/stock.service';
|
||||||
|
import { AutomationService } from './automation.service';
|
||||||
|
|
||||||
|
type Tx = Prisma.TransactionClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates the automation rules on domain events. Pure DB writer: it reads
|
||||||
|
* the record the event is about through the delivery transaction and lets
|
||||||
|
* AutomationService write suggestion, notification and audit entries in it.
|
||||||
|
* A record deleted before the event was dispatched, or of a colony that
|
||||||
|
* ended, is skipped. Errors propagate, so the delivery is rolled back as a
|
||||||
|
* whole and retried.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AutomationEventHandlers {
|
||||||
|
constructor(
|
||||||
|
private readonly automation: AutomationService,
|
||||||
|
private readonly stock: StockService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(
|
||||||
|
event: AnyDomainEventEnvelope<'InspectionRecorded' | 'InspectionCorrected'>,
|
||||||
|
tx: Tx,
|
||||||
|
): Promise<void> {
|
||||||
|
const inspection = await tx.inspection.findUnique({
|
||||||
|
where: { id: event.aggregateId },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
inspection === null ||
|
||||||
|
!(await this.isLivingColonyIn(tx, inspection.colonyId))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.type === 'InspectionRecorded') {
|
||||||
|
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
|
||||||
|
// like a new measurement; the open-suggestion check keeps it to one.
|
||||||
|
@OnDomainEvent(
|
||||||
|
['VarroaMeasured', 'VarroaCorrected'],
|
||||||
|
'automation.varroa-threshold',
|
||||||
|
)
|
||||||
|
async varroaThreshold(
|
||||||
|
event: AnyDomainEventEnvelope<'VarroaMeasured' | 'VarroaCorrected'>,
|
||||||
|
tx: Tx,
|
||||||
|
): Promise<void> {
|
||||||
|
const measurement = await tx.varroaMeasurement.findUnique({
|
||||||
|
where: { id: event.aggregateId },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
measurement === null ||
|
||||||
|
!(await this.isLivingColonyIn(tx, measurement.colonyId))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.automation.evaluateVarroaMeasurementIn(tx, measurement);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnDomainEvent('TreatmentRecorded', 'automation.treatment-follow-up')
|
||||||
|
async treatmentFollowUp(
|
||||||
|
event: AnyDomainEventEnvelope<'TreatmentRecorded'>,
|
||||||
|
tx: Tx,
|
||||||
|
): Promise<void> {
|
||||||
|
const treatment = await tx.treatment.findUnique({
|
||||||
|
where: { id: event.aggregateId },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
treatment === null ||
|
||||||
|
!(await this.isLivingColonyIn(tx, treatment.colonyId))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.automation.evaluateTreatmentIn(tx, treatment);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnDomainEvent('FeedingRecorded', 'automation.feeding-follow-up')
|
||||||
|
async feedingFollowUp(
|
||||||
|
event: AnyDomainEventEnvelope<'FeedingRecorded'>,
|
||||||
|
tx: Tx,
|
||||||
|
): Promise<void> {
|
||||||
|
const feeding = await tx.feeding.findUnique({
|
||||||
|
where: { id: event.aggregateId },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
feeding === null ||
|
||||||
|
!(await this.isLivingColonyIn(tx, feeding.colonyId))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.automation.evaluateFeedingIn(tx, feeding);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decides on the snapshots the event carries, not on the colony as it is
|
||||||
|
// now: a later edit must not hide the transition into CRITICAL.
|
||||||
|
@OnDomainEvent('ColonyStatusChanged', 'automation.colony-health')
|
||||||
|
async colonyHealth(
|
||||||
|
event: AnyDomainEventEnvelope<'ColonyStatusChanged'>,
|
||||||
|
tx: Tx,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!(await this.isLivingColonyIn(tx, event.aggregateId))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.automation.evaluateColonyHealthIn(
|
||||||
|
tx,
|
||||||
|
event.aggregateId,
|
||||||
|
event.payload.from,
|
||||||
|
event.payload.to,
|
||||||
|
{ id: event.id, occurredAt: event.occurredAt },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per kind the change touched, what is left of it across all its lots.
|
||||||
|
@OnDomainEvent('StockChanged', 'automation.raw-material-low')
|
||||||
|
async rawMaterialLow(
|
||||||
|
event: AnyDomainEventEnvelope<'StockChanged'>,
|
||||||
|
tx: Tx,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const kind of new Set(event.payload.rawMaterialKinds)) {
|
||||||
|
const remaining = await this.stock.remainingByRawMaterialKind(kind);
|
||||||
|
await this.automation.evaluateRawMaterialLowIn(tx, kind, remaining);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { StockModule } from '../stock/stock.module';
|
||||||
|
import { SuggestionsModule } from '../suggestions/suggestions.module';
|
||||||
import { AutomationController } from './automation.controller';
|
import { AutomationController } from './automation.controller';
|
||||||
|
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.
|
||||||
|
* The rules propose through SuggestionsService; the task follows when the
|
||||||
|
* beekeeper accepts (TasksService, with calendar entry and task.created
|
||||||
|
* webhook), so neither a TasksModule nor a WebhooksService is needed here.
|
||||||
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [NotificationsModule],
|
imports: [SuggestionsModule, StockModule],
|
||||||
controllers: [AutomationController],
|
controllers: [AutomationController],
|
||||||
providers: [AutomationService],
|
providers: [AutomationService, AutomationEventHandlers],
|
||||||
exports: [AutomationService],
|
exports: [AutomationService],
|
||||||
})
|
})
|
||||||
export class AutomationModule {}
|
export class AutomationModule {}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { DEFAULT_RULES, ruleConfigOf } from '../automation/rule-config';
|
||||||
|
import { calendarSeasonState } from '../season/season-calendar';
|
||||||
|
import { lateSummerStart, 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,
|
||||||
|
'08-01',
|
||||||
|
'2026-08-20',
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
'08-01',
|
||||||
|
'2026-08-20',
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
'08-01',
|
||||||
|
'2026-08-20',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(input?.lastTreatment).toBeNull();
|
||||||
|
expect(input?.varroaSinceLastTreatmentEnd).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts feedings and treatments from the real start of SPAETSOMMER', async () => {
|
||||||
|
const lateSummer = {
|
||||||
|
...season,
|
||||||
|
segment: 'SPAETSOMMER' as const,
|
||||||
|
segmentSince: '2026-07-25',
|
||||||
|
};
|
||||||
|
const db = tx([]);
|
||||||
|
|
||||||
|
await loadAutopilotInputIn(
|
||||||
|
db as never,
|
||||||
|
'c1',
|
||||||
|
lateSummer,
|
||||||
|
'08-01',
|
||||||
|
'2026-08-20',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(db.feeding.count).toHaveBeenLastCalledWith({
|
||||||
|
where: { colonyId: 'c1', date: { gte: d('2026-07-25') } },
|
||||||
|
});
|
||||||
|
expect(db.treatment.count).toHaveBeenCalledWith({
|
||||||
|
where: { colonyId: 'c1', date: { gte: d('2026-07-25') } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts nothing for late summer outside SPAETSOMMER and HERBST', async () => {
|
||||||
|
const db = tx([]);
|
||||||
|
|
||||||
|
const input = await loadAutopilotInputIn(
|
||||||
|
db as never,
|
||||||
|
'c1',
|
||||||
|
{ ...season, segment: 'SOMMER' },
|
||||||
|
'08-01',
|
||||||
|
'2026-08-20',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(input?.feedingsSinceLateSummer).toBe(0);
|
||||||
|
expect(input?.treatmentsSinceLateSummer).toBe(0);
|
||||||
|
expect(db.treatment.count).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('without an inspection counts feedings from the later of creation and the last 60 days', async () => {
|
||||||
|
const db = tx([]);
|
||||||
|
|
||||||
|
await loadAutopilotInputIn(
|
||||||
|
db as never,
|
||||||
|
'c1',
|
||||||
|
season,
|
||||||
|
'08-01',
|
||||||
|
'2026-08-20',
|
||||||
|
);
|
||||||
|
|
||||||
|
// created 2026-04-01, today 2026-08-20: 60 days back wins
|
||||||
|
expect(db.feeding.count).toHaveBeenNthCalledWith(1, {
|
||||||
|
where: { colonyId: 'c1', date: { gte: d('2026-06-21') } },
|
||||||
|
});
|
||||||
|
const young = tx([]);
|
||||||
|
young.colony.findUnique.mockResolvedValue({
|
||||||
|
id: 'c1',
|
||||||
|
code: 'B3',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
|
apiaryId: 'api-1',
|
||||||
|
createdAt: d('2026-08-10'),
|
||||||
|
feedStatus: null,
|
||||||
|
currentQueen: null,
|
||||||
|
});
|
||||||
|
await loadAutopilotInputIn(
|
||||||
|
young as never,
|
||||||
|
'c1',
|
||||||
|
season,
|
||||||
|
'08-01',
|
||||||
|
'2026-08-20',
|
||||||
|
);
|
||||||
|
expect(young.feeding.count).toHaveBeenNthCalledWith(1, {
|
||||||
|
where: { colonyId: 'c1', date: { gte: d('2026-08-10') } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('lateSummerStart', () => {
|
||||||
|
const state = (
|
||||||
|
segment: 'SPAETSOMMER' | 'HERBST' | 'SOMMER',
|
||||||
|
segmentSince: string,
|
||||||
|
) => ({ ...season, year: 2026, segment, segmentSince });
|
||||||
|
|
||||||
|
it('is the start of the segment in SPAETSOMMER', () => {
|
||||||
|
expect(lateSummerStart(state('SPAETSOMMER', '2026-07-25'), '08-01')).toBe(
|
||||||
|
'2026-07-25',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is the calendar day of TRACHTENDE in HERBST, at the latest the start of HERBST', () => {
|
||||||
|
expect(lateSummerStart(state('HERBST', '2026-09-15'), '08-01')).toBe(
|
||||||
|
'2026-08-01',
|
||||||
|
);
|
||||||
|
expect(lateSummerStart(state('HERBST', '2026-07-20'), '08-01')).toBe(
|
||||||
|
'2026-07-20',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is null in every other segment', () => {
|
||||||
|
expect(lateSummerStart(state('SOMMER', '2026-06-20'), '08-01')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import type { Prisma } from '../../generated/prisma';
|
||||||
|
import { addDays, berlinDayOf, dayToDate } from '../common/dates';
|
||||||
|
import type { SeasonState } from '../season/season-types';
|
||||||
|
import type { AutopilotInput } from './autopilot.types';
|
||||||
|
|
||||||
|
const LATEST_FIRST = [{ date: 'desc' }, { createdAt: 'desc' }] as const;
|
||||||
|
|
||||||
|
/** Without an inspection a feeding counts for A6 if it is at most this old. */
|
||||||
|
const FEEDING_LOOKBACK_DAYS = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The day SPAETSOMMER began, for the feedings and treatments of A4/A5. In
|
||||||
|
* SPAETSOMMER that is the real start of the segment. The stored state keeps
|
||||||
|
* only the start of the current segment, so in HERBST it is the calendar day
|
||||||
|
* of TRACHTENDE (`calendarTrachtende`, MM-DD), at the latest the start of
|
||||||
|
* HERBST.
|
||||||
|
*
|
||||||
|
* TODO: store the real start as `lateSummerSince` on ApiaryPhenology (needs
|
||||||
|
* a migration) so HERBST no longer depends on the calendar day.
|
||||||
|
*/
|
||||||
|
export function lateSummerStart(
|
||||||
|
season: SeasonState,
|
||||||
|
calendarTrachtende: string,
|
||||||
|
): string | null {
|
||||||
|
if (season.segment === 'SPAETSOMMER') {
|
||||||
|
return season.segmentSince;
|
||||||
|
}
|
||||||
|
if (season.segment !== 'HERBST') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const calendarDay = `${season.year}-${calendarTrachtende}`;
|
||||||
|
return calendarDay < season.segmentSince ? calendarDay : season.segmentSince;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
calendarTrachtende: string,
|
||||||
|
today: string,
|
||||||
|
): 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;
|
||||||
|
// Feedings that answer a low feed status: since the inspection; without
|
||||||
|
// one only recent ones, from the colony's creation at the earliest.
|
||||||
|
const createdOn = berlinDayOf(colony.createdAt);
|
||||||
|
const lookbackFrom = addDays(today, -FEEDING_LOOKBACK_DAYS);
|
||||||
|
const feedingsSince =
|
||||||
|
inspection === null
|
||||||
|
? dayToDate(createdOn > lookbackFrom ? createdOn : lookbackFrom)
|
||||||
|
: inspection.date;
|
||||||
|
const feedingsSinceInspection = await tx.feeding.count({
|
||||||
|
where: { colonyId, date: { gte: feedingsSince } },
|
||||||
|
});
|
||||||
|
const lateSummer = lateSummerStart(season, calendarTrachtende);
|
||||||
|
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,824 @@
|
|||||||
|
import { DEFAULT_RULES, ruleConfigOf } from '../automation/rule-config';
|
||||||
|
import { calendarSeasonState } from '../season/season-calendar';
|
||||||
|
import type {
|
||||||
|
PhaseSourceKey,
|
||||||
|
PhenologyPhaseKey,
|
||||||
|
SeasonSegmentKey,
|
||||||
|
SeasonState,
|
||||||
|
} from '../season/season-types';
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Treated and fed in late summer: keeps A4 and A5 out of the A3 tests. */
|
||||||
|
const LATE_SUMMER_DONE = {
|
||||||
|
treatmentsSinceLateSummer: 1,
|
||||||
|
feedingsSinceLateSummer: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
...LATE_SUMMER_DONE,
|
||||||
|
}),
|
||||||
|
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,
|
||||||
|
...LATE_SUMMER_DONE,
|
||||||
|
...LATE_SUMMER_DONE,
|
||||||
|
}),
|
||||||
|
configs(),
|
||||||
|
early,
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
|
||||||
|
const late = '2026-08-20';
|
||||||
|
expect(
|
||||||
|
evaluateAutopilot(
|
||||||
|
input(late, {
|
||||||
|
lastInspection: inspectionOn('2026-08-15'),
|
||||||
|
lastTreatment: TREATMENT,
|
||||||
|
...LATE_SUMMER_DONE,
|
||||||
|
...LATE_SUMMER_DONE,
|
||||||
|
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,
|
||||||
|
...LATE_SUMMER_DONE,
|
||||||
|
}),
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('autopilot rules A4-A10', () => {
|
||||||
|
/** A stored season state; the helper keeps the tests free of calendar maths. */
|
||||||
|
function inSeason(
|
||||||
|
segment: SeasonSegmentKey,
|
||||||
|
phase: PhenologyPhaseKey | null,
|
||||||
|
segmentSince: string,
|
||||||
|
phaseSource: PhaseSourceKey = 'REPORT',
|
||||||
|
): SeasonState {
|
||||||
|
return {
|
||||||
|
apiaryId: 'api_1',
|
||||||
|
year: Number(segmentSince.slice(0, 4)),
|
||||||
|
phase,
|
||||||
|
phaseSource,
|
||||||
|
reportedOn: null,
|
||||||
|
segment,
|
||||||
|
segmentSince,
|
||||||
|
springSince: `${segmentSince.slice(0, 4)}-03-20`,
|
||||||
|
gts: null,
|
||||||
|
gtsThroughDay: null,
|
||||||
|
note: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps A1 and A2 quiet: a fresh inspection and measurement. */
|
||||||
|
function quiet(today: string, patch: Partial<AutopilotInput>) {
|
||||||
|
return input(today, {
|
||||||
|
lastInspection: inspectionOn(today),
|
||||||
|
lastVarroa: { id: 'var_1', date: today },
|
||||||
|
...patch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const only = (rule: string, candidates: { rule: string }[]) =>
|
||||||
|
candidates.filter((candidate) => candidate.rule === rule);
|
||||||
|
|
||||||
|
it('A4: SPAETSOMMER without treatment since segment start -> SUMMER_TREATMENT, PLAN, HIGH, anchor year', () => {
|
||||||
|
const today = '2026-08-05';
|
||||||
|
const result = evaluateAutopilot(
|
||||||
|
quiet(today, {
|
||||||
|
season: inSeason('SPAETSOMMER', 'TRACHTENDE', '2026-07-25'),
|
||||||
|
}),
|
||||||
|
configs(),
|
||||||
|
today,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(only('A4', result)).toEqual([
|
||||||
|
{
|
||||||
|
rule: 'A4',
|
||||||
|
ruleKey: 'SEASON_AUTOPILOT',
|
||||||
|
intent: 'SUMMER_TREATMENT',
|
||||||
|
mode: 'PLAN',
|
||||||
|
reasonCode: 'SUMMER_TREATMENT_DUE',
|
||||||
|
evidence: {
|
||||||
|
colonyId: 'col_1',
|
||||||
|
phase: 'TRACHTENDE',
|
||||||
|
phaseSource: 'REPORT',
|
||||||
|
since: '2026-07-25',
|
||||||
|
reportedOn: null,
|
||||||
|
},
|
||||||
|
dueDate: today,
|
||||||
|
priority: 'HIGH',
|
||||||
|
anchor: '2026',
|
||||||
|
requiredPermission: 'treatments:read',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A4: a treatment since the segment start, another segment or the rule off -> nothing', () => {
|
||||||
|
const today = '2026-08-05';
|
||||||
|
const season = inSeason('SPAETSOMMER', 'TRACHTENDE', '2026-07-25');
|
||||||
|
const a4 = (candidates: { rule: string }[]) => only('A4', candidates);
|
||||||
|
expect(
|
||||||
|
a4(
|
||||||
|
evaluateAutopilot(
|
||||||
|
quiet(today, { season, treatmentsSinceLateSummer: 1 }),
|
||||||
|
configs(),
|
||||||
|
today,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
a4(
|
||||||
|
evaluateAutopilot(
|
||||||
|
quiet(today, { season: inSeason('SOMMER', 'LINDE', '2026-06-20') }),
|
||||||
|
configs(),
|
||||||
|
today,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
a4(
|
||||||
|
evaluateAutopilot(
|
||||||
|
quiet(today, { season }),
|
||||||
|
configs({
|
||||||
|
rules: { ...configs().autopilot.config.rules, A4: false },
|
||||||
|
}),
|
||||||
|
today,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A5: SPAETSOMMER or HERBST without feeding since late summer -> FEED, DO, NORMAL, anchor year', () => {
|
||||||
|
for (const [segment, since, today] of [
|
||||||
|
['SPAETSOMMER', '2026-07-25', '2026-08-05'],
|
||||||
|
['HERBST', '2026-09-15', '2026-09-20'],
|
||||||
|
] as const) {
|
||||||
|
const result = evaluateAutopilot(
|
||||||
|
quiet(today, { season: inSeason(segment, 'TRACHTENDE', since) }),
|
||||||
|
configs(),
|
||||||
|
today,
|
||||||
|
);
|
||||||
|
expect(only('A5', result)).toMatchObject([
|
||||||
|
{
|
||||||
|
rule: 'A5',
|
||||||
|
intent: 'FEED',
|
||||||
|
mode: 'DO',
|
||||||
|
reasonCode: 'AUTUMN_FEED_MISSING',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
anchor: '2026',
|
||||||
|
dueDate: today,
|
||||||
|
requiredPermission: 'feedings:read',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A5 and A6 never fire in WINTERRUHE; A5 is quiet after a feeding', () => {
|
||||||
|
const today = '2026-12-05';
|
||||||
|
const winter = quiet(today, {
|
||||||
|
season: inSeason('WINTERRUHE', 'WINTER', '2026-11-15'),
|
||||||
|
colony: { ...input(today).colony, feedStatus: 'CRITICAL' },
|
||||||
|
lastInspection: { ...inspectionOn('2026-11-01'), feedStatus: 'CRITICAL' },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
evaluateAutopilot(winter, configs(), today).filter((c) =>
|
||||||
|
['A5', 'A6'].includes(c.rule),
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
|
||||||
|
const augustToday = '2026-08-05';
|
||||||
|
const fed = quiet(augustToday, {
|
||||||
|
season: inSeason('SPAETSOMMER', 'TRACHTENDE', '2026-07-25'),
|
||||||
|
feedingsSinceLateSummer: 1,
|
||||||
|
});
|
||||||
|
expect(only('A5', evaluateAutopilot(fed, configs(), augustToday))).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A6 anchors on the inspection: WEAK or CRITICAL feed of the last inspection, no feeding since', () => {
|
||||||
|
const today = '2026-04-10';
|
||||||
|
const base: Partial<AutopilotInput> = {
|
||||||
|
season: inSeason('FRUEHJAHR', 'SALWEIDE', '2026-03-20'),
|
||||||
|
lastInspection: { ...inspectionOn('2026-04-03'), feedStatus: 'WEAK' },
|
||||||
|
lastVarroa: { id: 'var_1', date: today },
|
||||||
|
};
|
||||||
|
const result = only(
|
||||||
|
'A6',
|
||||||
|
evaluateAutopilot(input(today, base), configs(), today),
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject([
|
||||||
|
{
|
||||||
|
intent: 'FEED',
|
||||||
|
mode: 'DO',
|
||||||
|
reasonCode: 'FEED_LOW',
|
||||||
|
anchor: 'insp_9',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
dueDate: today,
|
||||||
|
requiredPermission: 'inspections:read',
|
||||||
|
evidence: {
|
||||||
|
colonyId: 'col_1',
|
||||||
|
inspectionId: 'insp_9',
|
||||||
|
inspectedOn: '2026-04-03',
|
||||||
|
condition: 'WEAK',
|
||||||
|
source: 'INSPECTION',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const fedSince = input(today, { ...base, feedingsSinceInspection: 1 });
|
||||||
|
expect(only('A6', evaluateAutopilot(fedSince, configs(), today))).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const good = input(today, {
|
||||||
|
...base,
|
||||||
|
lastInspection: { ...inspectionOn('2026-04-03'), feedStatus: 'GOOD' },
|
||||||
|
});
|
||||||
|
expect(only('A6', evaluateAutopilot(good, configs(), today))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A6: a CRITICAL colony card counts too, anchored on the inspection or none', () => {
|
||||||
|
const today = '2026-04-10';
|
||||||
|
const critical = input(today, {
|
||||||
|
season: inSeason('FRUEHJAHR', 'SALWEIDE', '2026-03-20'),
|
||||||
|
colony: { ...input(today).colony, feedStatus: 'CRITICAL' },
|
||||||
|
lastInspection: inspectionOn('2026-04-03'),
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
only('A6', evaluateAutopilot(critical, configs(), today)),
|
||||||
|
).toMatchObject([
|
||||||
|
{
|
||||||
|
anchor: 'insp_9',
|
||||||
|
evidence: { condition: 'CRITICAL', source: 'COLONY_CARD' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const noInspection = { ...critical, lastInspection: null };
|
||||||
|
expect(
|
||||||
|
only('A6', evaluateAutopilot(noInspection, configs(), today)),
|
||||||
|
).toMatchObject([{ anchor: 'none' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A7: KIRSCHE and 9 frames -> HONEY_SUPER; 7 frames -> nothing; LINDE -> nothing', () => {
|
||||||
|
const today = '2026-04-28';
|
||||||
|
const withFrames = (frames: number | null, phase: PhenologyPhaseKey) =>
|
||||||
|
only(
|
||||||
|
'A7',
|
||||||
|
evaluateAutopilot(
|
||||||
|
quiet(today, {
|
||||||
|
season: inSeason('SCHWARMZEIT', phase, '2026-04-15', 'GTS'),
|
||||||
|
lastInspection: { ...inspectionOn(today), colonyStrength: frames },
|
||||||
|
}),
|
||||||
|
configs(),
|
||||||
|
today,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(withFrames(9, 'KIRSCHE')).toMatchObject([
|
||||||
|
{
|
||||||
|
rule: 'A7',
|
||||||
|
intent: 'HONEY_SUPER',
|
||||||
|
mode: 'DO',
|
||||||
|
reasonCode: 'HONEY_SUPER_CHECK',
|
||||||
|
anchor: '2026',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
dueDate: today,
|
||||||
|
requiredPermission: 'inspections:read',
|
||||||
|
evidence: {
|
||||||
|
colonyId: 'col_1',
|
||||||
|
inspectionId: 'insp_9',
|
||||||
|
frames: 9,
|
||||||
|
phase: 'KIRSCHE',
|
||||||
|
phaseSource: 'GTS',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(withFrames(8, 'APFEL')).toHaveLength(1);
|
||||||
|
expect(withFrames(7, 'KIRSCHE')).toEqual([]);
|
||||||
|
expect(withFrames(null, 'KIRSCHE')).toEqual([]);
|
||||||
|
expect(withFrames(9, 'SALWEIDE')).toEqual([]);
|
||||||
|
expect(withFrames(9, 'LINDE')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A7 ignores an inspection from before the spring of this year', () => {
|
||||||
|
const today = '2026-04-28';
|
||||||
|
// inSeason puts springSince on 2026-03-20
|
||||||
|
const season = inSeason('SCHWARMZEIT', 'KIRSCHE', '2026-04-15');
|
||||||
|
const withInspection = (date: string) =>
|
||||||
|
only(
|
||||||
|
'A7',
|
||||||
|
evaluateAutopilot(
|
||||||
|
quiet(today, {
|
||||||
|
season,
|
||||||
|
lastInspection: { ...inspectionOn(date), colonyStrength: 12 },
|
||||||
|
}),
|
||||||
|
configs(),
|
||||||
|
today,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(withInspection('2025-09-10')).toEqual([]);
|
||||||
|
expect(withInspection('2026-03-19')).toEqual([]);
|
||||||
|
expect(withInspection('2026-03-20')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A7 honours honeySuperMinFrames and a missing inspection', () => {
|
||||||
|
const today = '2026-04-28';
|
||||||
|
const season = inSeason('SCHWARMZEIT', 'KIRSCHE', '2026-04-15');
|
||||||
|
const nine = quiet(today, {
|
||||||
|
season,
|
||||||
|
lastInspection: { ...inspectionOn(today), colonyStrength: 9 },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
only(
|
||||||
|
'A7',
|
||||||
|
evaluateAutopilot(nine, configs({ honeySuperMinFrames: 10 }), today),
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
const none = input(today, { season, lastInspection: null });
|
||||||
|
expect(only('A7', evaluateAutopilot(none, configs(), today))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A8: hatchYear 2024 in 2026 SCHWARMZEIT -> QUEEN_REPLACE, PLAN, anchor queen-year', () => {
|
||||||
|
const today = '2026-05-12';
|
||||||
|
const aged = quiet(today, {
|
||||||
|
season: inSeason('SCHWARMZEIT', 'KIRSCHE', '2026-04-15'),
|
||||||
|
queen: { id: 'q_12', hatchYear: 2024 },
|
||||||
|
});
|
||||||
|
expect(only('A8', evaluateAutopilot(aged, configs(), today))).toMatchObject(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
rule: 'A8',
|
||||||
|
intent: 'QUEEN_REPLACE',
|
||||||
|
mode: 'PLAN',
|
||||||
|
reasonCode: 'QUEEN_OLD',
|
||||||
|
anchor: 'q_12-2026',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
requiredPermission: 'queens:read',
|
||||||
|
evidence: { colonyId: 'col_1', queenId: 'q_12', hatchYear: 2024 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const sommerDay = '2026-07-01';
|
||||||
|
const sommer = {
|
||||||
|
...aged,
|
||||||
|
season: inSeason('SOMMER', 'LINDE', '2026-06-20'),
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
only('A8', evaluateAutopilot(sommer, configs(), sommerDay)),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A8: young queen, unknown hatch year, other segment or queenMaxAgeYears 3 -> nothing', () => {
|
||||||
|
const today = '2026-05-12';
|
||||||
|
const swarm = inSeason('SCHWARMZEIT', 'KIRSCHE', '2026-04-15');
|
||||||
|
const a8 = (
|
||||||
|
queen: AutopilotInput['queen'],
|
||||||
|
over?: object,
|
||||||
|
season = swarm,
|
||||||
|
) =>
|
||||||
|
only(
|
||||||
|
'A8',
|
||||||
|
evaluateAutopilot(
|
||||||
|
quiet(today, { season, queen }),
|
||||||
|
configs(over),
|
||||||
|
today,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(a8({ id: 'q', hatchYear: 2025 })).toEqual([]);
|
||||||
|
expect(a8({ id: 'q', hatchYear: null })).toEqual([]);
|
||||||
|
expect(a8(null)).toEqual([]);
|
||||||
|
expect(a8({ id: 'q', hatchYear: 2024 }, { queenMaxAgeYears: 3 })).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
a8(
|
||||||
|
{ id: 'q', hatchYear: 2024 },
|
||||||
|
undefined,
|
||||||
|
inSeason('FRUEHJAHR', 'SALWEIDE', '2026-03-20'),
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A9 and A10 follow the WinterCheck rows', () => {
|
||||||
|
const autumnDay = '2026-09-20';
|
||||||
|
const autumn = (winterChecks: AutopilotInput['winterChecks']) =>
|
||||||
|
only(
|
||||||
|
'A9',
|
||||||
|
evaluateAutopilot(
|
||||||
|
quiet(autumnDay, {
|
||||||
|
season: inSeason('HERBST', 'HERBST', '2026-09-15'),
|
||||||
|
winterChecks,
|
||||||
|
}),
|
||||||
|
configs(),
|
||||||
|
autumnDay,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(autumn([])).toMatchObject([
|
||||||
|
{
|
||||||
|
rule: 'A9',
|
||||||
|
intent: 'WINTER_CHECK',
|
||||||
|
mode: 'DO',
|
||||||
|
reasonCode: 'WINTER_CHECK_AUTUMN_MISSING',
|
||||||
|
anchor: '2026-AUTUMN',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
dueDate: autumnDay,
|
||||||
|
requiredPermission: 'winter-checks:read',
|
||||||
|
evidence: { colonyId: 'col_1', winterYear: 2026 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(autumn([{ winterYear: 2026, season: 'AUTUMN' }])).toEqual([]);
|
||||||
|
// The previous winter does not count for this one.
|
||||||
|
expect(autumn([{ winterYear: 2025, season: 'AUTUMN' }])).toHaveLength(1);
|
||||||
|
|
||||||
|
const springDay = '2026-03-25';
|
||||||
|
const spring = (winterChecks: AutopilotInput['winterChecks']) =>
|
||||||
|
only(
|
||||||
|
'A10',
|
||||||
|
evaluateAutopilot(
|
||||||
|
quiet(springDay, {
|
||||||
|
season: inSeason('FRUEHJAHR', 'SALWEIDE', '2026-03-20'),
|
||||||
|
winterChecks,
|
||||||
|
}),
|
||||||
|
configs(),
|
||||||
|
springDay,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(spring([{ winterYear: 2025, season: 'AUTUMN' }])).toMatchObject([
|
||||||
|
{
|
||||||
|
rule: 'A10',
|
||||||
|
intent: 'WINTER_CHECK',
|
||||||
|
mode: 'DO',
|
||||||
|
reasonCode: 'WINTER_CHECK_SPRING_MISSING',
|
||||||
|
anchor: '2025-SPRING',
|
||||||
|
dueDate: springDay,
|
||||||
|
requiredPermission: 'winter-checks:read',
|
||||||
|
evidence: { colonyId: 'col_1', winterYear: 2025, phase: 'SALWEIDE' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
// No wintering-in last autumn -> nothing to wind out; done -> nothing.
|
||||||
|
expect(spring([])).toEqual([]);
|
||||||
|
expect(
|
||||||
|
spring([
|
||||||
|
{ winterYear: 2025, season: 'AUTUMN' },
|
||||||
|
{ winterYear: 2025, season: 'SPRING' },
|
||||||
|
]),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the order A1...A10', () => {
|
||||||
|
const today = '2026-08-05';
|
||||||
|
const result = evaluateAutopilot(
|
||||||
|
input(today, {
|
||||||
|
season: inSeason('SPAETSOMMER', 'TRACHTENDE', '2026-07-25'),
|
||||||
|
lastInspection: inspectionOn('2026-07-01'),
|
||||||
|
lastVarroa: { id: 'var_0', date: '2026-06-01' },
|
||||||
|
}),
|
||||||
|
configs(),
|
||||||
|
today,
|
||||||
|
);
|
||||||
|
expect(result.map((c) => c.rule)).toEqual(['A1', 'A2', 'A4', 'A5']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
import type { AutopilotRuleId } from '../automation/rule-config';
|
||||||
|
import { addDays, daysBetween } from '../common/dates';
|
||||||
|
import { inspectionDueOn } from '../season/inspection-due';
|
||||||
|
import { isQueenAged } from '../season/queen-age';
|
||||||
|
import { PHENOLOGY_PHASES } from '../season/season-types';
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The phase window of A7: from KIRSCHE up to, not including, LINDE. */
|
||||||
|
const HONEY_SUPER_FROM_PHASE = PHENOLOGY_PHASES.indexOf('KIRSCHE');
|
||||||
|
const HONEY_SUPER_UNTIL_PHASE = PHENOLOGY_PHASES.indexOf('LINDE');
|
||||||
|
|
||||||
|
/** The season facts the texts of A4, A5, A7 and A10 quote. */
|
||||||
|
function seasonEvidence(input: AutopilotInput): {
|
||||||
|
phase: string | null;
|
||||||
|
phaseSource: string | null;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
phase: input.season.phase,
|
||||||
|
phaseSource: input.season.phaseSource,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A4: the honey flow is over and nothing was treated since. */
|
||||||
|
const a4SummerTreatment: AutopilotRule = (input, _configs, today) => {
|
||||||
|
const { season } = input;
|
||||||
|
if (season.segment !== 'SPAETSOMMER' || input.treatmentsSinceLateSummer > 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
rule: 'A4',
|
||||||
|
ruleKey: 'SEASON_AUTOPILOT',
|
||||||
|
intent: 'SUMMER_TREATMENT',
|
||||||
|
mode: 'PLAN',
|
||||||
|
reasonCode: 'SUMMER_TREATMENT_DUE',
|
||||||
|
evidence: {
|
||||||
|
colonyId: input.colony.id,
|
||||||
|
...seasonEvidence(input),
|
||||||
|
since: season.segmentSince,
|
||||||
|
reportedOn: season.reportedOn,
|
||||||
|
},
|
||||||
|
dueDate: today,
|
||||||
|
priority: 'HIGH',
|
||||||
|
anchor: String(season.year),
|
||||||
|
requiredPermission: 'treatments:read',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A5: late summer or autumn and no feeding since the honey flow ended. */
|
||||||
|
const a5AutumnFeeding: AutopilotRule = (input, _configs, today) => {
|
||||||
|
const { season } = input;
|
||||||
|
if (
|
||||||
|
(season.segment !== 'SPAETSOMMER' && season.segment !== 'HERBST') ||
|
||||||
|
input.feedingsSinceLateSummer > 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
rule: 'A5',
|
||||||
|
ruleKey: 'SEASON_AUTOPILOT',
|
||||||
|
intent: 'FEED',
|
||||||
|
mode: 'DO',
|
||||||
|
reasonCode: 'AUTUMN_FEED_MISSING',
|
||||||
|
evidence: {
|
||||||
|
colonyId: input.colony.id,
|
||||||
|
...seasonEvidence(input),
|
||||||
|
since: season.segmentSince,
|
||||||
|
},
|
||||||
|
dueDate: today,
|
||||||
|
priority: 'NORMAL',
|
||||||
|
anchor: String(season.year),
|
||||||
|
requiredPermission: 'feedings:read',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A6: low feed at the last inspection (or a critical colony card), not fed since. */
|
||||||
|
const a6LowFeed: AutopilotRule = (input, _configs, today) => {
|
||||||
|
if (
|
||||||
|
input.season.segment === 'WINTERRUHE' ||
|
||||||
|
input.feedingsSinceInspection > 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { lastInspection, colony } = input;
|
||||||
|
const inspectionFeed = lastInspection?.feedStatus ?? null;
|
||||||
|
const isInspectionLow =
|
||||||
|
lastInspection !== null &&
|
||||||
|
(inspectionFeed === 'WEAK' || inspectionFeed === 'CRITICAL');
|
||||||
|
if (!isInspectionLow && colony.feedStatus !== 'CRITICAL') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
rule: 'A6',
|
||||||
|
ruleKey: 'SEASON_AUTOPILOT',
|
||||||
|
intent: 'FEED',
|
||||||
|
mode: 'DO',
|
||||||
|
reasonCode: 'FEED_LOW',
|
||||||
|
evidence: {
|
||||||
|
colonyId: colony.id,
|
||||||
|
inspectionId: lastInspection?.id ?? null,
|
||||||
|
inspectedOn: isInspectionLow ? lastInspection.date : null,
|
||||||
|
condition: isInspectionLow ? inspectionFeed : 'CRITICAL',
|
||||||
|
source: isInspectionLow ? 'INSPECTION' : 'COLONY_CARD',
|
||||||
|
},
|
||||||
|
dueDate: today,
|
||||||
|
priority: 'NORMAL',
|
||||||
|
anchor: lastInspection?.id ?? 'none',
|
||||||
|
requiredPermission: 'inspections:read',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A7: the cherry blooms and the colony covers enough frames for a honey super. */
|
||||||
|
const a7HoneySuper: AutopilotRule = (input, configs, today) => {
|
||||||
|
const phaseIndex =
|
||||||
|
input.season.phase === null
|
||||||
|
? -1
|
||||||
|
: PHENOLOGY_PHASES.indexOf(input.season.phase);
|
||||||
|
// Only an inspection of this season says how strong the colony is now.
|
||||||
|
const seasonStart = input.season.springSince ?? `${input.season.year}-01-01`;
|
||||||
|
const inspection =
|
||||||
|
input.lastInspection !== null && input.lastInspection.date >= seasonStart
|
||||||
|
? input.lastInspection
|
||||||
|
: null;
|
||||||
|
const frames = inspection?.colonyStrength ?? null;
|
||||||
|
if (
|
||||||
|
phaseIndex < HONEY_SUPER_FROM_PHASE ||
|
||||||
|
phaseIndex >= HONEY_SUPER_UNTIL_PHASE ||
|
||||||
|
frames === null ||
|
||||||
|
frames < configs.autopilot.config.honeySuperMinFrames
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
rule: 'A7',
|
||||||
|
ruleKey: 'SEASON_AUTOPILOT',
|
||||||
|
intent: 'HONEY_SUPER',
|
||||||
|
mode: 'DO',
|
||||||
|
reasonCode: 'HONEY_SUPER_CHECK',
|
||||||
|
evidence: {
|
||||||
|
colonyId: input.colony.id,
|
||||||
|
inspectionId: inspection?.id ?? null,
|
||||||
|
frames,
|
||||||
|
...seasonEvidence(input),
|
||||||
|
},
|
||||||
|
dueDate: today,
|
||||||
|
priority: 'NORMAL',
|
||||||
|
anchor: String(input.season.year),
|
||||||
|
requiredPermission: 'inspections:read',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A8: the queen has reached the age the beekeeper plans to replace her at. */
|
||||||
|
const a8QueenReplace: AutopilotRule = (input, configs, today) => {
|
||||||
|
const { queen, season } = input;
|
||||||
|
if (
|
||||||
|
queen === null ||
|
||||||
|
(season.segment !== 'SCHWARMZEIT' && season.segment !== 'SOMMER') ||
|
||||||
|
!isQueenAged(
|
||||||
|
queen.hatchYear,
|
||||||
|
today,
|
||||||
|
configs.autopilot.config.queenMaxAgeYears,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
rule: 'A8',
|
||||||
|
ruleKey: 'SEASON_AUTOPILOT',
|
||||||
|
intent: 'QUEEN_REPLACE',
|
||||||
|
mode: 'PLAN',
|
||||||
|
reasonCode: 'QUEEN_OLD',
|
||||||
|
evidence: {
|
||||||
|
colonyId: input.colony.id,
|
||||||
|
queenId: queen.id,
|
||||||
|
hatchYear: queen.hatchYear,
|
||||||
|
},
|
||||||
|
dueDate: today,
|
||||||
|
priority: 'NORMAL',
|
||||||
|
anchor: `${queen.id}-${season.year}`,
|
||||||
|
requiredPermission: 'queens:read',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A9: autumn and no wintering-in entry for the winter that begins now. */
|
||||||
|
const a9WinteringIn: AutopilotRule = (input, _configs, today) => {
|
||||||
|
const { season } = input;
|
||||||
|
const isRecorded = input.winterChecks.some(
|
||||||
|
(check) => check.winterYear === season.year && check.season === 'AUTUMN',
|
||||||
|
);
|
||||||
|
if (season.segment !== 'HERBST' || isRecorded) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
rule: 'A9',
|
||||||
|
ruleKey: 'SEASON_AUTOPILOT',
|
||||||
|
intent: 'WINTER_CHECK',
|
||||||
|
mode: 'DO',
|
||||||
|
reasonCode: 'WINTER_CHECK_AUTUMN_MISSING',
|
||||||
|
evidence: { colonyId: input.colony.id, winterYear: season.year },
|
||||||
|
dueDate: today,
|
||||||
|
priority: 'NORMAL',
|
||||||
|
anchor: `${season.year}-AUTUMN`,
|
||||||
|
requiredPermission: 'winter-checks:read',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A10: spring, wintered in last autumn, not yet wintered out. */
|
||||||
|
const a10WinteringOut: AutopilotRule = (input, _configs, today) => {
|
||||||
|
const { season } = input;
|
||||||
|
const winterYear = season.year - 1;
|
||||||
|
const has = (kind: 'AUTUMN' | 'SPRING') =>
|
||||||
|
input.winterChecks.some(
|
||||||
|
(check) => check.winterYear === winterYear && check.season === kind,
|
||||||
|
);
|
||||||
|
if (season.segment !== 'FRUEHJAHR' || !has('AUTUMN') || has('SPRING')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
rule: 'A10',
|
||||||
|
ruleKey: 'SEASON_AUTOPILOT',
|
||||||
|
intent: 'WINTER_CHECK',
|
||||||
|
mode: 'DO',
|
||||||
|
reasonCode: 'WINTER_CHECK_SPRING_MISSING',
|
||||||
|
evidence: {
|
||||||
|
colonyId: input.colony.id,
|
||||||
|
winterYear,
|
||||||
|
...seasonEvidence(input),
|
||||||
|
},
|
||||||
|
dueDate: today,
|
||||||
|
priority: 'NORMAL',
|
||||||
|
anchor: `${winterYear}-SPRING`,
|
||||||
|
requiredPermission: 'winter-checks:read',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AUTOPILOT_RULES: Readonly<Record<AutopilotRuleId, AutopilotRule>> =
|
||||||
|
{
|
||||||
|
A1: a1Inspection,
|
||||||
|
A2: a2VarroaMeasure,
|
||||||
|
A3: a3VarroaRecheck,
|
||||||
|
A4: a4SummerTreatment,
|
||||||
|
A5: a5AutumnFeeding,
|
||||||
|
A6: a6LowFeed,
|
||||||
|
A7: a7HoneySuper,
|
||||||
|
A8: a8QueenReplace,
|
||||||
|
A9: a9WinteringIn,
|
||||||
|
A10: a10WinteringOut,
|
||||||
|
};
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if (!configs.autopilot.config.rules[id]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const candidate = AUTOPILOT_RULES[id](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,261 @@
|
|||||||
|
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 { lockRowsForUpdate } from '../prisma/row-lock';
|
||||||
|
import { AutopilotEventHandlers } from './autopilot.handlers';
|
||||||
|
import type { AutopilotService } from './autopilot.service';
|
||||||
|
|
||||||
|
jest.mock('../prisma/row-lock');
|
||||||
|
const lockRows = jest.mocked(lockRowsForUpdate);
|
||||||
|
|
||||||
|
const EXPECTED_TYPES: DomainEventType[] = [
|
||||||
|
'InspectionRecorded',
|
||||||
|
'InspectionCorrected',
|
||||||
|
'VarroaMeasured',
|
||||||
|
'VarroaCorrected',
|
||||||
|
'TreatmentRecorded',
|
||||||
|
'TreatmentCorrected',
|
||||||
|
'FeedingRecorded',
|
||||||
|
'FeedingCorrected',
|
||||||
|
'HarvestRecorded',
|
||||||
|
'HarvestCorrected',
|
||||||
|
'ColonyStatusChanged',
|
||||||
|
'ColonySplit',
|
||||||
|
'QueenAssigned',
|
||||||
|
'TaskCompleted',
|
||||||
|
'TaskUpdated',
|
||||||
|
'WithdrawalEnded',
|
||||||
|
'QueenAged',
|
||||||
|
'InspectionOverdue',
|
||||||
|
'VarroaMeasurementDue',
|
||||||
|
'WinterCheckRecorded',
|
||||||
|
];
|
||||||
|
|
||||||
|
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; syncApiaryIn: jest.Mock };
|
||||||
|
let tx: { task: { findUnique: jest.Mock } };
|
||||||
|
let handlers: AutopilotEventHandlers;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
lockRows.mockReset();
|
||||||
|
jest.useFakeTimers({ now: new Date('2026-05-12T08:00:00.000Z') });
|
||||||
|
autopilot = {
|
||||||
|
syncColonyIn: jest.fn().mockResolvedValue({}),
|
||||||
|
syncApiaryIn: 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' }],
|
||||||
|
['WinterCheckRecorded', { 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('locks all colonies of an event in one statement before syncing any', async () => {
|
||||||
|
await run(
|
||||||
|
event('HarvestRecorded', 'h1', {
|
||||||
|
apiaryId: 'a',
|
||||||
|
colonyIds: ['c3', 'c1', 'c2'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(lockRows).toHaveBeenCalledWith(tx, 'Colony', ['c1', 'c2', 'c3']);
|
||||||
|
expect(lockRows.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
|
autopilot.syncColonyIn.mock.invocationCallOrder[0],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes no extra lock for a single colony', async () => {
|
||||||
|
await run(event('InspectionRecorded', 'i1', { colonyId: 'c1' }));
|
||||||
|
|
||||||
|
expect(lockRows).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('apiarySync', () => {
|
||||||
|
it('is registered as autopilot.apiary-sync for SeasonPhaseChanged only', () => {
|
||||||
|
const entries = domainEventHandlerMetadata(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||||
|
AutopilotEventHandlers.prototype.apiarySync,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(entries).toHaveLength(1);
|
||||||
|
expect(entries[0].handlerId).toBe('autopilot.apiary-sync');
|
||||||
|
expect([...entries[0].types]).toEqual(['SeasonPhaseChanged']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// One event per apiary and day (dailyDedupeKey): a change later the same
|
||||||
|
// day is caught by the daily autopilot step.
|
||||||
|
it('syncs the apiary of the payload for the Berlin day', async () => {
|
||||||
|
await handlers.apiarySync(
|
||||||
|
event('SeasonPhaseChanged', 'api-1', {
|
||||||
|
apiaryId: 'api-1',
|
||||||
|
phase: 'KIRSCHE',
|
||||||
|
segment: 'SCHWARMZEIT',
|
||||||
|
previousPhase: 'SALWEIDE',
|
||||||
|
previousSegment: 'FRUEHJAHR',
|
||||||
|
source: 'REPORT',
|
||||||
|
}) as Parameters<AutopilotEventHandlers['apiarySync']>[0],
|
||||||
|
tx as unknown as Prisma.TransactionClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(autopilot.syncApiaryIn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(autopilot.syncApiaryIn).toHaveBeenCalledWith(
|
||||||
|
tx,
|
||||||
|
'api-1',
|
||||||
|
'2026-05-12',
|
||||||
|
);
|
||||||
|
expect(autopilot.syncColonyIn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets errors propagate so the delivery is retried', async () => {
|
||||||
|
autopilot.syncApiaryIn.mockRejectedValue(new Error('db down'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
handlers.apiarySync(
|
||||||
|
event('SeasonPhaseChanged', 'api-1', {
|
||||||
|
apiaryId: 'api-1',
|
||||||
|
}) as Parameters<AutopilotEventHandlers['apiarySync']>[0],
|
||||||
|
tx as unknown as Prisma.TransactionClient,
|
||||||
|
),
|
||||||
|
).rejects.toThrow('db down');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
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 { lockRowsForUpdate } from '../prisma/row-lock';
|
||||||
|
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',
|
||||||
|
'WinterCheckRecorded',
|
||||||
|
] 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 'WinterCheckRecorded':
|
||||||
|
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
|
||||||
|
* (autopilot.colony-sync) and, for a season change, of all colonies of the
|
||||||
|
* apiary (autopilot.apiary-sync).
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
if (colonyIds.length > 1) {
|
||||||
|
// All colonies in one statement before any sync, like syncApiaryIn.
|
||||||
|
await lockRowsForUpdate(tx, 'Colony', colonyIds);
|
||||||
|
}
|
||||||
|
const today = berlinToday();
|
||||||
|
for (const colonyId of colonyIds) {
|
||||||
|
await this.autopilot.syncColonyIn(tx, colonyId, today);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A season change (new phase or segment) alters the rules for every colony
|
||||||
|
* of the apiary: reconcile them all. There is one such event per apiary and
|
||||||
|
* day (dailyDedupeKey); a change later the same day is caught by the daily
|
||||||
|
* autopilot step.
|
||||||
|
*/
|
||||||
|
@OnDomainEvent(['SeasonPhaseChanged'], 'autopilot.apiary-sync')
|
||||||
|
async apiarySync(
|
||||||
|
event: AnyDomainEventEnvelope<'SeasonPhaseChanged'>,
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.autopilot.syncApiaryIn(
|
||||||
|
tx,
|
||||||
|
event.payload.apiaryId,
|
||||||
|
berlinToday(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,397 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
DEFAULT_RULES,
|
||||||
|
readRule,
|
||||||
|
ruleConfigOf,
|
||||||
|
} from '../automation/rule-config';
|
||||||
|
import { lockRowForUpdate, lockRowsForUpdate } 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 lockRows = jest.mocked(lockRowsForUpdate);
|
||||||
|
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; findMany: 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' }),
|
||||||
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
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, '08-01', TODAY);
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('syncApiaryIn', () => {
|
||||||
|
it('syncs every living colony of the apiary in id order with one read of rules and seasons', async () => {
|
||||||
|
tx.colony.findMany.mockResolvedValue([{ id: 'col-1' }, { id: 'col-2' }]);
|
||||||
|
loadInput.mockResolvedValue(input(null));
|
||||||
|
readRuleMock.mockClear();
|
||||||
|
|
||||||
|
const result = await service.syncApiaryIn(tx as never, 'api-1', TODAY);
|
||||||
|
|
||||||
|
expect(tx.colony.findMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
apiaryId: 'api-1',
|
||||||
|
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
expect(lockRow.mock.calls.map((call) => call.slice(1))).toEqual([
|
||||||
|
['Apiary', 'api-1'],
|
||||||
|
['Colony', 'col-1'],
|
||||||
|
['Colony', 'col-2'],
|
||||||
|
]);
|
||||||
|
expect(readRuleMock).toHaveBeenCalledTimes(4);
|
||||||
|
expect(seasons.statesFor).toHaveBeenCalledTimes(1);
|
||||||
|
expect(seasons.statesFor).toHaveBeenCalledWith(tx, ['api-1'], TODAY);
|
||||||
|
expect(result).toEqual({ opened: 2, refreshed: 0, resolved: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locks the apiary, then all its colonies in one statement, before any colony is synced', async () => {
|
||||||
|
tx.colony.findMany.mockResolvedValue([{ id: 'col-1' }, { id: 'col-2' }]);
|
||||||
|
loadInput.mockResolvedValue(input(null));
|
||||||
|
|
||||||
|
await service.syncApiaryIn(tx as never, 'api-1', TODAY);
|
||||||
|
|
||||||
|
expect(lockRows).toHaveBeenCalledWith(tx, 'Colony', ['col-1', 'col-2']);
|
||||||
|
const apiaryLock = lockRow.mock.invocationCallOrder[0];
|
||||||
|
const listing = tx.colony.findMany.mock.invocationCallOrder[0];
|
||||||
|
const colonyLock = lockRows.mock.invocationCallOrder[0];
|
||||||
|
const firstSync = lockRow.mock.invocationCallOrder[1];
|
||||||
|
expect(apiaryLock).toBeLessThan(listing);
|
||||||
|
expect(listing).toBeLessThan(colonyLock);
|
||||||
|
expect(colonyLock).toBeLessThan(firstSync);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing and reads nothing for an apiary without living colonies', async () => {
|
||||||
|
readRuleMock.mockClear();
|
||||||
|
|
||||||
|
expect(await service.syncApiaryIn(tx as never, 'api-1', TODAY)).toEqual({
|
||||||
|
opened: 0,
|
||||||
|
refreshed: 0,
|
||||||
|
resolved: 0,
|
||||||
|
});
|
||||||
|
expect(readRuleMock).not.toHaveBeenCalled();
|
||||||
|
expect(seasons.statesFor).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a client that is not a transaction', async () => {
|
||||||
|
await expect(
|
||||||
|
service.syncApiaryIn({ $connect: jest.fn() } as never, 'api-1', TODAY),
|
||||||
|
).rejects.toThrow('AutopilotService.syncApiaryIn');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import type { Prisma } from '../../generated/prisma';
|
||||||
|
import { readRule } from '../automation/rule-config';
|
||||||
|
import {
|
||||||
|
isLivingColonyStatus,
|
||||||
|
LIVING_COLONY_WHERE,
|
||||||
|
} 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, lockRowsForUpdate } 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,
|
||||||
|
configs.phenology.calendarFallback.TRACHTENDE,
|
||||||
|
today,
|
||||||
|
);
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconciles every living colony of the apiary, for a season change.
|
||||||
|
* Lock order: the apiary row, then (after reading the list under that
|
||||||
|
* lock) all its colonies in ONE statement in ascending id order, before any
|
||||||
|
* colony is synced. A concurrent single-colony or multi-colony sync takes
|
||||||
|
* its colony locks the same way, so no two syncs wait for each other while
|
||||||
|
* the proposals lock suggestion and rule rows. Rules and season are read
|
||||||
|
* once for all colonies. About ten queries per colony run in this one
|
||||||
|
* transaction.
|
||||||
|
*/
|
||||||
|
async syncApiaryIn(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
apiaryId: string,
|
||||||
|
today: string,
|
||||||
|
): Promise<AutopilotSyncResult> {
|
||||||
|
assertTransactionClient(tx, 'AutopilotService.syncApiaryIn');
|
||||||
|
if (!(await lockRowForUpdate(tx, 'Apiary', apiaryId))) {
|
||||||
|
return NOTHING;
|
||||||
|
}
|
||||||
|
const colonies = await tx.colony.findMany({
|
||||||
|
where: { apiaryId, ...LIVING_COLONY_WHERE },
|
||||||
|
select: { id: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
if (colonies.length === 0) {
|
||||||
|
return NOTHING;
|
||||||
|
}
|
||||||
|
await lockRowsForUpdate(
|
||||||
|
tx,
|
||||||
|
'Colony',
|
||||||
|
colonies.map((colony) => colony.id),
|
||||||
|
);
|
||||||
|
const preload = await this.preloadFor(tx, [apiaryId], today);
|
||||||
|
let total = NOTHING;
|
||||||
|
for (const { id } of colonies) {
|
||||||
|
const result = await this.syncColonyIn(tx, id, today, preload);
|
||||||
|
total = {
|
||||||
|
opened: total.opened + result.opened,
|
||||||
|
refreshed: total.refreshed + result.refreshed,
|
||||||
|
resolved: total.resolved + result.resolved,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,415 @@
|
|||||||
|
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 };
|
||||||
|
apiaryPhenology: { 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([]) },
|
||||||
|
apiaryPhenology: { 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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,41 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException, Logger } from '@nestjs/common';
|
||||||
import { isSealedBackup, openBackup, sealBackup } from './backup-crypto';
|
import {
|
||||||
|
isSealedBackup,
|
||||||
|
openBackup,
|
||||||
|
sealBackup,
|
||||||
|
type SealedBackup,
|
||||||
|
} from './backup-crypto';
|
||||||
|
|
||||||
|
// Sealed by the code before the key ring, JWT_ACCESS_SECRET = 'x' * 40:
|
||||||
|
// LEGACY_JWT_BACKUP without BACKUP_ENCRYPTION_KEY (key derived from the JWT
|
||||||
|
// secret), KEY_BACKUP with BACKUP_ENCRYPTION_KEY = 'k' * 40.
|
||||||
|
const FIXTURE_BUNDLE = {
|
||||||
|
version: 3,
|
||||||
|
exportedAt: '2026-10-01T09:00:00.000Z',
|
||||||
|
data: { user: [{ id: 'u1' }] },
|
||||||
|
};
|
||||||
|
const LEGACY_JWT_BACKUP: SealedBackup = {
|
||||||
|
format: 'apicore-backup',
|
||||||
|
version: 3,
|
||||||
|
keyId: '6ac8404ff5ae',
|
||||||
|
exportedAt: '2026-10-01T09:00:00.000Z',
|
||||||
|
algorithm: 'A256GCM+gzip',
|
||||||
|
iv: 'vD+47m3e5IFJAexz',
|
||||||
|
tag: 'LOgrmURu9vAMalQmlduDpw==',
|
||||||
|
ciphertext:
|
||||||
|
'yvuAGhVALAxXkqZM8FpeiPnGHD9llp/SVtdQ6S58oS4ezussfP7Lqj8ZDuNbxabwjUP4QBJvVdEyhKCFZ9w5YRbCRjDozizJyE6YwjqiCsAXWPyVZ2lZ6Fo8/G+/0HsoQg==',
|
||||||
|
};
|
||||||
|
const KEY_BACKUP: SealedBackup = {
|
||||||
|
format: 'apicore-backup',
|
||||||
|
version: 3,
|
||||||
|
keyId: '14b11a29dc33',
|
||||||
|
exportedAt: '2026-10-01T09:00:00.000Z',
|
||||||
|
algorithm: 'A256GCM+gzip',
|
||||||
|
iv: 'NKe1zNJfRPyBqeYL',
|
||||||
|
tag: 'zU/oVHyV4bj5+wwF0lvGiA==',
|
||||||
|
ciphertext:
|
||||||
|
'ArjmmWq18F3OkPQYfx6x9XcjWdqP1SZC9zarUCRfV7bo6KRYx3pi5sNMRro6mg0eqlr4+C4jRKn3y+110kaWEFrY6J/ashF76pWSl1gzfiewkpxdqoyjkXRxrd4AnPrnfw==',
|
||||||
|
};
|
||||||
|
|
||||||
describe('backup-crypto', () => {
|
describe('backup-crypto', () => {
|
||||||
const originalEnv = { ...process.env };
|
const originalEnv = { ...process.env };
|
||||||
@@ -12,10 +48,13 @@ describe('backup-crypto', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env.JWT_ACCESS_SECRET = 'x'.repeat(40);
|
process.env.JWT_ACCESS_SECRET = 'x'.repeat(40);
|
||||||
delete process.env.BACKUP_ENCRYPTION_KEY;
|
delete process.env.BACKUP_ENCRYPTION_KEY;
|
||||||
|
delete process.env.BACKUP_ENCRYPTION_KEY_PREVIOUS;
|
||||||
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
process.env = { ...originalEnv };
|
process.env = { ...originalEnv };
|
||||||
|
jest.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('round-trips a bundle and hides its contents', () => {
|
it('round-trips a bundle and hides its contents', () => {
|
||||||
@@ -45,7 +84,17 @@ describe('backup-crypto', () => {
|
|||||||
).toThrow(BadRequestException);
|
).toThrow(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects a file with a truncated auth tag', () => {
|
||||||
|
const sealed = sealBackup(bundle, bundle.exportedAt);
|
||||||
|
const tag = Buffer.from(sealed.tag, 'base64').subarray(0, 4);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
openBackup({ ...sealed, tag: tag.toString('base64') }),
|
||||||
|
).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects a file sealed with another key', () => {
|
it('rejects a file sealed with another key', () => {
|
||||||
|
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
|
||||||
const sealed = sealBackup(bundle, bundle.exportedAt);
|
const sealed = sealBackup(bundle, bundle.exportedAt);
|
||||||
process.env.BACKUP_ENCRYPTION_KEY = 'y'.repeat(40);
|
process.env.BACKUP_ENCRYPTION_KEY = 'y'.repeat(40);
|
||||||
|
|
||||||
@@ -64,4 +113,80 @@ describe('backup-crypto', () => {
|
|||||||
process.env.BACKUP_ENCRYPTION_KEY = 'short';
|
process.env.BACKUP_ENCRYPTION_KEY = 'short';
|
||||||
expect(() => sealBackup(bundle, bundle.exportedAt)).toThrow();
|
expect(() => sealBackup(bundle, bundle.exportedAt)).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('opens a backup sealed with BACKUP_ENCRYPTION_KEY_PREVIOUS', () => {
|
||||||
|
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
|
||||||
|
const sealed = sealBackup(bundle, bundle.exportedAt);
|
||||||
|
process.env.BACKUP_ENCRYPTION_KEY = 'y'.repeat(40);
|
||||||
|
process.env.BACKUP_ENCRYPTION_KEY_PREVIOUS = 'k'.repeat(40);
|
||||||
|
|
||||||
|
expect(openBackup(sealed)).toEqual(bundle);
|
||||||
|
expect(sealBackup(bundle, bundle.exportedAt).keyId).not.toBe(sealed.keyId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still opens files sealed before the key ring (file format v3 unchanged)', () => {
|
||||||
|
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
|
||||||
|
|
||||||
|
expect(openBackup(KEY_BACKUP)).toEqual(FIXTURE_BUNDLE);
|
||||||
|
expect(openBackup(LEGACY_JWT_BACKUP)).toEqual(FIXTURE_BUNDLE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only reads with the JWT-derived key, never seals with it once BACKUP_ENCRYPTION_KEY is set', () => {
|
||||||
|
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
|
||||||
|
|
||||||
|
expect(sealBackup(bundle, bundle.exportedAt).keyId).toBe(KEY_BACKUP.keyId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed on a JWT-derived backup once JWT_ACCESS_SECRET changed', () => {
|
||||||
|
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
|
||||||
|
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
|
||||||
|
|
||||||
|
expect(() => openBackup(LEGACY_JWT_BACKUP)).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to seal in production without BACKUP_ENCRYPTION_KEY', () => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
|
||||||
|
expect(() => sealBackup(bundle, bundle.exportedAt)).toThrow(
|
||||||
|
'BACKUP_ENCRYPTION_KEY is not set',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns once that a development key stands in for BACKUP_ENCRYPTION_KEY', () => {
|
||||||
|
jest.isolateModules(() => {
|
||||||
|
// A fresh module copy, so earlier tests cannot have used up the warning.
|
||||||
|
const nest =
|
||||||
|
jest.requireActual<typeof import('@nestjs/common')>('@nestjs/common');
|
||||||
|
const fresh =
|
||||||
|
jest.requireActual<typeof import('./backup-crypto')>('./backup-crypto');
|
||||||
|
const warn = jest
|
||||||
|
.spyOn(nest.Logger.prototype, 'warn')
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
const sealed = fresh.sealBackup(bundle, bundle.exportedAt);
|
||||||
|
fresh.sealBackup(bundle, bundle.exportedAt);
|
||||||
|
fresh.openBackup(sealed);
|
||||||
|
|
||||||
|
expect(warn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(warn).toHaveBeenCalledWith(
|
||||||
|
'BACKUP_ENCRYPTION_KEY not set – using development key derived from JWT_ACCESS_SECRET; never in production',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not warn when only reading a legacy file next to a configured key', () => {
|
||||||
|
jest.isolateModules(() => {
|
||||||
|
const nest =
|
||||||
|
jest.requireActual<typeof import('@nestjs/common')>('@nestjs/common');
|
||||||
|
const fresh =
|
||||||
|
jest.requireActual<typeof import('./backup-crypto')>('./backup-crypto');
|
||||||
|
const warn = jest
|
||||||
|
.spyOn(nest.Logger.prototype, 'warn')
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
|
||||||
|
|
||||||
|
expect(fresh.openBackup(LEGACY_JWT_BACKUP)).toEqual(FIXTURE_BUNDLE);
|
||||||
|
expect(warn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,17 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import * as crypto from 'node:crypto';
|
import * as crypto from 'node:crypto';
|
||||||
import * as zlib from 'node:zlib';
|
import * as zlib from 'node:zlib';
|
||||||
|
import {
|
||||||
|
ENCRYPTION_KEY_VARIABLES,
|
||||||
|
devFallbackSecret,
|
||||||
|
findRingKey,
|
||||||
|
gcmOpen,
|
||||||
|
gcmSeal,
|
||||||
|
readRingSecrets,
|
||||||
|
requireJwtAccessSecret,
|
||||||
|
type KeyRing,
|
||||||
|
type RingKey,
|
||||||
|
} from '../common/crypto/key-ring';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A backup holds everything - password hashes, encrypted MFA secrets,
|
* A backup holds everything - password hashes, encrypted MFA secrets,
|
||||||
@@ -9,17 +20,17 @@ import * as zlib from 'node:zlib';
|
|||||||
* keeps it unreadable outside ApiCore AND makes any change to it (or a
|
* keeps it unreadable outside ApiCore AND makes any change to it (or a
|
||||||
* hand-made file) fail authentication before a single row is touched.
|
* hand-made file) fail authentication before a single row is touched.
|
||||||
*
|
*
|
||||||
* Key: BACKUP_ENCRYPTION_KEY when set (needed to restore into another
|
* Keys follow the key ring (common/crypto/key-ring.ts): BACKUP_ENCRYPTION_KEY
|
||||||
* deployment), otherwise derived from JWT_ACCESS_SECRET - so a deployment
|
* seals, BACKUP_ENCRYPTION_KEY_PREVIOUS still opens, and files sealed before
|
||||||
* can always restore its own backups. Either way the AES key comes from
|
* the key was mandatory - with a key derived from JWT_ACCESS_SECRET - are
|
||||||
* HKDF with a backup-specific label, never the raw secret.
|
* still read (never written, except outside production without
|
||||||
|
* BACKUP_ENCRYPTION_KEY). The file names its key in `keyId`. Either way the
|
||||||
|
* AES key comes from HKDF with a backup-specific label, never the raw
|
||||||
|
* secret; derivation and file format v3 are unchanged, so older files open.
|
||||||
*/
|
*/
|
||||||
export const SEALED_BACKUP_FORMAT = 'apicore-backup';
|
export const SEALED_BACKUP_FORMAT = 'apicore-backup';
|
||||||
export const SEALED_BACKUP_VERSION = 3;
|
export const SEALED_BACKUP_VERSION = 3;
|
||||||
|
|
||||||
const ALGORITHM = 'aes-256-gcm';
|
|
||||||
const IV_BYTES = 12;
|
|
||||||
const MIN_KEY_LENGTH = 32;
|
|
||||||
// Decompressed size cap: a small file must not unpack into gigabytes.
|
// Decompressed size cap: a small file must not unpack into gigabytes.
|
||||||
const MAX_PLAINTEXT_BYTES = 512 * 1024 * 1024;
|
const MAX_PLAINTEXT_BYTES = 512 * 1024 * 1024;
|
||||||
|
|
||||||
@@ -35,25 +46,9 @@ export interface SealedBackup {
|
|||||||
ciphertext: string;
|
ciphertext: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function rootSecret(): { secret: string; source: 'backup' | 'jwt' } {
|
type KeySource = 'backup' | 'jwt';
|
||||||
const backupKey = process.env.BACKUP_ENCRYPTION_KEY;
|
|
||||||
if (backupKey) {
|
|
||||||
if (backupKey.length < MIN_KEY_LENGTH) {
|
|
||||||
throw new Error(
|
|
||||||
`BACKUP_ENCRYPTION_KEY must be at least ${MIN_KEY_LENGTH} characters`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { secret: backupKey, source: 'backup' };
|
|
||||||
}
|
|
||||||
const jwtSecret = process.env.JWT_ACCESS_SECRET;
|
|
||||||
if (!jwtSecret) {
|
|
||||||
throw new Error('JWT_ACCESS_SECRET is not set');
|
|
||||||
}
|
|
||||||
return { secret: jwtSecret, source: 'jwt' };
|
|
||||||
}
|
|
||||||
|
|
||||||
function deriveKey(): { key: Buffer; keyId: string } {
|
function deriveBackupKey(secret: string, source: KeySource): RingKey {
|
||||||
const { secret, source } = rootSecret();
|
|
||||||
const key = Buffer.from(
|
const key = Buffer.from(
|
||||||
crypto.hkdfSync(
|
crypto.hkdfSync(
|
||||||
'sha256',
|
'sha256',
|
||||||
@@ -63,12 +58,35 @@ function deriveKey(): { key: Buffer; keyId: string } {
|
|||||||
32,
|
32,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const keyId = crypto
|
const id = crypto.createHash('sha256').update(key).digest('hex').slice(0, 12);
|
||||||
.createHash('sha256')
|
return { id, key };
|
||||||
.update(key)
|
}
|
||||||
.digest('hex')
|
|
||||||
.slice(0, 12);
|
function legacyBackupKey(): RingKey {
|
||||||
return { key, keyId };
|
return deriveBackupKey(requireJwtAccessSecret(), 'jwt');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadBackupRing(): KeyRing {
|
||||||
|
const variable = ENCRYPTION_KEY_VARIABLES.backup;
|
||||||
|
const secrets = readRingSecrets(variable);
|
||||||
|
return {
|
||||||
|
current: secrets.current
|
||||||
|
? deriveBackupKey(secrets.current, 'backup')
|
||||||
|
: deriveBackupKey(devFallbackSecret(variable), 'jwt'),
|
||||||
|
previous: secrets.previous
|
||||||
|
? deriveBackupKey(secrets.previous, 'backup')
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ring's keys first; the JWT-derived key of older files only as a last resort for reading. */
|
||||||
|
function keyForReading(keyId: string): RingKey | null {
|
||||||
|
const ringKey = findRingKey(loadBackupRing(), keyId);
|
||||||
|
if (ringKey) {
|
||||||
|
return ringKey;
|
||||||
|
}
|
||||||
|
const legacy = legacyBackupKey();
|
||||||
|
return legacy.id === keyId ? legacy : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function associatedData(
|
function associatedData(
|
||||||
@@ -80,24 +98,21 @@ function associatedData(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function sealBackup(bundle: unknown, exportedAt: string): SealedBackup {
|
export function sealBackup(bundle: unknown, exportedAt: string): SealedBackup {
|
||||||
const { key, keyId } = deriveKey();
|
const { current } = loadBackupRing();
|
||||||
const iv = crypto.randomBytes(IV_BYTES);
|
|
||||||
const header = {
|
const header = {
|
||||||
format: SEALED_BACKUP_FORMAT,
|
format: SEALED_BACKUP_FORMAT,
|
||||||
version: SEALED_BACKUP_VERSION,
|
version: SEALED_BACKUP_VERSION,
|
||||||
keyId,
|
keyId: current.id,
|
||||||
exportedAt,
|
exportedAt,
|
||||||
} as const;
|
} as const;
|
||||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
||||||
cipher.setAAD(associatedData(header));
|
|
||||||
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(bundle)));
|
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(bundle)));
|
||||||
const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]);
|
const sealed = gcmSeal(current.key, compressed, associatedData(header));
|
||||||
return {
|
return {
|
||||||
...header,
|
...header,
|
||||||
algorithm: 'A256GCM+gzip',
|
algorithm: 'A256GCM+gzip',
|
||||||
iv: iv.toString('base64'),
|
iv: sealed.iv.toString('base64'),
|
||||||
tag: cipher.getAuthTag().toString('base64'),
|
tag: sealed.tag.toString('base64'),
|
||||||
ciphertext: ciphertext.toString('base64'),
|
ciphertext: sealed.ciphertext.toString('base64'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,32 +127,30 @@ export function isSealedBackup(value: unknown): value is SealedBackup {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The bundle inside a sealed backup; throws if the file was not made with this key or was changed. */
|
/** The bundle inside a sealed backup; throws if no known key made the file or it was changed. */
|
||||||
export function openBackup(sealed: SealedBackup): unknown {
|
export function openBackup(sealed: SealedBackup): unknown {
|
||||||
if (sealed.version !== SEALED_BACKUP_VERSION) {
|
if (sealed.version !== SEALED_BACKUP_VERSION) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Backup-Version ${String(sealed.version)} wird nicht unterstützt`,
|
`Backup-Version ${String(sealed.version)} wird nicht unterstützt`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { key, keyId } = deriveKey();
|
const ringKey = keyForReading(sealed.keyId);
|
||||||
if (sealed.keyId !== keyId) {
|
if (!ringKey) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Backup wurde mit einem anderen Schlüssel erstellt (BACKUP_ENCRYPTION_KEY prüfen)',
|
'Backup wurde mit einem anderen Schlüssel erstellt (BACKUP_ENCRYPTION_KEY bzw. BACKUP_ENCRYPTION_KEY_PREVIOUS prüfen)',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let compressed: Buffer;
|
let compressed: Buffer;
|
||||||
try {
|
try {
|
||||||
const decipher = crypto.createDecipheriv(
|
compressed = gcmOpen(
|
||||||
ALGORITHM,
|
ringKey.key,
|
||||||
key,
|
{
|
||||||
Buffer.from(sealed.iv, 'base64'),
|
iv: Buffer.from(sealed.iv, 'base64'),
|
||||||
|
tag: Buffer.from(sealed.tag, 'base64'),
|
||||||
|
ciphertext: Buffer.from(sealed.ciphertext, 'base64'),
|
||||||
|
},
|
||||||
|
associatedData(sealed),
|
||||||
);
|
);
|
||||||
decipher.setAAD(associatedData(sealed));
|
|
||||||
decipher.setAuthTag(Buffer.from(sealed.tag, 'base64'));
|
|
||||||
compressed = Buffer.concat([
|
|
||||||
decipher.update(Buffer.from(sealed.ciphertext, 'base64')),
|
|
||||||
decipher.final(),
|
|
||||||
]);
|
|
||||||
} catch {
|
} catch {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Backup-Datei ist beschädigt oder wurde verändert',
|
'Backup-Datei ist beschädigt oder wurde verändert',
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { runInNewContext } from 'node:vm';
|
||||||
|
import { BACKUP_SCHEDULE_TIME_ZONE, isBackupDue } from './backup-due';
|
||||||
|
|
||||||
|
describe('isBackupDue', () => {
|
||||||
|
it('reads the schedule in Europe/Berlin by default', () => {
|
||||||
|
expect(BACKUP_SCHEDULE_TIME_ZONE).toBe('Europe/Berlin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is due when there is no backup yet', () => {
|
||||||
|
expect(
|
||||||
|
isBackupDue('0 3 * * *', null, new Date('2026-10-13T00:30:00Z')),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not due again on the same day for "0 3 * * *"', () =>
|
||||||
|
expect(
|
||||||
|
isBackupDue(
|
||||||
|
'0 3 * * *',
|
||||||
|
new Date('2026-10-12T01:00:00Z'),
|
||||||
|
new Date('2026-10-13T00:30:00Z'),
|
||||||
|
),
|
||||||
|
).toBe(false));
|
||||||
|
|
||||||
|
it('is due after the next 03:00 Europe/Berlin', () =>
|
||||||
|
expect(
|
||||||
|
isBackupDue(
|
||||||
|
'0 3 * * *',
|
||||||
|
new Date('2026-10-12T01:00:00Z'),
|
||||||
|
new Date('2026-10-13T01:05:00Z'),
|
||||||
|
),
|
||||||
|
).toBe(true));
|
||||||
|
|
||||||
|
it('is due exactly at the next scheduled time', () =>
|
||||||
|
expect(
|
||||||
|
isBackupDue(
|
||||||
|
'0 3 * * *',
|
||||||
|
new Date('2026-10-12T01:00:00Z'),
|
||||||
|
new Date('2026-10-13T01:00:00Z'),
|
||||||
|
),
|
||||||
|
).toBe(true));
|
||||||
|
|
||||||
|
it('counts a backup written after the scheduled time (e.g. "Jetzt sichern") as covering it', () =>
|
||||||
|
expect(
|
||||||
|
isBackupDue(
|
||||||
|
'0 3 * * *',
|
||||||
|
new Date('2026-10-13T01:10:00Z'),
|
||||||
|
new Date('2026-10-13T02:00:00Z'),
|
||||||
|
),
|
||||||
|
).toBe(false));
|
||||||
|
|
||||||
|
it('stays due after a missed run until a backup exists', () =>
|
||||||
|
expect(
|
||||||
|
isBackupDue(
|
||||||
|
'0 3 * * *',
|
||||||
|
new Date('2026-10-10T01:00:00Z'),
|
||||||
|
new Date('2026-10-13T05:00:00Z'),
|
||||||
|
),
|
||||||
|
).toBe(true));
|
||||||
|
|
||||||
|
it('uses the given time zone', () => {
|
||||||
|
const lastBackupAt = new Date('2026-10-12T03:00:00Z');
|
||||||
|
const now = new Date('2026-10-13T02:00:00Z');
|
||||||
|
|
||||||
|
// Next 03:00 UTC is 03:00Z, next 03:00 Berlin was 01:00Z.
|
||||||
|
expect(isBackupDue('0 3 * * *', lastBackupAt, now, 'UTC')).toBe(false);
|
||||||
|
expect(isBackupDue('0 3 * * *', lastBackupAt, now)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// fs.Stats.mtime is created by Node's core; under Jest it is not an
|
||||||
|
// instance of the test's Date (cron checks `instanceof Date`).
|
||||||
|
it('accepts a Date from another realm', () => {
|
||||||
|
const foreign = runInNewContext('new Date("2026-10-12T01:00:00Z")') as Date;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isBackupDue('0 3 * * *', foreign, new Date('2026-10-13T00:30:00Z')),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid schedule even without a backup', () => {
|
||||||
|
expect(() => isBackupDue('every night', null, new Date())).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { CronTime } from 'cron';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The time zone a backup schedule is read in - the same as the CronJobs'
|
||||||
|
* `timeZone`, so "0 3 * * *" means 03:00 in Germany, summer and winter.
|
||||||
|
*/
|
||||||
|
export const BACKUP_SCHEDULE_TIME_ZONE = 'Europe/Berlin';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the hourly app-backup check has to write a backup: true while no
|
||||||
|
* backup exists, otherwise once the first scheduled time after the newest
|
||||||
|
* backup has come. A backup written after a scheduled time (a manual
|
||||||
|
* "Jetzt sichern", or a late run) covers that time; a missed or failed run
|
||||||
|
* stays due until a backup exists. Throws on an invalid schedule, also
|
||||||
|
* without a backup, so a broken schedule fails the run instead of being
|
||||||
|
* skipped silently.
|
||||||
|
*/
|
||||||
|
export function isBackupDue(
|
||||||
|
cronSchedule: string,
|
||||||
|
lastBackupAt: Date | null,
|
||||||
|
now: Date,
|
||||||
|
timeZone: string = BACKUP_SCHEDULE_TIME_ZONE,
|
||||||
|
): boolean {
|
||||||
|
const schedule = new CronTime(cronSchedule, timeZone);
|
||||||
|
if (lastBackupAt === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// A copy: fs.Stats.mtime comes from another realm than cron's Date check.
|
||||||
|
const next = schedule.getNextDateFrom(
|
||||||
|
new Date(lastBackupAt.getTime()),
|
||||||
|
timeZone,
|
||||||
|
);
|
||||||
|
return next.toMillis() <= now.getTime();
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
|
||||||
import { CronJob } from 'cron';
|
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { Prisma } from '../../generated/prisma';
|
import { JobRun } from '../../generated/prisma';
|
||||||
import { BackupSchedulerService } from './backup-scheduler.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { JobRunService } from '../system/job-run.service';
|
||||||
|
import {
|
||||||
|
APP_BACKUP_LOCK_TIMEOUT_MS,
|
||||||
|
BackupSchedulerService,
|
||||||
|
} from './backup-scheduler.service';
|
||||||
import { BackupService } from './backup.service';
|
import { BackupService } from './backup.service';
|
||||||
import { BackupSettingsService } from './backup-settings.service';
|
import { BackupSettingsService } from './backup-settings.service';
|
||||||
|
|
||||||
@@ -17,12 +20,30 @@ jest.mock('node:fs/promises', () => ({
|
|||||||
|
|
||||||
import * as fs from 'node:fs/promises';
|
import * as fs from 'node:fs/promises';
|
||||||
|
|
||||||
|
type RawCall = [readonly string[], ...unknown[]];
|
||||||
|
|
||||||
|
const SEALED = {
|
||||||
|
format: 'apicore-backup',
|
||||||
|
exportedAt: '2026-10-13T01:05:00.000Z',
|
||||||
|
ciphertext: 'x'.repeat(2000),
|
||||||
|
};
|
||||||
|
|
||||||
describe('BackupSchedulerService', () => {
|
describe('BackupSchedulerService', () => {
|
||||||
const originalEnv = { ...process.env };
|
const originalEnv = { ...process.env };
|
||||||
const directory = path.resolve('/data/backups');
|
const directory = path.resolve('/data/backups');
|
||||||
let service: BackupSchedulerService;
|
let service: BackupSchedulerService;
|
||||||
let backupService: { exportSealed: jest.Mock };
|
let backupService: { exportSealed: jest.Mock };
|
||||||
|
let settings: { get: jest.Mock };
|
||||||
|
let tx: {
|
||||||
|
$executeRaw: jest.Mock<Promise<number>, RawCall>;
|
||||||
|
$queryRaw: jest.Mock<Promise<{ now: Date }[]>, RawCall>;
|
||||||
|
};
|
||||||
|
let prisma: { $transaction: jest.Mock };
|
||||||
|
let jobRuns: { recent: jest.Mock };
|
||||||
let error: jest.SpyInstance;
|
let error: jest.SpyInstance;
|
||||||
|
let warn: jest.SpyInstance;
|
||||||
|
|
||||||
|
const sqlText = (call: RawCall): string => call[0].join('?');
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
@@ -31,33 +52,43 @@ describe('BackupSchedulerService', () => {
|
|||||||
delete process.env.BACKUP_RETENTION_COUNT;
|
delete process.env.BACKUP_RETENTION_COUNT;
|
||||||
delete process.env.BACKUP_CRON_SCHEDULE;
|
delete process.env.BACKUP_CRON_SCHEDULE;
|
||||||
error = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => {});
|
error = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => {});
|
||||||
|
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
backupService = {
|
backupService = { exportSealed: jest.fn().mockResolvedValue(SEALED) };
|
||||||
exportSealed: jest.fn().mockResolvedValue({
|
settings = {
|
||||||
format: 'apicore-backup',
|
|
||||||
exportedAt: '2026-10-12T03:00:00.000Z',
|
|
||||||
ciphertext: 'x'.repeat(2000),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const settings = {
|
|
||||||
get: jest.fn().mockResolvedValue({
|
get: jest.fn().mockResolvedValue({
|
||||||
storagePath: null,
|
storagePath: null,
|
||||||
cronSchedule: null,
|
cronSchedule: null,
|
||||||
retentionCount: null,
|
retentionCount: null,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
tx = {
|
||||||
|
$executeRaw: jest.fn<Promise<number>, RawCall>().mockResolvedValue(1),
|
||||||
|
$queryRaw: jest
|
||||||
|
.fn<Promise<{ now: Date }[]>, RawCall>()
|
||||||
|
.mockResolvedValue([{ now: new Date('2026-10-13T01:05:00Z') }]),
|
||||||
|
};
|
||||||
|
prisma = {
|
||||||
|
$transaction: jest.fn((work: (client: typeof tx) => Promise<unknown>) =>
|
||||||
|
work(tx),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
jobRuns = { recent: jest.fn().mockResolvedValue([]) };
|
||||||
service = new BackupSchedulerService(
|
service = new BackupSchedulerService(
|
||||||
backupService as unknown as BackupService,
|
backupService as unknown as BackupService,
|
||||||
settings as unknown as BackupSettingsService,
|
settings as unknown as BackupSettingsService,
|
||||||
{} as SchedulerRegistry,
|
prisma as unknown as PrismaService,
|
||||||
|
jobRuns as unknown as JobRunService,
|
||||||
);
|
);
|
||||||
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
|
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
|
||||||
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
|
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
|
||||||
(fs.readdir as jest.Mock).mockResolvedValue([]);
|
(fs.readdir as jest.Mock).mockResolvedValue([]);
|
||||||
|
(fs.unlink as jest.Mock).mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
error.mockRestore();
|
error.mockRestore();
|
||||||
|
warn.mockRestore();
|
||||||
process.env = { ...originalEnv };
|
process.env = { ...originalEnv };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,6 +103,19 @@ describe('BackupSchedulerService', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function jobRun(overrides: Partial<JobRun> = {}): JobRun {
|
||||||
|
return {
|
||||||
|
id: 'run-1',
|
||||||
|
job: 'app-backup',
|
||||||
|
status: 'SUCCEEDED',
|
||||||
|
startedAt: new Date('2026-10-13T01:00:02.000Z'),
|
||||||
|
finishedAt: new Date('2026-10-13T01:00:09.000Z'),
|
||||||
|
result: { written: true },
|
||||||
|
error: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('getStatus', () => {
|
describe('getStatus', () => {
|
||||||
it('reports the import limit and the size of the newest backup', async () => {
|
it('reports the import limit and the size of the newest backup', async () => {
|
||||||
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
|
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
|
||||||
@@ -129,6 +173,49 @@ describe('BackupSchedulerService', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reports the newest app-backup job run', async () => {
|
||||||
|
jobRuns.recent.mockResolvedValue([
|
||||||
|
jobRun({
|
||||||
|
status: 'FAILED',
|
||||||
|
finishedAt: new Date('2026-10-13T01:00:05.000Z'),
|
||||||
|
result: null,
|
||||||
|
error: 'EACCES: permission denied',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const status = await service.getStatus();
|
||||||
|
|
||||||
|
expect(jobRuns.recent).toHaveBeenCalledWith(1, 'app-backup');
|
||||||
|
expect(status.lastJobRun).toEqual({
|
||||||
|
status: 'FAILED',
|
||||||
|
startedAt: '2026-10-13T01:00:02.000Z',
|
||||||
|
finishedAt: '2026-10-13T01:00:05.000Z',
|
||||||
|
error: 'EACCES: permission denied',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a running job run without an end and the run also without a path', async () => {
|
||||||
|
delete process.env.BACKUP_STORAGE_PATH;
|
||||||
|
jobRuns.recent.mockResolvedValue([
|
||||||
|
jobRun({ status: 'RUNNING', finishedAt: null, result: null }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const status = await service.getStatus();
|
||||||
|
|
||||||
|
expect(status.lastJobRun).toEqual({
|
||||||
|
status: 'RUNNING',
|
||||||
|
startedAt: '2026-10-13T01:00:02.000Z',
|
||||||
|
finishedAt: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports no job run before the first one', async () => {
|
||||||
|
const status = await service.getStatus();
|
||||||
|
|
||||||
|
expect(status.lastJobRun).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('runNow', () => {
|
describe('runNow', () => {
|
||||||
@@ -151,48 +238,188 @@ describe('BackupSchedulerService', () => {
|
|||||||
expect(fs.writeFile).toHaveBeenCalledTimes(1);
|
expect(fs.writeFile).toHaveBeenCalledTimes(1);
|
||||||
expect(error).not.toHaveBeenCalled();
|
expect(error).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A human asked for it: no due check, no wait for the schedule lock.
|
||||||
|
it('always writes, without the schedule lock', async () => {
|
||||||
|
onDisk([
|
||||||
|
{
|
||||||
|
name: 'apicore-backup-new.json',
|
||||||
|
size: 900,
|
||||||
|
mtime: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.runNow();
|
||||||
|
|
||||||
|
expect(fs.writeFile).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// The export reads every table through Prisma; a Prisma message quotes
|
describe('runIfDue', () => {
|
||||||
// query values, so a failed scheduled run logs name, code and model only.
|
const lastNight = '2026-10-12T01:00:00.000Z';
|
||||||
describe('scheduled run', () => {
|
|
||||||
it('logs a failed scheduled backup without the Prisma message', async () => {
|
it('writes nothing without a path', async () => {
|
||||||
const registry = {
|
delete process.env.BACKUP_STORAGE_PATH;
|
||||||
doesExist: jest.fn().mockReturnValue(false),
|
|
||||||
addCronJob: jest.fn(),
|
const result = await service.runIfDue(new Date('2026-10-13T01:05:00Z'));
|
||||||
deleteCronJob: jest.fn(),
|
|
||||||
};
|
expect(result).toEqual({ written: false, reason: 'no-path' });
|
||||||
const scheduled = new BackupSchedulerService(
|
expect(backupService.exportSealed).not.toHaveBeenCalled();
|
||||||
backupService as unknown as BackupService,
|
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes nothing while the newest backup covers the last scheduled time', async () => {
|
||||||
|
onDisk([{ name: 'apicore-backup-a.json', size: 900, mtime: lastNight }]);
|
||||||
|
|
||||||
|
const result = await service.runIfDue(new Date('2026-10-13T00:30:00Z'));
|
||||||
|
|
||||||
|
expect(result).toEqual({ written: false, reason: 'not-due' });
|
||||||
|
expect(backupService.exportSealed).not.toHaveBeenCalled();
|
||||||
|
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||||
|
expect(fs.unlink).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes and prunes once the next scheduled time has passed', async () => {
|
||||||
|
process.env.BACKUP_RETENTION_COUNT = '1';
|
||||||
|
onDisk([
|
||||||
|
{ name: 'apicore-backup-a.json', size: 900, mtime: lastNight },
|
||||||
{
|
{
|
||||||
get: jest.fn().mockResolvedValue({
|
name: 'apicore-backup-b.json',
|
||||||
storagePath: null,
|
size: 800,
|
||||||
cronSchedule: null,
|
mtime: '2026-10-11T01:00:00.000Z',
|
||||||
retentionCount: null,
|
},
|
||||||
}),
|
]);
|
||||||
} as unknown as BackupSettingsService,
|
|
||||||
registry as unknown as SchedulerRegistry,
|
|
||||||
);
|
|
||||||
backupService.exportSealed.mockRejectedValue(
|
|
||||||
new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', {
|
|
||||||
code: 'P2010',
|
|
||||||
clientVersion: '7.9.1',
|
|
||||||
meta: { modelName: 'Task' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await scheduled.reload();
|
|
||||||
const [[, job]] = registry.addCronJob.mock.calls as [[string, CronJob]];
|
|
||||||
await job.stop();
|
|
||||||
|
|
||||||
await job.fireOnTick();
|
const result = await service.runIfDue(new Date('2026-10-13T01:05:00Z'));
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
|
||||||
|
|
||||||
const failure = error.mock.calls.find(([message]) =>
|
expect(fs.writeFile).toHaveBeenCalledTimes(1);
|
||||||
String(message).startsWith('Scheduled backup failed'),
|
expect(result).toEqual({
|
||||||
) as [string, unknown];
|
written: true,
|
||||||
expect(failure[0]).toContain('P2010');
|
fileName: 'apicore-backup-2026-10-13T01-05-00-000Z.json',
|
||||||
expect(failure[0]).not.toContain('Mustermann');
|
sizeBytes: Buffer.byteLength(JSON.stringify(SEALED)),
|
||||||
expect(failure[1]).toBeUndefined();
|
pruned: 1,
|
||||||
|
});
|
||||||
|
expect(fs.unlink).toHaveBeenCalledWith(
|
||||||
|
path.join(directory, 'apicore-backup-b.json'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes the first backup into an empty directory', async () => {
|
||||||
|
const result = await service.runIfDue(new Date('2026-10-13T00:30:00Z'));
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({ written: true }));
|
||||||
|
expect(fs.writeFile).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the schedule in Europe/Berlin', async () => {
|
||||||
|
process.env.BACKUP_CRON_SCHEDULE = '0 3 * * *';
|
||||||
|
onDisk([
|
||||||
|
{
|
||||||
|
name: 'apicore-backup-a.json',
|
||||||
|
size: 900,
|
||||||
|
mtime: '2026-10-12T03:00:00.000Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 03:00 Berlin = 01:00Z has passed, 03:00 UTC has not.
|
||||||
|
const result = await service.runIfDue(new Date('2026-10-13T02:00:00Z'));
|
||||||
|
|
||||||
|
expect(result.written).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not count a file it could not delete as pruned', async () => {
|
||||||
|
process.env.BACKUP_RETENTION_COUNT = '1';
|
||||||
|
onDisk([
|
||||||
|
{ name: 'apicore-backup-a.json', size: 900, mtime: lastNight },
|
||||||
|
{
|
||||||
|
name: 'apicore-backup-b.json',
|
||||||
|
size: 800,
|
||||||
|
mtime: '2026-10-11T01:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'apicore-backup-c.json',
|
||||||
|
size: 700,
|
||||||
|
mtime: '2026-10-10T01:00:00.000Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
(fs.unlink as jest.Mock)
|
||||||
|
.mockResolvedValueOnce(undefined)
|
||||||
|
.mockRejectedValueOnce(new Error('EBUSY'));
|
||||||
|
|
||||||
|
const result = await service.runIfDue(new Date('2026-10-13T01:05:00Z'));
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({ pruned: 1 }));
|
||||||
|
expect(warn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Two job pods (a CronJob run and a manual `kubectl create job`) must
|
||||||
|
// not both find the backup due: the check and the write happen under
|
||||||
|
// one database lock, so the second one sees the first one's file.
|
||||||
|
it('checks and writes only while holding the schedule lock', async () => {
|
||||||
|
await service.runIfDue(new Date('2026-10-13T01:05:00Z'));
|
||||||
|
|
||||||
|
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), {
|
||||||
|
timeout: APP_BACKUP_LOCK_TIMEOUT_MS,
|
||||||
|
maxWait: expect.any(Number) as number,
|
||||||
|
});
|
||||||
|
expect(tx.$executeRaw).toHaveBeenCalledTimes(1);
|
||||||
|
const [lockCall] = tx.$executeRaw.mock.calls;
|
||||||
|
expect(sqlText(lockCall)).toContain('pg_advisory_xact_lock');
|
||||||
|
expect(lockCall.slice(1)).toEqual(['app-backup:schedule']);
|
||||||
|
const lockedAt = tx.$executeRaw.mock.invocationCallOrder[0];
|
||||||
|
expect(
|
||||||
|
(fs.readdir as jest.Mock).mock.invocationCallOrder[0],
|
||||||
|
).toBeGreaterThan(lockedAt);
|
||||||
|
expect(
|
||||||
|
(fs.writeFile as jest.Mock).mock.invocationCallOrder[0],
|
||||||
|
).toBeGreaterThan(lockedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes the time from the database clock when none is given', async () => {
|
||||||
|
onDisk([{ name: 'apicore-backup-a.json', size: 900, mtime: lastNight }]);
|
||||||
|
tx.$queryRaw.mockResolvedValue([
|
||||||
|
{ now: new Date('2026-10-13T00:30:00Z') },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The pod's own clock (2026-10-14) would say due; the database's says no.
|
||||||
|
jest.useFakeTimers({ now: new Date('2026-10-14T12:00:00Z') });
|
||||||
|
try {
|
||||||
|
await expect(service.runIfDue()).resolves.toEqual({
|
||||||
|
written: false,
|
||||||
|
reason: 'not-due',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
jest.useRealTimers();
|
||||||
|
}
|
||||||
|
expect(sqlText(tx.$queryRaw.mock.calls[0])).toContain('now()');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives up the lock as soon as the backup is not due', async () => {
|
||||||
|
onDisk([{ name: 'apicore-backup-a.json', size: 900, mtime: lastNight }]);
|
||||||
|
|
||||||
|
await service.runIfDue(new Date('2026-10-13T00:30:00Z'));
|
||||||
|
|
||||||
|
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fs.mkdir).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails the run when the export fails', async () => {
|
||||||
|
backupService.exportSealed.mockRejectedValue(new Error('export broke'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.runIfDue(new Date('2026-10-13T01:05:00Z')),
|
||||||
|
).rejects.toThrow('export broke');
|
||||||
|
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails the run on an invalid schedule instead of skipping silently', async () => {
|
||||||
|
process.env.BACKUP_CRON_SCHEDULE = 'every night';
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.runIfDue(new Date('2026-10-13T01:05:00Z')),
|
||||||
|
).rejects.toThrow();
|
||||||
|
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,32 +1,51 @@
|
|||||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
|
||||||
import { CronJob } from 'cron';
|
|
||||||
import * as fs from 'node:fs/promises';
|
import * as fs from 'node:fs/promises';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
import { JobRunStatus, Prisma } from '../../generated/prisma';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { JobRunService } from '../system/job-run.service';
|
||||||
import { BackupService } from './backup.service';
|
import { BackupService } from './backup.service';
|
||||||
import { BackupSettingsService } from './backup-settings.service';
|
import { BackupSettingsService } from './backup-settings.service';
|
||||||
import { loggableError } from '../common/loggable-error';
|
import { isBackupDue } from './backup-due';
|
||||||
import {
|
import { resolveGuiBackupPath } from './backup-schedule-rules';
|
||||||
assertBackupCron,
|
|
||||||
resolveGuiBackupPath,
|
|
||||||
} from './backup-schedule-rules';
|
|
||||||
import {
|
import {
|
||||||
backupMaxImportBytes,
|
backupMaxImportBytes,
|
||||||
exceedsBackupImportLimit,
|
exceedsBackupImportLimit,
|
||||||
formatMegabytes,
|
formatMegabytes,
|
||||||
} from './backup-import-limit';
|
} from './backup-import-limit';
|
||||||
|
|
||||||
const SCHEDULED_JOB_NAME = 'automatic-backup';
|
|
||||||
const DEFAULT_CRON_SCHEDULE = '0 3 * * *';
|
const DEFAULT_CRON_SCHEDULE = '0 3 * * *';
|
||||||
const DEFAULT_RETENTION_COUNT = 14;
|
const DEFAULT_RETENTION_COUNT = 14;
|
||||||
const BACKUP_FILE_PREFIX = 'apicore-backup-';
|
const BACKUP_FILE_PREFIX = 'apicore-backup-';
|
||||||
const BACKUP_FILE_SUFFIX = '.json';
|
const BACKUP_FILE_SUFFIX = '.json';
|
||||||
|
|
||||||
// Guards against two replicas (deployment runs with replicas: 2, no shared
|
/**
|
||||||
// DB table for scheduling state) both firing the same cron tick: if the most
|
* Advisory lock (pg_advisory_xact_lock on its hash) around the due check
|
||||||
// recent backup file on this shared/mounted path is younger than this, a
|
* and the write of a scheduled backup.
|
||||||
// concurrent replica already handled the tick, so this one skips.
|
*/
|
||||||
const DUPLICATE_RUN_GUARD_MS = 2 * 60 * 1000;
|
const APP_BACKUP_LOCK = 'app-backup:schedule';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long a scheduled backup may hold the lock: the activeDeadlineSeconds
|
||||||
|
* of the app-backup CronJob (3600 s), after which the pod is gone anyway.
|
||||||
|
*/
|
||||||
|
export const APP_BACKUP_LOCK_TIMEOUT_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** How long to wait for a free pooled connection to take the lock on. */
|
||||||
|
const APP_BACKUP_LOCK_MAX_WAIT_MS = 10_000;
|
||||||
|
|
||||||
|
/** What one run of the app-backup job did (JobRun.result). */
|
||||||
|
export type AppBackupResult =
|
||||||
|
| { written: true; fileName: string; sizeBytes: number; pruned: number }
|
||||||
|
| { written: false; reason: 'no-path' | 'not-due' };
|
||||||
|
|
||||||
|
/** The newest app-backup job run, as GET /backup/schedule shows it. */
|
||||||
|
export interface BackupJobRunStatus {
|
||||||
|
status: JobRunStatus;
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BackupScheduleStatus {
|
export interface BackupScheduleStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -48,6 +67,9 @@ export interface BackupScheduleStatus {
|
|||||||
cronSchedule: boolean;
|
cronSchedule: boolean;
|
||||||
retentionCount: boolean;
|
retentionCount: boolean;
|
||||||
};
|
};
|
||||||
|
// The newest run of the hourly app-backup job (CronJob), null before the
|
||||||
|
// first one. lastRunAt above is the newest backup file instead.
|
||||||
|
lastJobRun: BackupJobRunStatus | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EffectiveBackupSettings {
|
interface EffectiveBackupSettings {
|
||||||
@@ -57,20 +79,23 @@ interface EffectiveBackupSettings {
|
|||||||
envOverrides: BackupScheduleStatus['envOverrides'];
|
envOverrides: BackupScheduleStatus['envOverrides'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatic backups. The schedule is not a timer in this process: the
|
||||||
|
* app-backup CronJob runs every hour and calls runIfDue(), which reads path,
|
||||||
|
* schedule and retention afresh (env before GUI), so a GUI change applies
|
||||||
|
* from the next hourly check on. runNow() is the "Jetzt sichern" button.
|
||||||
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BackupSchedulerService implements OnModuleInit {
|
export class BackupSchedulerService {
|
||||||
private readonly logger = new Logger(BackupSchedulerService.name);
|
private readonly logger = new Logger(BackupSchedulerService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly backupService: BackupService,
|
private readonly backupService: BackupService,
|
||||||
private readonly backupSettingsService: BackupSettingsService,
|
private readonly backupSettingsService: BackupSettingsService,
|
||||||
private readonly schedulerRegistry: SchedulerRegistry,
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly jobRuns: JobRunService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
|
||||||
await this.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async effectiveSettings(): Promise<EffectiveBackupSettings> {
|
private async effectiveSettings(): Promise<EffectiveBackupSettings> {
|
||||||
const envPath = process.env.BACKUP_STORAGE_PATH;
|
const envPath = process.env.BACKUP_STORAGE_PATH;
|
||||||
const hasEnvPath = Boolean(envPath && envPath.trim().length > 0);
|
const hasEnvPath = Boolean(envPath && envPath.trim().length > 0);
|
||||||
@@ -106,55 +131,10 @@ export class BackupSchedulerService implements OnModuleInit {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-registers (or tears down) the cron job to match the current effective
|
|
||||||
// settings. Called at boot and again after a GUI settings update, so an
|
|
||||||
// admin's schedule/path/retention change takes effect immediately without
|
|
||||||
// a pod restart.
|
|
||||||
async reload(): Promise<void> {
|
|
||||||
const settings = await this.effectiveSettings();
|
|
||||||
if (!settings.envOverrides.cronSchedule) {
|
|
||||||
try {
|
|
||||||
assertBackupCron(settings.cronSchedule);
|
|
||||||
} catch {
|
|
||||||
// Keep whatever is running instead of silently ending backups.
|
|
||||||
this.logger.error(
|
|
||||||
`Ungültiger Backup-Zeitplan "${settings.cronSchedule}" - bisheriger Zeitplan bleibt aktiv`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (this.schedulerRegistry.doesExist('cron', SCHEDULED_JOB_NAME)) {
|
|
||||||
this.schedulerRegistry.deleteCronJob(SCHEDULED_JOB_NAME);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!settings.path) {
|
|
||||||
this.logger.log(
|
|
||||||
'Kein Backup-Pfad konfiguriert (weder Env noch GUI) - automatische Backups deaktiviert',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const directory = settings.path;
|
|
||||||
const job = CronJob.from({
|
|
||||||
cronTime: settings.cronSchedule,
|
|
||||||
onTick: () => {
|
|
||||||
// Prisma errors by name/code/model only (see loggableError).
|
|
||||||
this.runScheduled(directory).catch((error: unknown) => {
|
|
||||||
const { message, stack } = loggableError(error);
|
|
||||||
this.logger.error(`Scheduled backup failed: ${message}`, stack);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
start: true,
|
|
||||||
});
|
|
||||||
this.schedulerRegistry.addCronJob(SCHEDULED_JOB_NAME, job);
|
|
||||||
this.logger.log(
|
|
||||||
`Automatische Backups aktiv: schedule="${settings.cronSchedule}" path="${directory}" retention=${settings.retentionCount}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getStatus(): Promise<BackupScheduleStatus> {
|
async getStatus(): Promise<BackupScheduleStatus> {
|
||||||
const settings = await this.effectiveSettings();
|
const settings = await this.effectiveSettings();
|
||||||
const importLimitBytes = backupMaxImportBytes();
|
const importLimitBytes = backupMaxImportBytes();
|
||||||
|
const lastJobRun = await this.lastJobRun();
|
||||||
if (!settings.path) {
|
if (!settings.path) {
|
||||||
return {
|
return {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -166,6 +146,7 @@ export class BackupSchedulerService implements OnModuleInit {
|
|||||||
lastBackupSizeBytes: null,
|
lastBackupSizeBytes: null,
|
||||||
lastBackupExceedsImportLimit: false,
|
lastBackupExceedsImportLimit: false,
|
||||||
envOverrides: settings.envOverrides,
|
envOverrides: settings.envOverrides,
|
||||||
|
lastJobRun,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,11 +166,25 @@ export class BackupSchedulerService implements OnModuleInit {
|
|||||||
lastBackupSizeBytes !== null &&
|
lastBackupSizeBytes !== null &&
|
||||||
exceedsBackupImportLimit(lastBackupSizeBytes),
|
exceedsBackupImportLimit(lastBackupSizeBytes),
|
||||||
envOverrides: settings.envOverrides,
|
envOverrides: settings.envOverrides,
|
||||||
|
lastJobRun,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manual trigger (e.g. "Jetzt sichern" button) - always runs, no
|
private async lastJobRun(): Promise<BackupJobRunStatus | null> {
|
||||||
// duplicate-tick guard, since a human explicitly asked for it.
|
const [run] = await this.jobRuns.recent(1, 'app-backup');
|
||||||
|
if (!run) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: run.status,
|
||||||
|
startedAt: run.startedAt.toISOString(),
|
||||||
|
finishedAt: run.finishedAt?.toISOString() ?? null,
|
||||||
|
error: run.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual trigger ("Jetzt sichern") - always writes: no due check and no
|
||||||
|
// schedule lock, since a human explicitly asked for it.
|
||||||
async runNow(): Promise<{ fileName: string }> {
|
async runNow(): Promise<{ fileName: string }> {
|
||||||
const settings = await this.effectiveSettings();
|
const settings = await this.effectiveSettings();
|
||||||
if (!settings.path) {
|
if (!settings.path) {
|
||||||
@@ -197,32 +192,61 @@ export class BackupSchedulerService implements OnModuleInit {
|
|||||||
'Kein Backup-Pfad konfiguriert - weder per Umgebungsvariable noch über die GUI',
|
'Kein Backup-Pfad konfiguriert - weder per Umgebungsvariable noch über die GUI',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const filePath = await this.writeBackup(settings.path);
|
const { fileName } = await this.writeBackup(settings.path);
|
||||||
await this.pruneOldBackups(settings.path, settings.retentionCount);
|
await this.pruneOldBackups(settings.path, settings.retentionCount);
|
||||||
// Only the name: the server's directory layout stays on the server.
|
// Only the name: the server's directory layout stays on the server.
|
||||||
return { fileName: path.basename(filePath) };
|
return { fileName };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runScheduled(directory: string): Promise<void> {
|
/**
|
||||||
|
* One hourly check of the app-backup job: writes a backup if one is due
|
||||||
|
* (isBackupDue against the newest backup file, schedule in
|
||||||
|
* Europe/Berlin) and prunes beyond the retention. Check and write run
|
||||||
|
* under one database advisory lock, so a second job pod that checks at
|
||||||
|
* the same time waits and then finds the first pod's file - two pods
|
||||||
|
* never both write. Failures are thrown and fail the job run.
|
||||||
|
*/
|
||||||
|
async runIfDue(now?: Date): Promise<AppBackupResult> {
|
||||||
const settings = await this.effectiveSettings();
|
const settings = await this.effectiveSettings();
|
||||||
const files = await this.listBackupFiles(directory);
|
if (!settings.path) {
|
||||||
const mostRecent = files[0];
|
return { written: false, reason: 'no-path' };
|
||||||
|
}
|
||||||
|
const directory = settings.path;
|
||||||
|
return this.prisma.$transaction(
|
||||||
|
async (tx): Promise<AppBackupResult> => {
|
||||||
|
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${APP_BACKUP_LOCK}, 0))`;
|
||||||
|
const [newest] = await this.listBackupFiles(directory);
|
||||||
|
// The database clock, like every other time comparison of the
|
||||||
|
// platform: all job pods see the same time.
|
||||||
|
const checkedAt = now ?? (await this.databaseNow(tx));
|
||||||
if (
|
if (
|
||||||
mostRecent &&
|
!isBackupDue(settings.cronSchedule, newest?.mtime ?? null, checkedAt)
|
||||||
Date.now() - mostRecent.mtime.getTime() < DUPLICATE_RUN_GUARD_MS
|
|
||||||
) {
|
) {
|
||||||
this.logger.log(
|
return { written: false, reason: 'not-due' };
|
||||||
'Skipping scheduled backup - another replica already wrote one for this tick',
|
}
|
||||||
|
const { fileName, sizeBytes } = await this.writeBackup(directory);
|
||||||
|
this.logger.log(`Scheduled backup ${fileName} written`);
|
||||||
|
const pruned = await this.pruneOldBackups(
|
||||||
|
directory,
|
||||||
|
settings.retentionCount,
|
||||||
|
);
|
||||||
|
return { written: true, fileName, sizeBytes, pruned };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timeout: APP_BACKUP_LOCK_TIMEOUT_MS,
|
||||||
|
maxWait: APP_BACKUP_LOCK_MAX_WAIT_MS,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const filePath = await this.writeBackup(directory);
|
private async databaseNow(tx: Prisma.TransactionClient): Promise<Date> {
|
||||||
this.logger.log(`Scheduled backup written to ${filePath}`);
|
const [row] = await tx.$queryRaw<{ now: Date }[]>`SELECT now() AS now`;
|
||||||
await this.pruneOldBackups(directory, settings.retentionCount);
|
return row.now;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async writeBackup(directory: string): Promise<string> {
|
private async writeBackup(
|
||||||
|
directory: string,
|
||||||
|
): Promise<{ fileName: string; sizeBytes: number }> {
|
||||||
const sealed = await this.backupService.exportSealed();
|
const sealed = await this.backupService.exportSealed();
|
||||||
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||||
const timestamp = sealed.exportedAt.replace(/[:.]/g, '-');
|
const timestamp = sealed.exportedAt.replace(/[:.]/g, '-');
|
||||||
@@ -241,26 +265,30 @@ export class BackupSchedulerService implements OnModuleInit {
|
|||||||
`Backup ${path.basename(filePath)} ist ${formatMegabytes(sizeBytes)} groß und überschreitet das Import-Limit von ${formatMegabytes(backupMaxImportBytes())} - eine Wiederherstellung würde scheitern (BACKUP_MAX_IMPORT_BYTES erhöhen)`,
|
`Backup ${path.basename(filePath)} ist ${formatMegabytes(sizeBytes)} groß und überschreitet das Import-Limit von ${formatMegabytes(backupMaxImportBytes())} - eine Wiederherstellung würde scheitern (BACKUP_MAX_IMPORT_BYTES erhöhen)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return filePath;
|
return { fileName: path.basename(filePath), sizeBytes };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Deletes all but the newest `retentionCount` backups; returns how many. */
|
||||||
private async pruneOldBackups(
|
private async pruneOldBackups(
|
||||||
directory: string,
|
directory: string,
|
||||||
retentionCount: number,
|
retentionCount: number,
|
||||||
): Promise<void> {
|
): Promise<number> {
|
||||||
const files = await this.listBackupFiles(directory);
|
const files = await this.listBackupFiles(directory);
|
||||||
const toDelete = files.slice(retentionCount);
|
const toDelete = files.slice(retentionCount);
|
||||||
await Promise.all(
|
const deleted = await Promise.all(
|
||||||
toDelete.map((file) =>
|
toDelete.map((file) =>
|
||||||
fs
|
fs.unlink(file.filePath).then(
|
||||||
.unlink(file.filePath)
|
() => true,
|
||||||
.catch((error) =>
|
(error: unknown) => {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Could not delete old backup ${file.filePath}: ${String(error)}`,
|
`Could not delete old backup ${file.filePath}: ${String(error)}`,
|
||||||
),
|
);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
return deleted.filter(Boolean).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async listBackupFiles(
|
private async listBackupFiles(
|
||||||
|
|||||||
Loaded 100 of 1060 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user