Compare commits
106
Commits
7097fcd97c
..
main
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
# Shell scripts must keep LF line endings, also on Windows checkouts with
|
||||||
|
# core.autocrlf, or bash fails on the carriage returns.
|
||||||
|
*.sh text eol=lf
|
||||||
|
|
||||||
|
# The Whisper word list is generated (npm run export:voice-vocabulary) and
|
||||||
|
# copied into the image; keep LF so the file stays byte-identical everywhere.
|
||||||
|
ApiCore-Whisper/vocabulary.txt text eol=lf
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
name: Backend E2E
|
||||||
|
|
||||||
|
# Fuehrt `npm run test:e2e` (Jest, maxWorkers 4, siehe test/jest-e2e.json)
|
||||||
|
# gegen einen frischen Postgres-Service-Container aus. Das Backend-Image
|
||||||
|
# (backend-image.yml) fuehrt nur Lint, Typecheck und Unit-Tests aus, weil der
|
||||||
|
# Docker-Build keine Datenbank hat; diese Pipeline schliesst die Luecke.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "ApiCore-Backend/**"
|
||||||
|
- ".gitea/workflows/backend-e2e.yml"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Ein neuer Push auf denselben Ref ersetzt den laufenden Lauf.
|
||||||
|
concurrency:
|
||||||
|
group: backend-e2e-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
e2e:
|
||||||
|
runs-on: docker-builder
|
||||||
|
timeout-minutes: 30
|
||||||
|
# Gitea act_runner unterstuetzt `services:`. Annahme (beim ersten Lauf
|
||||||
|
# pruefen): der Job-Container teilt das Netz der Services und erreicht
|
||||||
|
# Postgres unter dem Service-Namen `postgres`. Laeuft der Runner
|
||||||
|
# stattdessen im Host-Modus, ist der Port auf localhost veroeffentlicht;
|
||||||
|
# der Schritt "Datenbank-Host ermitteln" probiert beide Wege.
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
# Gleiche Major-Version wie Produktion (deploy/k8s/base/postgres).
|
||||||
|
# Per Digest gepinnt (Index-Digest von 18.6-alpine, nachgeschlagen
|
||||||
|
# 2026-10-16); die k8s-Manifeste pinnen Postgres bisher nur per Tag.
|
||||||
|
image: postgres:18.6-alpine@sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873
|
||||||
|
env:
|
||||||
|
# Wegwerf-Datenbank im Service-Container, nur im Job-Netz erreichbar
|
||||||
|
# und nach dem Lauf verworfen: kein echtes Secret.
|
||||||
|
POSTGRES_USER: apicore
|
||||||
|
POSTGRES_PASSWORD: apicore-ci-only
|
||||||
|
POSTGRES_DB: apicore_test
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U apicore -d apicore_test"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 20
|
||||||
|
env:
|
||||||
|
NODE_ENV: test
|
||||||
|
# Das Wetter-e2e ruft die echte Open-Meteo-API; in CI ueberspringen.
|
||||||
|
SKIP_EXTERNAL: "1"
|
||||||
|
JWT_ACCESS_EXPIRES_IN: 15m
|
||||||
|
JWT_REFRESH_EXPIRES_IN: 7d
|
||||||
|
PORT: "3001"
|
||||||
|
FRONTEND_ORIGIN: http://localhost:3000
|
||||||
|
DOCUMENTS_STORAGE_PATH: ./test-uploads
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ApiCore-Backend
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
|
- name: Node einrichten
|
||||||
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Datenbank-Host ermitteln und Testumgebung erzeugen
|
||||||
|
# jest-e2e.json laedt .env.test per dotenv, das vorhandene Variablen
|
||||||
|
# nicht ueberschreibt: alles kommt aus der Job-Umgebung, .env.test
|
||||||
|
# existiert in CI nicht (gitignored). Die JWT-Secrets sind frisch
|
||||||
|
# erzeugte Wegwerfwerte (mind. 32 Zeichen, voneinander verschieden),
|
||||||
|
# nie echte Secrets.
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
host=""
|
||||||
|
for candidate in postgres localhost; do
|
||||||
|
if node -e "const s=require('net').connect(5432,process.argv[1]);s.on('connect',()=>process.exit(0));s.on('error',()=>process.exit(1));setTimeout(()=>process.exit(1),3000)" "$candidate"; then
|
||||||
|
host="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -z "$host" ]; then
|
||||||
|
echo "Postgres weder unter 'postgres' noch unter 'localhost' erreichbar" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Postgres-Host: $host"
|
||||||
|
access="$(openssl rand -hex 32)"
|
||||||
|
refresh="$(openssl rand -hex 32)"
|
||||||
|
echo "::add-mask::$access"
|
||||||
|
echo "::add-mask::$refresh"
|
||||||
|
{
|
||||||
|
echo "DATABASE_URL=postgresql://apicore:apicore-ci-only@${host}:5432/apicore_test?schema=public"
|
||||||
|
echo "JWT_ACCESS_SECRET=$access"
|
||||||
|
echo "JWT_REFRESH_SECRET=$refresh"
|
||||||
|
} >> "$GITEA_ENV"
|
||||||
|
|
||||||
|
- name: Abhaengigkeiten installieren
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Prisma-Client erzeugen
|
||||||
|
run: npx prisma generate
|
||||||
|
|
||||||
|
- name: Migrationen anwenden
|
||||||
|
# Die Rollen (z. B. BEEKEEPER) kommen aus den Migrationen, ein Seed
|
||||||
|
# ist fuer die e2e-Tests nicht noetig.
|
||||||
|
run: npx prisma migrate deploy
|
||||||
|
|
||||||
|
- name: E2E-Tests
|
||||||
|
# Auf dem geteilten Runner brauchten App-Start plus bcrypt-Logins in
|
||||||
|
# beforeAll mehr als die lokalen 20 s (Laeufe 826, 836); hier 60 s.
|
||||||
|
run: npm run test:e2e -- --ci --testTimeout=60000
|
||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
REGISTRY_PUSH: gitea:80
|
REGISTRY_PUSH: gitea:80
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
- name: Image-Namen in Kleinbuchstaben ermitteln
|
- name: Image-Namen in Kleinbuchstaben ermitteln
|
||||||
# OCI-Registries erlauben nur Kleinbuchstaben im Repository-Pfad;
|
# OCI-Registries erlauben nur Kleinbuchstaben im Repository-Pfad;
|
||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
run: echo "name=$(echo '${{ gitea.repository_owner }}/apicore-backend' | tr '[:upper:]' '[:lower:]')" >> "$GITEA_OUTPUT"
|
run: echo "name=$(echo '${{ gitea.repository_owner }}/apicore-backend' | tr '[:upper:]' '[:lower:]')" >> "$GITEA_OUTPUT"
|
||||||
|
|
||||||
- name: Docker Buildx einrichten
|
- name: Docker Buildx einrichten
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||||
with:
|
with:
|
||||||
# REGISTRY_PUSH ist Klartext-HTTP (Gitea-Service intern ohne TLS)
|
# REGISTRY_PUSH ist Klartext-HTTP (Gitea-Service intern ohne TLS)
|
||||||
# statt HTTPS — BuildKit lehnt das ohne diese Freigabe ab.
|
# statt HTTPS — BuildKit lehnt das ohne diese Freigabe ab.
|
||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
http = true
|
http = true
|
||||||
|
|
||||||
- name: Bei der Gitea-Container-Registry anmelden
|
- name: Bei der Gitea-Container-Registry anmelden
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY_PUSH }}
|
registry: ${{ env.REGISTRY_PUSH }}
|
||||||
username: ${{ gitea.actor }}
|
username: ${{ gitea.actor }}
|
||||||
@@ -50,7 +50,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Metadaten (Tags/Labels) ermitteln
|
- name: Metadaten (Tags/Labels) ermitteln
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
||||||
tags: |
|
tags: |
|
||||||
@@ -58,7 +58,7 @@ jobs:
|
|||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
- name: Image bauen und pushen
|
- name: Image bauen und pushen
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||||
with:
|
with:
|
||||||
context: ./ApiCore-Backend
|
context: ./ApiCore-Backend
|
||||||
push: true
|
push: true
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
REGISTRY_PUSH: gitea:80
|
REGISTRY_PUSH: gitea:80
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
- name: Image-Namen in Kleinbuchstaben ermitteln
|
- name: Image-Namen in Kleinbuchstaben ermitteln
|
||||||
# OCI-Registries erlauben nur Kleinbuchstaben im Repository-Pfad;
|
# OCI-Registries erlauben nur Kleinbuchstaben im Repository-Pfad;
|
||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
run: echo "name=$(echo '${{ gitea.repository_owner }}/apicore-frontend' | tr '[:upper:]' '[:lower:]')" >> "$GITEA_OUTPUT"
|
run: echo "name=$(echo '${{ gitea.repository_owner }}/apicore-frontend' | tr '[:upper:]' '[:lower:]')" >> "$GITEA_OUTPUT"
|
||||||
|
|
||||||
- name: Docker Buildx einrichten
|
- name: Docker Buildx einrichten
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||||
with:
|
with:
|
||||||
# REGISTRY_PUSH ist Klartext-HTTP (Gitea-Service intern ohne TLS)
|
# REGISTRY_PUSH ist Klartext-HTTP (Gitea-Service intern ohne TLS)
|
||||||
# statt HTTPS — BuildKit lehnt das ohne diese Freigabe ab.
|
# statt HTTPS — BuildKit lehnt das ohne diese Freigabe ab.
|
||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
http = true
|
http = true
|
||||||
|
|
||||||
- name: Bei der Gitea-Container-Registry anmelden
|
- name: Bei der Gitea-Container-Registry anmelden
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY_PUSH }}
|
registry: ${{ env.REGISTRY_PUSH }}
|
||||||
username: ${{ gitea.actor }}
|
username: ${{ gitea.actor }}
|
||||||
@@ -50,7 +50,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Metadaten (Tags/Labels) ermitteln
|
- name: Metadaten (Tags/Labels) ermitteln
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
||||||
tags: |
|
tags: |
|
||||||
@@ -58,7 +58,7 @@ jobs:
|
|||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
- name: Image bauen und pushen
|
- name: Image bauen und pushen
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||||
with:
|
with:
|
||||||
context: ./ApiCore-Frontend
|
context: ./ApiCore-Frontend
|
||||||
push: true
|
push: true
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ jobs:
|
|||||||
YQ_SHA256: c5f056448f973ae7d39b5401949648a78f2dc1947d6a8eb65be60d5c504b9385
|
YQ_SHA256: c5f056448f973ae7d39b5401949648a78f2dc1947d6a8eb65be60d5c504b9385
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
- name: Node einrichten
|
- name: Node einrichten
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
name: Whisper Image
|
||||||
|
|
||||||
|
# Nur bei Aenderungen am Abschreib-Image (Spec T7 11.1): das Image ist rund
|
||||||
|
# 1,2 GB gross (Modell + Python-Abhaengigkeiten) und aendert sich selten.
|
||||||
|
# Tag sha-<commit>; das Overlay setzt danach den Digest.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths: ['ApiCore-Whisper/**', '.gitea/workflows/whisper-image.yml']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: docker-builder
|
||||||
|
env:
|
||||||
|
# Nur fuer Doku/OCI-Labels, siehe REGISTRY_PUSH fuer den eigentlichen Push.
|
||||||
|
REGISTRY: git.encry.de
|
||||||
|
# Cluster-interner Service-Name statt git.encry.de: Registry-Blob-
|
||||||
|
# Uploads (PUT) vertragen sich nicht mit dem Cloudflare-Proxy vor
|
||||||
|
# git.encry.de ("413 Payload Too Large" unabhaengig von der Groesse).
|
||||||
|
# Der Runner-Pod erreicht den Gitea-Service direkt ueber Kubernetes-DNS.
|
||||||
|
# Push und Pull landen im selben Registry-Pfad (Owner/Repo:Tag), der
|
||||||
|
# Hostname ist nur die Verbindung, daher bleibt der Pull in den
|
||||||
|
# k8s-Manifesten unveraendert ueber git.encry.de.
|
||||||
|
REGISTRY_PUSH: gitea:80
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
|
- name: Image-Namen in Kleinbuchstaben ermitteln
|
||||||
|
# OCI-Registries erlauben nur Kleinbuchstaben im Repository-Pfad;
|
||||||
|
# der Gitea-Owner-Name behaelt Gross-/Kleinschreibung bei.
|
||||||
|
id: image
|
||||||
|
run: echo "name=$(echo '${{ gitea.repository_owner }}/apicore-whisper' | tr '[:upper:]' '[:lower:]')" >> "$GITEA_OUTPUT"
|
||||||
|
|
||||||
|
- name: Docker Buildx einrichten
|
||||||
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||||
|
with:
|
||||||
|
# REGISTRY_PUSH ist Klartext-HTTP (Gitea-Service intern ohne TLS)
|
||||||
|
# statt HTTPS — BuildKit lehnt das ohne diese Freigabe ab.
|
||||||
|
config-inline: |
|
||||||
|
[registry."gitea:80"]
|
||||||
|
http = true
|
||||||
|
|
||||||
|
- name: Bei der Gitea-Container-Registry anmelden
|
||||||
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY_PUSH }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
# GITEA_TOKEN hat kein Package-Write-Recht — separates Personal
|
||||||
|
# Access Token (Scope "package") als Secret REGISTRY_TOKEN anlegen.
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Metadaten (Tags/Labels) ermitteln
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
||||||
|
tags: |
|
||||||
|
type=sha,format=short
|
||||||
|
|
||||||
|
- name: Image bauen und pushen
|
||||||
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||||
|
with:
|
||||||
|
context: ./ApiCore-Whisper
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha,scope=apicore-whisper
|
||||||
|
cache-to: type=gha,scope=apicore-whisper,mode=max
|
||||||
|
|
||||||
|
- name: Schwachstellen-Scan (nur wenn trivy im Runner vorhanden ist)
|
||||||
|
# Hinweisend (exit-code 0): die Ausgabe gehoert ins Runbook, ein Fund
|
||||||
|
# blockiert den Push nicht, weil das Image dann schon in der Registry
|
||||||
|
# liegt. Der Build selbst ist die harte Sperre (Hashes, Modell-SHA,
|
||||||
|
# pytest in der Test-Stufe).
|
||||||
|
run: |
|
||||||
|
if command -v trivy >/dev/null 2>&1; then
|
||||||
|
first_tag="$(echo '${{ steps.meta.outputs.tags }}' | head -n1)"
|
||||||
|
trivy image --insecure --severity HIGH,CRITICAL --exit-code 0 "$first_tag"
|
||||||
|
else
|
||||||
|
echo "trivy ist im Runner nicht installiert, Scan uebersprungen"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Root-level artefacts only; each project has its own .gitignore.
|
||||||
|
|
||||||
|
# Tool caches created when tests or scripts run from the repo root.
|
||||||
|
/node_modules/
|
||||||
|
|
||||||
|
# Working notes of the SDD orchestration (ledgers, briefs, review reports)
|
||||||
|
# and design mockups/screenshots (~90 MB). Decisions that matter are in
|
||||||
|
# ARCHITECTURE.md, the specs/plans under docs/ and the Gitea issues.
|
||||||
|
/.superpowers/
|
||||||
+852
-36
File diff suppressed because it is too large.
Load diff
@@ -56,6 +56,16 @@ 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
|
||||||
|
# Obergrenze fuer eine einzuspielende Backup-Datei in Bytes (Standard 50 MB,
|
||||||
|
# hoechstens 256 MB, weil der Upload komplett im RAM verarbeitet wird).
|
||||||
|
# Export und Zeitplan warnen, wenn eine Sicherung groesser wird.
|
||||||
|
# BACKUP_MAX_IMPORT_BYTES=52428800
|
||||||
|
|
||||||
|
# Erster Administrator (prisma/seed.ts): wird angelegt, wenn es noch keinen
|
||||||
|
# Benutzer mit dieser Adresse gibt. Ohne Passwort erzeugt der Seed ein
|
||||||
|
# zufaelliges und gibt es einmalig im Log aus.
|
||||||
|
# [email protected]
|
||||||
|
# SEED_ADMIN_PASSWORD=
|
||||||
|
|
||||||
# Encryption keys, one per purpose (AES-256-GCM, key derived per purpose with
|
# Encryption keys, one per purpose (AES-256-GCM, key derived per purpose with
|
||||||
# HKDF): TOTP secrets, webhook signing secrets, backup files. Required in
|
# HKDF): TOTP secrets, webhook signing secrets, backup files. Required in
|
||||||
@@ -72,9 +82,12 @@ BACKUP_RETENTION_COUNT=14
|
|||||||
# MFA_ENCRYPTION_KEY=""
|
# MFA_ENCRYPTION_KEY=""
|
||||||
# WEBHOOK_ENCRYPTION_KEY=""
|
# WEBHOOK_ENCRYPTION_KEY=""
|
||||||
# BACKUP_ENCRYPTION_KEY=""
|
# BACKUP_ENCRYPTION_KEY=""
|
||||||
|
# Seals voice notes (audio, transcripts, suggestions); required in production.
|
||||||
|
# VOICE_ENCRYPTION_KEY=""
|
||||||
# MFA_ENCRYPTION_KEY_PREVIOUS=""
|
# MFA_ENCRYPTION_KEY_PREVIOUS=""
|
||||||
# WEBHOOK_ENCRYPTION_KEY_PREVIOUS=""
|
# WEBHOOK_ENCRYPTION_KEY_PREVIOUS=""
|
||||||
# BACKUP_ENCRYPTION_KEY_PREVIOUS=""
|
# BACKUP_ENCRYPTION_KEY_PREVIOUS=""
|
||||||
|
# VOICE_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).
|
||||||
@@ -108,3 +121,31 @@ ENABLE_SWAGGER=false
|
|||||||
# APICORE_ROLE=api
|
# APICORE_ROLE=api
|
||||||
# Port des Health-Endpunkts des Worker-Prozesses.
|
# Port des Health-Endpunkts des Worker-Prozesses.
|
||||||
# WORKER_HEALTH_PORT=3002
|
# 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/
|
||||||
+32
-86
@@ -1,98 +1,44 @@
|
|||||||
<p align="center">
|
# ApiCore-Backend
|
||||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
NestJS-API von ApiCore (TypeScript, Prisma, PostgreSQL). Alle fachlichen Funktionen liegen unter `/api/v1/...`. Überblick über das ganze Projekt: [`../README.md`](../README.md), Architektur: [`../ARCHITECTURE.md`](../ARCHITECTURE.md).
|
||||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
|
||||||
|
|
||||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
## Einrichten
|
||||||
<p align="center">
|
|
||||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
|
||||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
|
||||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
|
||||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
|
||||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
|
||||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
|
||||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
|
||||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
|
||||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
|
||||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
|
||||||
</p>
|
|
||||||
<!--[](https://opencollective.com/nest#backer)
|
|
||||||
[](https://opencollective.com/nest#sponsor)-->
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
|
||||||
|
|
||||||
## Project setup
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ npm install
|
npm install
|
||||||
|
cp .env.example .env # DATABASE_URL, JWT- und Verschlüsselungs-Schlüssel setzen
|
||||||
|
npx prisma migrate dev # Schema anwenden (nur lokal; gegen Produktion nur migrate deploy)
|
||||||
|
npm run start:dev # http://localhost:3001
|
||||||
```
|
```
|
||||||
|
|
||||||
## Compile and run the project
|
Alle Variablen mit Bedeutung stehen in [`.env.example`](.env.example). Swagger unter `/api/v1/docs` ist nur mit `ENABLE_SWAGGER=true` erreichbar.
|
||||||
|
|
||||||
|
## Laufrollen
|
||||||
|
|
||||||
|
Ein Build, drei Einstiegspunkte (`ARCHITECTURE.md` Abschnitt 10):
|
||||||
|
|
||||||
|
| Befehl | Rolle | Zweck |
|
||||||
|
|---|---|---|
|
||||||
|
| `npm run start:dev` / `node dist/main.js` | `api` (lokal ohne `APICORE_ROLE`: `all`) | HTTP-API; mit `all` laufen API und Worker in einem Prozess, nur für Entwicklung und Tests |
|
||||||
|
| `npm run worker` (`node dist/worker.js`) | `worker` | Zustellung der Outbox `DomainEvent`, Webhooks, Kalender-Sync, Wetter, Telegram; Health auf Port 3002 |
|
||||||
|
| `npm run job -- <name>` (`node dist/cli.js <name>`) | `job` | `cleanup`, `daily-evaluation`, `task-templates`, `app-backup`, `rekey` (auch `rekey --dry-run`); im Cluster als CronJobs |
|
||||||
|
|
||||||
|
In Produktion ist `APICORE_ROLE` Pflicht und `all` verboten. Migrationen laufen im Cluster als initContainer `migrate` (`prisma migrate deploy`), der Seed als initContainer `seed` (`node dist/seed.js`).
|
||||||
|
|
||||||
|
## Prüfen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# development
|
npm run lint # eslint mit --fix
|
||||||
$ npm run start
|
npm run typecheck # tsc --noEmit
|
||||||
|
npm test # Jest-Unit-Tests
|
||||||
# watch mode
|
npm run test:e2e # Jest-e2e gegen die Datenbank aus .env.test (vorher migrieren)
|
||||||
$ npm run start:dev
|
npm run build # dist/main.js, dist/worker.js, dist/cli.js, dist/seed.js
|
||||||
|
|
||||||
# production mode
|
|
||||||
$ npm run start:prod
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Run tests
|
Die e2e-Suites laufen parallel (`maxWorkers: 4` in `test/jest-e2e.json`) und scheitern dabei derzeit sporadisch; mit `npm run test:e2e -- --runInBand` sind sie grün ([Issue #32](https://git.encry.de/Nick.Rosenloecher/ApiCore/issues/32)). Der Image-Build (`Dockerfile`) führt Lint, Typecheck und Unit-Tests aus und bricht bei Fehlern ab; die e2e-Tests laufen dort nicht.
|
||||||
|
|
||||||
```bash
|
## Aufbau
|
||||||
# unit tests
|
|
||||||
$ npm run test
|
|
||||||
|
|
||||||
# e2e tests
|
- `src/<modul>/`: ein NestJS-Modul je Fachbereich (z. B. `colonies`, `inspections`, `treatments`, `honey-batches`, `compliance`, `hive`), dazu Querschnitt in `common`, `prisma`, `domain-events`, `worker`, `jobs`, `runtime`.
|
||||||
$ npm run test:e2e
|
- `prisma/schema.prisma` und `prisma/migrations/` sind die einzige Quelle des Schemas; `prisma db push` wird nirgends verwendet.
|
||||||
|
- `test/`: e2e-Specs und gemeinsame Fixtures (u. a. `test/fixtures/hive-ops/`, die auch das Frontend nutzt).
|
||||||
# test coverage
|
|
||||||
$ npm run test:cov
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
|
||||||
|
|
||||||
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ npm install -g @nestjs/mau
|
|
||||||
$ mau deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
Check out a few resources that may come in handy when working with NestJS:
|
|
||||||
|
|
||||||
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
|
||||||
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
|
||||||
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
|
||||||
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
|
||||||
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
|
||||||
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
|
||||||
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
|
||||||
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
|
||||||
|
|
||||||
## Stay in touch
|
|
||||||
|
|
||||||
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
|
||||||
- Website - [https://nestjs.com](https://nestjs.com/)
|
|
||||||
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
|
||||||
Generated
+72
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "0.131.0",
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^4.0.4",
|
"@nestjs/config": "^4.0.4",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
@@ -213,6 +214,27 @@
|
|||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@anthropic-ai/sdk": {
|
||||||
|
"version": "0.131.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.131.0.tgz",
|
||||||
|
"integrity": "sha512-9+PepoU7qVMmM70jSahdnfcMHAvAnzhMOLv2W9WjRLO+OG/MAiBETkwKZqv7KIJ1Cj5h8eZ019bHzZaq+Mbepw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"json-schema-to-ts": "^3.1.1",
|
||||||
|
"standardwebhooks": "^1.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"anthropic-ai-sdk": "bin/cli"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"zod": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
@@ -674,6 +696,15 @@
|
|||||||
"@babel/core": "^7.0.0-0"
|
"@babel/core": "^7.0.0-0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.29.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
|
||||||
|
"integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||||
@@ -3213,6 +3244,12 @@
|
|||||||
"@sinonjs/commons": "^3.0.1"
|
"@sinonjs/commons": "^3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@stablelib/base64": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -6925,6 +6962,12 @@
|
|||||||
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-sha256": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
|
||||||
@@ -8780,6 +8823,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/json-schema-to-ts": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.18.3",
|
||||||
|
"ts-algebra": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/json-schema-traverse": {
|
"node_modules/json-schema-traverse": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||||
@@ -11249,6 +11305,16 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/standardwebhooks": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@stablelib/base64": "^1.0.0",
|
||||||
|
"fast-sha256": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
@@ -11785,6 +11851,12 @@
|
|||||||
"url": "https://github.com/sponsors/Borewit"
|
"url": "https://github.com/sponsors/Borewit"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ts-algebra": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
|
|||||||
@@ -22,9 +22,12 @@
|
|||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
"migrate:deploy": "prisma migrate deploy",
|
"migrate:deploy": "prisma migrate deploy",
|
||||||
"seed": "node dist/seed.js"
|
"seed": "node dist/seed.js",
|
||||||
|
"export:voice-vocabulary": "ts-node scripts/export-voice-vocabulary.ts",
|
||||||
|
"eval:voice": "ts-node scripts/eval-voice.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "0.131.0",
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^4.0.4",
|
"@nestjs/config": "^4.0.4",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
@@ -102,6 +105,7 @@
|
|||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
],
|
],
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node"
|
"testEnvironment": "node",
|
||||||
|
"testTimeout": 20000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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');
|
||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
-- T6 migration 1/5: veterinary medicine catalogue, packs and the pack ledger.
|
||||||
|
-- A treatment may point at a catalogue entry and at the pack it was taken
|
||||||
|
-- from; the pack ledger is booked, never edited. Rows of the catalogue and
|
||||||
|
-- the packs are never deleted while something references them (RESTRICT).
|
||||||
|
-- CHECKs: a pack holds more than nothing; a booking moves something; only a
|
||||||
|
-- TREATMENT booking belongs to a treatment.
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "MedicineUnit" AS ENUM ('ML', 'G', 'PIECE');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "VeterinaryMedicineMovementType" AS ENUM ('TREATMENT', 'CORRECTION', 'DISPOSAL');
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "DocumentEntityType" ADD VALUE 'MEDICINE_LOT';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Treatment" ADD COLUMN "doseQuantity" DOUBLE PRECISION,
|
||||||
|
ADD COLUMN "medicineId" TEXT,
|
||||||
|
ADD COLUMN "medicineLotId" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "VeterinaryMedicine" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"activeIngredient" TEXT,
|
||||||
|
"authorisationNumber" TEXT,
|
||||||
|
"prescriptionOnly" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"withdrawalPeriodDays" INTEGER,
|
||||||
|
"unit" "MedicineUnit" NOT NULL,
|
||||||
|
"dosePerColony" DOUBLE PRECISION,
|
||||||
|
"doseNote" TEXT,
|
||||||
|
"applicationMethod" TEXT,
|
||||||
|
"minTemperatureC" DOUBLE PRECISION,
|
||||||
|
"maxTemperatureC" DOUBLE PRECISION,
|
||||||
|
"defaultSupplier" TEXT,
|
||||||
|
"notes" TEXT,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "VeterinaryMedicine_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "VeterinaryMedicineLot" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"medicineId" TEXT NOT NULL,
|
||||||
|
"batchNumber" TEXT,
|
||||||
|
"purchasedOn" TIMESTAMP(3) NOT NULL,
|
||||||
|
"quantity" DOUBLE PRECISION NOT NULL,
|
||||||
|
"expiresOn" TIMESTAMP(3),
|
||||||
|
"supplier" TEXT,
|
||||||
|
"purchaseProof" TEXT,
|
||||||
|
"prescribingVet" TEXT,
|
||||||
|
"notes" TEXT,
|
||||||
|
"userId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "VeterinaryMedicineLot_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "VeterinaryMedicineMovement" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"lotId" TEXT NOT NULL,
|
||||||
|
"type" "VeterinaryMedicineMovementType" NOT NULL,
|
||||||
|
"quantity" DOUBLE PRECISION NOT NULL,
|
||||||
|
"treatmentId" TEXT,
|
||||||
|
"reason" TEXT,
|
||||||
|
"userId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "VeterinaryMedicineMovement_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VeterinaryMedicine_active_idx" ON "VeterinaryMedicine"("active");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VeterinaryMedicineLot_medicineId_expiresOn_idx" ON "VeterinaryMedicineLot"("medicineId", "expiresOn");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VeterinaryMedicineMovement_lotId_idx" ON "VeterinaryMedicineMovement"("lotId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VeterinaryMedicineMovement_treatmentId_idx" ON "VeterinaryMedicineMovement"("treatmentId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Treatment_medicineId_idx" ON "Treatment"("medicineId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Treatment_medicineLotId_idx" ON "Treatment"("medicineLotId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Treatment" ADD CONSTRAINT "Treatment_medicineId_fkey" FOREIGN KEY ("medicineId") REFERENCES "VeterinaryMedicine"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Treatment" ADD CONSTRAINT "Treatment_medicineLotId_fkey" FOREIGN KEY ("medicineLotId") REFERENCES "VeterinaryMedicineLot"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VeterinaryMedicineLot" ADD CONSTRAINT "VeterinaryMedicineLot_medicineId_fkey" FOREIGN KEY ("medicineId") REFERENCES "VeterinaryMedicine"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VeterinaryMedicineLot" ADD CONSTRAINT "VeterinaryMedicineLot_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VeterinaryMedicineMovement" ADD CONSTRAINT "VeterinaryMedicineMovement_lotId_fkey" FOREIGN KEY ("lotId") REFERENCES "VeterinaryMedicineLot"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VeterinaryMedicineMovement" ADD CONSTRAINT "VeterinaryMedicineMovement_treatmentId_fkey" FOREIGN KEY ("treatmentId") REFERENCES "Treatment"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VeterinaryMedicineMovement" ADD CONSTRAINT "VeterinaryMedicineMovement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Constraints Prisma cannot express.
|
||||||
|
ALTER TABLE "VeterinaryMedicineLot"
|
||||||
|
ADD CONSTRAINT "VeterinaryMedicineLot_quantity_positive" CHECK ("quantity" > 0);
|
||||||
|
|
||||||
|
ALTER TABLE "VeterinaryMedicineMovement"
|
||||||
|
ADD CONSTRAINT "VeterinaryMedicineMovement_quantity_nonzero" CHECK ("quantity" <> 0);
|
||||||
|
|
||||||
|
ALTER TABLE "VeterinaryMedicineMovement"
|
||||||
|
ADD CONSTRAINT "VeterinaryMedicineMovement_treatment_matches_type"
|
||||||
|
CHECK (("type" = 'TREATMENT') = ("treatmentId" IS NOT NULL));
|
||||||
|
|
||||||
|
-- Permissions: the boot-time catalog sync only grants new permissions to
|
||||||
|
-- system roles (ADMIN), so the grants are made here (same as winter_checks).
|
||||||
|
-- API_CLIENT gets nothing: receipts and invoice numbers are not for the shop.
|
||||||
|
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'veterinary-medicines:read', 'veterinary-medicines', 'read', 'Ansehen', 'Tierarzneimittel', 3700),
|
||||||
|
(gen_random_uuid()::text, 'veterinary-medicines:create', 'veterinary-medicines', 'create', 'Anlegen', 'Tierarzneimittel', 3701),
|
||||||
|
(gen_random_uuid()::text, 'veterinary-medicines:update', 'veterinary-medicines', 'update', 'Bearbeiten', 'Tierarzneimittel', 3702)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||||
|
SELECT r.id, p.id, now()
|
||||||
|
FROM "Role" r, "Permission" p
|
||||||
|
WHERE p.resource = 'veterinary-medicines'
|
||||||
|
AND (
|
||||||
|
r.key IN ('ADMIN', 'BEEKEEPER', 'STAFF')
|
||||||
|
OR (r.key = 'READONLY' AND p.action = 'read')
|
||||||
|
)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- T6 migration 2/5: mandatory label particulars. The food business operator
|
||||||
|
-- (name and address) is separate from the bee keeper of the Bestandsbuch; a
|
||||||
|
-- product carries the legal name of the food; a label template carries the
|
||||||
|
-- minimum x-height (CHECK 0.5 to 5 mm) the print check warns about. Printing
|
||||||
|
-- a label is audited as PRINT.
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "AuditAction" ADD VALUE 'PRINT';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "SiteSettings" ADD COLUMN "foodBusinessAddress" TEXT,
|
||||||
|
ADD COLUMN "foodBusinessName" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Product" ADD COLUMN "legalName" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "LabelTemplate" ADD COLUMN "minXHeightMm" DOUBLE PRECISION NOT NULL DEFAULT 1.2;
|
||||||
|
|
||||||
|
ALTER TABLE "LabelTemplate"
|
||||||
|
ADD CONSTRAINT "LabelTemplate_minXHeightMm_range" CHECK ("minXHeightMm" BETWEEN 0.5 AND 5);
|
||||||
+140
@@ -0,0 +1,140 @@
|
|||||||
|
-- T6 migration 3/5: authority package and readiness. ComplianceSettings is a
|
||||||
|
-- singleton like SiteSettings; the default net-quantity digit rules are an
|
||||||
|
-- assumption the owner has to check (ARCHITECTURE). AuthorityNotice.openKey
|
||||||
|
-- is a nullable unique key: at most one open draft per kind and year, any
|
||||||
|
-- number of rows without a key. ApiaryRestriction ends on or after its start.
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "AuthorityNoticeKind" AS ENUM ('REGISTRATION', 'HERD_CHANGE', 'TSK_REPORT');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "AuthorityNoticeStatus" AS ENUM ('DRAFT', 'SENT', 'CANCELLED');
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
-- This migration adds more than one value to an enum. They are not used in
|
||||||
|
-- this migration (PostgreSQL forbids it inside the same transaction).
|
||||||
|
ALTER TYPE "DocumentEntityType" ADD VALUE 'AUTHORITY_NOTICE';
|
||||||
|
ALTER TYPE "DocumentEntityType" ADD VALUE 'APIARY_RESTRICTION';
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ComplianceSettings" (
|
||||||
|
"id" TEXT NOT NULL DEFAULT 'singleton',
|
||||||
|
"veterinaryOfficeName" TEXT,
|
||||||
|
"veterinaryOfficeAddress" TEXT,
|
||||||
|
"registrationNumber" TEXT,
|
||||||
|
"noticeOnColonyCountChange" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"noticeOnApiaryChange" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"tskReferenceDay" TEXT NOT NULL DEFAULT '01-01',
|
||||||
|
"tskReportDueDay" TEXT,
|
||||||
|
"tskMemberNumber" TEXT,
|
||||||
|
"tskExcludedColonyTypes" "ColonyType"[] DEFAULT ARRAY[]::"ColonyType"[],
|
||||||
|
"netQuantityDigitRules" JSONB NOT NULL DEFAULT '[{"maxGrams":50,"minMm":2},{"maxGrams":200,"minMm":3},{"maxGrams":1000,"minMm":4},{"maxGrams":null,"minMm":6}]',
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ComplianceSettings_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AuthorityNotice" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"kind" "AuthorityNoticeKind" NOT NULL,
|
||||||
|
"status" "AuthorityNoticeStatus" NOT NULL DEFAULT 'DRAFT',
|
||||||
|
"year" INTEGER,
|
||||||
|
"referenceDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"countedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"snapshot" JSONB NOT NULL,
|
||||||
|
"totalColonies" INTEGER NOT NULL,
|
||||||
|
"sentOn" TIMESTAMP(3),
|
||||||
|
"sentVia" TEXT,
|
||||||
|
"reference" TEXT,
|
||||||
|
"notes" TEXT,
|
||||||
|
"openKey" TEXT,
|
||||||
|
"createdByUserId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "AuthorityNotice_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ApiaryRestriction" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"apiaryId" TEXT NOT NULL,
|
||||||
|
"disease" TEXT NOT NULL,
|
||||||
|
"authority" TEXT,
|
||||||
|
"reference" TEXT,
|
||||||
|
"startsOn" TIMESTAMP(3) NOT NULL,
|
||||||
|
"endsOn" TIMESTAMP(3),
|
||||||
|
"notes" TEXT,
|
||||||
|
"createdByUserId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ApiaryRestriction_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ComplianceAcknowledgement" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"itemKey" TEXT NOT NULL,
|
||||||
|
"subjectId" TEXT NOT NULL,
|
||||||
|
"fingerprint" TEXT NOT NULL,
|
||||||
|
"note" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"revokedAt" TIMESTAMP(3),
|
||||||
|
"revokedByUserId" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "ComplianceAcknowledgement_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "AuthorityNotice_openKey_key" ON "AuthorityNotice"("openKey");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AuthorityNotice_kind_status_idx" ON "AuthorityNotice"("kind", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AuthorityNotice_year_idx" ON "AuthorityNotice"("year");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ApiaryRestriction_apiaryId_startsOn_idx" ON "ApiaryRestriction"("apiaryId", "startsOn");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ComplianceAcknowledgement_itemKey_subjectId_idx" ON "ComplianceAcknowledgement"("itemKey", "subjectId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AuthorityNotice" ADD CONSTRAINT "AuthorityNotice_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ApiaryRestriction" ADD CONSTRAINT "ApiaryRestriction_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ApiaryRestriction" ADD CONSTRAINT "ApiaryRestriction_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ComplianceAcknowledgement" ADD CONSTRAINT "ComplianceAcknowledgement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Constraints Prisma cannot express.
|
||||||
|
ALTER TABLE "ApiaryRestriction"
|
||||||
|
ADD CONSTRAINT "ApiaryRestriction_endsOn_after_startsOn"
|
||||||
|
CHECK ("endsOn" IS NULL OR "endsOn" >= "startsOn");
|
||||||
|
|
||||||
|
-- Permissions (see the winter_checks migration): read for everyone who
|
||||||
|
-- reads the records, manage (settings, notices, restrictions, readiness
|
||||||
|
-- sign-off) only for ADMIN and BEEKEEPER. API_CLIENT gets nothing.
|
||||||
|
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'compliance:read', 'compliance', 'read', 'Ansehen', 'Prüfbereitschaft & Behörden', 3800),
|
||||||
|
(gen_random_uuid()::text, 'compliance:manage', 'compliance', 'manage', 'Verwalten', 'Prüfbereitschaft & Behörden', 3801)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||||
|
SELECT r.id, p.id, now()
|
||||||
|
FROM "Role" r, "Permission" p
|
||||||
|
WHERE p.resource = 'compliance'
|
||||||
|
AND (
|
||||||
|
r.key IN ('ADMIN', 'BEEKEEPER')
|
||||||
|
OR (r.key IN ('STAFF', 'READONLY') AND p.action = 'read')
|
||||||
|
)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
-- T6 migration 4/5: notification category for the authority hints ("Bestand
|
||||||
|
-- geaendert", "TSK-Stichtag"). The value is only added here; migration 5
|
||||||
|
-- uses it (PostgreSQL forbids using a new enum value in the same
|
||||||
|
-- transaction that adds it).
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "NotificationCategory" ADD VALUE 'COMPLIANCE';
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
-- T6 migration 5/5: COMPLIANCE is a Telegram category chosen by default. New
|
||||||
|
-- preferences start with it; every existing preference gets it appended
|
||||||
|
-- (the app shows all categories, the list only steers Telegram). A user can
|
||||||
|
-- still remove it in the profile: an empty list is a deliberate opt-out and
|
||||||
|
-- stays empty (only lists with at least one entry get the new category).
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "NotificationPreference" ALTER COLUMN "telegramCategories" SET DEFAULT ARRAY['TASK_DUE', 'DIGEST', 'COMPLIANCE']::"NotificationCategory"[];
|
||||||
|
|
||||||
|
UPDATE "NotificationPreference"
|
||||||
|
SET "telegramCategories" = array_append("telegramCategories", 'COMPLIANCE'::"NotificationCategory")
|
||||||
|
WHERE cardinality("telegramCategories") > 0
|
||||||
|
AND NOT ('COMPLIANCE'::"NotificationCategory" = ANY("telegramCategories"));
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
-- T6 migration 6/6: the ComplianceSettings singleton row exists from the
|
||||||
|
-- start. The label check reads the net-quantity digit rules from it and fails
|
||||||
|
-- closed when the row is missing, so no legal value lives in the code. The
|
||||||
|
-- column defaults (digit rules, notice switches) fill the row; the owner has
|
||||||
|
-- to check them (ARCHITECTURE).
|
||||||
|
|
||||||
|
INSERT INTO "ComplianceSettings" ("id", "updatedAt")
|
||||||
|
VALUES ('singleton', now())
|
||||||
|
ON CONFLICT ("id") DO NOTHING;
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
-- Beute in 3D: hive systems, part types, per-colony setup and its change
|
||||||
|
-- history (spec 2026-10-15, section 5.1).
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "HivePartKind" AS ENUM ('FLOOR', 'BOX', 'DIVIDER_BOARD', 'QUEEN_EXCLUDER', 'FEEDER', 'COVER_SHEET', 'PROPOLIS_SCREEN', 'INNER_LID', 'INSULATION', 'ROOF', 'FLOOR_INSERT', 'EVAPORATOR');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "HiveBoxDepth" AS ENUM ('FULL', 'SHALLOW');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "HiveChangeSource" AS ENUM ('INIT', 'CARD', 'EDITOR', 'INSPECTION', 'AI_SUGGESTION');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "HiveSystem" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"frameWidthMm" INTEGER NOT NULL,
|
||||||
|
"frameHeightMm" INTEGER NOT NULL,
|
||||||
|
"footprintLMm" INTEGER NOT NULL,
|
||||||
|
"footprintWMm" INTEGER NOT NULL,
|
||||||
|
"template" JSONB NOT NULL,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "HiveSystem_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "HivePartType" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"systemId" TEXT NOT NULL,
|
||||||
|
"kind" "HivePartKind" NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"articleRef" TEXT,
|
||||||
|
"lengthMm" INTEGER NOT NULL,
|
||||||
|
"widthMm" INTEGER NOT NULL,
|
||||||
|
"heightMm" INTEGER NOT NULL,
|
||||||
|
"boxDepth" "HiveBoxDepth",
|
||||||
|
"frameSlots" INTEGER,
|
||||||
|
"stateSchema" JSONB NOT NULL,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "HivePartType_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "HiveSetup" (
|
||||||
|
"colonyId" TEXT NOT NULL,
|
||||||
|
"systemId" TEXT NOT NULL,
|
||||||
|
"version" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"state" JSONB NOT NULL,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "HiveSetup_pkey" PRIMARY KEY ("colonyId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "HiveChange" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"colonyId" TEXT NOT NULL,
|
||||||
|
"version" INTEGER NOT NULL,
|
||||||
|
"occurredOn" TIMESTAMP(3) NOT NULL,
|
||||||
|
"source" "HiveChangeSource" NOT NULL,
|
||||||
|
"inspectionId" TEXT,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"ops" JSONB NOT NULL,
|
||||||
|
"stateAfter" JSONB NOT NULL,
|
||||||
|
"note" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "HiveChange_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "HiveSystem_name_key" ON "HiveSystem"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "HivePartType_systemId_name_key" ON "HivePartType"("systemId", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "HiveChange_inspectionId_key" ON "HiveChange"("inspectionId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "HiveChange_colonyId_occurredOn_idx" ON "HiveChange"("colonyId", "occurredOn");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "HiveChange_colonyId_version_key" ON "HiveChange"("colonyId", "version");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "HivePartType" ADD CONSTRAINT "HivePartType_systemId_fkey" FOREIGN KEY ("systemId") REFERENCES "HiveSystem"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "HiveSetup" ADD CONSTRAINT "HiveSetup_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "HiveSetup" ADD CONSTRAINT "HiveSetup_systemId_fkey" FOREIGN KEY ("systemId") REFERENCES "HiveSystem"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "HiveChange" ADD CONSTRAINT "HiveChange_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "HiveSetup"("colonyId") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "HiveChange" ADD CONSTRAINT "HiveChange_inspectionId_fkey" FOREIGN KEY ("inspectionId") REFERENCES "Inspection"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "HiveChange" ADD CONSTRAINT "HiveChange_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
-- Beute in 3D: start catalog for the Liebig-Zander equipment (spec 2026-10-15,
|
||||||
|
-- sections 4.3 and 4.4) under fixed ids, so the system template has stable
|
||||||
|
-- references and a restore into a fresh install creates no duplicate names.
|
||||||
|
-- Dimensions are catalog data and editable in the settings.
|
||||||
|
|
||||||
|
-- Hive system
|
||||||
|
INSERT INTO "HiveSystem" ("id", "name", "frameWidthMm", "frameHeightMm", "footprintLMm", "footprintWMm", "template", "active", "updatedAt")
|
||||||
|
VALUES (
|
||||||
|
'hive-system-zander-liebig-10',
|
||||||
|
'Zander nach Dr. Liebig, 10 Rähmchen',
|
||||||
|
477,
|
||||||
|
220,
|
||||||
|
525,
|
||||||
|
430,
|
||||||
|
'{"schemaVersion":1,"stack":[{"partTypeId":"hive-part-zl10-floor","state":{}},{"partTypeId":"hive-part-zl10-box-10","state":{},"frames":[{"slot":1,"type":"FOUNDATION"},{"slot":2,"type":"FOUNDATION"},{"slot":3,"type":"FOUNDATION"},{"slot":4,"type":"FOUNDATION"},{"slot":5,"type":"FOUNDATION"},{"slot":6,"type":"FOUNDATION"},{"slot":7,"type":"FOUNDATION"},{"slot":8,"type":"FOUNDATION"},{"slot":9,"type":"FOUNDATION"},{"slot":10,"type":"FOUNDATION"}]},{"partTypeId":"hive-part-zl10-cover-abdeckfix","state":{}},{"partTypeId":"hive-part-zl10-inner-lid","state":{}},{"partTypeId":"hive-part-zl10-insulation","state":{}},{"partTypeId":"hive-part-zl10-roof","state":{}}]}'::jsonb,
|
||||||
|
true,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Part types
|
||||||
|
INSERT INTO "HivePartType" ("id", "systemId", "kind", "name", "lengthMm", "widthMm", "heightMm", "boxDepth", "frameSlots", "stateSchema", "active", "updatedAt")
|
||||||
|
VALUES
|
||||||
|
('hive-part-zl10-floor', 'hive-system-zander-liebig-10', 'FLOOR', 'Liebig Wanderboden', 525, 430, 120, NULL, NULL, '[{"key":"entrance","label":"Flugloch","kind":"choice","options":["OPEN","NARROW","CLOSED"],"optionLabels":["offen","eng","zu"]},{"key":"varroaTray","label":"Varroa-Schublade","kind":"choice","options":["IN","OUT"],"optionLabels":["drin","draußen"]}]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||||
|
('hive-part-zl10-floor-insert', 'hive-system-zander-liebig-10', 'FLOOR_INSERT', 'Liebig Bausperre', 475, 375, 5, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||||
|
('hive-part-zl10-box-10', 'hive-system-zander-liebig-10', 'BOX', 'Liebig Zander Ganzzarge, 10 Rähmchen', 525, 430, 230, 'FULL', 10, '[]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||||
|
('hive-part-zl10-box-empty', 'hive-system-zander-liebig-10', 'BOX', 'Leerzarge (Liebig Zander Ganzzarge ohne Rähmchen)', 525, 430, 230, 'FULL', 0, '[]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||||
|
('hive-part-zl10-divider-board', 'hive-system-zander-liebig-10', 'DIVIDER_BOARD', 'Liebig Zwischenboden', 525, 430, 50, NULL, NULL, '[{"key":"bungHoles","label":"Spundlöcher","kind":"choice","options":["OPEN","CLOSED","BEE_ESCAPE"],"optionLabels":["offen","zu","Bienenflucht"]}]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||||
|
('hive-part-zl10-queen-excluder', 'hive-system-zander-liebig-10', 'QUEEN_EXCLUDER', 'Niro-Absperrgitter Zander Liebig', 478, 376, 2, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||||
|
('hive-part-zl10-feeder', 'hive-system-zander-liebig-10', 'FEEDER', 'Liebig Zweikammer-Futterzarge', 520, 420, 70, NULL, NULL, '[{"key":"syrupLiters","label":"Flüssigfutter","kind":"level","unit":"l","max":3,"step":0.5,"feedType":"SUGAR_WATER"},{"key":"fondantKg","label":"Futterteig","kind":"level","unit":"kg","max":2.5,"step":0.5,"feedType":"FEED_PASTE"}]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||||
|
('hive-part-zl10-cover-abdeckfix', 'hive-system-zander-liebig-10', 'COVER_SHEET', 'Abdeck-Fix (Folie, klar, 0,4 mm)', 478, 378, 1, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||||
|
('hive-part-zl10-cover-lotus', 'hive-system-zander-liebig-10', 'COVER_SHEET', 'Imgut Lotus-Abdeckung (0,5 mm)', 478, 378, 1, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||||
|
('hive-part-zl10-propolis-screen', 'hive-system-zander-liebig-10', 'PROPOLIS_SCREEN', 'Imgut Propolisgitter', 478, 378, 3, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||||
|
('hive-part-zl10-inner-lid', 'hive-system-zander-liebig-10', 'INNER_LID', 'Liebig Holzdeckel', 525, 430, 35, NULL, NULL, '[{"key":"feedHole","label":"Futterloch","kind":"choice","options":["OPEN","CLOSED"],"optionLabels":["offen","zu"]}]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||||
|
('hive-part-zl10-insulation', 'hive-system-zander-liebig-10', 'INSULATION', 'Styropor-Einlage (selbst gebaut)', 465, 365, 20, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||||
|
('hive-part-zl10-roof', 'hive-system-zander-liebig-10', 'ROOF', 'Edelstahldeckel konisch Liebig', 525, 430, 60, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||||
|
('hive-part-zl10-evaporator', 'hive-system-zander-liebig-10', 'EVAPORATOR', 'Nassenheider Verdunster Professional', 200, 140, 120, NULL, NULL, '[{"key":"acidMl","label":"Ameisensäure","kind":"level","unit":"ml","max":290,"step":10}]'::jsonb, true, CURRENT_TIMESTAMP);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Beute in 3D: Futtertasche (FRAME_FEEDER, spec 2026-10-15 sections 4.1, 4.3,
|
||||||
|
-- 5.1). A frame feeder hangs in a box in place of frames and covers
|
||||||
|
-- `slotWidth` frame slots. The enum value cannot be used in the migration that
|
||||||
|
-- adds it, so the catalog row comes in 20261018100100.
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "HivePartKind" ADD VALUE 'FRAME_FEEDER';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "HivePartType" ADD COLUMN "slotWidth" INTEGER;
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
-- Beute in 3D: start catalog row for the Futtertasche (owner wish 2026-10-16,
|
||||||
|
-- spec sections 4.3 and 4.1). Fixed id like the other start data; dimensions and
|
||||||
|
-- the 2 l capacity are assumptions the owner confirms (catalog data, editable).
|
||||||
|
|
||||||
|
INSERT INTO "HivePartType" ("id", "systemId", "kind", "name", "lengthMm", "widthMm", "heightMm", "boxDepth", "frameSlots", "slotWidth", "stateSchema", "active", "updatedAt")
|
||||||
|
VALUES (
|
||||||
|
'hive-part-zl10-frame-feeder',
|
||||||
|
'hive-system-zander-liebig-10',
|
||||||
|
'FRAME_FEEDER',
|
||||||
|
'Holz-Futtertasche Zander, doppelte Wabenbreite',
|
||||||
|
477,
|
||||||
|
70,
|
||||||
|
220,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
2,
|
||||||
|
'[{"key":"syrupLiters","label":"Flüssigfutter","kind":"level","unit":"l","max":2,"step":0.25,"feedType":"SUGAR_WATER"}]'::jsonb,
|
||||||
|
true,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "VoiceCaptureSource" AS ENUM ('AUDIO', 'TEXT');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "VoiceCaptureStatus" AS ENUM ('QUEUED', 'TRANSCRIBING', 'TRANSCRIBED', 'EXTRACTING', 'READY', 'CONFIRMED', 'DISCARDED', 'FAILED', 'EXPIRED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "VoiceCapture" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"ownerUserId" TEXT NOT NULL,
|
||||||
|
"colonyId" TEXT NOT NULL,
|
||||||
|
"inspectionDate" DATE NOT NULL,
|
||||||
|
"source" "VoiceCaptureSource" NOT NULL,
|
||||||
|
"status" "VoiceCaptureStatus" NOT NULL DEFAULT 'QUEUED',
|
||||||
|
"audioMime" TEXT,
|
||||||
|
"audioBytes" INTEGER,
|
||||||
|
"durationSec" INTEGER,
|
||||||
|
"transcriptEnc" TEXT,
|
||||||
|
"suggestionEnc" TEXT,
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"claimedAt" TIMESTAMP(3),
|
||||||
|
"claimToken" TEXT,
|
||||||
|
"lastErrorCode" TEXT,
|
||||||
|
"model" TEXT,
|
||||||
|
"inputTokens" INTEGER,
|
||||||
|
"outputTokens" INTEGER,
|
||||||
|
"inspectionId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"readyAt" TIMESTAMP(3),
|
||||||
|
"contentExpiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"contentDeletedAt" TIMESTAMP(3),
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "VoiceCapture_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "VoiceCaptureAudio" (
|
||||||
|
"captureId" TEXT NOT NULL,
|
||||||
|
"dataEnc" BYTEA NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "VoiceCaptureAudio_pkey" PRIMARY KEY ("captureId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VoiceCapture_status_availableAt_idx" ON "VoiceCapture"("status", "availableAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VoiceCapture_ownerUserId_status_idx" ON "VoiceCapture"("ownerUserId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VoiceCapture_contentExpiresAt_idx" ON "VoiceCapture"("contentExpiresAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VoiceCapture" ADD CONSTRAINT "VoiceCapture_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VoiceCapture" ADD CONSTRAINT "VoiceCapture_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VoiceCaptureAudio" ADD CONSTRAINT "VoiceCaptureAudio_captureId_fkey" FOREIGN KEY ("captureId") REFERENCES "VoiceCapture"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
-- Constraints Prisma cannot express.
|
||||||
|
ALTER TABLE "VoiceCapture"
|
||||||
|
ADD CONSTRAINT "VoiceCapture_audioBytes_range"
|
||||||
|
CHECK ("audioBytes" IS NULL OR "audioBytes" BETWEEN 1 AND 2097152);
|
||||||
|
|
||||||
|
ALTER TABLE "VoiceCapture"
|
||||||
|
ADD CONSTRAINT "VoiceCapture_durationSec_range"
|
||||||
|
CHECK ("durationSec" IS NULL OR "durationSec" BETWEEN 1 AND 240);
|
||||||
|
|
||||||
|
ALTER TABLE "VoiceCapture"
|
||||||
|
ADD CONSTRAINT "VoiceCapture_confirmed_matches_inspection"
|
||||||
|
CHECK (("status" = 'CONFIRMED') = ("inspectionId" IS NOT NULL));
|
||||||
|
|
||||||
|
ALTER TABLE "VoiceCapture"
|
||||||
|
ADD CONSTRAINT "VoiceCapture_audioMime_only_for_audio"
|
||||||
|
CHECK ("source" = 'AUDIO' OR "audioMime" IS NULL);
|
||||||
|
|
||||||
|
-- Permission: the boot-time catalog sync only grants new permissions to
|
||||||
|
-- system roles (ADMIN), so the grants are made here (same as T6). READONLY
|
||||||
|
-- and API_CLIENT get nothing: voice notes are for people who inspect.
|
||||||
|
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'voice-captures:use', 'voice-captures', 'use', 'Benutzen', 'Sprache', 4000)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||||
|
SELECT r.id, p.id, now()
|
||||||
|
FROM "Role" r, "Permission" p
|
||||||
|
WHERE p.key = 'voice-captures:use'
|
||||||
|
AND r.key IN ('ADMIN', 'BEEKEEPER', 'STAFF')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "VoiceExtractionOutcome" AS ENUM ('OK', 'REFUSED', 'TRUNCATED', 'INVALID', 'RETRY', 'BAD_REQUEST', 'AUTH', 'ABORTED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "VoiceExtractionCall" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"model" TEXT NOT NULL,
|
||||||
|
"inputTokens" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"outputTokens" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"outcome" "VoiceExtractionOutcome" NOT NULL,
|
||||||
|
"captureId" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "VoiceExtractionCall_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VoiceExtractionCall_at_idx" ON "VoiceExtractionCall"("at");
|
||||||
@@ -104,6 +104,8 @@ model User {
|
|||||||
refreshTokens RefreshToken[]
|
refreshTokens RefreshToken[]
|
||||||
|
|
||||||
inspections Inspection[]
|
inspections Inspection[]
|
||||||
|
voiceCaptures VoiceCapture[]
|
||||||
|
hiveChanges HiveChange[]
|
||||||
varroaMeasurements VarroaMeasurement[]
|
varroaMeasurements VarroaMeasurement[]
|
||||||
swarmControlChecks SwarmControlCheck[]
|
swarmControlChecks SwarmControlCheck[]
|
||||||
winterChecks WinterCheck[]
|
winterChecks WinterCheck[]
|
||||||
@@ -111,10 +113,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,12 +128,22 @@ 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[]
|
||||||
honeyBatchRelocations HoneyBatchRelocation[]
|
honeyBatchRelocations HoneyBatchRelocation[]
|
||||||
apiKeys ApiKey[]
|
apiKeys ApiKey[]
|
||||||
|
|
||||||
|
veterinaryMedicineLots VeterinaryMedicineLot[]
|
||||||
|
veterinaryMedicineMovements VeterinaryMedicineMovement[]
|
||||||
|
authorityNotices AuthorityNotice[]
|
||||||
|
apiaryRestrictions ApiaryRestriction[]
|
||||||
|
complianceAcknowledgements ComplianceAcknowledgement[]
|
||||||
|
|
||||||
@@index([roleId])
|
@@index([roleId])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +210,7 @@ enum AuditAction {
|
|||||||
MFA_ENABLED
|
MFA_ENABLED
|
||||||
MFA_DISABLED
|
MFA_DISABLED
|
||||||
ACCOUNT_LOCKED
|
ACCOUNT_LOCKED
|
||||||
|
PRINT
|
||||||
}
|
}
|
||||||
|
|
||||||
model AuditLog {
|
model AuditLog {
|
||||||
@@ -243,6 +258,10 @@ model Apiary {
|
|||||||
harvests Harvest[]
|
harvests Harvest[]
|
||||||
treatments Treatment[]
|
treatments Treatment[]
|
||||||
rawMaterialLots RawMaterialLot[]
|
rawMaterialLots RawMaterialLot[]
|
||||||
|
phenologyReports PhenologyReport[]
|
||||||
|
phenologyStates ApiaryPhenology[]
|
||||||
|
weatherDays ApiaryWeatherDay[]
|
||||||
|
restrictions ApiaryRestriction[]
|
||||||
|
|
||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
@@ -290,6 +309,7 @@ model Colony {
|
|||||||
colonyType ColonyType
|
colonyType ColonyType
|
||||||
status ColonyStatus @default(ACTIVE)
|
status ColonyStatus @default(ACTIVE)
|
||||||
strength Int?
|
strength Int?
|
||||||
|
occupiedFrames Int?
|
||||||
broodStatus ColonyCondition?
|
broodStatus ColonyCondition?
|
||||||
feedStatus ColonyCondition?
|
feedStatus ColonyCondition?
|
||||||
healthStatus ColonyCondition?
|
healthStatus ColonyCondition?
|
||||||
@@ -312,6 +332,9 @@ model Colony {
|
|||||||
feedings Feeding[]
|
feedings Feeding[]
|
||||||
harvests HarvestColony[]
|
harvests HarvestColony[]
|
||||||
queenEvaluations QueenEvaluation[]
|
queenEvaluations QueenEvaluation[]
|
||||||
|
suggestions Suggestion[]
|
||||||
|
hiveSetup HiveSetup?
|
||||||
|
voiceCaptures VoiceCapture[]
|
||||||
|
|
||||||
@@index([apiaryId])
|
@@index([apiaryId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@ -340,6 +363,12 @@ enum LineageType {
|
|||||||
|
|
||||||
// Ableger bilden (SPLIT: from=Muttervolk, to=Ableger) und Völker vereinigen
|
// Ableger bilden (SPLIT: from=Muttervolk, to=Ableger) und Völker vereinigen
|
||||||
// (MERGE: mehrere from-Zeilen auf denselben to=Zielvolk) – Abschnitt 4.4.
|
// (MERGE: mehrere from-Zeilen auf denselben to=Zielvolk) – Abschnitt 4.4.
|
||||||
|
enum SplitQueenSource {
|
||||||
|
RAISE_OWN
|
||||||
|
QUEEN_CELL
|
||||||
|
QUEEN
|
||||||
|
}
|
||||||
|
|
||||||
model ColonyLineage {
|
model ColonyLineage {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
type LineageType
|
type LineageType
|
||||||
@@ -348,6 +377,7 @@ model ColonyLineage {
|
|||||||
toColonyId String
|
toColonyId String
|
||||||
toColony Colony @relation("LineageTo", fields: [toColonyId], references: [id])
|
toColony Colony @relation("LineageTo", fields: [toColonyId], references: [id])
|
||||||
date DateTime
|
date DateTime
|
||||||
|
queenSource SplitQueenSource?
|
||||||
notes String?
|
notes String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@ -467,7 +497,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?
|
||||||
@@ -476,6 +506,7 @@ model Inspection {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
tasks Task[]
|
tasks Task[]
|
||||||
|
hiveChange HiveChange?
|
||||||
|
|
||||||
@@index([colonyId, date])
|
@@index([colonyId, date])
|
||||||
}
|
}
|
||||||
@@ -529,12 +560,99 @@ model Task {
|
|||||||
// rule see that it already left an open task for the same object instead
|
// rule see that it already left an open task for the same object instead
|
||||||
// of piling up a duplicate on every further trigger.
|
// of piling up a duplicate on every further trigger.
|
||||||
sourceAutomationTrigger AutomationTriggerType?
|
sourceAutomationTrigger AutomationTriggerType?
|
||||||
|
// Set when the task was created by accepting a suggestion.
|
||||||
|
sourceSuggestionId String?
|
||||||
|
sourceSuggestion Suggestion? @relation(fields: [sourceSuggestionId], references: [id], onDelete: SetNull)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
completedAt DateTime?
|
completedAt DateTime?
|
||||||
|
|
||||||
@@index([status, dueDate])
|
@@index([status, dueDate])
|
||||||
@@index([relatedType, relatedId])
|
@@index([relatedType, relatedId])
|
||||||
@@index([sourceAutomationTrigger, relatedType, relatedId, status])
|
@@index([sourceAutomationTrigger, relatedType, relatedId, status])
|
||||||
|
@@index([sourceSuggestionId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Vorschlaege (T4): Regeln, Ketten und der Saison-Autopilot schlagen vor, der
|
||||||
|
// Imker entscheidet. Uebernehmen legt eine Aufgabe an (Task.sourceSuggestionId).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
enum SuggestionStatus {
|
||||||
|
OPEN
|
||||||
|
ACCEPTED
|
||||||
|
DISMISSED
|
||||||
|
RESOLVED
|
||||||
|
EXPIRED
|
||||||
|
OBSOLETE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SuggestionMode {
|
||||||
|
PLAN
|
||||||
|
DO
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SuggestionSource {
|
||||||
|
RULE
|
||||||
|
CHAIN
|
||||||
|
AUTOPILOT
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SuggestionIntent {
|
||||||
|
INSPECT
|
||||||
|
SWARM_CHECK
|
||||||
|
QUEEN_CHECK
|
||||||
|
QUEEN_ACCEPTANCE
|
||||||
|
QUEEN_REPLACE
|
||||||
|
SPLIT_PLAN
|
||||||
|
VARROA_MEASURE
|
||||||
|
VARROA_RECHECK
|
||||||
|
VARROA_TREAT
|
||||||
|
SUMMER_TREATMENT
|
||||||
|
FEED
|
||||||
|
HONEY_SUPER
|
||||||
|
WINTER_CHECK
|
||||||
|
HEALTH_CHECK
|
||||||
|
FOLLOW_UP
|
||||||
|
STOCK_REORDER
|
||||||
|
CLEANUP
|
||||||
|
}
|
||||||
|
|
||||||
|
model Suggestion {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
ruleKey AutomationTriggerType
|
||||||
|
source SuggestionSource
|
||||||
|
mode SuggestionMode
|
||||||
|
intent SuggestionIntent
|
||||||
|
status SuggestionStatus @default(OPEN)
|
||||||
|
priority TaskPriority @default(NORMAL)
|
||||||
|
relatedType TaskRelatedType
|
||||||
|
relatedId String?
|
||||||
|
colonyId String?
|
||||||
|
colony Colony? @relation(fields: [colonyId], references: [id])
|
||||||
|
reasonCode String
|
||||||
|
// Only IDs, numbers and YYYY-MM-DD.
|
||||||
|
evidence Json
|
||||||
|
// e.g. 'varroa:read'; without that permission the suggestion is invisible.
|
||||||
|
requiredPermission String?
|
||||||
|
dueDate DateTime?
|
||||||
|
expiresAt DateTime?
|
||||||
|
// PLAN with several dates: [{ step, offsetDays, windowDays? }]
|
||||||
|
plan Json?
|
||||||
|
// '<ruleKey>:<relatedId|none>:<anchor>'; EXPIRED/RESOLVED clear it.
|
||||||
|
dedupeKey String? @unique
|
||||||
|
sourceEventId String?
|
||||||
|
decidedAt DateTime?
|
||||||
|
decidedByUserId String?
|
||||||
|
decidedByUser User? @relation("SuggestionDecider", fields: [decidedByUserId], references: [id])
|
||||||
|
decisionNote String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
tasks Task[]
|
||||||
|
|
||||||
|
@@index([status, colonyId])
|
||||||
|
@@index([status, dueDate])
|
||||||
|
@@index([ruleKey, relatedType, relatedId, status])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum TaskTemplateRecurrenceType {
|
enum TaskTemplateRecurrenceType {
|
||||||
@@ -589,6 +707,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?
|
||||||
@@ -711,9 +830,21 @@ model Treatment {
|
|||||||
supplier String?
|
supplier String?
|
||||||
purchaseProof String?
|
purchaseProof String?
|
||||||
prescribingVet String?
|
prescribingVet String?
|
||||||
|
// T6: catalogue entry, the pack the dose was taken from and the dose per
|
||||||
|
// colony in the medicine's unit. With a pack, supplier / proof / batch
|
||||||
|
// number / vet are read from the pack, never copied.
|
||||||
|
medicineId String?
|
||||||
|
medicine VeterinaryMedicine? @relation(fields: [medicineId], references: [id], onDelete: Restrict)
|
||||||
|
medicineLotId String?
|
||||||
|
medicineLot VeterinaryMedicineLot? @relation(fields: [medicineLotId], references: [id], onDelete: Restrict)
|
||||||
|
doseQuantity Float?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
medicineMovements VeterinaryMedicineMovement[]
|
||||||
|
|
||||||
@@index([colonyId, date])
|
@@index([colonyId, date])
|
||||||
|
@@index([medicineId])
|
||||||
|
@@index([medicineLotId])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -829,6 +960,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])
|
||||||
@@ -840,6 +974,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])
|
||||||
@@ -1013,6 +1148,10 @@ 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?
|
||||||
|
// Bezeichnung des Lebensmittels auf dem Etikett, z. B. "Bluetenhonig" (T6).
|
||||||
|
legalName String?
|
||||||
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)
|
||||||
@@ -1051,6 +1190,8 @@ model LabelTemplate {
|
|||||||
// Schnittlinie - zum Anlegen am Papierschneider. Aus bei vorgestanzten
|
// Schnittlinie - zum Anlegen am Papierschneider. Aus bei vorgestanzten
|
||||||
// Etikettenbögen, die nicht geschnitten werden.
|
// Etikettenbögen, die nicht geschnitten werden.
|
||||||
cropMarks Boolean @default(true)
|
cropMarks Boolean @default(true)
|
||||||
|
// Mindest-x-Hoehe der Pflichtangaben in mm (T6), CHECK 0,5 bis 5.
|
||||||
|
minXHeightMm Float @default(1.2)
|
||||||
isDefault Boolean @default(false)
|
isDefault Boolean @default(false)
|
||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -1076,6 +1217,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])
|
||||||
@@ -1172,6 +1314,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 {
|
||||||
@@ -1194,6 +1338,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
|
||||||
@@ -1209,6 +1359,8 @@ 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).
|
// ERP-Export /bewegungen: sortiert und blättert nach (createdAt, id).
|
||||||
@@index([createdAt, id])
|
@@index([createdAt, id])
|
||||||
@@ -1275,21 +1427,126 @@ enum NotificationPriority {
|
|||||||
KRITISCH
|
KRITISCH
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum NotificationCategory {
|
||||||
|
TASK_DUE
|
||||||
|
DIGEST
|
||||||
|
SUGGESTION
|
||||||
|
WITHDRAWAL
|
||||||
|
SEASON
|
||||||
|
SYSTEM
|
||||||
|
COMPLIANCE
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, COMPLIANCE])
|
||||||
|
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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1320,6 +1577,9 @@ enum DocumentEntityType {
|
|||||||
QUEEN
|
QUEEN
|
||||||
INSPECTION
|
INSPECTION
|
||||||
HONEY_BATCH
|
HONEY_BATCH
|
||||||
|
MEDICINE_LOT
|
||||||
|
AUTHORITY_NOTICE
|
||||||
|
APIARY_RESTRICTION
|
||||||
}
|
}
|
||||||
|
|
||||||
model Document {
|
model Document {
|
||||||
@@ -1358,6 +1618,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 {
|
||||||
@@ -1410,6 +1679,10 @@ model SiteSettings {
|
|||||||
// part of any public page.
|
// part of any public page.
|
||||||
holderName String?
|
holderName String?
|
||||||
holderAddress String?
|
holderAddress String?
|
||||||
|
// Name/firm and postal address of the food business operator printed on
|
||||||
|
// the label (T6) - the brand, not the bee keeper of the Bestandsbuch.
|
||||||
|
foodBusinessName String?
|
||||||
|
foodBusinessAddress String?
|
||||||
// Country of origin printed on labels and shop output.
|
// Country of origin printed on labels and shop output.
|
||||||
originCountry String @default("Deutschland")
|
originCountry String @default("Deutschland")
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -1446,6 +1719,7 @@ enum CalendarSyncSourceType {
|
|||||||
FEEDING
|
FEEDING
|
||||||
HARVEST
|
HARVEST
|
||||||
QUEEN_ASSIGNMENT
|
QUEEN_ASSIGNMENT
|
||||||
|
WITHDRAWAL
|
||||||
}
|
}
|
||||||
|
|
||||||
enum CalendarSyncStatus {
|
enum CalendarSyncStatus {
|
||||||
@@ -1465,6 +1739,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1646,3 +1921,468 @@ model ThrottlerHit {
|
|||||||
@@id([key, throttlerName])
|
@@id([key, throttlerName])
|
||||||
@@index([expiresAt])
|
@@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])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// T6 Pruefbereitschaft (docs/superpowers/specs/2026-10-13-t6-compliance-design.md)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
enum MedicineUnit {
|
||||||
|
ML
|
||||||
|
G
|
||||||
|
PIECE
|
||||||
|
}
|
||||||
|
|
||||||
|
// Katalog des Imkers. Jeder Wert kommt aus Packungsbeilage oder Rezept und
|
||||||
|
// wird vom Imker eingetragen; der Code liefert keine Arzneimitteldaten
|
||||||
|
// (ARCHITECTURE Annahme 10).
|
||||||
|
model VeterinaryMedicine {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
activeIngredient String?
|
||||||
|
authorisationNumber String?
|
||||||
|
prescriptionOnly Boolean @default(false)
|
||||||
|
// Wartezeit Honig in Tagen, auch 0.
|
||||||
|
withdrawalPeriodDays Int?
|
||||||
|
// Nur aenderbar, solange keine Packung existiert.
|
||||||
|
unit MedicineUnit
|
||||||
|
dosePerColony Float?
|
||||||
|
doseNote String?
|
||||||
|
applicationMethod String?
|
||||||
|
minTemperatureC Float?
|
||||||
|
maxTemperatureC Float?
|
||||||
|
defaultSupplier String?
|
||||||
|
notes String?
|
||||||
|
active Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
lots VeterinaryMedicineLot[]
|
||||||
|
treatments Treatment[]
|
||||||
|
|
||||||
|
@@index([active])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine gekaufte Packung. Ch.-B., Lieferant, Beleg und Tierarzt stehen hier
|
||||||
|
// einmal und gelten fuer jede Behandlung aus dieser Packung. CHECK quantity > 0.
|
||||||
|
model VeterinaryMedicineLot {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
// Nur aenderbar, solange keine Bewegung existiert.
|
||||||
|
medicineId String
|
||||||
|
medicine VeterinaryMedicine @relation(fields: [medicineId], references: [id], onDelete: Restrict)
|
||||||
|
batchNumber String?
|
||||||
|
purchasedOn DateTime
|
||||||
|
quantity Float
|
||||||
|
expiresOn DateTime?
|
||||||
|
supplier String?
|
||||||
|
purchaseProof String?
|
||||||
|
prescribingVet String?
|
||||||
|
notes String?
|
||||||
|
userId String?
|
||||||
|
user User? @relation(fields: [userId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
movements VeterinaryMedicineMovement[]
|
||||||
|
treatments Treatment[]
|
||||||
|
|
||||||
|
@@index([medicineId, expiresOn])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VeterinaryMedicineMovementType {
|
||||||
|
TREATMENT
|
||||||
|
CORRECTION
|
||||||
|
DISPOSAL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bewegungsbuch der Packungen: nie geaendert, nie geloescht; Fehler werden
|
||||||
|
// gegengebucht. Rest = quantity der Packung + Summe aller quantity.
|
||||||
|
// CHECKs: quantity <> 0; (type = 'TREATMENT') = (treatmentId IS NOT NULL).
|
||||||
|
model VeterinaryMedicineMovement {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
lotId String
|
||||||
|
lot VeterinaryMedicineLot @relation(fields: [lotId], references: [id], onDelete: Restrict)
|
||||||
|
type VeterinaryMedicineMovementType
|
||||||
|
quantity Float
|
||||||
|
treatmentId String?
|
||||||
|
treatment Treatment? @relation(fields: [treatmentId], references: [id], onDelete: Restrict)
|
||||||
|
reason String?
|
||||||
|
userId String?
|
||||||
|
user User? @relation(fields: [userId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([lotId])
|
||||||
|
@@index([treatmentId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton wie SiteSettings. Werte mit "Annahme" sind vom Inhaber zu pruefen.
|
||||||
|
model ComplianceSettings {
|
||||||
|
id String @id @default("singleton")
|
||||||
|
veterinaryOfficeName String?
|
||||||
|
veterinaryOfficeAddress String?
|
||||||
|
registrationNumber String?
|
||||||
|
noticeOnColonyCountChange Boolean @default(true)
|
||||||
|
noticeOnApiaryChange Boolean @default(true)
|
||||||
|
// MM-TT.
|
||||||
|
tskReferenceDay String @default("01-01")
|
||||||
|
tskReportDueDay String?
|
||||||
|
tskMemberNumber String?
|
||||||
|
tskExcludedColonyTypes ColonyType[] @default([])
|
||||||
|
// Vorgabe in der Migration (Spec 5.4), Annahme - vom Inhaber zu pruefen.
|
||||||
|
netQuantityDigitRules Json @default("[{\"maxGrams\":50,\"minMm\":2},{\"maxGrams\":200,\"minMm\":3},{\"maxGrams\":1000,\"minMm\":4},{\"maxGrams\":null,\"minMm\":6}]")
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AuthorityNoticeKind {
|
||||||
|
REGISTRATION
|
||||||
|
HERD_CHANGE
|
||||||
|
TSK_REPORT
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AuthorityNoticeStatus {
|
||||||
|
DRAFT
|
||||||
|
SENT
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
model AuthorityNotice {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
kind AuthorityNoticeKind
|
||||||
|
status AuthorityNoticeStatus @default(DRAFT)
|
||||||
|
year Int?
|
||||||
|
referenceDate DateTime
|
||||||
|
countedAt DateTime
|
||||||
|
snapshot Json
|
||||||
|
totalColonies Int
|
||||||
|
sentOn DateTime?
|
||||||
|
sentVia String?
|
||||||
|
reference String?
|
||||||
|
notes String?
|
||||||
|
// Nur bei DRAFT gesetzt ("REGISTRATION", "HERD_CHANGE", "TSK_REPORT:2027"),
|
||||||
|
// sonst null: hoechstens ein offener Entwurf je Art und Jahr. Nullbares
|
||||||
|
// @unique statt partiellem Index, wie dedupeKey im Rueckgrat.
|
||||||
|
openKey String? @unique
|
||||||
|
createdByUserId String?
|
||||||
|
createdBy User? @relation(fields: [createdByUserId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([kind, status])
|
||||||
|
@@index([year])
|
||||||
|
}
|
||||||
|
|
||||||
|
// CHECK "endsOn" IS NULL OR "endsOn" >= "startsOn".
|
||||||
|
model ApiaryRestriction {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
apiaryId String
|
||||||
|
apiary Apiary @relation(fields: [apiaryId], references: [id], onDelete: Restrict)
|
||||||
|
disease String
|
||||||
|
authority String?
|
||||||
|
reference String?
|
||||||
|
startsOn DateTime
|
||||||
|
endsOn DateTime?
|
||||||
|
notes String?
|
||||||
|
createdByUserId String?
|
||||||
|
createdBy User? @relation(fields: [createdByUserId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([apiaryId, startsOn])
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Geprueft, in Ordnung" fuer einen Pruefen-Punkt. Gilt nur, solange der
|
||||||
|
// Fingerabdruck zum aktuellen Befund passt.
|
||||||
|
model ComplianceAcknowledgement {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
itemKey String
|
||||||
|
subjectId String
|
||||||
|
fingerprint String
|
||||||
|
note String
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
revokedAt DateTime?
|
||||||
|
revokedByUserId String?
|
||||||
|
|
||||||
|
@@index([itemKey, subjectId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Beute in 3D: Beutensysteme, Teile-Arten, Aufbau je Volk (Spec 2026-10-15)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
model HiveSystem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String @unique
|
||||||
|
frameWidthMm Int // Oberträger
|
||||||
|
frameHeightMm Int
|
||||||
|
footprintLMm Int
|
||||||
|
footprintWMm Int
|
||||||
|
template Json // Aufbau-JSON ohne Ids, Schema 5.2
|
||||||
|
active Boolean @default(true)
|
||||||
|
partTypes HivePartType[]
|
||||||
|
setups HiveSetup[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model HivePartType {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
systemId String
|
||||||
|
system HiveSystem @relation(fields: [systemId], references: [id])
|
||||||
|
kind HivePartKind
|
||||||
|
name String
|
||||||
|
articleRef String? // z. B. Holtermann-Art.-Nr.
|
||||||
|
lengthMm Int
|
||||||
|
widthMm Int
|
||||||
|
heightMm Int
|
||||||
|
boxDepth HiveBoxDepth?
|
||||||
|
frameSlots Int? // nur BOX
|
||||||
|
slotWidth Int? // nur FRAME_FEEDER: belegte Rähmchen-Plätze
|
||||||
|
stateSchema Json // Abschnitt 4.2
|
||||||
|
active Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([systemId, name])
|
||||||
|
}
|
||||||
|
|
||||||
|
model HiveSetup {
|
||||||
|
colonyId String @id
|
||||||
|
colony Colony @relation(fields: [colonyId], references: [id])
|
||||||
|
systemId String
|
||||||
|
system HiveSystem @relation(fields: [systemId], references: [id])
|
||||||
|
version Int @default(1)
|
||||||
|
state Json // Abschnitt 5.2, immer vollständig
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
changes HiveChange[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model HiveChange {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
colonyId String
|
||||||
|
setup HiveSetup @relation(fields: [colonyId], references: [colonyId])
|
||||||
|
version Int // Version nach der Änderung
|
||||||
|
occurredOn DateTime // fachlicher Tag (Durchsicht oder Eingabe)
|
||||||
|
source HiveChangeSource
|
||||||
|
inspectionId String? @unique
|
||||||
|
inspection Inspection? @relation(fields: [inspectionId], references: [id])
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
ops Json // angewandte Operationen, Abschnitt 6
|
||||||
|
stateAfter Json
|
||||||
|
note String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([colonyId, version])
|
||||||
|
@@index([colonyId, occurredOn])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HivePartKind {
|
||||||
|
FLOOR
|
||||||
|
BOX
|
||||||
|
DIVIDER_BOARD
|
||||||
|
QUEEN_EXCLUDER
|
||||||
|
FEEDER
|
||||||
|
COVER_SHEET
|
||||||
|
PROPOLIS_SCREEN
|
||||||
|
INNER_LID
|
||||||
|
INSULATION
|
||||||
|
ROOF
|
||||||
|
FLOOR_INSERT
|
||||||
|
EVAPORATOR
|
||||||
|
FRAME_FEEDER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HiveBoxDepth {
|
||||||
|
FULL
|
||||||
|
SHALLOW
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HiveChangeSource {
|
||||||
|
INIT
|
||||||
|
CARD
|
||||||
|
EDITOR
|
||||||
|
INSPECTION
|
||||||
|
AI_SUGGESTION
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VoiceCaptureSource {
|
||||||
|
AUDIO
|
||||||
|
TEXT
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VoiceCaptureStatus {
|
||||||
|
QUEUED // audio uploaded, waits for Whisper
|
||||||
|
TRANSCRIBING // claimed by the CronJob
|
||||||
|
TRANSCRIBED // text present, waits for Claude (also source TEXT)
|
||||||
|
EXTRACTING // claimed by the worker
|
||||||
|
READY // suggestions present, waits for the user
|
||||||
|
CONFIRMED // inspection saved, content deleted
|
||||||
|
DISCARDED // discarded by the user, content deleted
|
||||||
|
FAILED // finally failed, code in lastErrorCode
|
||||||
|
EXPIRED // deadline passed, content deleted
|
||||||
|
}
|
||||||
|
|
||||||
|
// A voice note. Audio and text are personal data: content lives here only
|
||||||
|
// encrypted and only until it is taken over, at most 7 days. The row itself
|
||||||
|
// holds no content and stays 90 days for the error and cost overview.
|
||||||
|
model VoiceCapture {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
ownerUserId String
|
||||||
|
owner User @relation(fields: [ownerUserId], references: [id])
|
||||||
|
colonyId String
|
||||||
|
colony Colony @relation(fields: [colonyId], references: [id])
|
||||||
|
inspectionDate DateTime @db.Date // date of the inspection per the form
|
||||||
|
source VoiceCaptureSource
|
||||||
|
status VoiceCaptureStatus @default(QUEUED)
|
||||||
|
|
||||||
|
audioMime String? // audio/webm, audio/ogg, audio/mp4
|
||||||
|
audioBytes Int?
|
||||||
|
durationSec Int? // reported by the device; Whisper measures again
|
||||||
|
|
||||||
|
transcriptEnc String? // key-ring ciphertext
|
||||||
|
suggestionEnc String? // key-ring ciphertext of the checked suggestion
|
||||||
|
|
||||||
|
attempts Int @default(0)
|
||||||
|
availableAt DateTime @default(now())
|
||||||
|
claimedAt DateTime?
|
||||||
|
claimToken String? // guards against completion by a process that lost the claim
|
||||||
|
lastErrorCode String? // fixed code, never content
|
||||||
|
|
||||||
|
model String? // e.g. "claude-sonnet-5-5"
|
||||||
|
inputTokens Int?
|
||||||
|
outputTokens Int?
|
||||||
|
|
||||||
|
inspectionId String? // set when CONFIRMED
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
readyAt DateTime?
|
||||||
|
contentExpiresAt DateTime // createdAt + 7 days
|
||||||
|
contentDeletedAt DateTime? // when audio/text/suggestion were deleted
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
audio VoiceCaptureAudio?
|
||||||
|
|
||||||
|
@@index([status, availableAt])
|
||||||
|
@@index([ownerUserId, status])
|
||||||
|
@@index([contentExpiresAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// The audio lives in its own table: deleting is a DELETE of the row (not
|
||||||
|
// nulling a big column), and the row is never loaded into lists.
|
||||||
|
model VoiceCaptureAudio {
|
||||||
|
captureId String @id
|
||||||
|
capture VoiceCapture @relation(fields: [captureId], references: [id], onDelete: Cascade)
|
||||||
|
dataEnc Bytes // key-ring ciphertext of the audio, at most 2 MB plaintext
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a call to the language model counts as. The daily budget counts every
|
||||||
|
// outcome except AUTH and ABORTED (those never reached the model).
|
||||||
|
enum VoiceExtractionOutcome {
|
||||||
|
OK
|
||||||
|
REFUSED
|
||||||
|
TRUNCATED
|
||||||
|
INVALID
|
||||||
|
RETRY
|
||||||
|
BAD_REQUEST
|
||||||
|
AUTH
|
||||||
|
ABORTED
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ledger of paid model calls for voice extraction: the daily budget and the
|
||||||
|
// cost card read it, not the notes (a retry or a refused call costs money
|
||||||
|
// too). Ids and numbers only - no content, no foreign key (a note may be
|
||||||
|
// deleted while the call stays counted). Rows go after 90 days (cleanup).
|
||||||
|
model VoiceExtractionCall {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
at DateTime @default(now())
|
||||||
|
model String
|
||||||
|
inputTokens Int @default(0)
|
||||||
|
outputTokens Int @default(0)
|
||||||
|
outcome VoiceExtractionOutcome
|
||||||
|
captureId String?
|
||||||
|
|
||||||
|
@@index([at])
|
||||||
|
}
|
||||||
@@ -16,18 +16,28 @@ function generateRandomPassword(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only printed the moment the account is actually created (first deploy on
|
// Only printed the moment the account is actually created (first deploy on
|
||||||
// an empty database), never on later restarts: by then the real admin may
|
// an empty user table), never on later restarts: by then the real admin may
|
||||||
// have changed the password via /profile, so repeating it would just be
|
// have changed the password via /profile, so repeating it would just be
|
||||||
// stale and would leak credentials into logs indefinitely for no reason.
|
// stale and would leak credentials into logs indefinitely for no reason.
|
||||||
function printLoginBanner(email: string, password: string): void {
|
// An operator-supplied password is never printed: the operator already has
|
||||||
|
// it, and the log would only be one more place it leaks to.
|
||||||
|
function printLoginBanner(
|
||||||
|
email: string,
|
||||||
|
password: string | null,
|
||||||
|
log: (line: string) => void,
|
||||||
|
): void {
|
||||||
const url = process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000';
|
const url = process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000';
|
||||||
const line = '='.repeat(60);
|
const line = '='.repeat(60);
|
||||||
console.log(line);
|
log(line);
|
||||||
console.log('Seed: Admin-Zugang eingerichtet.');
|
log('Seed: Admin-Zugang eingerichtet.');
|
||||||
console.log(` URL: ${url}`);
|
log(` URL: ${url}`);
|
||||||
console.log(` Benutzer: ${email}`);
|
log(` Benutzer: ${email}`);
|
||||||
console.log(` Passwort: ${password}`);
|
log(
|
||||||
console.log(line);
|
password === null
|
||||||
|
? ' Passwort: wie in SEED_ADMIN_PASSWORD gesetzt'
|
||||||
|
: ` Passwort: ${password}`,
|
||||||
|
);
|
||||||
|
log(line);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Duplicated from src/task-templates/recurrence.ts rather than imported:
|
// Duplicated from src/task-templates/recurrence.ts rather than imported:
|
||||||
@@ -189,22 +199,26 @@ async function seedDefaultTaskTemplates(prisma: PrismaClient): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
type AdminSeedClient = Pick<PrismaClient, 'user' | 'role'>;
|
||||||
const prisma = new PrismaClient({
|
|
||||||
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
|
|
||||||
});
|
|
||||||
|
|
||||||
await seedDefaultTaskTemplates(prisma);
|
/**
|
||||||
|
* Creates the first administrator, but only while the user table is still
|
||||||
const email = process.env.SEED_ADMIN_EMAIL ?? DEFAULT_ADMIN_EMAIL;
|
* empty. Checking SEED_ADMIN_EMAIL instead would add a second ADMIN (and log
|
||||||
const password = process.env.SEED_ADMIN_PASSWORD ?? generateRandomPassword();
|
* its password) on every start after the real admin changed their address.
|
||||||
const existing = await prisma.user.findUnique({ where: { email } });
|
*/
|
||||||
if (existing) {
|
export async function seedAdmin(
|
||||||
console.log(`Seed: admin user ${email} already exists, skipping.`);
|
prisma: AdminSeedClient,
|
||||||
await prisma.$disconnect();
|
env: NodeJS.ProcessEnv,
|
||||||
return;
|
log: (line: string) => void,
|
||||||
|
): Promise<'created' | 'skipped'> {
|
||||||
|
if ((await prisma.user.count()) > 0) {
|
||||||
|
log('Seed: Es gibt bereits Benutzer, kein Admin angelegt.');
|
||||||
|
return 'skipped';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const email = env.SEED_ADMIN_EMAIL ?? DEFAULT_ADMIN_EMAIL;
|
||||||
|
const suppliedPassword = env.SEED_ADMIN_PASSWORD;
|
||||||
|
const password = suppliedPassword ?? generateRandomPassword();
|
||||||
const passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
|
const passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
|
||||||
const adminRole = await prisma.role.findUniqueOrThrow({
|
const adminRole = await prisma.role.findUniqueOrThrow({
|
||||||
where: { key: 'ADMIN' },
|
where: { key: 'ADMIN' },
|
||||||
@@ -218,27 +232,42 @@ async function main(): Promise<void> {
|
|||||||
roleId: adminRole.id,
|
roleId: adminRole.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
printLoginBanner(email, password);
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// P2002 = unique constraint violation on email: another replica's
|
// P2002 = unique constraint violation on email: another replica's
|
||||||
// startup won this race between the existence check above and this
|
// startup won this race between the count above and this create()
|
||||||
// create() call. The user now exists either way, so this is a no-op,
|
// call. The user now exists either way, so this is a no-op, not a
|
||||||
// not a failure - container startup must not crash on it.
|
// failure - container startup must not crash on it.
|
||||||
if (
|
if (
|
||||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
error.code === 'P2002'
|
error.code === 'P2002'
|
||||||
) {
|
) {
|
||||||
console.log(
|
log(`Seed: admin user ${email} was created concurrently, skipping.`);
|
||||||
`Seed: admin user ${email} was created concurrently, skipping.`,
|
return 'skipped';
|
||||||
);
|
}
|
||||||
} else {
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
printLoginBanner(
|
||||||
|
email,
|
||||||
|
suppliedPassword === undefined ? password : null,
|
||||||
|
log,
|
||||||
|
);
|
||||||
|
return 'created';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const prisma = new PrismaClient({
|
||||||
|
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await seedDefaultTaskTemplates(prisma);
|
||||||
|
await seedAdmin(prisma, process.env, (line) => console.log(line));
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error: unknown) => {
|
// Only when run as `node dist/seed.js`, not when a test imports seedAdmin.
|
||||||
|
if (require.main === module) {
|
||||||
|
main().catch((error: unknown) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* Evaluates voice extraction against the gold set (test/fixtures/voice/gold).
|
||||||
|
*
|
||||||
|
* npm run eval:voice offline, replays the expected answers, no network, free
|
||||||
|
* npm run eval:voice -- --live --yes real Claude calls, costs money
|
||||||
|
* npm run eval:voice -- --live --yes --model=claude-haiku-5-5
|
||||||
|
*
|
||||||
|
* Live mode needs ANTHROPIC_API_KEY in the environment AND both --live and
|
||||||
|
* --yes; it is never the default and never runs in CI (no workflow calls
|
||||||
|
* this script). This is an operator tool, not a request path: it lives
|
||||||
|
* outside src/, so the network guard (src/common/no-network-outside-loops)
|
||||||
|
* does not scan it, and it talks to the adapter only through the
|
||||||
|
* StructuredModel port.
|
||||||
|
*
|
||||||
|
* Exit code: 0 pass, 1 below the thresholds (precision < 98 % or recall <
|
||||||
|
* 85 %, see EVAL_THRESHOLDS), 2 usage or setup error.
|
||||||
|
*/
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { anthropicApiKey } from '../src/voice/anthropic-config';
|
||||||
|
import { formatReport } from '../src/voice/eval/eval-report';
|
||||||
|
import { loadGoldCases } from '../src/voice/eval/gold-case';
|
||||||
|
import { estimateCostUsd } from '../src/voice/eval/voice-prices';
|
||||||
|
import {
|
||||||
|
offlineModelFor,
|
||||||
|
runEval,
|
||||||
|
summarize,
|
||||||
|
} from '../src/voice/eval/voice-eval';
|
||||||
|
import { AnthropicStructuredModel } from '../src/voice/extraction/anthropic-structured-model';
|
||||||
|
import { FAKE_USAGE } from '../src/voice/extraction/fake-structured-model';
|
||||||
|
import { DEFAULT_VOICE_MODEL, VOICE_MODELS } from '../src/voice/voice-config';
|
||||||
|
|
||||||
|
const GOLD_DIR = join(__dirname, '..', 'test', 'fixtures', 'voice', 'gold');
|
||||||
|
const EXIT_PASS = 0;
|
||||||
|
const EXIT_BELOW_THRESHOLD = 1;
|
||||||
|
const EXIT_USAGE = 2;
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
live: boolean;
|
||||||
|
yes: boolean;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(args: readonly string[]): Options | string {
|
||||||
|
let live = false;
|
||||||
|
let yes = false;
|
||||||
|
let model: string = DEFAULT_VOICE_MODEL;
|
||||||
|
for (const arg of args) {
|
||||||
|
if (arg === '--live') {
|
||||||
|
live = true;
|
||||||
|
} else if (arg === '--yes') {
|
||||||
|
yes = true;
|
||||||
|
} else if (arg.startsWith('--model=')) {
|
||||||
|
model = arg.slice('--model='.length);
|
||||||
|
} else {
|
||||||
|
return `Unknown argument: ${arg}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!(VOICE_MODELS as readonly string[]).includes(model)) {
|
||||||
|
return `--model must be one of ${VOICE_MODELS.join(', ')}`;
|
||||||
|
}
|
||||||
|
return { live, yes, model };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message: string): never {
|
||||||
|
console.error(message);
|
||||||
|
process.exit(EXIT_USAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<number> {
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
if (typeof options === 'string') {
|
||||||
|
return fail(options);
|
||||||
|
}
|
||||||
|
const cases = loadGoldCases(GOLD_DIR);
|
||||||
|
if (!options.live) {
|
||||||
|
const results = await runEval(
|
||||||
|
cases,
|
||||||
|
offlineModelFor(options.model),
|
||||||
|
options.model,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
formatReport(results, summarize(results), {
|
||||||
|
mode: 'offline',
|
||||||
|
model: options.model,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return summarizeExit(results);
|
||||||
|
}
|
||||||
|
const key = anthropicApiKey(process.env);
|
||||||
|
if (key === null) {
|
||||||
|
return fail('--live needs a valid ANTHROPIC_API_KEY in the environment.');
|
||||||
|
}
|
||||||
|
const estimate = estimateCostUsd(options.model, {
|
||||||
|
input: FAKE_USAGE.input * cases.length,
|
||||||
|
output: FAKE_USAGE.output * cases.length,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
});
|
||||||
|
console.warn(
|
||||||
|
`WARNING: ${cases.length} real Claude calls on ${options.model}, roughly ${
|
||||||
|
estimate === null ? 'an unknown amount' : `$${estimate.toFixed(2)}`
|
||||||
|
} of API cost.`,
|
||||||
|
);
|
||||||
|
if (!options.yes) {
|
||||||
|
return fail('Add --yes to confirm and run the live evaluation.');
|
||||||
|
}
|
||||||
|
const live = new AnthropicStructuredModel(key);
|
||||||
|
const results = await runEval(cases, () => live, options.model);
|
||||||
|
console.log(
|
||||||
|
formatReport(results, summarize(results), {
|
||||||
|
mode: 'live',
|
||||||
|
model: options.model,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return summarizeExit(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeExit(results: Parameters<typeof summarize>[0]): number {
|
||||||
|
return summarize(results).failures.length === 0
|
||||||
|
? EXIT_PASS
|
||||||
|
: EXIT_BELOW_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
main().then(
|
||||||
|
(code) => process.exit(code),
|
||||||
|
(error: unknown) => {
|
||||||
|
console.error(error instanceof Error ? error.message : 'eval failed');
|
||||||
|
process.exit(EXIT_USAGE);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Writes the Whisper initial prompt to ApiCore-Whisper/vocabulary.txt, so the
|
||||||
|
* Python image uses the same word list as the backend (one source:
|
||||||
|
* src/voice/core/voice-vocabulary.ts). Run `npm run export:voice-vocabulary`
|
||||||
|
* after changing the list; voice-vocabulary-export.spec.ts fails until you do.
|
||||||
|
*/
|
||||||
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { whisperInitialPrompt } from '../src/voice/core/voice-vocabulary';
|
||||||
|
|
||||||
|
const target = join(__dirname, '..', '..', 'ApiCore-Whisper', 'vocabulary.txt');
|
||||||
|
|
||||||
|
mkdirSync(dirname(target), { recursive: true });
|
||||||
|
writeFileSync(target, `${whisperInitialPrompt()}\n`, 'utf8');
|
||||||
|
console.log(`Wrote ${target}`);
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AutopilotModule } from '../autopilot/autopilot.module';
|
||||||
|
import { DomainEventsModule } from '../domain-events/domain-events.module';
|
||||||
|
import { HiveModule } from '../hive/hive.module';
|
||||||
import { ApiariesController } from './apiaries.controller';
|
import { ApiariesController } from './apiaries.controller';
|
||||||
import { ApiariesService } from './apiaries.service';
|
import { ApiariesService } from './apiaries.service';
|
||||||
import { ApiaryFieldService } from './apiary-field.service';
|
import { ApiaryFieldService } from './apiary-field.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [AutopilotModule, DomainEventsModule, HiveModule],
|
||||||
controllers: [ApiariesController],
|
controllers: [ApiariesController],
|
||||||
providers: [ApiariesService, ApiaryFieldService],
|
providers: [ApiariesService, ApiaryFieldService],
|
||||||
exports: [ApiariesService],
|
exports: [ApiariesService],
|
||||||
|
|||||||
@@ -2,44 +2,78 @@ import { ConflictException, NotFoundException } from '@nestjs/common';
|
|||||||
import { ApiariesService } from './apiaries.service';
|
import { ApiariesService } from './apiaries.service';
|
||||||
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 { DomainEventsService } from '../domain-events/domain-events.service';
|
||||||
|
import { expectOnlyIdPayloads } from '../domain-events/herd-payload.spec-helper';
|
||||||
|
|
||||||
describe('ApiariesService', () => {
|
describe('ApiariesService', () => {
|
||||||
let service: ApiariesService;
|
let service: ApiariesService;
|
||||||
let prisma: {
|
let order: string[];
|
||||||
apiary: {
|
let tx: {
|
||||||
findMany: jest.Mock;
|
$queryRaw: jest.Mock;
|
||||||
findUnique: jest.Mock;
|
apiary: { findUnique: jest.Mock; create: jest.Mock; update: jest.Mock };
|
||||||
create: jest.Mock;
|
|
||||||
update: jest.Mock;
|
|
||||||
};
|
|
||||||
colony: { count: jest.Mock };
|
colony: { count: jest.Mock };
|
||||||
};
|
};
|
||||||
|
let prisma: {
|
||||||
|
apiary: { findMany: jest.Mock; findUnique: jest.Mock };
|
||||||
|
$transaction: jest.Mock;
|
||||||
|
};
|
||||||
let auditLog: { record: jest.Mock };
|
let auditLog: { record: jest.Mock };
|
||||||
|
let events: { record: jest.Mock };
|
||||||
|
|
||||||
const existingApiary = {
|
const existingApiary = {
|
||||||
id: 'apiary-1',
|
id: 'apiary-1',
|
||||||
name: 'Heimstand',
|
name: 'Heimstand',
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
deactivatedAt: null,
|
deactivatedAt: null,
|
||||||
|
address: 'Weg 1',
|
||||||
|
location: 'Garten',
|
||||||
|
latitude: 48.1,
|
||||||
|
longitude: 11.5,
|
||||||
|
notes: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const changes = (): string[] =>
|
||||||
|
(
|
||||||
|
events.record.mock.calls as [unknown, { payload: { change: string } }][]
|
||||||
|
).map(([, event]) => event.payload.change);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
prisma = {
|
order = [];
|
||||||
|
tx = {
|
||||||
|
$queryRaw: jest.fn().mockImplementation(() => {
|
||||||
|
order.push('lock');
|
||||||
|
return Promise.resolve([{ id: 'apiary-1' }]);
|
||||||
|
}),
|
||||||
apiary: {
|
apiary: {
|
||||||
findMany: jest.fn(),
|
findUnique: jest.fn().mockImplementation(() => {
|
||||||
findUnique: jest.fn(),
|
order.push('read');
|
||||||
|
return Promise.resolve(existingApiary);
|
||||||
|
}),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
colony: { count: jest.fn().mockResolvedValue(0) },
|
colony: { count: jest.fn().mockResolvedValue(0) },
|
||||||
};
|
};
|
||||||
|
prisma = {
|
||||||
|
apiary: { findMany: jest.fn(), findUnique: jest.fn() },
|
||||||
|
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
|
||||||
|
callback(tx),
|
||||||
|
),
|
||||||
|
};
|
||||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
events = { record: jest.fn().mockResolvedValue(undefined) };
|
||||||
service = new ApiariesService(
|
service = new ApiariesService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
auditLog as unknown as AuditLogService,
|
auditLog as unknown as AuditLogService,
|
||||||
|
events as unknown as DomainEventsService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Whatever a test recorded, the herd payloads carry only ids.
|
||||||
|
afterEach(() => {
|
||||||
|
expectOnlyIdPayloads(events.record.mock.calls);
|
||||||
|
});
|
||||||
|
|
||||||
it('findById throws NotFoundException when missing', async () => {
|
it('findById throws NotFoundException when missing', async () => {
|
||||||
prisma.apiary.findUnique.mockResolvedValue(null);
|
prisma.apiary.findUnique.mockResolvedValue(null);
|
||||||
await expect(service.findById('missing')).rejects.toBeInstanceOf(
|
await expect(service.findById('missing')).rejects.toBeInstanceOf(
|
||||||
@@ -47,89 +81,152 @@ describe('ApiariesService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('create persists the apiary and records an audit entry', async () => {
|
it('create writes record, audit and ApiaryChanged CREATED in one transaction', async () => {
|
||||||
prisma.apiary.create.mockResolvedValue(existingApiary);
|
tx.apiary.create.mockResolvedValue(existingApiary);
|
||||||
|
|
||||||
const result = await service.create({ name: 'Heimstand' }, 'user-1');
|
const result = await service.create({ name: 'Heimstand' }, 'user-1');
|
||||||
|
|
||||||
expect(result).toEqual(existingApiary);
|
expect(result).toEqual(existingApiary);
|
||||||
|
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||||
expect(auditLog.record).toHaveBeenCalledWith(
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
entityType: 'Apiary',
|
entityType: 'Apiary',
|
||||||
action: 'CREATE',
|
action: 'CREATE',
|
||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
}),
|
}),
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
expect(events.record).toHaveBeenCalledWith(tx, {
|
||||||
|
type: 'ApiaryChanged',
|
||||||
|
aggregateType: 'Apiary',
|
||||||
|
aggregateId: 'apiary-1',
|
||||||
|
actorUserId: 'user-1',
|
||||||
|
payload: { change: 'CREATED' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a failing audit write rolls back the apiary change', async () => {
|
||||||
|
tx.apiary.create.mockResolvedValue(existingApiary);
|
||||||
|
auditLog.record.mockRejectedValue(new Error('audit down'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.create({ name: 'Heimstand' }, 'user-1'),
|
||||||
|
).rejects.toThrow('audit down');
|
||||||
|
// Record, audit and event share one transaction callback: its rejection
|
||||||
|
// is what rolls the record back.
|
||||||
|
expect(events.record).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update reads before after locking the Apiary row', async () => {
|
||||||
|
tx.apiary.update.mockResolvedValue(existingApiary);
|
||||||
|
|
||||||
|
await service.update('apiary-1', { notes: 'neu' }, 'user-1');
|
||||||
|
|
||||||
|
expect(order).toEqual(['lock', 'read']);
|
||||||
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: 'UPDATE',
|
||||||
|
beforeData: existingApiary,
|
||||||
|
}),
|
||||||
|
tx,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deactivate sets status to INACTIVE and records an audit entry', async () => {
|
it('update of a missing apiary answers 404', async () => {
|
||||||
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
tx.apiary.findUnique.mockResolvedValue(null);
|
||||||
prisma.apiary.update.mockResolvedValue({
|
await expect(
|
||||||
|
service.update('missing', { notes: 'x' }, 'user-1'),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
expect(tx.apiary.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ address: 'Neuer Weg 2' }],
|
||||||
|
[{ location: 'Wiese' }],
|
||||||
|
[{ latitude: 50 }],
|
||||||
|
[{ longitude: 12 }],
|
||||||
|
])('update records LOCATION_CHANGED for %j', async (dto) => {
|
||||||
|
tx.apiary.update.mockResolvedValue({ ...existingApiary, ...dto });
|
||||||
|
|
||||||
|
await service.update('apiary-1', dto, 'user-1');
|
||||||
|
|
||||||
|
expect(changes()).toEqual(['LOCATION_CHANGED']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update records LOCATION_CHANGED only when address, location, latitude or longitude changed', async () => {
|
||||||
|
tx.apiary.update.mockResolvedValue({ ...existingApiary, notes: 'neu' });
|
||||||
|
await service.update(
|
||||||
|
'apiary-1',
|
||||||
|
{ name: 'Anders', notes: 'neu' },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
// The same value sent again is no change either.
|
||||||
|
tx.apiary.update.mockResolvedValue(existingApiary);
|
||||||
|
await service.update('apiary-1', { address: 'Weg 1' }, 'user-1');
|
||||||
|
|
||||||
|
expect(events.record).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deactivate counts living colonies inside the transaction and records DEACTIVATED', async () => {
|
||||||
|
tx.apiary.update.mockResolvedValue({
|
||||||
...existingApiary,
|
...existingApiary,
|
||||||
status: 'INACTIVE',
|
status: 'INACTIVE',
|
||||||
deactivatedAt: new Date(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.deactivate('apiary-1', 'user-1');
|
const result = await service.deactivate('apiary-1', 'user-1');
|
||||||
|
|
||||||
expect(result.status).toBe('INACTIVE');
|
expect(result.status).toBe('INACTIVE');
|
||||||
expect(auditLog.record).toHaveBeenCalledWith(
|
expect(order).toEqual(['lock', 'read']);
|
||||||
expect.objectContaining({ entityType: 'Apiary', action: 'DEACTIVATE' }),
|
expect(tx.colony.count).toHaveBeenCalledWith({
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deactivate counts every living colony, weak and queenless ones included', async () => {
|
|
||||||
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
|
||||||
prisma.apiary.update.mockResolvedValue(existingApiary);
|
|
||||||
|
|
||||||
await service.deactivate('apiary-1', 'user-1');
|
|
||||||
|
|
||||||
expect(prisma.colony.count).toHaveBeenCalledWith({
|
|
||||||
where: {
|
where: {
|
||||||
apiaryId: 'apiary-1',
|
apiaryId: 'apiary-1',
|
||||||
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
|
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ entityType: 'Apiary', action: 'DEACTIVATE' }),
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
expect(changes()).toEqual(['DEACTIVATED']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deactivate throws ConflictException when active colonies remain', async () => {
|
it('deactivate throws ConflictException when active colonies remain', async () => {
|
||||||
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
tx.colony.count.mockResolvedValue(2);
|
||||||
prisma.colony.count.mockResolvedValue(2);
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.deactivate('apiary-1', 'user-1'),
|
service.deactivate('apiary-1', 'user-1'),
|
||||||
).rejects.toBeInstanceOf(ConflictException);
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
expect(prisma.apiary.update).not.toHaveBeenCalled();
|
expect(tx.apiary.update).not.toHaveBeenCalled();
|
||||||
|
expect(events.record).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reactivate throws ConflictException when already active', async () => {
|
it('reactivate throws ConflictException when already active', async () => {
|
||||||
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
|
||||||
await expect(
|
await expect(
|
||||||
service.reactivate('apiary-1', 'user-1'),
|
service.reactivate('apiary-1', 'user-1'),
|
||||||
).rejects.toBeInstanceOf(ConflictException);
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
expect(events.record).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reactivate sets status to ACTIVE and clears deactivatedAt', async () => {
|
it('reactivate records REACTIVATED and clears deactivatedAt', async () => {
|
||||||
prisma.apiary.findUnique.mockResolvedValue({
|
tx.apiary.findUnique.mockResolvedValue({
|
||||||
...existingApiary,
|
...existingApiary,
|
||||||
status: 'INACTIVE',
|
status: 'INACTIVE',
|
||||||
deactivatedAt: new Date(),
|
deactivatedAt: new Date(),
|
||||||
});
|
});
|
||||||
prisma.apiary.update.mockResolvedValue({
|
tx.apiary.update.mockResolvedValue(existingApiary);
|
||||||
...existingApiary,
|
|
||||||
status: 'ACTIVE',
|
|
||||||
deactivatedAt: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.reactivate('apiary-1', 'user-1');
|
const result = await service.reactivate('apiary-1', 'user-1');
|
||||||
|
|
||||||
expect(result.status).toBe('ACTIVE');
|
expect(result.status).toBe('ACTIVE');
|
||||||
expect(prisma.apiary.update).toHaveBeenCalledWith({
|
expect(tx.apiary.update).toHaveBeenCalledWith({
|
||||||
where: { id: 'apiary-1' },
|
where: { id: 'apiary-1' },
|
||||||
data: { status: 'ACTIVE', deactivatedAt: null },
|
data: { status: 'ACTIVE', deactivatedAt: null },
|
||||||
});
|
});
|
||||||
expect(auditLog.record).toHaveBeenCalledWith(
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ entityType: 'Apiary', action: 'UPDATE' }),
|
expect.objectContaining({ entityType: 'Apiary', action: 'UPDATE' }),
|
||||||
|
tx,
|
||||||
);
|
);
|
||||||
|
expect(changes()).toEqual(['REACTIVATED']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -5,8 +5,11 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
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 { DomainEventsService } from '../domain-events/domain-events.service';
|
||||||
|
import type { DomainEventPayloads } from '../domain-events/domain-event-types';
|
||||||
|
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||||
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
||||||
import { Apiary, ApiaryStatus } from '../../generated/prisma';
|
import { Apiary, ApiaryStatus, Prisma } from '../../generated/prisma';
|
||||||
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';
|
||||||
|
|
||||||
@@ -15,11 +18,22 @@ export interface ApiaryFilter {
|
|||||||
location?: string;
|
location?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ApiaryChange = DomainEventPayloads['ApiaryChanged']['change'];
|
||||||
|
|
||||||
|
// The fields a change of which counts as a new location of the apiary.
|
||||||
|
const LOCATION_FIELDS = [
|
||||||
|
'address',
|
||||||
|
'location',
|
||||||
|
'latitude',
|
||||||
|
'longitude',
|
||||||
|
] as const;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApiariesService {
|
export class ApiariesService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
|
private readonly events: DomainEventsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(filter: ApiaryFilter = {}): Promise<Apiary[]> {
|
async findAll(filter: ApiaryFilter = {}): Promise<Apiary[]> {
|
||||||
@@ -43,15 +57,21 @@ export class ApiariesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateApiaryDto, actingUserId: string): Promise<Apiary> {
|
async create(dto: CreateApiaryDto, actingUserId: string): Promise<Apiary> {
|
||||||
const apiary = await this.prisma.apiary.create({ data: { ...dto } });
|
return this.prisma.$transaction(async (tx) => {
|
||||||
await this.auditLog.record({
|
const apiary = await tx.apiary.create({ data: { ...dto } });
|
||||||
|
await this.auditLog.record(
|
||||||
|
{
|
||||||
entityType: 'Apiary',
|
entityType: 'Apiary',
|
||||||
entityId: apiary.id,
|
entityId: apiary.id,
|
||||||
action: 'CREATE',
|
action: 'CREATE',
|
||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
afterData: apiary,
|
afterData: apiary,
|
||||||
});
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
await this.recordChange(tx, apiary.id, 'CREATED', actingUserId);
|
||||||
return apiary;
|
return apiary;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(
|
async update(
|
||||||
@@ -59,30 +79,41 @@ export class ApiariesService {
|
|||||||
dto: UpdateApiaryDto,
|
dto: UpdateApiaryDto,
|
||||||
actingUserId: string,
|
actingUserId: string,
|
||||||
): Promise<Apiary> {
|
): Promise<Apiary> {
|
||||||
const before = await this.findById(id);
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const apiary = await this.prisma.apiary.update({
|
const before = await this.lockApiary(tx, id);
|
||||||
|
const apiary = await tx.apiary.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { ...dto },
|
data: { ...dto },
|
||||||
});
|
});
|
||||||
await this.auditLog.record({
|
await this.auditLog.record(
|
||||||
|
{
|
||||||
entityType: 'Apiary',
|
entityType: 'Apiary',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
action: 'UPDATE',
|
action: 'UPDATE',
|
||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
beforeData: before,
|
beforeData: before,
|
||||||
afterData: apiary,
|
afterData: apiary,
|
||||||
});
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
if (LOCATION_FIELDS.some((field) => before[field] !== apiary[field])) {
|
||||||
|
await this.recordChange(tx, id, 'LOCATION_CHANGED', actingUserId);
|
||||||
|
}
|
||||||
return apiary;
|
return apiary;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async deactivate(id: string, actingUserId: string): Promise<Apiary> {
|
async deactivate(id: string, actingUserId: string): Promise<Apiary> {
|
||||||
const before = await this.findById(id);
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const before = await this.lockApiary(tx, id);
|
||||||
// Colony.apiaryId is a plain FK, not enforced against apiary status -
|
// Colony.apiaryId is a plain FK, not enforced against apiary status -
|
||||||
// without this guard a colony can silently keep pointing at an
|
// without this guard a colony can silently keep pointing at an
|
||||||
// inactive apiary (still shows ACTIVE, still countable, but its
|
// inactive apiary (still shows ACTIVE, still countable, but its
|
||||||
// "location" has vanished from the active apiary list). Move the
|
// "location" has vanished from the active apiary list). Move the
|
||||||
// colonies elsewhere or dissolve them first instead.
|
// colonies elsewhere or dissolve them first instead. Counted under the
|
||||||
const activeColonyCount = await this.prisma.colony.count({
|
// apiary lock: the colony paths hold the apiary FOR SHARE, so a move
|
||||||
|
// into it that is still running finishes first.
|
||||||
|
const activeColonyCount = await tx.colony.count({
|
||||||
where: { apiaryId: id, ...LIVING_COLONY_WHERE },
|
where: { apiaryId: id, ...LIVING_COLONY_WHERE },
|
||||||
});
|
});
|
||||||
if (activeColonyCount > 0) {
|
if (activeColonyCount > 0) {
|
||||||
@@ -90,38 +121,78 @@ export class ApiariesService {
|
|||||||
`Der Bienenstand hat noch ${activeColonyCount} aktive(s) Volk/Völker - zuerst verlegen oder auflösen`,
|
`Der Bienenstand hat noch ${activeColonyCount} aktive(s) Volk/Völker - zuerst verlegen oder auflösen`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const apiary = await this.prisma.apiary.update({
|
const apiary = await tx.apiary.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: 'INACTIVE', deactivatedAt: new Date() },
|
data: { status: 'INACTIVE', deactivatedAt: new Date() },
|
||||||
});
|
});
|
||||||
await this.auditLog.record({
|
await this.auditLog.record(
|
||||||
|
{
|
||||||
entityType: 'Apiary',
|
entityType: 'Apiary',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
action: 'DEACTIVATE',
|
action: 'DEACTIVATE',
|
||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
beforeData: before,
|
beforeData: before,
|
||||||
afterData: apiary,
|
afterData: apiary,
|
||||||
});
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
await this.recordChange(tx, id, 'DEACTIVATED', actingUserId);
|
||||||
return apiary;
|
return apiary;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async reactivate(id: string, actingUserId: string): Promise<Apiary> {
|
async reactivate(id: string, actingUserId: string): Promise<Apiary> {
|
||||||
const before = await this.findById(id);
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const before = await this.lockApiary(tx, id);
|
||||||
if (before.status === 'ACTIVE') {
|
if (before.status === 'ACTIVE') {
|
||||||
throw new ConflictException('Apiary is already active');
|
throw new ConflictException('Apiary is already active');
|
||||||
}
|
}
|
||||||
const apiary = await this.prisma.apiary.update({
|
const apiary = await tx.apiary.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: 'ACTIVE', deactivatedAt: null },
|
data: { status: 'ACTIVE', deactivatedAt: null },
|
||||||
});
|
});
|
||||||
await this.auditLog.record({
|
await this.auditLog.record(
|
||||||
|
{
|
||||||
entityType: 'Apiary',
|
entityType: 'Apiary',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
action: 'UPDATE',
|
action: 'UPDATE',
|
||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
beforeData: before,
|
beforeData: before,
|
||||||
afterData: apiary,
|
afterData: apiary,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
await this.recordChange(tx, id, 'REACTIVATED', actingUserId);
|
||||||
|
return apiary;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locks the apiary row and reads it afterwards: the result - not an earlier
|
||||||
|
// read - is "before" for the comparison, the audit and the event.
|
||||||
|
private async lockApiary(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
id: string,
|
||||||
|
): Promise<Apiary> {
|
||||||
|
await lockRowForUpdate(tx, 'Apiary', id);
|
||||||
|
const apiary = await tx.apiary.findUnique({ where: { id } });
|
||||||
|
if (!apiary) {
|
||||||
|
throw new NotFoundException('Apiary not found');
|
||||||
|
}
|
||||||
return apiary;
|
return apiary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private recordChange(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
apiaryId: string,
|
||||||
|
change: ApiaryChange,
|
||||||
|
actorUserId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.events.record(tx, {
|
||||||
|
type: 'ApiaryChanged',
|
||||||
|
aggregateType: 'Apiary',
|
||||||
|
aggregateId: apiaryId,
|
||||||
|
actorUserId,
|
||||||
|
payload: { change },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,10 @@ import { NotFoundException } from '@nestjs/common';
|
|||||||
import { ApiaryFieldService } from './apiary-field.service';
|
import { ApiaryFieldService } from './apiary-field.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
|
import type { ColonyNextSteps } from '../autopilot/next-step-ranking';
|
||||||
|
import { NextStepsService } from '../autopilot/next-steps.service';
|
||||||
|
import { HiveService } from '../hive/hive.service';
|
||||||
|
import { HiveCatalogService } from '../hive/hive-catalog.service';
|
||||||
|
|
||||||
describe('ApiaryFieldService', () => {
|
describe('ApiaryFieldService', () => {
|
||||||
let service: ApiaryFieldService;
|
let service: ApiaryFieldService;
|
||||||
@@ -9,11 +13,28 @@ describe('ApiaryFieldService', () => {
|
|||||||
apiary: { findUnique: jest.Mock };
|
apiary: { findUnique: jest.Mock };
|
||||||
colony: { findMany: jest.Mock };
|
colony: { findMany: jest.Mock };
|
||||||
inspection: { groupBy: jest.Mock; findMany: jest.Mock };
|
inspection: { groupBy: jest.Mock; findMany: jest.Mock };
|
||||||
notification: { groupBy: jest.Mock };
|
$queryRaw: jest.Mock;
|
||||||
queen: { findMany: jest.Mock };
|
queen: { findMany: jest.Mock };
|
||||||
task: { findMany: jest.Mock };
|
task: { findMany: jest.Mock };
|
||||||
};
|
};
|
||||||
let permissionResolver: { getPermissions: jest.Mock };
|
let permissionResolver: { getPermissions: jest.Mock };
|
||||||
|
let nextSteps: { forColonies: jest.Mock; warningCounts: jest.Mock };
|
||||||
|
let hives: { getMany: jest.Mock };
|
||||||
|
let hiveCatalogs: { listSystems: jest.Mock };
|
||||||
|
|
||||||
|
const stepsOf = (
|
||||||
|
colonyId: string,
|
||||||
|
overrides: Partial<ColonyNextSteps> = {},
|
||||||
|
): ColonyNextSteps => ({
|
||||||
|
colonyId,
|
||||||
|
segment: 'SCHWARMZEIT',
|
||||||
|
phase: null,
|
||||||
|
phaseSource: null,
|
||||||
|
inspectionDueOn: null,
|
||||||
|
withdrawalUntil: null,
|
||||||
|
steps: [],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
const colonyRow = (id: string, code: string, queenId: string | null) => ({
|
const colonyRow = (id: string, code: string, queenId: string | null) => ({
|
||||||
id,
|
id,
|
||||||
@@ -23,6 +44,7 @@ describe('ApiaryFieldService', () => {
|
|||||||
colonyType: 'PRODUCTION_COLONY',
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
hiveType: null,
|
hiveType: null,
|
||||||
strength: 5,
|
strength: 5,
|
||||||
|
occupiedFrames: 12,
|
||||||
broodStatus: null,
|
broodStatus: null,
|
||||||
feedStatus: null,
|
feedStatus: null,
|
||||||
healthStatus: null,
|
healthStatus: null,
|
||||||
@@ -55,14 +77,28 @@ describe('ApiaryFieldService', () => {
|
|||||||
groupBy: jest.fn().mockResolvedValue([]),
|
groupBy: jest.fn().mockResolvedValue([]),
|
||||||
findMany: jest.fn().mockResolvedValue([]),
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
},
|
},
|
||||||
notification: { groupBy: jest.fn().mockResolvedValue([]) },
|
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||||
queen: { findMany: jest.fn().mockResolvedValue([]) },
|
queen: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
task: { findMany: jest.fn().mockResolvedValue([]) },
|
task: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
};
|
};
|
||||||
permissionResolver = { getPermissions: jest.fn() };
|
permissionResolver = { getPermissions: jest.fn() };
|
||||||
|
nextSteps = {
|
||||||
|
forColonies: jest.fn().mockResolvedValue(
|
||||||
|
new Map([
|
||||||
|
['c10', stepsOf('c10')],
|
||||||
|
['c2', stepsOf('c2')],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
warningCounts: jest.fn().mockResolvedValue(new Map()),
|
||||||
|
};
|
||||||
|
hives = { getMany: jest.fn().mockResolvedValue({}) };
|
||||||
|
hiveCatalogs = { listSystems: jest.fn().mockResolvedValue([]) };
|
||||||
service = new ApiaryFieldService(
|
service = new ApiaryFieldService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
permissionResolver as unknown as PermissionResolverService,
|
permissionResolver as unknown as PermissionResolverService,
|
||||||
|
nextSteps as unknown as NextStepsService,
|
||||||
|
hives as unknown as HiveService,
|
||||||
|
hiveCatalogs as unknown as HiveCatalogService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,6 +137,40 @@ describe('ApiaryFieldService', () => {
|
|||||||
expect(prisma.task.findMany).not.toHaveBeenCalled();
|
expect(prisma.task.findMany).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('carries the hives of the apiary colonies and the hive catalog', async () => {
|
||||||
|
grant('colonies:read');
|
||||||
|
const view = { colonyId: 'c2', systemId: 's1', version: 3 };
|
||||||
|
hives.getMany.mockResolvedValue({ c2: view });
|
||||||
|
const systems = [{ id: 's1', name: 'Zander', active: true, partTypes: [] }];
|
||||||
|
hiveCatalogs.listSystems.mockResolvedValue(systems);
|
||||||
|
|
||||||
|
const pack = await service.offlinePack('apiary-1', 'u1');
|
||||||
|
|
||||||
|
expect(hives.getMany).toHaveBeenCalledWith(['c2', 'c10']);
|
||||||
|
expect(pack.hives).toEqual({ c2: view });
|
||||||
|
expect(pack.hiveCatalog).toEqual(systems);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limits the hive catalog to active systems and those the hives use', async () => {
|
||||||
|
grant('colonies:read');
|
||||||
|
hives.getMany.mockResolvedValue({ c2: { colonyId: 'c2', systemId: 's2' } });
|
||||||
|
hiveCatalogs.listSystems.mockResolvedValue([
|
||||||
|
{ id: 's1', active: true },
|
||||||
|
{ id: 's2', active: false },
|
||||||
|
{ id: 's3', active: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pack = await service.offlinePack('apiary-1', 'u1');
|
||||||
|
|
||||||
|
expect(pack.hiveCatalog?.map((system) => system.id)).toEqual(['s1', 's2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps colonies without a hive setup out of the hives', async () => {
|
||||||
|
grant('colonies:read');
|
||||||
|
const pack = await service.offlinePack('apiary-1', 'u1');
|
||||||
|
expect(pack.hives).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
it('fills the permitted sections and the last inspection date', async () => {
|
it('fills the permitted sections and the last inspection date', async () => {
|
||||||
grant('colonies:read', 'inspections:read', 'queens:read', 'tasks:read');
|
grant('colonies:read', 'inspections:read', 'queens:read', 'tasks:read');
|
||||||
const date = new Date('2026-05-01T10:00:00.000Z');
|
const date = new Date('2026-05-01T10:00:00.000Z');
|
||||||
@@ -172,26 +242,18 @@ describe('ApiaryFieldService', () => {
|
|||||||
expect(inspectionArgs.omit).toEqual({ userId: true });
|
expect(inspectionArgs.omit).toEqual({ userId: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('counts only unread WARNUNG and KRITISCH colony notifications visible to the caller', async () => {
|
it('takes the warning counts from the open suggestions the caller may see', async () => {
|
||||||
grant('colonies:read');
|
grant('colonies:read', 'tasks:read');
|
||||||
prisma.notification.groupBy.mockResolvedValue([
|
nextSteps.warningCounts.mockResolvedValue(
|
||||||
{ relatedId: 'c2', priority: 'WARNUNG', _count: { _all: 2 } },
|
new Map([['c2', { warnings: 2, critical: 1 }]]),
|
||||||
{ relatedId: 'c2', priority: 'KRITISCH', _count: { _all: 1 } },
|
);
|
||||||
]);
|
|
||||||
|
|
||||||
const overview = await service.colonyOverview('apiary-1', 'u1');
|
const overview = await service.colonyOverview('apiary-1', 'u1');
|
||||||
|
|
||||||
expect(prisma.notification.groupBy).toHaveBeenCalledWith(
|
expect(nextSteps.warningCounts).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
['c2', 'c10'],
|
||||||
by: ['relatedId', 'priority'],
|
'u1',
|
||||||
where: {
|
new Set(['colonies:read', 'tasks:read']),
|
||||||
relatedType: 'COLONY',
|
|
||||||
relatedId: { in: ['c2', 'c10'] },
|
|
||||||
read: false,
|
|
||||||
priority: { in: ['WARNUNG', 'KRITISCH'] },
|
|
||||||
OR: [{ userId: 'u1' }, { userId: null }],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
expect(overview.rows[0]).toMatchObject({
|
expect(overview.rows[0]).toMatchObject({
|
||||||
code: 'V-2',
|
code: 'V-2',
|
||||||
@@ -202,5 +264,69 @@ describe('ApiaryFieldService', () => {
|
|||||||
openWarnings: 0,
|
openWarnings: 0,
|
||||||
openCritical: 0,
|
openCritical: 0,
|
||||||
});
|
});
|
||||||
|
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries inspectionDueOn, segment and the title of the first next step', async () => {
|
||||||
|
grant('colonies:read', 'tasks:read');
|
||||||
|
nextSteps.forColonies.mockResolvedValue(
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
'c2',
|
||||||
|
stepsOf('c2', {
|
||||||
|
segment: 'SOMMER',
|
||||||
|
inspectionDueOn: '2026-05-10',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
key: 'suggestion:s1',
|
||||||
|
kind: 'SUGGESTION',
|
||||||
|
intent: 'INSPECT',
|
||||||
|
title: 'Durchsicht mit Schwarmkontrolle',
|
||||||
|
reasons: [],
|
||||||
|
suggestionIds: ['s1'],
|
||||||
|
dueOn: '2026-05-10',
|
||||||
|
urgency: 'OVERDUE',
|
||||||
|
action: { type: 'ACCEPT', suggestionId: 's1' },
|
||||||
|
dismissible: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
['c10', stepsOf('c10', { segment: 'WINTERRUHE' })],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const overview = await service.colonyOverview('apiary-1', 'u1');
|
||||||
|
|
||||||
|
expect(overview.rows[0]).toMatchObject({
|
||||||
|
inspectionDueOn: '2026-05-10',
|
||||||
|
segment: 'SOMMER',
|
||||||
|
nextStepTitle: 'Durchsicht mit Schwarmkontrolle',
|
||||||
|
});
|
||||||
|
expect(overview.rows[1]).toMatchObject({
|
||||||
|
inspectionDueOn: null,
|
||||||
|
segment: 'WINTERRUHE',
|
||||||
|
nextStepTitle: null,
|
||||||
|
});
|
||||||
|
expect(nextSteps.forColonies).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the segment null when the colony has no season state', async () => {
|
||||||
|
grant('colonies:read');
|
||||||
|
nextSteps.forColonies.mockResolvedValue(new Map());
|
||||||
|
|
||||||
|
const overview = await service.colonyOverview('apiary-1', 'u1');
|
||||||
|
|
||||||
|
expect(overview.rows.every((row) => row.segment === null)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds the next steps per colony to the offline pack', async () => {
|
||||||
|
grant('colonies:read');
|
||||||
|
|
||||||
|
const pack = await service.offlinePack('apiary-1', 'u1');
|
||||||
|
|
||||||
|
expect(pack.version).toBe(1);
|
||||||
|
expect(pack.nextSteps).toEqual({ c2: [], c10: [] });
|
||||||
|
expect(nextSteps.forColonies).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
|
import type { ColonyNextSteps } from '../autopilot/next-step-ranking';
|
||||||
|
import { NextStepsService } from '../autopilot/next-steps.service';
|
||||||
|
import { HiveCatalogService } from '../hive/hive-catalog.service';
|
||||||
|
import { HiveService } from '../hive/hive.service';
|
||||||
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import type {
|
import type {
|
||||||
@@ -10,6 +14,15 @@ import type {
|
|||||||
|
|
||||||
const INSPECTIONS_PER_COLONY = 3;
|
const INSPECTIONS_PER_COLONY = 3;
|
||||||
|
|
||||||
|
/** Active systems plus the ones the pack's hives use (a hidden system still builds an old hive). */
|
||||||
|
function activeOrUsed<S extends { id: string; active: boolean }>(
|
||||||
|
systems: S[],
|
||||||
|
hives: Record<string, { systemId: string }>,
|
||||||
|
): S[] {
|
||||||
|
const used = new Set(Object.values(hives).map((hive) => hive.systemId));
|
||||||
|
return systems.filter((system) => system.active || used.has(system.id));
|
||||||
|
}
|
||||||
|
|
||||||
type PackColony = OfflinePackV1['colonies'][number];
|
type PackColony = OfflinePackV1['colonies'][number];
|
||||||
|
|
||||||
interface FieldData {
|
interface FieldData {
|
||||||
@@ -17,6 +30,7 @@ interface FieldData {
|
|||||||
colonies: PackColony[];
|
colonies: PackColony[];
|
||||||
permissions: ReadonlySet<string>;
|
permissions: ReadonlySet<string>;
|
||||||
overview: ColonyOverview;
|
overview: ColonyOverview;
|
||||||
|
nextSteps: ReadonlyMap<string, ColonyNextSteps>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,6 +43,9 @@ export class ApiaryFieldService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly permissionResolver: PermissionResolverService,
|
private readonly permissionResolver: PermissionResolverService,
|
||||||
|
private readonly nextSteps: NextStepsService,
|
||||||
|
private readonly hives: HiveService,
|
||||||
|
private readonly hiveCatalogs: HiveCatalogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async colonyOverview(
|
async colonyOverview(
|
||||||
@@ -39,16 +56,16 @@ export class ApiaryFieldService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async offlinePack(apiaryId: string, userId: string): Promise<OfflinePackV1> {
|
async offlinePack(apiaryId: string, userId: string): Promise<OfflinePackV1> {
|
||||||
const { apiary, colonies, permissions, overview } = await this.load(
|
const { apiary, colonies, permissions, overview, nextSteps } =
|
||||||
apiaryId,
|
await this.load(apiaryId, userId);
|
||||||
userId,
|
const [queens, inspections, tasks, hives, hiveCatalog] = await Promise.all([
|
||||||
);
|
|
||||||
const [queens, inspections, tasks] = await Promise.all([
|
|
||||||
permissions.has('queens:read') ? this.loadQueens(colonies) : null,
|
permissions.has('queens:read') ? this.loadQueens(colonies) : null,
|
||||||
permissions.has('inspections:read')
|
permissions.has('inspections:read')
|
||||||
? this.loadInspections(colonies)
|
? this.loadInspections(colonies)
|
||||||
: null,
|
: null,
|
||||||
permissions.has('tasks:read') ? this.loadTasks(apiaryId, colonies) : null,
|
permissions.has('tasks:read') ? this.loadTasks(apiaryId, colonies) : null,
|
||||||
|
this.hives.getMany(colonies.map((colony) => colony.id)),
|
||||||
|
this.hiveCatalogs.listSystems(),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -59,6 +76,14 @@ export class ApiaryFieldService {
|
|||||||
queens,
|
queens,
|
||||||
inspections,
|
inspections,
|
||||||
tasks,
|
tasks,
|
||||||
|
hives,
|
||||||
|
hiveCatalog: activeOrUsed(hiveCatalog, hives),
|
||||||
|
nextSteps: Object.fromEntries(
|
||||||
|
colonies.map((colony) => [
|
||||||
|
colony.id,
|
||||||
|
nextSteps.get(colony.id)?.steps ?? [],
|
||||||
|
]),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +108,7 @@ export class ApiaryFieldService {
|
|||||||
colonyType: true,
|
colonyType: true,
|
||||||
hiveType: true,
|
hiveType: true,
|
||||||
strength: true,
|
strength: true,
|
||||||
|
occupiedFrames: true,
|
||||||
broodStatus: true,
|
broodStatus: true,
|
||||||
feedStatus: true,
|
feedStatus: true,
|
||||||
healthStatus: true,
|
healthStatus: true,
|
||||||
@@ -94,27 +120,34 @@ export class ApiaryFieldService {
|
|||||||
a.code.localeCompare(b.code, 'de', { numeric: true }),
|
a.code.localeCompare(b.code, 'de', { numeric: true }),
|
||||||
);
|
);
|
||||||
const inspectionsVisible = permissions.has('inspections:read');
|
const inspectionsVisible = permissions.has('inspections:read');
|
||||||
const [lastInspections, warnings] = await Promise.all([
|
const colonyIds = colonies.map((colony) => colony.id);
|
||||||
|
const [lastInspections, warnings, nextSteps] = await Promise.all([
|
||||||
inspectionsVisible ? this.lastInspectionDates(colonies) : null,
|
inspectionsVisible ? this.lastInspectionDates(colonies) : null,
|
||||||
this.warningCounts(colonies, userId),
|
this.nextSteps.warningCounts(colonyIds, userId, permissions),
|
||||||
|
this.nextSteps.forColonies(colonyIds, userId, undefined, permissions),
|
||||||
]);
|
]);
|
||||||
const rows = colonies.map((colony): ColonyOverviewRow => ({
|
const rows = colonies.map((colony): ColonyOverviewRow => ({
|
||||||
id: colony.id,
|
id: colony.id,
|
||||||
code: colony.code,
|
code: colony.code,
|
||||||
status: colony.status,
|
status: colony.status,
|
||||||
strength: colony.strength,
|
strength: colony.strength,
|
||||||
|
occupiedFrames: colony.occupiedFrames,
|
||||||
broodStatus: colony.broodStatus,
|
broodStatus: colony.broodStatus,
|
||||||
feedStatus: colony.feedStatus,
|
feedStatus: colony.feedStatus,
|
||||||
healthStatus: colony.healthStatus,
|
healthStatus: colony.healthStatus,
|
||||||
lastInspectionDate: lastInspections?.get(colony.id) ?? null,
|
lastInspectionDate: lastInspections?.get(colony.id) ?? null,
|
||||||
openWarnings: warnings.get(colony.id)?.WARNUNG ?? 0,
|
inspectionDueOn: nextSteps.get(colony.id)?.inspectionDueOn ?? null,
|
||||||
openCritical: warnings.get(colony.id)?.KRITISCH ?? 0,
|
segment: nextSteps.get(colony.id)?.segment ?? null,
|
||||||
|
nextStepTitle: nextSteps.get(colony.id)?.steps.at(0)?.title ?? null,
|
||||||
|
openWarnings: warnings.get(colony.id)?.warnings ?? 0,
|
||||||
|
openCritical: warnings.get(colony.id)?.critical ?? 0,
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
apiary,
|
apiary,
|
||||||
colonies,
|
colonies,
|
||||||
permissions,
|
permissions,
|
||||||
overview: { apiaryId, inspectionsVisible, rows },
|
overview: { apiaryId, inspectionsVisible, rows },
|
||||||
|
nextSteps,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,36 +168,6 @@ export class ApiaryFieldService {
|
|||||||
return dates;
|
return dates;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async warningCounts(
|
|
||||||
colonies: PackColony[],
|
|
||||||
userId: string,
|
|
||||||
): Promise<Map<string, { WARNUNG?: number; KRITISCH?: number }>> {
|
|
||||||
const groups = await this.prisma.notification.groupBy({
|
|
||||||
by: ['relatedId', 'priority'],
|
|
||||||
where: {
|
|
||||||
relatedType: 'COLONY',
|
|
||||||
relatedId: { in: colonies.map((colony) => colony.id) },
|
|
||||||
read: false,
|
|
||||||
priority: { in: ['WARNUNG', 'KRITISCH'] },
|
|
||||||
OR: [{ userId }, { userId: null }],
|
|
||||||
},
|
|
||||||
_count: { _all: true },
|
|
||||||
});
|
|
||||||
const counts = new Map<string, { WARNUNG?: number; KRITISCH?: number }>();
|
|
||||||
for (const group of groups) {
|
|
||||||
if (
|
|
||||||
group.relatedId &&
|
|
||||||
(group.priority === 'WARNUNG' || group.priority === 'KRITISCH')
|
|
||||||
) {
|
|
||||||
counts.set(group.relatedId, {
|
|
||||||
...counts.get(group.relatedId),
|
|
||||||
[group.priority]: group._count._all,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return counts;
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadQueens(colonies: PackColony[]): Promise<OfflinePackV1['queens']> {
|
private loadQueens(colonies: PackColony[]): Promise<OfflinePackV1['queens']> {
|
||||||
const queenIds = colonies.flatMap((colony) =>
|
const queenIds = colonies.flatMap((colony) =>
|
||||||
colony.currentQueenId ? [colony.currentQueenId] : [],
|
colony.currentQueenId ? [colony.currentQueenId] : [],
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import type {
|
|||||||
Queen,
|
Queen,
|
||||||
Task,
|
Task,
|
||||||
} from '../../generated/prisma';
|
} from '../../generated/prisma';
|
||||||
|
import type { HiveSystemDto } from '../hive/hive-catalog.service';
|
||||||
|
import type { HiveSetupView } from '../hive/hive.service';
|
||||||
|
import type { NextStep } from '../autopilot/next-step-ranking';
|
||||||
|
import type { SeasonSegmentKey } from '../season/season-types';
|
||||||
|
|
||||||
/** One living colony on the apiary page, reduced to what the traffic light needs. */
|
/** One living colony on the apiary page, reduced to what the traffic light needs. */
|
||||||
export interface ColonyOverviewRow {
|
export interface ColonyOverviewRow {
|
||||||
@@ -14,14 +18,24 @@ export interface ColonyOverviewRow {
|
|||||||
code: string;
|
code: string;
|
||||||
status: ColonyStatus;
|
status: ColonyStatus;
|
||||||
strength: number | null;
|
strength: number | null;
|
||||||
|
occupiedFrames: number | null;
|
||||||
broodStatus: ColonyCondition | null;
|
broodStatus: ColonyCondition | null;
|
||||||
feedStatus: ColonyCondition | null;
|
feedStatus: ColonyCondition | null;
|
||||||
healthStatus: ColonyCondition | null;
|
healthStatus: ColonyCondition | null;
|
||||||
/** ISO timestamp; always null when the caller may not read inspections. */
|
/** ISO timestamp; always null when the caller may not read inspections. */
|
||||||
lastInspectionDate: string | null;
|
lastInspectionDate: string | null;
|
||||||
/** Unread WARNUNG notifications visible to the caller. */
|
/**
|
||||||
|
* The day the next inspection is due (YYYY-MM-DD); null in the winter
|
||||||
|
* rest, when the interval rule is off and without `inspections:read`.
|
||||||
|
*/
|
||||||
|
inspectionDueOn: string | null;
|
||||||
|
/** The season segment of the apiary today; null when it is unknown. */
|
||||||
|
segment: SeasonSegmentKey | null;
|
||||||
|
/** Title of the first next step; null without one or without `tasks:read`. */
|
||||||
|
nextStepTitle: string | null;
|
||||||
|
/** Open suggestions of priority HIGH the caller may see. */
|
||||||
openWarnings: number;
|
openWarnings: number;
|
||||||
/** Unread KRITISCH notifications visible to the caller. */
|
/** Open suggestions of priority URGENT the caller may see. */
|
||||||
openCritical: number;
|
openCritical: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +70,7 @@ export interface OfflinePackV1 {
|
|||||||
| 'colonyType'
|
| 'colonyType'
|
||||||
| 'hiveType'
|
| 'hiveType'
|
||||||
| 'strength'
|
| 'strength'
|
||||||
|
| 'occupiedFrames'
|
||||||
| 'broodStatus'
|
| 'broodStatus'
|
||||||
| 'feedStatus'
|
| 'feedStatus'
|
||||||
| 'healthStatus'
|
| 'healthStatus'
|
||||||
@@ -88,4 +103,16 @@ export interface OfflinePackV1 {
|
|||||||
| 'relatedId'
|
| 'relatedId'
|
||||||
>[]
|
>[]
|
||||||
| null;
|
| null;
|
||||||
|
/**
|
||||||
|
* The next steps per colony id (what `GET /colonies/:id/next-steps`
|
||||||
|
* returns as `steps`). Absent in packs cached before T4.
|
||||||
|
*/
|
||||||
|
nextSteps?: Record<string, NextStep[]>;
|
||||||
|
/**
|
||||||
|
* The hive setup per colony id, only for colonies of this apiary that have
|
||||||
|
* one. Absent in packs cached before the hive card.
|
||||||
|
*/
|
||||||
|
hives?: Record<string, HiveSetupView>;
|
||||||
|
/** The hive systems with their part types. Absent in older packs. */
|
||||||
|
hiveCatalog?: HiveSystemDto[];
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import {
|
||||||
|
describeNullContracts,
|
||||||
|
NullContract,
|
||||||
|
} from '../common/validation/null-contract.spec-helper';
|
||||||
|
import { UpdateColonyDto } from '../colonies/dto/update-colony.dto';
|
||||||
|
import { UpdateApiaryRestrictionDto } from './dto/update-apiary-restriction.dto';
|
||||||
|
|
||||||
|
const contracts: Record<string, NullContract> = {
|
||||||
|
UpdateApiaryRestrictionDto: {
|
||||||
|
dto: UpdateApiaryRestrictionDto,
|
||||||
|
notNull: ['disease', 'startsOn'],
|
||||||
|
clearable: ['endsOn', 'authority', 'reference', 'notes'],
|
||||||
|
},
|
||||||
|
// The reason is only read, never stored: null means "no override".
|
||||||
|
UpdateColonyDto: {
|
||||||
|
dto: UpdateColonyDto,
|
||||||
|
notNull: ['status'],
|
||||||
|
clearable: ['restrictionOverrideReason'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describeNullContracts('Apiary restriction DTOs', contracts);
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, 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 { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
|
import { Idempotent } from '../common/idempotency/idempotent.decorator';
|
||||||
|
import { ApiaryRestrictionsService } from './apiary-restrictions.service';
|
||||||
|
import { CreateApiaryRestrictionDto } from './dto/create-apiary-restriction.dto';
|
||||||
|
import { RestrictionListQuery } from './dto/restriction-list-query.dto';
|
||||||
|
import { UpdateApiaryRestrictionDto } from './dto/update-apiary-restriction.dto';
|
||||||
|
|
||||||
|
// Full paths on an empty prefix: the list lives under /apiary-restrictions,
|
||||||
|
// the per-apiary read under /apiaries/:id/restrictions.
|
||||||
|
@ApiTags('apiary-restrictions')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller()
|
||||||
|
export class ApiaryRestrictionsController {
|
||||||
|
constructor(
|
||||||
|
private readonly restrictions: ApiaryRestrictionsService,
|
||||||
|
private readonly permissions: PermissionResolverService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Authority, file reference and notes are for compliance:read only.
|
||||||
|
private async mayReadDetails(actor: AuthenticatedUser): Promise<boolean> {
|
||||||
|
return (await this.permissions.getPermissions(actor.id)).has(
|
||||||
|
'compliance:read',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('apiaries:read')
|
||||||
|
@Get('apiary-restrictions')
|
||||||
|
async findAll(
|
||||||
|
@Query() query: RestrictionListQuery,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.restrictions.list(
|
||||||
|
query.active,
|
||||||
|
await this.mayReadDetails(actor),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('apiaries:read')
|
||||||
|
@Get('apiaries/:id/restrictions')
|
||||||
|
async findForApiary(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.restrictions.findForApiary(
|
||||||
|
id,
|
||||||
|
await this.mayReadDetails(actor),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('compliance:manage')
|
||||||
|
@Idempotent()
|
||||||
|
@Post('apiary-restrictions')
|
||||||
|
create(
|
||||||
|
@Body() dto: CreateApiaryRestrictionDto,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.restrictions.create(dto, actor.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('compliance:manage')
|
||||||
|
@Patch('apiary-restrictions/:id')
|
||||||
|
update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateApiaryRestrictionDto,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.restrictions.update(id, dto, actor.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ApiaryRestrictionsController } from './apiary-restrictions.controller';
|
||||||
|
import { ApiaryRestrictionsService } from './apiary-restrictions.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Not needed in WorkerModule: the service injects no permission resolver and
|
||||||
|
* the guard helper (compliance/restricted-zone.ts) is a plain function.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
controllers: [ApiaryRestrictionsController],
|
||||||
|
providers: [ApiaryRestrictionsService],
|
||||||
|
exports: [ApiaryRestrictionsService],
|
||||||
|
})
|
||||||
|
export class ApiaryRestrictionsModule {}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import { Prisma } from '../../generated/prisma';
|
||||||
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { ApiaryRestrictionsService } from './apiary-restrictions.service';
|
||||||
|
|
||||||
|
const day = (value: string): Date => new Date(`${value}T00:00:00.000Z`);
|
||||||
|
|
||||||
|
describe('ApiaryRestrictionsService', () => {
|
||||||
|
let service: ApiaryRestrictionsService;
|
||||||
|
let tx: {
|
||||||
|
$queryRaw: jest.Mock;
|
||||||
|
apiary: { findMany: jest.Mock };
|
||||||
|
apiaryRestriction: {
|
||||||
|
create: jest.Mock;
|
||||||
|
update: jest.Mock;
|
||||||
|
findUnique: jest.Mock;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let prisma: {
|
||||||
|
apiary: { findUnique: jest.Mock };
|
||||||
|
apiaryRestriction: { findMany: jest.Mock; findUnique: jest.Mock };
|
||||||
|
$transaction: jest.Mock;
|
||||||
|
};
|
||||||
|
let auditLog: { record: jest.Mock };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tx = {
|
||||||
|
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||||
|
apiary: { findMany: jest.fn() },
|
||||||
|
apiaryRestriction: {
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
prisma = {
|
||||||
|
apiary: { findUnique: jest.fn() },
|
||||||
|
apiaryRestriction: { findMany: jest.fn(), findUnique: jest.fn() },
|
||||||
|
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
|
||||||
|
callback(tx),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
service = new ApiaryRestrictionsService(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
auditLog as unknown as AuditLogService,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function lockStatements(): string[] {
|
||||||
|
return (tx.$queryRaw.mock.calls as [Prisma.Sql][]).map(
|
||||||
|
([query]) => query.sql,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
const dto = {
|
||||||
|
apiaryIds: ['a2', 'a1'],
|
||||||
|
disease: 'Faulbrut',
|
||||||
|
startsOn: '2026-10-01',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('creates one row per apiary in one transaction, audits each, locks the apiaries first', async () => {
|
||||||
|
tx.apiary.findMany.mockResolvedValue([{ id: 'a1' }, { id: 'a2' }]);
|
||||||
|
tx.apiaryRestriction.create.mockImplementation(
|
||||||
|
({ data }: { data: { apiaryId: string } }) =>
|
||||||
|
Promise.resolve({ id: `r-${data.apiaryId}`, ...data }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const created = await service.create(dto, 'user-1');
|
||||||
|
|
||||||
|
expect(created.map((row) => row.apiaryId)).toEqual(['a1', 'a2']);
|
||||||
|
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||||
|
expect(lockStatements()).toEqual([
|
||||||
|
'SELECT id FROM "Apiary" WHERE id = ANY(?) ORDER BY id FOR UPDATE',
|
||||||
|
]);
|
||||||
|
const [[firstRow]] = tx.apiaryRestriction.create.mock.calls as [
|
||||||
|
[{ data: Record<string, unknown> }],
|
||||||
|
];
|
||||||
|
expect(firstRow.data).toMatchObject({
|
||||||
|
apiaryId: 'a1',
|
||||||
|
disease: 'Faulbrut',
|
||||||
|
startsOn: day('2026-10-01'),
|
||||||
|
createdByUserId: 'user-1',
|
||||||
|
});
|
||||||
|
expect(auditLog.record).toHaveBeenCalledTimes(2);
|
||||||
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
entityType: 'ApiaryRestriction',
|
||||||
|
action: 'CREATE',
|
||||||
|
userId: 'user-1',
|
||||||
|
}),
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
|
tx.apiaryRestriction.create.mock.invocationCallOrder[0],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls all back when one apiary is unknown (404 after the lock, nothing written)', async () => {
|
||||||
|
tx.apiary.findMany.mockResolvedValue([{ id: 'a1' }]);
|
||||||
|
|
||||||
|
await expect(service.create(dto, 'user-1')).rejects.toMatchObject({
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
|
||||||
|
expect(tx.apiaryRestriction.create).not.toHaveBeenCalled();
|
||||||
|
expect(auditLog.record).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an endsOn before startsOn with 400 before any lock', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create({ ...dto, endsOn: '2026-09-30' }, 'user-1'),
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects no apiary and more than 50 apiaries with 400', async () => {
|
||||||
|
const ids = Array.from({ length: 51 }, (_, index) => `a${index}`);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.create({ ...dto, apiaryIds: ids }, 'user-1'),
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
await expect(
|
||||||
|
service.create({ ...dto, apiaryIds: [] }, 'user-1'),
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
const stored = {
|
||||||
|
id: 'r1',
|
||||||
|
apiaryId: 'a1',
|
||||||
|
disease: 'Faulbrut',
|
||||||
|
startsOn: day('2026-10-01'),
|
||||||
|
endsOn: null as Date | null,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma.apiaryRestriction.findUnique.mockResolvedValue(stored);
|
||||||
|
tx.apiaryRestriction.findUnique.mockResolvedValue(stored);
|
||||||
|
tx.apiaryRestriction.update.mockImplementation(
|
||||||
|
({ data }: { data: object }) => Promise.resolve({ ...stored, ...data }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locks the apiary FOR UPDATE, then the restriction, then reads before', async () => {
|
||||||
|
await service.update('r1', { endsOn: '2026-10-20' }, 'user-1');
|
||||||
|
|
||||||
|
expect(lockStatements()).toEqual([
|
||||||
|
'SELECT id FROM "Apiary" WHERE id = ANY(?) ORDER BY id FOR UPDATE',
|
||||||
|
'SELECT id FROM "ApiaryRestriction" WHERE id = ? FOR UPDATE',
|
||||||
|
]);
|
||||||
|
expect(tx.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
|
||||||
|
tx.apiaryRestriction.findUnique.mock.invocationCallOrder[0],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ending a restriction is an update of endsOn, nothing is deleted', async () => {
|
||||||
|
const result = await service.update(
|
||||||
|
'r1',
|
||||||
|
{ endsOn: '2026-10-20' },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tx.apiaryRestriction.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'r1' },
|
||||||
|
data: { endsOn: day('2026-10-20') },
|
||||||
|
});
|
||||||
|
expect(result.endsOn).toEqual(day('2026-10-20'));
|
||||||
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
entityType: 'ApiaryRestriction',
|
||||||
|
entityId: 'r1',
|
||||||
|
action: 'UPDATE',
|
||||||
|
beforeData: stored,
|
||||||
|
}),
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('null clears endsOn', async () => {
|
||||||
|
tx.apiaryRestriction.findUnique.mockResolvedValue({
|
||||||
|
...stored,
|
||||||
|
endsOn: day('2026-10-20'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.update('r1', { endsOn: null }, 'user-1');
|
||||||
|
|
||||||
|
expect(tx.apiaryRestriction.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'r1' },
|
||||||
|
data: { endsOn: null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks endsOn >= startsOn on the merged state (400)', async () => {
|
||||||
|
await expect(
|
||||||
|
service.update('r1', { endsOn: '2026-09-30' }, 'user-1'),
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
await expect(
|
||||||
|
service.update('r1', { startsOn: '2026-11-01' }, 'user-1'),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
tx.apiaryRestriction.findUnique.mockResolvedValue({
|
||||||
|
...stored,
|
||||||
|
endsOn: day('2026-10-20'),
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
service.update('r1', { startsOn: '2026-11-01' }, 'user-1'),
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
expect(tx.apiaryRestriction.update).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers 404 for an unknown restriction without locking', async () => {
|
||||||
|
prisma.apiaryRestriction.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.update('missing', { notes: 'x' }, 'user-1'),
|
||||||
|
).rejects.toMatchObject({ status: 404 });
|
||||||
|
expect(tx.$queryRaw).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reads', () => {
|
||||||
|
it('findActive lists only restrictions active today, with the apiary name', async () => {
|
||||||
|
prisma.apiaryRestriction.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'r1',
|
||||||
|
apiaryId: 'a1',
|
||||||
|
apiary: { name: 'Nord' },
|
||||||
|
disease: 'Faulbrut',
|
||||||
|
startsOn: day('2000-01-01'),
|
||||||
|
endsOn: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'r2',
|
||||||
|
apiaryId: 'a2',
|
||||||
|
apiary: { name: 'Sued' },
|
||||||
|
disease: 'Faulbrut',
|
||||||
|
startsOn: day('2000-01-01'),
|
||||||
|
endsOn: day('2000-01-02'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(service.findActive()).resolves.toEqual([
|
||||||
|
{
|
||||||
|
id: 'r1',
|
||||||
|
apiaryId: 'a1',
|
||||||
|
apiaryName: 'Nord',
|
||||||
|
disease: 'Faulbrut',
|
||||||
|
startsOn: '2000-01-01',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('without compliance:read the lists carry no authority, reference, notes or creator', async () => {
|
||||||
|
const row = {
|
||||||
|
id: 'r1',
|
||||||
|
apiaryId: 'a1',
|
||||||
|
disease: 'Faulbrut',
|
||||||
|
authority: 'Veterinaeramt',
|
||||||
|
reference: 'AZ 12/3',
|
||||||
|
notes: 'intern',
|
||||||
|
createdByUserId: 'u1',
|
||||||
|
startsOn: day('2026-10-01'),
|
||||||
|
endsOn: null,
|
||||||
|
};
|
||||||
|
prisma.apiaryRestriction.findMany.mockResolvedValue([row]);
|
||||||
|
prisma.apiary.findUnique.mockResolvedValue({ id: 'a1' });
|
||||||
|
const expected = [
|
||||||
|
{
|
||||||
|
id: 'r1',
|
||||||
|
apiaryId: 'a1',
|
||||||
|
disease: 'Faulbrut',
|
||||||
|
startsOn: '2026-10-01',
|
||||||
|
endsOn: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await expect(service.list(undefined, false)).resolves.toEqual(expected);
|
||||||
|
await expect(service.findForApiary('a1', false)).resolves.toEqual(
|
||||||
|
expected,
|
||||||
|
);
|
||||||
|
await expect(service.list(undefined, true)).resolves.toEqual([row]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findForApiary answers 404 for an unknown apiary', async () => {
|
||||||
|
prisma.apiary.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.findForApiary('missing')).rejects.toMatchObject({
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiaryRestriction } from '../../generated/prisma';
|
||||||
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { berlinToday, dayToDate } from '../common/dates';
|
||||||
|
import { optionalDate } from '../common/patch-values';
|
||||||
|
import {
|
||||||
|
ActiveRestrictionInfo,
|
||||||
|
isRestrictionActive,
|
||||||
|
startedRestrictions,
|
||||||
|
toActiveRestrictionInfo,
|
||||||
|
} from '../compliance/restricted-zone';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
|
||||||
|
import {
|
||||||
|
CreateApiaryRestrictionDto,
|
||||||
|
MAX_RESTRICTION_APIARIES,
|
||||||
|
} from './dto/create-apiary-restriction.dto';
|
||||||
|
import { UpdateApiaryRestrictionDto } from './dto/update-apiary-restriction.dto';
|
||||||
|
|
||||||
|
const AUDIT_ENTITY = 'ApiaryRestriction';
|
||||||
|
|
||||||
|
/** What a reader without compliance:read sees: no authority, reference, notes or creator. */
|
||||||
|
export interface RestrictionSummary {
|
||||||
|
id: string;
|
||||||
|
apiaryId: string;
|
||||||
|
disease: string;
|
||||||
|
startsOn: string;
|
||||||
|
endsOn: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSummary(row: ApiaryRestriction): RestrictionSummary {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
apiaryId: row.apiaryId,
|
||||||
|
disease: row.disease,
|
||||||
|
startsOn: row.startsOn.toISOString().slice(0, 10),
|
||||||
|
endsOn: row.endsOn ? row.endsOn.toISOString().slice(0, 10) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEndNotBeforeStart(startsOn: Date, endsOn: Date | null): void {
|
||||||
|
if (endsOn !== null && endsOn < startsOn) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Das Ende der Sperre darf nicht vor ihrem Beginn liegen',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restricted zones (Sperrbezirke) of apiaries. A restriction is never deleted:
|
||||||
|
* it ends by an endsOn date, so the colony history stays explainable.
|
||||||
|
*
|
||||||
|
* Lock order (Task 1): the apiary rows FOR UPDATE first, then the restriction
|
||||||
|
* row. A colony path holds its apiaries FOR SHARE until it commits, so a
|
||||||
|
* restriction and a move into that apiary queue up behind each other.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ApiaryRestrictionsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly auditLog: AuditLogService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** One row per apiary, all in one transaction; an unknown apiary rolls all back. */
|
||||||
|
async create(
|
||||||
|
dto: CreateApiaryRestrictionDto,
|
||||||
|
actingUserId: string,
|
||||||
|
): Promise<ApiaryRestriction[]> {
|
||||||
|
const apiaryIds = [...new Set(dto.apiaryIds)].sort();
|
||||||
|
if (apiaryIds.length < 1 || apiaryIds.length > MAX_RESTRICTION_APIARIES) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Es sind 1 bis ${MAX_RESTRICTION_APIARIES} Stände möglich`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const startsOn = dayToDate(dto.startsOn);
|
||||||
|
const endsOn = dto.endsOn ? dayToDate(dto.endsOn) : null;
|
||||||
|
assertEndNotBeforeStart(startsOn, endsOn);
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
await lockRowsForUpdate(tx, 'Apiary', apiaryIds);
|
||||||
|
const known = await tx.apiary.findMany({
|
||||||
|
where: { id: { in: apiaryIds } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (known.length !== apiaryIds.length) {
|
||||||
|
throw new NotFoundException('Bienenstand nicht gefunden');
|
||||||
|
}
|
||||||
|
const created: ApiaryRestriction[] = [];
|
||||||
|
for (const apiaryId of apiaryIds) {
|
||||||
|
const restriction = await tx.apiaryRestriction.create({
|
||||||
|
data: {
|
||||||
|
apiaryId,
|
||||||
|
disease: dto.disease,
|
||||||
|
authority: dto.authority,
|
||||||
|
reference: dto.reference,
|
||||||
|
startsOn,
|
||||||
|
endsOn,
|
||||||
|
notes: dto.notes,
|
||||||
|
createdByUserId: actingUserId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.auditLog.record(
|
||||||
|
{
|
||||||
|
entityType: AUDIT_ENTITY,
|
||||||
|
entityId: restriction.id,
|
||||||
|
action: 'CREATE',
|
||||||
|
userId: actingUserId,
|
||||||
|
afterData: restriction,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
created.push(restriction);
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
dto: UpdateApiaryRestrictionDto,
|
||||||
|
actingUserId: string,
|
||||||
|
): Promise<ApiaryRestriction> {
|
||||||
|
const seen = await this.prisma.apiaryRestriction.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { apiaryId: true },
|
||||||
|
});
|
||||||
|
if (!seen) {
|
||||||
|
throw new NotFoundException('Sperrbezirk nicht gefunden');
|
||||||
|
}
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
await lockRowsForUpdate(tx, 'Apiary', [seen.apiaryId]);
|
||||||
|
await lockRowForUpdate(tx, 'ApiaryRestriction', id);
|
||||||
|
const before = await tx.apiaryRestriction.findUnique({ where: { id } });
|
||||||
|
if (!before) {
|
||||||
|
throw new NotFoundException('Sperrbezirk nicht gefunden');
|
||||||
|
}
|
||||||
|
const startsOnPatch =
|
||||||
|
dto.startsOn === undefined ? undefined : dayToDate(dto.startsOn);
|
||||||
|
const startsOn = startsOnPatch ?? before.startsOn;
|
||||||
|
const endsOn = optionalDate(dto.endsOn);
|
||||||
|
assertEndNotBeforeStart(
|
||||||
|
startsOn,
|
||||||
|
endsOn === undefined ? before.endsOn : endsOn,
|
||||||
|
);
|
||||||
|
const updated = await tx.apiaryRestriction.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
disease: dto.disease,
|
||||||
|
authority: dto.authority,
|
||||||
|
reference: dto.reference,
|
||||||
|
startsOn: startsOnPatch,
|
||||||
|
endsOn,
|
||||||
|
notes: dto.notes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.auditLog.record(
|
||||||
|
{
|
||||||
|
entityType: AUDIT_ENTITY,
|
||||||
|
entityId: id,
|
||||||
|
action: 'UPDATE',
|
||||||
|
userId: actingUserId,
|
||||||
|
beforeData: before,
|
||||||
|
afterData: updated,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The restrictions in force today (Berlin day), with the apiary name. */
|
||||||
|
async findActive(): Promise<ActiveRestrictionInfo[]> {
|
||||||
|
const today = berlinToday();
|
||||||
|
const started = await startedRestrictions(this.prisma, undefined, today);
|
||||||
|
return started
|
||||||
|
.filter((row) => isRestrictionActive(row, today))
|
||||||
|
.map(toActiveRestrictionInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every restriction (newest first); `active` narrows to in force / ended.
|
||||||
|
* Authority, file reference, notes and the creator are for callers with
|
||||||
|
* compliance:read (`detailed`); everybody else gets the summary.
|
||||||
|
*/
|
||||||
|
async list(
|
||||||
|
active?: boolean,
|
||||||
|
detailed = true,
|
||||||
|
): Promise<
|
||||||
|
ApiaryRestriction[] | ActiveRestrictionInfo[] | RestrictionSummary[]
|
||||||
|
> {
|
||||||
|
if (active === true) {
|
||||||
|
return this.findActive();
|
||||||
|
}
|
||||||
|
const rows = await this.prisma.apiaryRestriction.findMany({
|
||||||
|
orderBy: [{ startsOn: 'desc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
const wanted =
|
||||||
|
active === false
|
||||||
|
? rows.filter((row) => !isRestrictionActive(row, berlinToday()))
|
||||||
|
: rows;
|
||||||
|
return detailed ? wanted : wanted.map(toSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findForApiary(
|
||||||
|
apiaryId: string,
|
||||||
|
detailed = true,
|
||||||
|
): Promise<ApiaryRestriction[] | RestrictionSummary[]> {
|
||||||
|
const apiary = await this.prisma.apiary.findUnique({
|
||||||
|
where: { id: apiaryId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!apiary) {
|
||||||
|
throw new NotFoundException('Bienenstand nicht gefunden');
|
||||||
|
}
|
||||||
|
const rows = await this.prisma.apiaryRestriction.findMany({
|
||||||
|
where: { apiaryId },
|
||||||
|
orderBy: [{ startsOn: 'desc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
return detailed ? rows : rows.map(toSummary);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
ArrayMinSize,
|
||||||
|
ArrayUnique,
|
||||||
|
IsArray,
|
||||||
|
IsDateString,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export const MAX_RESTRICTION_APIARIES = 50;
|
||||||
|
const MAX_ID_LENGTH = 64;
|
||||||
|
const MAX_TEXT_LENGTH = 200;
|
||||||
|
const MAX_NOTE_LENGTH = 2000;
|
||||||
|
const ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||||
|
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
const DAY_MESSAGE = 'muss ein Datum im Format JJJJ-MM-TT sein';
|
||||||
|
|
||||||
|
/** A restricted zone declared by the authority, for one or several apiaries. */
|
||||||
|
export class CreateApiaryRestrictionDto {
|
||||||
|
@ApiProperty({
|
||||||
|
type: [String],
|
||||||
|
description: 'Ein Datensatz je Stand, 1 bis 50 Stände',
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ArrayMaxSize(MAX_RESTRICTION_APIARIES)
|
||||||
|
@ArrayUnique()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@Matches(ID_PATTERN, { each: true })
|
||||||
|
@MaxLength(MAX_ID_LENGTH, { each: true })
|
||||||
|
apiaryIds!: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Seuche, z. B. Amerikanische Faulbrut' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Matches(/\S/, { message: 'disease darf nicht nur aus Leerzeichen bestehen' })
|
||||||
|
@MaxLength(MAX_TEXT_LENGTH)
|
||||||
|
disease!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ nullable: true, description: 'Anordnende Behörde' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(MAX_TEXT_LENGTH)
|
||||||
|
authority?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ nullable: true, description: 'Aktenzeichen' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(MAX_TEXT_LENGTH)
|
||||||
|
reference?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2026-10-01', description: 'Erster Tag der Sperre' })
|
||||||
|
@Matches(DAY_PATTERN, { message: `startsOn ${DAY_MESSAGE}` })
|
||||||
|
@IsDateString({ strict: true })
|
||||||
|
startsOn!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
nullable: true,
|
||||||
|
example: '2026-12-01',
|
||||||
|
description: 'Letzter Tag der Sperre; leer = bis auf Weiteres',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Matches(DAY_PATTERN, { message: `endsOn ${DAY_MESSAGE}` })
|
||||||
|
@IsDateString({ strict: true })
|
||||||
|
endsOn?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(MAX_NOTE_LENGTH)
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsBoolean, IsOptional } from 'class-validator';
|
||||||
|
import { booleanQuery } from '../../veterinary-medicines/dto/medicine-list-query.dto';
|
||||||
|
|
||||||
|
export class RestrictionListQuery {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'true: nur heute geltende Sperren; false: nur beendete',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(booleanQuery)
|
||||||
|
@IsBoolean()
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { OmitType, PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateApiaryRestrictionDto } from './create-apiary-restriction.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The apiary of a restriction is fixed: a restriction for another stand is a
|
||||||
|
* new restriction. Omitted stays, null clears a nullable field (endsOn ends
|
||||||
|
* the restriction as open again), null on disease or startsOn is a 400.
|
||||||
|
*/
|
||||||
|
export class UpdateApiaryRestrictionDto extends PartialType(
|
||||||
|
OmitType(CreateApiaryRestrictionDto, ['apiaryIds'] as const),
|
||||||
|
{ skipNullProperties: false },
|
||||||
|
) {}
|
||||||
@@ -28,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';
|
||||||
@@ -53,7 +55,16 @@ import { BackupModule } from './backup/backup.module';
|
|||||||
import { ShopSyncModule } from './shop-sync/shop-sync.module';
|
import { ShopSyncModule } from './shop-sync/shop-sync.module';
|
||||||
import { ErpExportModule } from './erp-export/erp-export.module';
|
import { ErpExportModule } from './erp-export/erp-export.module';
|
||||||
import { ApiKeysModule } from './api-keys/api-keys.module';
|
import { ApiKeysModule } from './api-keys/api-keys.module';
|
||||||
|
import { CleanupModule } from './suggestions/cleanup.module';
|
||||||
|
import { SuggestionsModule } from './suggestions/suggestions.module';
|
||||||
|
import { AutopilotModule } from './autopilot/autopilot.module';
|
||||||
import { SystemModule } from './system/system.module';
|
import { SystemModule } from './system/system.module';
|
||||||
|
import { WithdrawalModule } from './withdrawal/withdrawal.module';
|
||||||
|
import { HiveModule } from './hive/hive.module';
|
||||||
|
import { VeterinaryMedicinesModule } from './veterinary-medicines/veterinary-medicines.module';
|
||||||
|
import { ComplianceModule } from './compliance/compliance.module';
|
||||||
|
import { ReadinessModule } from './compliance/readiness/readiness.module';
|
||||||
|
import { VoiceModule } from './voice/voice.module';
|
||||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||||
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
||||||
import { PermissionsModule } from './auth/permissions/permissions.module';
|
import { PermissionsModule } from './auth/permissions/permissions.module';
|
||||||
@@ -106,6 +117,12 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
|
|||||||
WebhooksModule,
|
WebhooksModule,
|
||||||
DocumentsModule,
|
DocumentsModule,
|
||||||
AutomationModule,
|
AutomationModule,
|
||||||
|
SuggestionsModule,
|
||||||
|
CleanupModule,
|
||||||
|
AutopilotModule,
|
||||||
|
PhenologyModule,
|
||||||
|
WithdrawalModule,
|
||||||
|
RemindersModule,
|
||||||
LabelTemplatesModule,
|
LabelTemplatesModule,
|
||||||
ImportModule,
|
ImportModule,
|
||||||
PublicModule,
|
PublicModule,
|
||||||
@@ -120,6 +137,11 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
|
|||||||
ErpExportModule,
|
ErpExportModule,
|
||||||
ApiKeysModule,
|
ApiKeysModule,
|
||||||
SystemModule,
|
SystemModule,
|
||||||
|
VeterinaryMedicinesModule,
|
||||||
|
HiveModule,
|
||||||
|
ComplianceModule,
|
||||||
|
ReadinessModule,
|
||||||
|
VoiceModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -379,6 +391,32 @@ export const PERMISSION_CATALOG = [
|
|||||||
[['read', 'Inventar-Spiegel für ERPNext lesen']],
|
[['read', 'Inventar-Spiegel für ERPNext lesen']],
|
||||||
3600,
|
3600,
|
||||||
),
|
),
|
||||||
|
// T6: the veterinary medicine catalogue and its packs. Receipts, invoice
|
||||||
|
// numbers and supplier addresses live here, so the shop account gets none.
|
||||||
|
...group(
|
||||||
|
'Tierarzneimittel',
|
||||||
|
'veterinary-medicines',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['create', 'Anlegen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
],
|
||||||
|
3700,
|
||||||
|
),
|
||||||
|
...group(
|
||||||
|
'Prüfbereitschaft & Behörden',
|
||||||
|
'compliance',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['manage', 'Verwalten'],
|
||||||
|
],
|
||||||
|
3800,
|
||||||
|
),
|
||||||
|
// Beute in 3D: hive systems and their part-type catalog. Reading the
|
||||||
|
// catalog and writing a colony's setup use the colonies permissions.
|
||||||
|
...group('Beutensysteme', 'hive-systems', [['manage', 'Pflegen']], 3900),
|
||||||
|
// T7 Freihand: voice notes. Every endpoint also needs inspections:create.
|
||||||
|
...group('Sprache', 'voice-captures', [['use', 'Benutzen']], 4000),
|
||||||
] as const satisfies readonly PermissionCatalogEntry[];
|
] as const satisfies readonly PermissionCatalogEntry[];
|
||||||
|
|
||||||
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
|
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
|
||||||
@@ -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,61 @@
|
|||||||
|
import { UnauthorizedException } from '@nestjs/common';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import { ReauthService } from './reauth.service';
|
||||||
|
|
||||||
|
describe('ReauthService', () => {
|
||||||
|
const baseUser = {
|
||||||
|
id: 'u1',
|
||||||
|
email: '[email protected]',
|
||||||
|
passwordHash: bcrypt.hashSync('richtig', 4),
|
||||||
|
mfaEnabled: false,
|
||||||
|
mfaSecret: null,
|
||||||
|
mfaLastTimeStep: null,
|
||||||
|
authLockedUntil: null,
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
const accountGuard = {
|
||||||
|
isLocked: jest.fn(),
|
||||||
|
recordFailure: jest.fn(),
|
||||||
|
recordSuccess: jest.fn(),
|
||||||
|
consumeTotp: jest.fn(),
|
||||||
|
};
|
||||||
|
const prisma = { user: { findUnique: jest.fn() } };
|
||||||
|
const service = new ReauthService(prisma as never, accountGuard as never);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
prisma.user.findUnique.mockResolvedValue(baseUser);
|
||||||
|
accountGuard.isLocked.mockReturnValue(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sagt bei falschem Passwort, dass es nicht stimmt', async () => {
|
||||||
|
await expect(service.verify('u1', { password: 'falsch' })).rejects.toThrow(
|
||||||
|
new UnauthorizedException('Das Passwort stimmt nicht.'),
|
||||||
|
);
|
||||||
|
expect(accountGuard.recordFailure).toHaveBeenCalledWith('u1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sagt bei falschem Code, dass er nicht stimmt', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue({ ...baseUser, mfaEnabled: true });
|
||||||
|
accountGuard.consumeTotp.mockResolvedValue(false);
|
||||||
|
await expect(
|
||||||
|
service.verify('u1', { password: 'richtig', mfaCode: '000000' }),
|
||||||
|
).rejects.toThrow(new UnauthorizedException('Der Code stimmt nicht.'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nennt bei gesperrtem Konto die Sperre', async () => {
|
||||||
|
accountGuard.isLocked.mockReturnValue(true);
|
||||||
|
await expect(service.verify('u1', { password: 'richtig' })).rejects.toThrow(
|
||||||
|
new UnauthorizedException(
|
||||||
|
'Zu viele Fehlversuche. Bestätigen ist für 15 Minuten gesperrt.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt richtige Angaben durch', async () => {
|
||||||
|
await expect(
|
||||||
|
service.verify('u1', { password: 'richtig' }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(accountGuard.recordSuccess).toHaveBeenCalledWith('u1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,7 +37,7 @@ export class ReauthService {
|
|||||||
});
|
});
|
||||||
if (!user || !user.isActive || this.accountGuard.isLocked(user)) {
|
if (!user || !user.isActive || this.accountGuard.isLocked(user)) {
|
||||||
throw new UnauthorizedException(
|
throw new UnauthorizedException(
|
||||||
'Bestätigung derzeit nicht möglich. Nach mehreren Fehlversuchen ist sie für 15 Minuten gesperrt.',
|
'Zu viele Fehlversuche. Bestätigen ist für 15 Minuten gesperrt.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const passwordMatches =
|
const passwordMatches =
|
||||||
@@ -45,9 +45,7 @@ export class ReauthService {
|
|||||||
(await bcrypt.compare(proof.password, user.passwordHash));
|
(await bcrypt.compare(proof.password, user.passwordHash));
|
||||||
if (!passwordMatches) {
|
if (!passwordMatches) {
|
||||||
await this.accountGuard.recordFailure(user.id);
|
await this.accountGuard.recordFailure(user.id);
|
||||||
throw new UnauthorizedException(
|
throw new UnauthorizedException('Das Passwort stimmt nicht.');
|
||||||
'Bitte zur Bestätigung Ihr aktuelles Passwort eingeben',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (user.mfaEnabled) {
|
if (user.mfaEnabled) {
|
||||||
const codeMatches =
|
const codeMatches =
|
||||||
@@ -55,9 +53,7 @@ export class ReauthService {
|
|||||||
(await this.accountGuard.consumeTotp(user, proof.mfaCode));
|
(await this.accountGuard.consumeTotp(user, proof.mfaCode));
|
||||||
if (!codeMatches) {
|
if (!codeMatches) {
|
||||||
await this.accountGuard.recordFailure(user.id);
|
await this.accountGuard.recordFailure(user.id);
|
||||||
throw new UnauthorizedException(
|
throw new UnauthorizedException('Der Code stimmt nicht.');
|
||||||
'Bitte zur Bestätigung den aktuellen Code aus Ihrer Authenticator-App eingeben',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.accountGuard.recordSuccess(user.id);
|
await this.accountGuard.recordSuccess(user.id);
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ describe('AutomationEventHandlers', () => {
|
|||||||
let tx: Record<RecordModel, { findUnique: jest.Mock }>;
|
let tx: Record<RecordModel, { findUnique: jest.Mock }>;
|
||||||
let automation: Record<
|
let automation: Record<
|
||||||
| 'evaluateInspectionIn'
|
| 'evaluateInspectionIn'
|
||||||
|
| 'evaluateInspectionCorrectionIn'
|
||||||
| 'evaluateVarroaMeasurementIn'
|
| 'evaluateVarroaMeasurementIn'
|
||||||
| 'evaluateTreatmentIn'
|
| 'evaluateTreatmentIn'
|
||||||
| 'evaluateFeedingIn'
|
| 'evaluateFeedingIn'
|
||||||
@@ -63,6 +64,7 @@ describe('AutomationEventHandlers', () => {
|
|||||||
) as Record<RecordModel, { findUnique: jest.Mock }>;
|
) as Record<RecordModel, { findUnique: jest.Mock }>;
|
||||||
automation = {
|
automation = {
|
||||||
evaluateInspectionIn: jest.fn().mockResolvedValue(undefined),
|
evaluateInspectionIn: jest.fn().mockResolvedValue(undefined),
|
||||||
|
evaluateInspectionCorrectionIn: jest.fn().mockResolvedValue(undefined),
|
||||||
evaluateVarroaMeasurementIn: jest.fn().mockResolvedValue(undefined),
|
evaluateVarroaMeasurementIn: jest.fn().mockResolvedValue(undefined),
|
||||||
evaluateTreatmentIn: jest.fn().mockResolvedValue(undefined),
|
evaluateTreatmentIn: jest.fn().mockResolvedValue(undefined),
|
||||||
evaluateFeedingIn: jest.fn().mockResolvedValue(undefined),
|
evaluateFeedingIn: jest.fn().mockResolvedValue(undefined),
|
||||||
@@ -84,7 +86,7 @@ describe('AutomationEventHandlers', () => {
|
|||||||
|
|
||||||
expect(registered('inspectionRules')).toEqual([
|
expect(registered('inspectionRules')).toEqual([
|
||||||
{
|
{
|
||||||
types: ['InspectionRecorded'],
|
types: ['InspectionRecorded', 'InspectionCorrected'],
|
||||||
handlerId: 'automation.inspection-rules',
|
handlerId: 'automation.inspection-rules',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -154,12 +156,17 @@ describe('AutomationEventHandlers', () => {
|
|||||||
async (method, type, model, core) => {
|
async (method, type, model, core) => {
|
||||||
const record = { id: 'record-1', colonyId: 'colony-1' };
|
const record = { id: 'record-1', colonyId: 'colony-1' };
|
||||||
tx[model].findUnique.mockResolvedValueOnce(record);
|
tx[model].findUnique.mockResolvedValueOnce(record);
|
||||||
|
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
|
||||||
|
|
||||||
await run(method, type);
|
await run(method, type);
|
||||||
|
|
||||||
expect(tx[model].findUnique).toHaveBeenCalledWith({
|
expect(tx[model].findUnique).toHaveBeenCalledWith({
|
||||||
where: { id: 'record-1' },
|
where: { id: 'record-1' },
|
||||||
});
|
});
|
||||||
|
expect(tx.colony.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'colony-1' },
|
||||||
|
select: { status: true },
|
||||||
|
});
|
||||||
expect(automation[core]).toHaveBeenCalledWith(tx, record);
|
expect(automation[core]).toHaveBeenCalledWith(tx, record);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -174,8 +181,56 @@ describe('AutomationEventHandlers', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it.each(cases)(
|
||||||
|
'rule handlers write nothing for an ended colony: %s on %s',
|
||||||
|
async (method, type, model, core) => {
|
||||||
|
tx[model].findUnique.mockResolvedValueOnce({
|
||||||
|
id: 'record-1',
|
||||||
|
colonyId: 'colony-1',
|
||||||
|
});
|
||||||
|
tx.colony.findUnique.mockResolvedValueOnce({ status: 'DISSOLVED' });
|
||||||
|
|
||||||
|
await run(method, type);
|
||||||
|
|
||||||
|
expect(automation[core]).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(cases)(
|
||||||
|
'rule handlers write nothing for a colony that is gone: %s on %s',
|
||||||
|
async (method, type, model, core) => {
|
||||||
|
tx[model].findUnique.mockResolvedValueOnce({
|
||||||
|
id: 'record-1',
|
||||||
|
colonyId: 'colony-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
await run(method, type);
|
||||||
|
|
||||||
|
expect(automation[core]).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('a corrected inspection only follows open suggestions', async () => {
|
||||||
|
tx.inspection.findUnique.mockResolvedValueOnce({
|
||||||
|
id: 'record-1',
|
||||||
|
colonyId: 'colony-1',
|
||||||
|
});
|
||||||
|
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
|
||||||
|
|
||||||
|
await run('inspectionRules', 'InspectionCorrected' as never);
|
||||||
|
|
||||||
|
expect(automation.evaluateInspectionCorrectionIn).toHaveBeenCalledTimes(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(automation.evaluateInspectionIn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('lets an evaluation failure propagate, so the delivery is retried', async () => {
|
it('lets an evaluation failure propagate, so the delivery is retried', async () => {
|
||||||
tx.inspection.findUnique.mockResolvedValueOnce({ id: 'record-1' });
|
tx.inspection.findUnique.mockResolvedValueOnce({
|
||||||
|
id: 'record-1',
|
||||||
|
colonyId: 'colony-1',
|
||||||
|
});
|
||||||
|
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
|
||||||
automation.evaluateInspectionIn.mockRejectedValue(
|
automation.evaluateInspectionIn.mockRejectedValue(
|
||||||
new Error('notifications down'),
|
new Error('notifications down'),
|
||||||
);
|
);
|
||||||
@@ -192,22 +247,24 @@ describe('AutomationEventHandlers', () => {
|
|||||||
const event = () =>
|
const event = () =>
|
||||||
envelope('ColonyStatusChanged', 'colony-1', { from, to });
|
envelope('ColonyStatusChanged', 'colony-1', { from, to });
|
||||||
|
|
||||||
it('hands the transition of the event to evaluateColonyHealthIn', async () => {
|
it('hands the transition and the event to evaluateColonyHealthIn', async () => {
|
||||||
tx.colony.findUnique.mockResolvedValueOnce({ id: 'colony-1' });
|
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
|
||||||
|
|
||||||
await handlers.colonyHealth(event(), asTx());
|
await handlers.colonyHealth(event(), asTx());
|
||||||
|
|
||||||
expect(tx.colony.findUnique).toHaveBeenCalledWith({
|
expect(tx.colony.findUnique).toHaveBeenCalledWith({
|
||||||
where: { id: 'colony-1' },
|
where: { id: 'colony-1' },
|
||||||
select: { id: true },
|
select: { status: true },
|
||||||
});
|
});
|
||||||
// The rule decides on the snapshots of the event, not on the colony
|
// The rule decides on the snapshots of the event, not on the colony
|
||||||
// as it is now: a later change must not hide the transition.
|
// as it is now: a later change must not hide the transition. The
|
||||||
|
// event is the anchor of the suggestion.
|
||||||
expect(automation.evaluateColonyHealthIn).toHaveBeenCalledWith(
|
expect(automation.evaluateColonyHealthIn).toHaveBeenCalledWith(
|
||||||
tx,
|
tx,
|
||||||
'colony-1',
|
'colony-1',
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
|
{ id: 'e1', occurredAt: new Date('2026-10-13T08:00:00.000Z') },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -216,6 +273,14 @@ describe('AutomationEventHandlers', () => {
|
|||||||
|
|
||||||
expect(automation.evaluateColonyHealthIn).not.toHaveBeenCalled();
|
expect(automation.evaluateColonyHealthIn).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('skips a colony that ended', async () => {
|
||||||
|
tx.colony.findUnique.mockResolvedValueOnce({ status: 'SOLD' });
|
||||||
|
|
||||||
|
await handlers.colonyHealth(event(), asTx());
|
||||||
|
|
||||||
|
expect(automation.evaluateColonyHealthIn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('raw-material-low', () => {
|
describe('raw-material-low', () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
|
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
|
||||||
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
|
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
|
||||||
|
import { isLivingColonyStatus } from '../colonies/colony-status';
|
||||||
import { Prisma } from '../../generated/prisma';
|
import { Prisma } from '../../generated/prisma';
|
||||||
import { StockService } from '../stock/stock.service';
|
import { StockService } from '../stock/stock.service';
|
||||||
import { AutomationService } from './automation.service';
|
import { AutomationService } from './automation.service';
|
||||||
@@ -10,9 +11,10 @@ type Tx = Prisma.TransactionClient;
|
|||||||
/**
|
/**
|
||||||
* Evaluates the automation rules on domain events. Pure DB writer: it reads
|
* Evaluates the automation rules on domain events. Pure DB writer: it reads
|
||||||
* the record the event is about through the delivery transaction and lets
|
* the record the event is about through the delivery transaction and lets
|
||||||
* AutomationService write task, warning and audit entries in it. A record
|
* AutomationService write suggestion, notification and audit entries in it.
|
||||||
* deleted before the event was dispatched is skipped. Errors propagate, so
|
* A record deleted before the event was dispatched, or of a colony that
|
||||||
* the delivery is rolled back as a whole and retried.
|
* ended, is skipped. Errors propagate, so the delivery is rolled back as a
|
||||||
|
* whole and retried.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AutomationEventHandlers {
|
export class AutomationEventHandlers {
|
||||||
@@ -21,22 +23,46 @@ export class AutomationEventHandlers {
|
|||||||
private readonly stock: StockService,
|
private readonly stock: StockService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@OnDomainEvent('InspectionRecorded', 'automation.inspection-rules')
|
/**
|
||||||
|
* Fast path only: proposeForRuleIn re-checks the colony under its lock.
|
||||||
|
* Whether the colony still lives (a colony that ended takes no proposals).
|
||||||
|
*/
|
||||||
|
private async isLivingColonyIn(tx: Tx, colonyId: string): Promise<boolean> {
|
||||||
|
const colony = await tx.colony.findUnique({
|
||||||
|
where: { id: colonyId },
|
||||||
|
select: { status: true },
|
||||||
|
});
|
||||||
|
return colony !== null && isLivingColonyStatus(colony.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A correction proposes nothing new; it only lets an open suggestion
|
||||||
|
// follow the corrected values.
|
||||||
|
@OnDomainEvent(
|
||||||
|
['InspectionRecorded', 'InspectionCorrected'],
|
||||||
|
'automation.inspection-rules',
|
||||||
|
)
|
||||||
async inspectionRules(
|
async inspectionRules(
|
||||||
event: AnyDomainEventEnvelope<'InspectionRecorded'>,
|
event: AnyDomainEventEnvelope<'InspectionRecorded' | 'InspectionCorrected'>,
|
||||||
tx: Tx,
|
tx: Tx,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const inspection = await tx.inspection.findUnique({
|
const inspection = await tx.inspection.findUnique({
|
||||||
where: { id: event.aggregateId },
|
where: { id: event.aggregateId },
|
||||||
});
|
});
|
||||||
if (inspection === null) {
|
if (
|
||||||
|
inspection === null ||
|
||||||
|
!(await this.isLivingColonyIn(tx, inspection.colonyId))
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (event.type === 'InspectionRecorded') {
|
||||||
await this.automation.evaluateInspectionIn(tx, inspection);
|
await this.automation.evaluateInspectionIn(tx, inspection);
|
||||||
|
} else {
|
||||||
|
await this.automation.evaluateInspectionCorrectionIn(tx, inspection);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A correction can lift a count over the threshold, so it is evaluated
|
// A correction can lift a count over the threshold, so it is evaluated
|
||||||
// like a new measurement; the open-task check keeps it to one task.
|
// like a new measurement; the open-suggestion check keeps it to one.
|
||||||
@OnDomainEvent(
|
@OnDomainEvent(
|
||||||
['VarroaMeasured', 'VarroaCorrected'],
|
['VarroaMeasured', 'VarroaCorrected'],
|
||||||
'automation.varroa-threshold',
|
'automation.varroa-threshold',
|
||||||
@@ -48,7 +74,10 @@ export class AutomationEventHandlers {
|
|||||||
const measurement = await tx.varroaMeasurement.findUnique({
|
const measurement = await tx.varroaMeasurement.findUnique({
|
||||||
where: { id: event.aggregateId },
|
where: { id: event.aggregateId },
|
||||||
});
|
});
|
||||||
if (measurement === null) {
|
if (
|
||||||
|
measurement === null ||
|
||||||
|
!(await this.isLivingColonyIn(tx, measurement.colonyId))
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.automation.evaluateVarroaMeasurementIn(tx, measurement);
|
await this.automation.evaluateVarroaMeasurementIn(tx, measurement);
|
||||||
@@ -62,7 +91,10 @@ export class AutomationEventHandlers {
|
|||||||
const treatment = await tx.treatment.findUnique({
|
const treatment = await tx.treatment.findUnique({
|
||||||
where: { id: event.aggregateId },
|
where: { id: event.aggregateId },
|
||||||
});
|
});
|
||||||
if (treatment === null) {
|
if (
|
||||||
|
treatment === null ||
|
||||||
|
!(await this.isLivingColonyIn(tx, treatment.colonyId))
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.automation.evaluateTreatmentIn(tx, treatment);
|
await this.automation.evaluateTreatmentIn(tx, treatment);
|
||||||
@@ -76,7 +108,10 @@ export class AutomationEventHandlers {
|
|||||||
const feeding = await tx.feeding.findUnique({
|
const feeding = await tx.feeding.findUnique({
|
||||||
where: { id: event.aggregateId },
|
where: { id: event.aggregateId },
|
||||||
});
|
});
|
||||||
if (feeding === null) {
|
if (
|
||||||
|
feeding === null ||
|
||||||
|
!(await this.isLivingColonyIn(tx, feeding.colonyId))
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.automation.evaluateFeedingIn(tx, feeding);
|
await this.automation.evaluateFeedingIn(tx, feeding);
|
||||||
@@ -89,18 +124,15 @@ export class AutomationEventHandlers {
|
|||||||
event: AnyDomainEventEnvelope<'ColonyStatusChanged'>,
|
event: AnyDomainEventEnvelope<'ColonyStatusChanged'>,
|
||||||
tx: Tx,
|
tx: Tx,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const colony = await tx.colony.findUnique({
|
if (!(await this.isLivingColonyIn(tx, event.aggregateId))) {
|
||||||
where: { id: event.aggregateId },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (colony === null) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.automation.evaluateColonyHealthIn(
|
await this.automation.evaluateColonyHealthIn(
|
||||||
tx,
|
tx,
|
||||||
colony.id,
|
event.aggregateId,
|
||||||
event.payload.from,
|
event.payload.from,
|
||||||
event.payload.to,
|
event.payload.to,
|
||||||
|
{ id: event.id, occurredAt: event.occurredAt },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
|
||||||
import { StockModule } from '../stock/stock.module';
|
import { StockModule } from '../stock/stock.module';
|
||||||
import { TasksModule } from '../tasks/tasks.module';
|
import { SuggestionsModule } from '../suggestions/suggestions.module';
|
||||||
import { AutomationController } from './automation.controller';
|
import { AutomationController } from './automation.controller';
|
||||||
import { AutomationEventHandlers } from './automation.handlers';
|
import { AutomationEventHandlers } from './automation.handlers';
|
||||||
import { AutomationService } from './automation.service';
|
import { AutomationService } from './automation.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Automation rules and the handlers that evaluate them on domain events.
|
* Automation rules and the handlers that evaluate them on domain events.
|
||||||
* Rule tasks go through TasksService (audit entry, TaskCreated - and with
|
* The rules propose through SuggestionsService; the task follows when the
|
||||||
* it calendar entry and task.created webhook), so no WebhooksService here.
|
* beekeeper accepts (TasksService, with calendar entry and task.created
|
||||||
|
* webhook), so neither a TasksModule nor a WebhooksService is needed here.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TasksModule, NotificationsModule, StockModule],
|
imports: [SuggestionsModule, StockModule],
|
||||||
controllers: [AutomationController],
|
controllers: [AutomationController],
|
||||||
providers: [AutomationService, AutomationEventHandlers],
|
providers: [AutomationService, AutomationEventHandlers],
|
||||||
exports: [AutomationService],
|
exports: [AutomationService],
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,11 +1,24 @@
|
|||||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||||
import { assertTransactionClient, DbClient } from '../prisma/db-client';
|
import { assertTransactionClient, DbClient } from '../prisma/db-client';
|
||||||
import { runAfterCommit } from '../common/after-commit';
|
import { runAfterCommit } from '../common/after-commit';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { addDays, berlinDayOf, berlinToday } from '../common/dates';
|
||||||
import { TasksService } from '../tasks/tasks.service';
|
import { isLivingColonyStatus } from '../colonies/colony-status';
|
||||||
|
import type { PermissionKey } from '../auth/permissions/permission-catalog';
|
||||||
import type { ColonyStateSnapshot } from '../domain-events/domain-event-types';
|
import type { ColonyStateSnapshot } from '../domain-events/domain-event-types';
|
||||||
|
import { SuggestionsService } from '../suggestions/suggestions.service';
|
||||||
|
import {
|
||||||
|
suggestionDedupeKey,
|
||||||
|
type SuggestionEvidence,
|
||||||
|
} from '../suggestions/suggestion.types';
|
||||||
|
import { treatmentWindowsIn } from '../treatments/treatment-windows';
|
||||||
import {
|
import {
|
||||||
AutomationRule,
|
AutomationRule,
|
||||||
AutomationTriggerType,
|
AutomationTriggerType,
|
||||||
@@ -14,6 +27,8 @@ import {
|
|||||||
Prisma,
|
Prisma,
|
||||||
RawMaterialKind,
|
RawMaterialKind,
|
||||||
StockItem,
|
StockItem,
|
||||||
|
Suggestion,
|
||||||
|
SuggestionIntent,
|
||||||
SwarmControlCheck,
|
SwarmControlCheck,
|
||||||
TaskRelatedType,
|
TaskRelatedType,
|
||||||
Treatment,
|
Treatment,
|
||||||
@@ -21,7 +36,13 @@ import {
|
|||||||
} from '../../generated/prisma';
|
} from '../../generated/prisma';
|
||||||
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
|
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
|
||||||
import { formatGeneratedNumber } from './number-format';
|
import { formatGeneratedNumber } from './number-format';
|
||||||
import { varroaThresholdOf } from './varroa-threshold';
|
import {
|
||||||
|
DEFAULT_RULES,
|
||||||
|
lockRuleIn,
|
||||||
|
readRule,
|
||||||
|
validateRuleConfig,
|
||||||
|
} from './rule-config';
|
||||||
|
import { rateMeasurement } from './varroa-bands';
|
||||||
|
|
||||||
type Tx = Prisma.TransactionClient;
|
type Tx = Prisma.TransactionClient;
|
||||||
|
|
||||||
@@ -32,11 +53,6 @@ export type NumberFormatTrigger =
|
|||||||
| 'RAW_MATERIAL_LOT_NUMBER_FORMAT'
|
| 'RAW_MATERIAL_LOT_NUMBER_FORMAT'
|
||||||
| 'PRODUCTION_BATCH_NUMBER_FORMAT';
|
| 'PRODUCTION_BATCH_NUMBER_FORMAT';
|
||||||
|
|
||||||
const RAW_MATERIAL_KIND_LABELS: Record<RawMaterialKind, string> = {
|
|
||||||
BEESWAX: 'Bienenwachs',
|
|
||||||
PROPOLIS: 'Propolis',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** What {ART} turns into in the raw material number format. */
|
/** What {ART} turns into in the raw material number format. */
|
||||||
export const RAW_MATERIAL_KIND_CODES: Record<RawMaterialKind, string> = {
|
export const RAW_MATERIAL_KIND_CODES: Record<RawMaterialKind, string> = {
|
||||||
BEESWAX: 'WA',
|
BEESWAX: 'WA',
|
||||||
@@ -56,115 +72,55 @@ function isUniqueViolation(error: unknown): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RuleDefaults {
|
/** The rules that propose instead of creating anything themselves. */
|
||||||
config: Record<string, unknown>;
|
export type RuleSuggestionTrigger =
|
||||||
actionTemplate: string;
|
| 'QUEENLESS_NO_EGGS'
|
||||||
/** Rules without a sensible built-in setting start switched off. */
|
| 'FEED_STATUS_CRITICAL'
|
||||||
active?: boolean;
|
| 'VARROA_THRESHOLD'
|
||||||
|
| 'SWARM_MOOD_HIGH'
|
||||||
|
| 'COLONY_HEALTH_CRITICAL'
|
||||||
|
| 'STOCK_ITEM_LOW'
|
||||||
|
| 'RAW_MATERIAL_LOW'
|
||||||
|
| 'TREATMENT_FOLLOW_UP'
|
||||||
|
| 'FEEDING_FOLLOW_UP';
|
||||||
|
|
||||||
|
export interface RuleSuggestionTarget {
|
||||||
|
relatedType: TaskRelatedType;
|
||||||
|
relatedId: string;
|
||||||
|
colonyId: string | null;
|
||||||
|
intent: SuggestionIntent;
|
||||||
|
reasonCode: string;
|
||||||
|
evidence: SuggestionEvidence;
|
||||||
|
anchor: string;
|
||||||
|
requiredPermission: PermissionKey;
|
||||||
|
dueDate?: string | null;
|
||||||
|
sourceEventId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
|
/** The anchor of a finding that has no record to hang on. */
|
||||||
QUEENLESS_NO_EGGS: {
|
const NO_ANCHOR = 'none';
|
||||||
config: {},
|
|
||||||
actionTemplate:
|
|
||||||
'Kontrolle empfohlen: Königin nicht gesehen und keine Stifte vorhanden.',
|
|
||||||
},
|
|
||||||
VARROA_THRESHOLD: {
|
|
||||||
config: { thresholdMitesPerDay: 10 },
|
|
||||||
actionTemplate: 'Behandlung prüfen: Varroa-Schwellenwert überschritten.',
|
|
||||||
},
|
|
||||||
FEED_STATUS_CRITICAL: {
|
|
||||||
config: {},
|
|
||||||
actionTemplate: 'Fütterung erforderlich: Futterstatus kritisch.',
|
|
||||||
},
|
|
||||||
SWARM_MOOD_HIGH: {
|
|
||||||
config: {},
|
|
||||||
actionTemplate:
|
|
||||||
'Schwarmkontrolle dringend: hohe Schwarmstimmung festgestellt.',
|
|
||||||
},
|
|
||||||
COLONY_HEALTH_CRITICAL: {
|
|
||||||
config: {},
|
|
||||||
actionTemplate: 'Kontrolle erforderlich: Gesundheitsstatus kritisch.',
|
|
||||||
},
|
|
||||||
STOCK_ITEM_LOW: {
|
|
||||||
config: {},
|
|
||||||
actionTemplate: 'Nachbestellung prüfen: Lagerartikel unter Mindestbestand.',
|
|
||||||
},
|
|
||||||
// Not task-generating rules like the ones above - `active` here toggles
|
|
||||||
// whether HoneyBatchesService/BottlingService auto-fill the batch/lot
|
|
||||||
// number from `config.format` instead of requiring manual entry.
|
|
||||||
HONEY_BATCH_NUMBER_FORMAT: {
|
|
||||||
config: { format: 'WH-{YYYY}-{SEQ:4}' },
|
|
||||||
actionTemplate: '',
|
|
||||||
},
|
|
||||||
BOTTLING_LOT_NUMBER_FORMAT: {
|
|
||||||
config: { format: 'WH-{YYYY}-{SEQ:4}' },
|
|
||||||
actionTemplate: '',
|
|
||||||
},
|
|
||||||
// {ART} (WA/PR) gives wax and propolis a counter each.
|
|
||||||
RAW_MATERIAL_LOT_NUMBER_FORMAT: {
|
|
||||||
config: { format: '{ART}-{YYYY}-{SEQ:4}' },
|
|
||||||
actionTemplate: '',
|
|
||||||
},
|
|
||||||
PRODUCTION_BATCH_NUMBER_FORMAT: {
|
|
||||||
config: { format: 'HS-{YYYY}-{SEQ:4}' },
|
|
||||||
actionTemplate: '',
|
|
||||||
},
|
|
||||||
// The raw-material counterpart of STOCK_ITEM_LOW. Raw material lots carry
|
|
||||||
// no minimum of their own, so the minimum per kind (config.minGrams) lives
|
|
||||||
// here - and without one there is nothing to compare, hence off by default.
|
|
||||||
RAW_MATERIAL_LOW: {
|
|
||||||
config: {},
|
|
||||||
actionTemplate: 'Rohstoff unter Mindestmenge: Nachschub einplanen.',
|
|
||||||
active: false,
|
|
||||||
},
|
|
||||||
// Follow-up checks: how many days after a treatment or feeding a check
|
|
||||||
// makes sense depends on the product and the season, so there is no
|
|
||||||
// built-in interval - the beekeeper switches the rule on and sets
|
|
||||||
// config.dueInDays.
|
|
||||||
TREATMENT_FOLLOW_UP: {
|
|
||||||
config: {},
|
|
||||||
actionTemplate: 'Behandlungserfolg kontrollieren',
|
|
||||||
active: false,
|
|
||||||
},
|
|
||||||
FEEDING_FOLLOW_UP: {
|
|
||||||
config: {},
|
|
||||||
actionTemplate: 'Futterabnahme und Futtervorrat kontrollieren',
|
|
||||||
active: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional per-rule follow-up interval (config.dueInDays). There is no
|
* Optional per-rule follow-up interval (config.dueInDays) counted from the
|
||||||
* built-in default: how soon a queenless colony or a high mite fall needs a
|
* business day. There is no built-in default: how soon a queenless colony or
|
||||||
* look depends on season and practice, so the rule stays date-less unless the
|
* a high mite fall needs a look depends on season and practice, so the
|
||||||
* beekeeper configures one.
|
* suggestion stays date-less unless the beekeeper configures one (accepting
|
||||||
|
* it then creates a task due today).
|
||||||
*/
|
*/
|
||||||
function formatDay(date: Date): string {
|
function dueDayOf(config: object, baseDay: string): string | null {
|
||||||
return date.toLocaleDateString('de-DE', {
|
const { dueInDays } = config as { dueInDays?: unknown };
|
||||||
day: '2-digit',
|
return typeof dueInDays === 'number' ? addDays(baseDay, dueInDays) : null;
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
timeZone: 'UTC',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function dueDateFromConfig(
|
function inspectionEvidence(
|
||||||
dueInDays: unknown,
|
inspection: Inspection,
|
||||||
baseDate: Date = new Date(),
|
inspectedOn: string,
|
||||||
): Date | undefined {
|
): SuggestionEvidence {
|
||||||
if (
|
return {
|
||||||
typeof dueInDays !== 'number' ||
|
colonyId: inspection.colonyId,
|
||||||
!Number.isInteger(dueInDays) ||
|
inspectionId: inspection.id,
|
||||||
dueInDays < 0 ||
|
inspectedOn,
|
||||||
dueInDays > 365
|
};
|
||||||
) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const due = new Date(baseDate);
|
|
||||||
due.setUTCHours(0, 0, 0, 0);
|
|
||||||
due.setUTCDate(due.getUTCDate() + dueInDays);
|
|
||||||
return due;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -174,8 +130,7 @@ export class AutomationService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly suggestions: SuggestionsService,
|
||||||
private readonly tasks: TasksService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -223,13 +178,34 @@ export class AutomationService {
|
|||||||
return rule;
|
return rule;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The row is locked before it is read, so "before" (and the audit entry)
|
||||||
|
* is the state this write replaces. A config with an invalid known value
|
||||||
|
* is refused with the fields named; unknown keys pass unchanged.
|
||||||
|
*/
|
||||||
async update(
|
async update(
|
||||||
id: string,
|
id: string,
|
||||||
dto: UpdateAutomationRuleDto,
|
dto: UpdateAutomationRuleDto,
|
||||||
actingUserId: string,
|
actingUserId: string,
|
||||||
): Promise<AutomationRule> {
|
): Promise<AutomationRule> {
|
||||||
const before = await this.findById(id);
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const rule = await this.prisma.automationRule.update({
|
const locked = await lockRowForUpdate(tx, 'AutomationRule', id);
|
||||||
|
const before = locked
|
||||||
|
? await tx.automationRule.findUnique({ where: { id } })
|
||||||
|
: null;
|
||||||
|
if (!before) {
|
||||||
|
throw new NotFoundException('Automation rule not found');
|
||||||
|
}
|
||||||
|
if (typeof dto.config === 'object' && dto.config !== null) {
|
||||||
|
const issues = validateRuleConfig(before.triggerType, dto.config);
|
||||||
|
if (issues.length > 0) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
message: 'Die Einstellung ist ungültig.',
|
||||||
|
issues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rule = await tx.automationRule.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
...dto,
|
...dto,
|
||||||
@@ -241,15 +217,19 @@ export class AutomationService {
|
|||||||
: (dto.config as Prisma.InputJsonValue | undefined),
|
: (dto.config as Prisma.InputJsonValue | undefined),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await this.auditLog.record({
|
await this.auditLog.record(
|
||||||
|
{
|
||||||
entityType: 'AutomationRule',
|
entityType: 'AutomationRule',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
action: 'UPDATE',
|
action: 'UPDATE',
|
||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
beforeData: before,
|
beforeData: before,
|
||||||
afterData: rule,
|
afterData: rule,
|
||||||
});
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
return rule;
|
return rule;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -260,44 +240,151 @@ export class AutomationService {
|
|||||||
// transaction, the legacy wrappers below with a transaction of their own.
|
// transaction, the legacy wrappers below with a transaction of their own.
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A new inspection: proposes new findings and follows the open ones. */
|
||||||
async evaluateInspectionIn(tx: Tx, inspection: Inspection): Promise<void> {
|
async evaluateInspectionIn(tx: Tx, inspection: Inspection): Promise<void> {
|
||||||
assertTransactionClient(tx, 'AutomationService.evaluateInspectionIn');
|
assertTransactionClient(tx, 'AutomationService.evaluateInspectionIn');
|
||||||
await this.ensureDefaults(tx);
|
await this.syncInspectionRulesIn(tx, inspection, true);
|
||||||
const rules = await tx.automationRule.findMany({
|
}
|
||||||
where: {
|
|
||||||
triggerType: { in: ['QUEENLESS_NO_EGGS', 'FEED_STATUS_CRITICAL'] },
|
/**
|
||||||
active: true,
|
* A corrected inspection proposes nothing new, but an open suggestion it
|
||||||
},
|
* supersedes follows the corrected values (resolved or refreshed).
|
||||||
|
*/
|
||||||
|
async evaluateInspectionCorrectionIn(
|
||||||
|
tx: Tx,
|
||||||
|
inspection: Inspection,
|
||||||
|
): Promise<void> {
|
||||||
|
assertTransactionClient(
|
||||||
|
tx,
|
||||||
|
'AutomationService.evaluateInspectionCorrectionIn',
|
||||||
|
);
|
||||||
|
await this.syncInspectionRulesIn(tx, inspection, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Colony lock first, then everything that decides is read under it: the
|
||||||
|
* colony's actual latest inspection (date, createdAt, id) is the only one
|
||||||
|
* that counts. An event of any other inspection - a late or retried
|
||||||
|
* delivery, a backdated entry, the correction of an older one - proposes
|
||||||
|
* nothing and changes nothing; the latest inspection has its own event.
|
||||||
|
* (An inspection moved to another colony raises no event for the colony
|
||||||
|
* it left, so a suggestion there stays until that colony's next
|
||||||
|
* inspection.)
|
||||||
|
*/
|
||||||
|
private async syncInspectionRulesIn(
|
||||||
|
tx: Tx,
|
||||||
|
inspection: Inspection,
|
||||||
|
proposeNew: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!(await lockRowForUpdate(tx, 'Colony', inspection.colonyId))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const latest = await tx.inspection.findFirst({
|
||||||
|
where: { colonyId: inspection.colonyId },
|
||||||
|
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
|
||||||
});
|
});
|
||||||
const ruleByType = new Map(rules.map((rule) => [rule.triggerType, rule]));
|
if (latest === null || latest.id !== inspection.id) {
|
||||||
|
return;
|
||||||
const queenlessRule = ruleByType.get('QUEENLESS_NO_EGGS');
|
}
|
||||||
if (
|
await this.syncInspectionRuleIn(
|
||||||
queenlessRule &&
|
|
||||||
inspection.queenSeen === false &&
|
|
||||||
inspection.eggsPresent === false
|
|
||||||
) {
|
|
||||||
await this.triggerActionIn(
|
|
||||||
tx,
|
tx,
|
||||||
queenlessRule,
|
'QUEENLESS_NO_EGGS',
|
||||||
'COLONY',
|
'QUEEN_CHECK',
|
||||||
inspection.colonyId,
|
latest,
|
||||||
inspection.id,
|
latest.queenSeen === false && latest.eggsPresent === false,
|
||||||
|
proposeNew,
|
||||||
|
);
|
||||||
|
await this.syncInspectionRuleIn(
|
||||||
|
tx,
|
||||||
|
'FEED_STATUS_CRITICAL',
|
||||||
|
'FEED',
|
||||||
|
latest,
|
||||||
|
latest.feedStatus === 'CRITICAL',
|
||||||
|
proposeNew,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
|
/**
|
||||||
if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') {
|
* One inspection rule against the colony's latest inspection (colony
|
||||||
await this.triggerActionIn(
|
* locked). No open suggestion: a met condition proposes - new inspections
|
||||||
tx,
|
* only, a correction never proposes (a corrected value is not a new
|
||||||
feedRule,
|
* finding; the beekeeper was told about the original). An open one follows
|
||||||
'COLONY',
|
* the latest inspection: condition gone, it is resolved; still met, its
|
||||||
inspection.colonyId,
|
* evidence is refreshed. The refresh keeps the original dedupeKey/anchor,
|
||||||
inspection.id,
|
* so evidence.inspectionId may differ from the anchor. An inactive rule
|
||||||
);
|
* leaves open suggestions untouched (as the varroa rule does).
|
||||||
|
*/
|
||||||
|
private async syncInspectionRuleIn(
|
||||||
|
tx: Tx,
|
||||||
|
trigger: 'QUEENLESS_NO_EGGS' | 'FEED_STATUS_CRITICAL',
|
||||||
|
intent: SuggestionIntent,
|
||||||
|
latest: Inspection,
|
||||||
|
met: boolean,
|
||||||
|
proposeNew: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
const open = await tx.suggestion.findFirst({
|
||||||
|
where: {
|
||||||
|
ruleKey: trigger,
|
||||||
|
colonyId: latest.colonyId,
|
||||||
|
status: 'OPEN',
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (open === null) {
|
||||||
|
if (met && proposeNew) {
|
||||||
|
await this.proposeOnInspectionIn(tx, trigger, intent, latest);
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rule = await readRule(tx, trigger);
|
||||||
|
if (!rule.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Colony (held), then suggestion.
|
||||||
|
await lockRowForUpdate(tx, 'Suggestion', open.id);
|
||||||
|
if (!met) {
|
||||||
|
await this.suggestions.resolveIn(tx, [open.id], 'Bedingung entfällt');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const inspectedOn = berlinDayOf(latest.date);
|
||||||
|
await this.suggestions.refreshIn(tx, open.id, {
|
||||||
|
evidence: inspectionEvidence(latest, inspectedOn),
|
||||||
|
dueDate: dueDayOf(rule.config, inspectedOn),
|
||||||
|
priority: 'HIGH',
|
||||||
|
expiresAt: null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The reason code of an inspection rule is the rule's own name. */
|
||||||
|
private async proposeOnInspectionIn(
|
||||||
|
tx: Tx,
|
||||||
|
trigger: 'QUEENLESS_NO_EGGS' | 'FEED_STATUS_CRITICAL',
|
||||||
|
intent: SuggestionIntent,
|
||||||
|
inspection: Inspection,
|
||||||
|
): Promise<void> {
|
||||||
|
const rule = await readRule(tx, trigger);
|
||||||
|
if (!rule.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const inspectedOn = berlinDayOf(inspection.date);
|
||||||
|
await this.proposeForRuleIn(tx, trigger, {
|
||||||
|
relatedType: 'COLONY',
|
||||||
|
relatedId: inspection.colonyId,
|
||||||
|
colonyId: inspection.colonyId,
|
||||||
|
intent,
|
||||||
|
reasonCode: trigger,
|
||||||
|
evidence: inspectionEvidence(inspection, inspectedOn),
|
||||||
|
anchor: inspection.id,
|
||||||
|
requiredPermission: 'inspections:read',
|
||||||
|
dueDate: dueDayOf(rule.config, inspectedOn),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A red measurement outside the effect of a treatment proposes a
|
||||||
|
* treatment check. The light comes from the same bands as the colony view
|
||||||
|
* (season, young colony, method), so rule and screen never disagree.
|
||||||
|
*/
|
||||||
async evaluateVarroaMeasurementIn(
|
async evaluateVarroaMeasurementIn(
|
||||||
tx: Tx,
|
tx: Tx,
|
||||||
measurement: VarroaMeasurement,
|
measurement: VarroaMeasurement,
|
||||||
@@ -306,26 +393,78 @@ export class AutomationService {
|
|||||||
tx,
|
tx,
|
||||||
'AutomationService.evaluateVarroaMeasurementIn',
|
'AutomationService.evaluateVarroaMeasurementIn',
|
||||||
);
|
);
|
||||||
await this.ensureDefaults(tx);
|
const rule = await readRule(tx, 'VARROA_THRESHOLD');
|
||||||
const rule = await tx.automationRule.findUnique({
|
if (!rule.active) {
|
||||||
where: { triggerType: 'VARROA_THRESHOLD' },
|
return;
|
||||||
|
}
|
||||||
|
const colony = await tx.colony.findUnique({
|
||||||
|
where: { id: measurement.colonyId },
|
||||||
|
select: { colonyType: true },
|
||||||
});
|
});
|
||||||
// null for an inactive rule; the one source of the default value.
|
if (colony === null) {
|
||||||
const threshold = varroaThresholdOf(rule);
|
|
||||||
if (!rule || threshold === null) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The threshold is a natural mite fall rate (mites per day). Only that
|
const rating = rateMeasurement(
|
||||||
// method yields a value in this unit - a mite count from a sugar roll or
|
measurement,
|
||||||
// wash is per bee sample and would be compared against the wrong scale,
|
colony.colonyType,
|
||||||
// as would a raw board count without the number of days it covered.
|
rule.config,
|
||||||
if (measurement.method !== 'NATURAL_MITE_FALL') {
|
await treatmentWindowsIn(tx, measurement.colonyId),
|
||||||
|
);
|
||||||
|
// A correction can move a measurement out of red (or into it): an open
|
||||||
|
// suggestion of this measurement follows the corrected value.
|
||||||
|
const open = await tx.suggestion.findFirst({
|
||||||
|
where: {
|
||||||
|
ruleKey: 'VARROA_THRESHOLD',
|
||||||
|
status: 'OPEN',
|
||||||
|
dedupeKey: suggestionDedupeKey(
|
||||||
|
'VARROA_THRESHOLD',
|
||||||
|
measurement.colonyId,
|
||||||
|
measurement.id,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
rating.light !== 'red' ||
|
||||||
|
rating.band === null ||
|
||||||
|
measurement.calculatedValue === null
|
||||||
|
) {
|
||||||
|
if (open !== null) {
|
||||||
|
await this.suggestions.resolveIn(tx, [open.id], 'Bedingung entfällt');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const value = measurement.calculatedValue;
|
const measuredOn = berlinDayOf(measurement.date);
|
||||||
if (value !== null && value >= threshold) {
|
const evidence = {
|
||||||
await this.triggerActionIn(tx, rule, 'COLONY', measurement.colonyId);
|
colonyId: measurement.colonyId,
|
||||||
|
measurementId: measurement.id,
|
||||||
|
measuredOn,
|
||||||
|
value: measurement.calculatedValue,
|
||||||
|
redAbove: rating.band.redAbove,
|
||||||
|
unit: rating.band.unit,
|
||||||
|
};
|
||||||
|
const dueDate = dueDayOf(rule.config, measuredOn);
|
||||||
|
if (open !== null) {
|
||||||
|
await this.suggestions.refreshIn(tx, open.id, {
|
||||||
|
reasonCode: 'VARROA_RED',
|
||||||
|
evidence,
|
||||||
|
dueDate,
|
||||||
|
priority: 'HIGH',
|
||||||
|
expiresAt: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
await this.proposeForRuleIn(tx, 'VARROA_THRESHOLD', {
|
||||||
|
relatedType: 'COLONY',
|
||||||
|
relatedId: measurement.colonyId,
|
||||||
|
colonyId: measurement.colonyId,
|
||||||
|
intent: 'VARROA_TREAT',
|
||||||
|
reasonCode: 'VARROA_RED',
|
||||||
|
evidence,
|
||||||
|
anchor: measurement.id,
|
||||||
|
requiredPermission: 'varroa:read',
|
||||||
|
dueDate,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async evaluateSwarmControlCheckIn(
|
async evaluateSwarmControlCheckIn(
|
||||||
@@ -336,24 +475,38 @@ export class AutomationService {
|
|||||||
tx,
|
tx,
|
||||||
'AutomationService.evaluateSwarmControlCheckIn',
|
'AutomationService.evaluateSwarmControlCheckIn',
|
||||||
);
|
);
|
||||||
await this.ensureDefaults(tx);
|
if (check.swarmMood !== 'HIGH') {
|
||||||
const rule = await tx.automationRule.findUnique({
|
|
||||||
where: { triggerType: 'SWARM_MOOD_HIGH' },
|
|
||||||
});
|
|
||||||
if (!rule || !rule.active) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (check.swarmMood === 'HIGH') {
|
const rule = await readRule(tx, 'SWARM_MOOD_HIGH');
|
||||||
await this.triggerActionIn(tx, rule, 'COLONY', check.colonyId);
|
if (!rule.active) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
const checkedOn = berlinDayOf(check.date);
|
||||||
|
await this.proposeForRuleIn(tx, 'SWARM_MOOD_HIGH', {
|
||||||
|
relatedType: 'COLONY',
|
||||||
|
relatedId: check.colonyId,
|
||||||
|
colonyId: check.colonyId,
|
||||||
|
intent: 'SWARM_CHECK',
|
||||||
|
reasonCode: 'SWARM_MOOD_HIGH',
|
||||||
|
evidence: { colonyId: check.colonyId, checkId: check.id, checkedOn },
|
||||||
|
anchor: check.id,
|
||||||
|
requiredPermission: 'swarm-control:read',
|
||||||
|
dueDate: dueDayOf(rule.config, checkedOn),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fires only on the transition into CRITICAL, not on every edit while already critical. */
|
/**
|
||||||
|
* Fires only on the transition into CRITICAL, not on every edit while
|
||||||
|
* already critical. The event is the anchor: a later transition into
|
||||||
|
* CRITICAL is a new finding.
|
||||||
|
*/
|
||||||
async evaluateColonyHealthIn(
|
async evaluateColonyHealthIn(
|
||||||
tx: Tx,
|
tx: Tx,
|
||||||
colonyId: string,
|
colonyId: string,
|
||||||
from: ColonyStateSnapshot,
|
from: ColonyStateSnapshot,
|
||||||
to: ColonyStateSnapshot,
|
to: ColonyStateSnapshot,
|
||||||
|
event: { id: string; occurredAt: Date },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
assertTransactionClient(tx, 'AutomationService.evaluateColonyHealthIn');
|
assertTransactionClient(tx, 'AutomationService.evaluateColonyHealthIn');
|
||||||
// ColonyStatusChanged comes with every status or health edit: decide on
|
// ColonyStatusChanged comes with every status or health edit: decide on
|
||||||
@@ -361,16 +514,29 @@ export class AutomationService {
|
|||||||
if (to.healthStatus !== 'CRITICAL' || from.healthStatus === 'CRITICAL') {
|
if (to.healthStatus !== 'CRITICAL' || from.healthStatus === 'CRITICAL') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.ensureDefaults(tx);
|
const rule = await readRule(tx, 'COLONY_HEALTH_CRITICAL');
|
||||||
const rule = await tx.automationRule.findUnique({
|
if (!rule.active) {
|
||||||
where: { triggerType: 'COLONY_HEALTH_CRITICAL' },
|
|
||||||
});
|
|
||||||
if (!rule || !rule.active) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.triggerActionIn(tx, rule, 'COLONY', colonyId);
|
const changedOn = berlinDayOf(event.occurredAt);
|
||||||
|
await this.proposeForRuleIn(tx, 'COLONY_HEALTH_CRITICAL', {
|
||||||
|
relatedType: 'COLONY',
|
||||||
|
relatedId: colonyId,
|
||||||
|
colonyId,
|
||||||
|
intent: 'HEALTH_CHECK',
|
||||||
|
reasonCode: 'COLONY_HEALTH_CRITICAL',
|
||||||
|
evidence: { colonyId, changedOn },
|
||||||
|
anchor: event.id,
|
||||||
|
requiredPermission: 'colonies:read',
|
||||||
|
dueDate: dueDayOf(rule.config, changedOn),
|
||||||
|
sourceEventId: event.id,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anchored on the newest incoming movement: a dismissed reorder stays
|
||||||
|
* dismissed until the item was restocked and ran low again.
|
||||||
|
*/
|
||||||
async evaluateStockItemIn(tx: Tx, item: StockItem): Promise<void> {
|
async evaluateStockItemIn(tx: Tx, item: StockItem): Promise<void> {
|
||||||
assertTransactionClient(tx, 'AutomationService.evaluateStockItemIn');
|
assertTransactionClient(tx, 'AutomationService.evaluateStockItemIn');
|
||||||
// Every movement of the item comes here: only one below its minimum
|
// Every movement of the item comes here: only one below its minimum
|
||||||
@@ -378,22 +544,39 @@ export class AutomationService {
|
|||||||
if (item.minThreshold === null || item.quantity >= item.minThreshold) {
|
if (item.minThreshold === null || item.quantity >= item.minThreshold) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.ensureDefaults(tx);
|
const rule = await readRule(tx, 'STOCK_ITEM_LOW');
|
||||||
const rule = await tx.automationRule.findUnique({
|
if (!rule.active) {
|
||||||
where: { triggerType: 'STOCK_ITEM_LOW' },
|
|
||||||
});
|
|
||||||
if (!rule || !rule.active) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.triggerActionIn(tx, rule, 'STORAGE', item.id);
|
const restock = await tx.stockMovement.findFirst({
|
||||||
|
where: { stockItemId: item.id, type: 'IN' },
|
||||||
|
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
await this.proposeForRuleIn(tx, 'STOCK_ITEM_LOW', {
|
||||||
|
relatedType: 'STORAGE',
|
||||||
|
relatedId: item.id,
|
||||||
|
colonyId: null,
|
||||||
|
intent: 'STOCK_REORDER',
|
||||||
|
reasonCode: 'STOCK_ITEM_LOW',
|
||||||
|
evidence: {
|
||||||
|
stockItemId: item.id,
|
||||||
|
quantity: item.quantity,
|
||||||
|
minThreshold: item.minThreshold,
|
||||||
|
},
|
||||||
|
anchor: restock?.id ?? NO_ANCHOR,
|
||||||
|
requiredPermission: 'storage:read',
|
||||||
|
dueDate: dueDayOf(rule.config, berlinToday()),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* After raw material left its lots (consumption, withdrawal, inventory):
|
* After raw material left its lots (consumption, withdrawal, inventory):
|
||||||
* compares what is left of the kind across all lots with config.minGrams.
|
* compares what is left of the kind across all lots with config.minGrams.
|
||||||
* The task is about the kind, not one lot - a new lot of the same kind is
|
* The suggestion is about the kind, not one lot - a new lot of the same
|
||||||
* the remedy - so it hangs on GENERAL with the kind as relatedId, which
|
* kind is the remedy - so it hangs on GENERAL with the kind as relatedId,
|
||||||
* keeps one open task per kind.
|
* which keeps one open suggestion per kind, and is anchored on the newest
|
||||||
|
* lot of the kind.
|
||||||
*/
|
*/
|
||||||
async evaluateRawMaterialLowIn(
|
async evaluateRawMaterialLowIn(
|
||||||
tx: Tx,
|
tx: Tx,
|
||||||
@@ -401,69 +584,73 @@ export class AutomationService {
|
|||||||
remainingGrams: number,
|
remainingGrams: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
assertTransactionClient(tx, 'AutomationService.evaluateRawMaterialLowIn');
|
assertTransactionClient(tx, 'AutomationService.evaluateRawMaterialLowIn');
|
||||||
await this.ensureDefaults(tx);
|
const rule = await readRule(tx, 'RAW_MATERIAL_LOW');
|
||||||
const rule = await tx.automationRule.findUnique({
|
if (!rule.active) {
|
||||||
where: { triggerType: 'RAW_MATERIAL_LOW' },
|
|
||||||
});
|
|
||||||
if (!rule || !rule.active) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const config = rule.config as {
|
const minGrams = rule.config.minGrams?.[kind];
|
||||||
minGrams?: Partial<Record<RawMaterialKind, unknown>>;
|
|
||||||
} | null;
|
|
||||||
const minGrams = config?.minGrams?.[kind];
|
|
||||||
if (typeof minGrams !== 'number' || remainingGrams >= minGrams) {
|
if (typeof minGrams !== 'number' || remainingGrams >= minGrams) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.triggerActionIn(tx, rule, 'GENERAL', kind, undefined, {
|
const newestLot = await tx.rawMaterialLot.findFirst({
|
||||||
description: `${RAW_MATERIAL_KIND_LABELS[kind]}: noch ${remainingGrams} g auf Lager, Mindestmenge ${minGrams} g.`,
|
where: { kind },
|
||||||
|
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
await this.proposeForRuleIn(tx, 'RAW_MATERIAL_LOW', {
|
||||||
|
relatedType: 'GENERAL',
|
||||||
|
relatedId: kind,
|
||||||
|
colonyId: null,
|
||||||
|
intent: 'STOCK_REORDER',
|
||||||
|
reasonCode: 'RAW_MATERIAL_LOW',
|
||||||
|
evidence: { kind, remainingGrams, minGrams },
|
||||||
|
anchor: newestLot?.id ?? NO_ANCHOR,
|
||||||
|
requiredPermission: 'raw-material:read',
|
||||||
|
dueDate: dueDayOf(rule.config, berlinToday()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async evaluateTreatmentIn(tx: Tx, treatment: Treatment): Promise<void> {
|
async evaluateTreatmentIn(tx: Tx, treatment: Treatment): Promise<void> {
|
||||||
assertTransactionClient(tx, 'AutomationService.evaluateTreatmentIn');
|
assertTransactionClient(tx, 'AutomationService.evaluateTreatmentIn');
|
||||||
const measureDate = treatment.endDate ?? treatment.date;
|
const rule = await readRule(tx, 'TREATMENT_FOLLOW_UP');
|
||||||
await this.evaluateFollowUpIn(
|
if (!rule.active) {
|
||||||
tx,
|
return;
|
||||||
'TREATMENT_FOLLOW_UP',
|
}
|
||||||
treatment.colonyId,
|
const endedOn = berlinDayOf(treatment.endDate ?? treatment.date);
|
||||||
measureDate,
|
await this.proposeForRuleIn(tx, 'TREATMENT_FOLLOW_UP', {
|
||||||
`Nach Behandlung mit ${treatment.product} am ${formatDay(measureDate)}.`,
|
relatedType: 'COLONY',
|
||||||
);
|
relatedId: treatment.colonyId,
|
||||||
|
colonyId: treatment.colonyId,
|
||||||
|
intent: 'FOLLOW_UP',
|
||||||
|
reasonCode: 'TREATMENT_FOLLOW_UP',
|
||||||
|
evidence: {
|
||||||
|
colonyId: treatment.colonyId,
|
||||||
|
treatmentId: treatment.id,
|
||||||
|
endedOn,
|
||||||
|
},
|
||||||
|
anchor: treatment.id,
|
||||||
|
requiredPermission: 'treatments:read',
|
||||||
|
dueDate: dueDayOf(rule.config, endedOn),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async evaluateFeedingIn(tx: Tx, feeding: Feeding): Promise<void> {
|
async evaluateFeedingIn(tx: Tx, feeding: Feeding): Promise<void> {
|
||||||
assertTransactionClient(tx, 'AutomationService.evaluateFeedingIn');
|
assertTransactionClient(tx, 'AutomationService.evaluateFeedingIn');
|
||||||
await this.evaluateFollowUpIn(
|
const rule = await readRule(tx, 'FEEDING_FOLLOW_UP');
|
||||||
tx,
|
if (!rule.active) {
|
||||||
'FEEDING_FOLLOW_UP',
|
|
||||||
feeding.colonyId,
|
|
||||||
feeding.date,
|
|
||||||
`Nach Fütterung (${feeding.amount} ${feeding.unit}) am ${formatDay(feeding.date)}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A check counted from the measure's own date, not from today. Its public
|
|
||||||
* callers check the transaction client, so an error names them.
|
|
||||||
*/
|
|
||||||
private async evaluateFollowUpIn(
|
|
||||||
tx: Tx,
|
|
||||||
triggerType: 'TREATMENT_FOLLOW_UP' | 'FEEDING_FOLLOW_UP',
|
|
||||||
colonyId: string,
|
|
||||||
measureDate: Date,
|
|
||||||
description: string,
|
|
||||||
): Promise<void> {
|
|
||||||
await this.ensureDefaults(tx);
|
|
||||||
const rule = await tx.automationRule.findUnique({
|
|
||||||
where: { triggerType },
|
|
||||||
});
|
|
||||||
if (!rule || !rule.active) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.triggerActionIn(tx, rule, 'COLONY', colonyId, undefined, {
|
const fedOn = berlinDayOf(feeding.date);
|
||||||
baseDate: measureDate,
|
await this.proposeForRuleIn(tx, 'FEEDING_FOLLOW_UP', {
|
||||||
description,
|
relatedType: 'COLONY',
|
||||||
|
relatedId: feeding.colonyId,
|
||||||
|
colonyId: feeding.colonyId,
|
||||||
|
intent: 'FOLLOW_UP',
|
||||||
|
reasonCode: 'FEEDING_FOLLOW_UP',
|
||||||
|
evidence: { colonyId: feeding.colonyId, feedingId: feeding.id, fedOn },
|
||||||
|
anchor: feeding.id,
|
||||||
|
requiredPermission: 'feedings:read',
|
||||||
|
dueDate: dueDayOf(rule.config, fedOn),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -593,76 +780,80 @@ export class AutomationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Leaves a suggestion task (never a record of work done) plus a warning,
|
* Proposes what a rule found, on the caller's transaction: the suggestion
|
||||||
* all on the caller's transaction: task, its audit entry and TaskCreated
|
* (SuggestionsService.proposeIn: colony then rule lock, one open thing per
|
||||||
* (TasksService.createInTx - the task.created webhook and the calendar
|
* intent and object, an anchor is proposed once, the notification when the
|
||||||
* entry follow from that event), the warning and the rule's audit entry.
|
* rule says `notify`) and the rule's audit entry. A failing write rolls
|
||||||
* A failing write rolls back all of it; a handler delivery is retried.
|
* back all of it; a handler delivery is retried. Resolves to null when
|
||||||
|
* nothing was proposed (rule off, colony ended, anchor or intent taken).
|
||||||
*
|
*
|
||||||
* At most one open task per rule and object: while the reorder task for a
|
* The colony is locked and read before the rule is locked - the order
|
||||||
* stock item or the check task for a colony is still open, further triggers
|
* every proposal uses - so a colony that ends in between is not missed.
|
||||||
* (the next removal below minimum, a second high varroa count) add nothing.
|
|
||||||
* Repeating the same alarm would only bury the one task that matters.
|
|
||||||
*
|
|
||||||
* The check runs under a lock on the rule's row, held until the caller
|
|
||||||
* commits: two evaluations at the same moment (parallel requests, a
|
|
||||||
* handler and a legacy path, several pods) would otherwise both see
|
|
||||||
* "no open task" and both create one.
|
|
||||||
*/
|
*/
|
||||||
private async triggerActionIn(
|
async proposeForRuleIn(
|
||||||
tx: Tx,
|
tx: Tx,
|
||||||
rule: AutomationRule,
|
trigger: RuleSuggestionTrigger,
|
||||||
relatedType: TaskRelatedType,
|
target: RuleSuggestionTarget,
|
||||||
relatedId: string,
|
): Promise<Suggestion | null> {
|
||||||
sourceInspectionId?: string,
|
assertTransactionClient(tx, 'AutomationService.proposeForRuleIn');
|
||||||
options: { baseDate?: Date; description?: string } = {},
|
if (
|
||||||
): Promise<void> {
|
target.colonyId !== null &&
|
||||||
await tx.$queryRaw`SELECT id FROM "AutomationRule" WHERE id = ${rule.id} FOR UPDATE`;
|
!(await this.isLivingColonyLockedIn(tx, target.colonyId))
|
||||||
const alreadyOpen = await tx.task.findFirst({
|
) {
|
||||||
where: {
|
return null;
|
||||||
sourceAutomationTrigger: rule.triggerType,
|
|
||||||
relatedType,
|
|
||||||
relatedId,
|
|
||||||
status: { in: ['OPEN', 'IN_PROGRESS'] },
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (alreadyOpen) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const title = rule.actionTemplate ?? 'Automatisierte Aufgabe';
|
const rule = await lockRuleIn(tx, trigger);
|
||||||
const config = rule.config as { dueInDays?: unknown } | null;
|
if (!rule.active) {
|
||||||
const task = await this.tasks.createInTx(
|
return null;
|
||||||
tx,
|
}
|
||||||
{
|
const suggestion = await this.suggestions.proposeIn(tx, {
|
||||||
title,
|
ruleKey: trigger,
|
||||||
|
source: 'RULE',
|
||||||
|
mode: 'PLAN',
|
||||||
|
intent: target.intent,
|
||||||
priority: 'HIGH',
|
priority: 'HIGH',
|
||||||
relatedType,
|
relatedType: target.relatedType,
|
||||||
relatedId,
|
relatedId: target.relatedId,
|
||||||
sourceInspectionId,
|
colonyId: target.colonyId,
|
||||||
sourceAutomationTrigger: rule.triggerType,
|
reasonCode: target.reasonCode,
|
||||||
description: options.description,
|
evidence: target.evidence,
|
||||||
dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate),
|
requiredPermission: target.requiredPermission,
|
||||||
},
|
dueDate: target.dueDate ?? null,
|
||||||
null,
|
anchor: target.anchor,
|
||||||
);
|
sourceEventId: target.sourceEventId ?? null,
|
||||||
await this.notifications.record(
|
notify: rule.config.notify,
|
||||||
{ priority: 'WARNUNG', message: title, relatedType, relatedId },
|
});
|
||||||
tx,
|
if (suggestion === null) {
|
||||||
);
|
return null;
|
||||||
|
}
|
||||||
await this.auditLog.record(
|
await this.auditLog.record(
|
||||||
{
|
{
|
||||||
entityType: 'AutomationRule',
|
entityType: 'AutomationRule',
|
||||||
entityId: rule.id,
|
entityId: rule.id,
|
||||||
action: 'CREATE',
|
action: 'CREATE',
|
||||||
afterData: {
|
afterData: {
|
||||||
taskId: task.id,
|
suggestionId: suggestion.id,
|
||||||
relatedType,
|
relatedType: target.relatedType,
|
||||||
relatedId,
|
relatedId: target.relatedId,
|
||||||
triggerType: rule.triggerType,
|
triggerType: trigger,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
|
return suggestion;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isLivingColonyLockedIn(
|
||||||
|
tx: Tx,
|
||||||
|
colonyId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!(await lockRowForUpdate(tx, 'Colony', colonyId))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const colony = await tx.colony.findUnique({
|
||||||
|
where: { id: colonyId },
|
||||||
|
select: { status: true },
|
||||||
|
});
|
||||||
|
return colony !== null && isLivingColonyStatus(colony.status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { bool, intIn, makeParser } from './parse-helpers';
|
||||||
|
import type { ChainConfig } from './rule-config.types';
|
||||||
|
|
||||||
|
const MAX_DUE_IN_DAYS = 365;
|
||||||
|
|
||||||
|
/** Follow-up chains (queen cells, queen acceptance): one check some days later. */
|
||||||
|
export function chainParser(trigger: string, defaults: ChainConfig) {
|
||||||
|
return makeParser<ChainConfig>(trigger, defaults, (scope) => ({
|
||||||
|
dueInDays: intIn(
|
||||||
|
scope,
|
||||||
|
'dueInDays',
|
||||||
|
defaults.dueInDays,
|
||||||
|
0,
|
||||||
|
MAX_DUE_IN_DAYS,
|
||||||
|
),
|
||||||
|
notify: bool(scope, 'notify', defaults.notify),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_CHAIN_CONFIG: ChainConfig = {
|
||||||
|
dueInDays: 7,
|
||||||
|
notify: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_WITHDRAWAL_ENDED_CONFIG = { notify: true };
|
||||||
|
|
||||||
|
export const withdrawalEndedParser = makeParser<{ notify: boolean }>(
|
||||||
|
'WITHDRAWAL_ENDED',
|
||||||
|
DEFAULT_WITHDRAWAL_ENDED_CONFIG,
|
||||||
|
(scope) => ({
|
||||||
|
notify: bool(scope, 'notify', DEFAULT_WITHDRAWAL_ENDED_CONFIG.notify),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The cleanup rule has no setting; it is only switched on or off. */
|
||||||
|
export const colonyEndedCleanupParser = makeParser<Record<string, never>>(
|
||||||
|
'COLONY_ENDED_CLEANUP',
|
||||||
|
{},
|
||||||
|
() => ({}),
|
||||||
|
);
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { AutomationTriggerType } from '../../../generated/prisma';
|
||||||
|
import { RULE_CONFIG_PARSERS } from './rule-config.registry';
|
||||||
|
|
||||||
|
export interface RuleDefaults {
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
actionTemplate: string;
|
||||||
|
/** Rules without a sensible built-in setting start switched off. */
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Texts of the rules that create a task; the new rules have none yet. */
|
||||||
|
const ACTION_TEMPLATES: Record<AutomationTriggerType, string> = {
|
||||||
|
QUEENLESS_NO_EGGS:
|
||||||
|
'Kontrolle empfohlen: Königin nicht gesehen und keine Stifte vorhanden.',
|
||||||
|
VARROA_THRESHOLD: 'Behandlung prüfen: Varroa-Schwellenwert überschritten.',
|
||||||
|
FEED_STATUS_CRITICAL: 'Fütterung erforderlich: Futterstatus kritisch.',
|
||||||
|
SWARM_MOOD_HIGH:
|
||||||
|
'Schwarmkontrolle dringend: hohe Schwarmstimmung festgestellt.',
|
||||||
|
COLONY_HEALTH_CRITICAL: 'Kontrolle erforderlich: Gesundheitsstatus kritisch.',
|
||||||
|
STOCK_ITEM_LOW: 'Nachbestellung prüfen: Lagerartikel unter Mindestbestand.',
|
||||||
|
// Not task-generating rules like the ones above - `active` here toggles
|
||||||
|
// whether HoneyBatchesService/BottlingService auto-fill the batch/lot
|
||||||
|
// number from `config.format` instead of requiring manual entry.
|
||||||
|
HONEY_BATCH_NUMBER_FORMAT: '',
|
||||||
|
BOTTLING_LOT_NUMBER_FORMAT: '',
|
||||||
|
RAW_MATERIAL_LOT_NUMBER_FORMAT: '',
|
||||||
|
PRODUCTION_BATCH_NUMBER_FORMAT: '',
|
||||||
|
RAW_MATERIAL_LOW: 'Rohstoff unter Mindestmenge: Nachschub einplanen.',
|
||||||
|
TREATMENT_FOLLOW_UP: 'Behandlungserfolg kontrollieren',
|
||||||
|
FEEDING_FOLLOW_UP: 'Futterabnahme und Futtervorrat kontrollieren',
|
||||||
|
INSPECTION_INTERVAL: '',
|
||||||
|
QUEEN_CELLS_SWARM_CHECK: '',
|
||||||
|
QUEEN_ACCEPTANCE_CHECK: '',
|
||||||
|
SPLIT_SCHEDULE: '',
|
||||||
|
COLONY_ENDED_CLEANUP: '',
|
||||||
|
WITHDRAWAL_ENDED: '',
|
||||||
|
HONEY_WATER_CONTENT: '',
|
||||||
|
SEASON_AUTOPILOT: '',
|
||||||
|
PHENOLOGY: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// - RAW_MATERIAL_LOW: raw material lots carry no minimum of their own, so
|
||||||
|
// the minimum per kind (config.minGrams) lives on the rule - and without
|
||||||
|
// one there is nothing to compare, hence off by default.
|
||||||
|
// - Follow-up checks: how many days after a treatment or feeding a check
|
||||||
|
// makes sense depends on the product and the season, so there is no
|
||||||
|
// built-in interval - the beekeeper switches the rule on and sets
|
||||||
|
// config.dueInDays.
|
||||||
|
const INACTIVE_BY_DEFAULT: ReadonlySet<AutomationTriggerType> = new Set([
|
||||||
|
'RAW_MATERIAL_LOW',
|
||||||
|
'TREATMENT_FOLLOW_UP',
|
||||||
|
'FEEDING_FOLLOW_UP',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function defaultsOf(trigger: AutomationTriggerType): RuleDefaults {
|
||||||
|
return {
|
||||||
|
config: structuredClone(RULE_CONFIG_PARSERS[trigger].defaults) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>,
|
||||||
|
actionTemplate: ACTION_TEMPLATES[trigger],
|
||||||
|
...(INACTIVE_BY_DEFAULT.has(trigger) ? { active: false } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The rule rows created on first use; the config is the parser's default. */
|
||||||
|
export const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> =
|
||||||
|
Object.fromEntries(
|
||||||
|
(Object.keys(ACTION_TEMPLATES) as AutomationTriggerType[]).map(
|
||||||
|
(trigger) => [trigger, defaultsOf(trigger)],
|
||||||
|
),
|
||||||
|
) as Record<AutomationTriggerType, RuleDefaults>;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export * from './rule-config.types';
|
||||||
|
export {
|
||||||
|
RULE_CONFIG_PARSERS,
|
||||||
|
ruleConfigOf,
|
||||||
|
validateRuleConfig,
|
||||||
|
} from './rule-config.registry';
|
||||||
|
export { DEFAULT_RULES, type RuleDefaults } from './default-rules';
|
||||||
|
export { lockRuleIn, readRule, type ActiveRule } from './rule-config.reader';
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { SeasonSegmentKey } from '../../season/season-types';
|
||||||
|
import { childScope, intIn, makeParser } from './parse-helpers';
|
||||||
|
import type { InspectionIntervalConfig } from './rule-config.types';
|
||||||
|
|
||||||
|
type IntervalSegment = Exclude<SeasonSegmentKey, 'WINTERRUHE'>;
|
||||||
|
|
||||||
|
const MIN_INTERVAL_DAYS = 7;
|
||||||
|
const MAX_INTERVAL_DAYS = 35;
|
||||||
|
/** In the swarm season a colony is looked at weekly, at the latest every 9 days. */
|
||||||
|
const MAX_SWARM_INTERVAL_DAYS = 9;
|
||||||
|
|
||||||
|
export const DEFAULT_INSPECTION_INTERVAL_CONFIG: InspectionIntervalConfig = {
|
||||||
|
intervalDays: {
|
||||||
|
FRUEHJAHR: 14,
|
||||||
|
SCHWARMZEIT: 7,
|
||||||
|
SOMMER: 14,
|
||||||
|
SPAETSOMMER: 21,
|
||||||
|
HERBST: 21,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const SEGMENTS = Object.keys(
|
||||||
|
DEFAULT_INSPECTION_INTERVAL_CONFIG.intervalDays,
|
||||||
|
) as IntervalSegment[];
|
||||||
|
|
||||||
|
export const inspectionIntervalParser = makeParser<InspectionIntervalConfig>(
|
||||||
|
'INSPECTION_INTERVAL',
|
||||||
|
DEFAULT_INSPECTION_INTERVAL_CONFIG,
|
||||||
|
(scope) => {
|
||||||
|
const days = childScope(scope, 'intervalDays');
|
||||||
|
const defaults = DEFAULT_INSPECTION_INTERVAL_CONFIG.intervalDays;
|
||||||
|
const intervalDays = { ...defaults };
|
||||||
|
for (const segment of SEGMENTS) {
|
||||||
|
intervalDays[segment] = intIn(
|
||||||
|
days,
|
||||||
|
segment,
|
||||||
|
defaults[segment],
|
||||||
|
MIN_INTERVAL_DAYS,
|
||||||
|
segment === 'SCHWARMZEIT' ? MAX_SWARM_INTERVAL_DAYS : MAX_INTERVAL_DAYS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { intervalDays };
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import type { RuleConfigIssue, RuleConfigParser } from './rule-config.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a value is read from: the object, its field path (for messages) and
|
||||||
|
* the list the issues go to. Helpers never throw - an invalid or missing key
|
||||||
|
* gives the fallback, and an invalid one that is present is reported.
|
||||||
|
*/
|
||||||
|
export interface Scope {
|
||||||
|
readonly raw: Readonly<Record<string, unknown>>;
|
||||||
|
readonly path: string;
|
||||||
|
readonly issues: RuleConfigIssue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_VALUES: Readonly<Record<string, unknown>> = Object.freeze({});
|
||||||
|
|
||||||
|
export function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rootScope(json: unknown, issues: RuleConfigIssue[]): Scope {
|
||||||
|
return { raw: asRecord(json) ?? NO_VALUES, path: '', issues };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fieldPath(scope: Scope, key: string): string {
|
||||||
|
return scope.path === '' ? key : `${scope.path}.${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function report(scope: Scope, key: string, message: string): void {
|
||||||
|
scope.issues.push({ field: fieldPath(scope, key), message });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key counts as given when it is present - an explicit null too, which the
|
||||||
|
* readers then report as invalid instead of silently using the default.
|
||||||
|
*/
|
||||||
|
export function has(scope: Scope, key: string): boolean {
|
||||||
|
if (!Object.hasOwn(scope.raw, key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return scope.raw[key] !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An explicit null: on an optional key it clears the value. */
|
||||||
|
export function isCleared(scope: Scope, key: string): boolean {
|
||||||
|
return Object.hasOwn(scope.raw, key) && scope.raw[key] === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The nested object under `key`; a non-object is reported and read as empty. */
|
||||||
|
export function childScope(scope: Scope, key: string): Scope {
|
||||||
|
const path = fieldPath(scope, key);
|
||||||
|
if (!has(scope, key)) {
|
||||||
|
return { raw: NO_VALUES, path, issues: scope.issues };
|
||||||
|
}
|
||||||
|
const nested = asRecord(scope.raw[key]);
|
||||||
|
if (nested === null) {
|
||||||
|
report(scope, key, 'Bitte die Angaben als Gruppe senden.');
|
||||||
|
return { raw: NO_VALUES, path, issues: scope.issues };
|
||||||
|
}
|
||||||
|
return { raw: nested, path, issues: scope.issues };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs `read`; if it reported anything, `fallback()` replaces its result. */
|
||||||
|
export function orFallback<T>(
|
||||||
|
scope: Scope,
|
||||||
|
read: () => T,
|
||||||
|
fallback: () => T,
|
||||||
|
): T {
|
||||||
|
const before = scope.issues.length;
|
||||||
|
const value = read();
|
||||||
|
return scope.issues.length > before ? fallback() : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberFormat = new Intl.NumberFormat('de-DE', {
|
||||||
|
maximumFractionDigits: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function formatDe(value: number): string {
|
||||||
|
return numberFormat.format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function intIn(
|
||||||
|
scope: Scope,
|
||||||
|
key: string,
|
||||||
|
fallback: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
): number {
|
||||||
|
if (!has(scope, key)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const value = scope.raw[key];
|
||||||
|
if (
|
||||||
|
typeof value === 'number' &&
|
||||||
|
Number.isInteger(value) &&
|
||||||
|
value >= min &&
|
||||||
|
value <= max
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
report(
|
||||||
|
scope,
|
||||||
|
key,
|
||||||
|
`Bitte eine ganze Zahl zwischen ${formatDe(min)} und ${formatDe(max)} wählen.`,
|
||||||
|
);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Like intIn, but an absent key stays undefined. */
|
||||||
|
export function optionalIntIn(
|
||||||
|
scope: Scope,
|
||||||
|
key: string,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
): number | undefined {
|
||||||
|
if (!has(scope, key) || isCleared(scope, key)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const value = intIn(scope, key, Number.NaN, min, max);
|
||||||
|
return Number.isNaN(value) ? undefined : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberOptions {
|
||||||
|
/** The lower limit itself is not allowed (min < x). */
|
||||||
|
exclusiveMin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function numberIn(
|
||||||
|
scope: Scope,
|
||||||
|
key: string,
|
||||||
|
fallback: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
options: NumberOptions = {},
|
||||||
|
): number {
|
||||||
|
if (!has(scope, key)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const value = scope.raw[key];
|
||||||
|
if (
|
||||||
|
typeof value === 'number' &&
|
||||||
|
Number.isFinite(value) &&
|
||||||
|
(options.exclusiveMin ? value > min : value >= min) &&
|
||||||
|
value <= max
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const lower = options.exclusiveMin
|
||||||
|
? `größer als ${formatDe(min)}`
|
||||||
|
: `mindestens ${formatDe(min)}`;
|
||||||
|
report(
|
||||||
|
scope,
|
||||||
|
key,
|
||||||
|
Number.isFinite(max)
|
||||||
|
? `Bitte eine Zahl ${lower} und höchstens ${formatDe(max)} angeben.`
|
||||||
|
: `Bitte eine Zahl ${lower} angeben.`,
|
||||||
|
);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Like numberIn, but an absent key stays undefined. */
|
||||||
|
export function optionalNumberIn(
|
||||||
|
scope: Scope,
|
||||||
|
key: string,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
options: NumberOptions = {},
|
||||||
|
): number | undefined {
|
||||||
|
if (!has(scope, key) || isCleared(scope, key)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const value = numberIn(scope, key, Number.NaN, min, max, options);
|
||||||
|
return Number.isNaN(value) ? undefined : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bool(scope: Scope, key: string, fallback: boolean): boolean {
|
||||||
|
if (!has(scope, key)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const value = scope.raw[key];
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
report(scope, key, 'Bitte Ja oder Nein wählen.');
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stringIn(
|
||||||
|
scope: Scope,
|
||||||
|
key: string,
|
||||||
|
fallback: string,
|
||||||
|
minLength: number,
|
||||||
|
maxLength: number,
|
||||||
|
pattern?: RegExp,
|
||||||
|
): string {
|
||||||
|
if (!has(scope, key)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const value = scope.raw[key];
|
||||||
|
if (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
value.length >= minLength &&
|
||||||
|
value.length <= maxLength &&
|
||||||
|
(pattern === undefined || pattern.test(value))
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
report(
|
||||||
|
scope,
|
||||||
|
key,
|
||||||
|
`Bitte einen Text mit ${minLength} bis ${maxLength} Zeichen angeben.`,
|
||||||
|
);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTH_DAY = /^(\d{2})-(\d{2})$/;
|
||||||
|
|
||||||
|
/** 'MM-DD' that exists in a leap year (02-29 is allowed). */
|
||||||
|
export function isMonthDay(value: unknown): value is string {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const match = MONTH_DAY.exec(value);
|
||||||
|
if (!match) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const month = Number(match[1]);
|
||||||
|
const day = Number(match[2]);
|
||||||
|
const date = new Date(Date.UTC(2024, month - 1, day));
|
||||||
|
return date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function monthDay(scope: Scope, key: string, fallback: string): string {
|
||||||
|
if (!has(scope, key)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const value = scope.raw[key];
|
||||||
|
if (isMonthDay(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
report(scope, key, 'Bitte ein Datum als MM-TT angeben, zum Beispiel 03-20.');
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function months(
|
||||||
|
scope: Scope,
|
||||||
|
key: string,
|
||||||
|
fallback: readonly number[],
|
||||||
|
): number[] {
|
||||||
|
if (!has(scope, key)) {
|
||||||
|
return [...fallback];
|
||||||
|
}
|
||||||
|
const value = scope.raw[key];
|
||||||
|
if (
|
||||||
|
Array.isArray(value) &&
|
||||||
|
value.every(
|
||||||
|
(month): month is number =>
|
||||||
|
typeof month === 'number' &&
|
||||||
|
Number.isInteger(month) &&
|
||||||
|
month >= 1 &&
|
||||||
|
month <= 12,
|
||||||
|
) &&
|
||||||
|
new Set(value).size === value.length
|
||||||
|
) {
|
||||||
|
return [...value];
|
||||||
|
}
|
||||||
|
report(scope, key, 'Bitte Monate von 1 bis 12 angeben, jeden nur einmal.');
|
||||||
|
return [...fallback];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enumValue<T extends string>(
|
||||||
|
scope: Scope,
|
||||||
|
key: string,
|
||||||
|
fallback: T,
|
||||||
|
allowed: readonly T[],
|
||||||
|
): T {
|
||||||
|
if (!has(scope, key)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const value = scope.raw[key];
|
||||||
|
if (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
(allowed as readonly string[]).includes(value)
|
||||||
|
) {
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
|
report(scope, key, 'Bitte einen gültigen Wert wählen.');
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enumList<T extends string>(
|
||||||
|
scope: Scope,
|
||||||
|
key: string,
|
||||||
|
fallback: readonly T[],
|
||||||
|
allowed: readonly T[],
|
||||||
|
): T[] {
|
||||||
|
if (!has(scope, key)) {
|
||||||
|
return [...fallback];
|
||||||
|
}
|
||||||
|
const value = scope.raw[key];
|
||||||
|
if (
|
||||||
|
Array.isArray(value) &&
|
||||||
|
value.every(
|
||||||
|
(entry): entry is T =>
|
||||||
|
typeof entry === 'string' &&
|
||||||
|
(allowed as readonly string[]).includes(entry),
|
||||||
|
) &&
|
||||||
|
new Set(value).size === value.length
|
||||||
|
) {
|
||||||
|
return [...value];
|
||||||
|
}
|
||||||
|
report(scope, key, 'Bitte nur gültige Werte wählen, jeden nur einmal.');
|
||||||
|
return [...fallback];
|
||||||
|
}
|
||||||
|
|
||||||
|
const logger = new Logger('RuleConfig');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the parser of one rule from its reader. The reader turns the scope
|
||||||
|
* into a complete config and reports every invalid key; `parse` logs those
|
||||||
|
* (trigger and field, never the value) and returns the config, `validate`
|
||||||
|
* returns them.
|
||||||
|
*/
|
||||||
|
export function makeParser<T>(
|
||||||
|
trigger: string,
|
||||||
|
defaults: T,
|
||||||
|
read: (scope: Scope) => T,
|
||||||
|
): RuleConfigParser<T> {
|
||||||
|
return {
|
||||||
|
defaults,
|
||||||
|
parse(json) {
|
||||||
|
const issues: RuleConfigIssue[] = [];
|
||||||
|
const value = read(rootScope(json, issues));
|
||||||
|
for (const issue of issues) {
|
||||||
|
logger.warn(
|
||||||
|
`Invalid setting ${trigger}.${issue.field}; using the default`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
validate(json) {
|
||||||
|
const issues: RuleConfigIssue[] = [];
|
||||||
|
read(rootScope(json, issues));
|
||||||
|
return issues;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import {
|
||||||
|
PHENOLOGY_PHASES,
|
||||||
|
type PhenologyPhaseKey,
|
||||||
|
} from '../../season/season-types';
|
||||||
|
import {
|
||||||
|
bool,
|
||||||
|
childScope,
|
||||||
|
enumList,
|
||||||
|
enumValue,
|
||||||
|
has,
|
||||||
|
isCleared,
|
||||||
|
makeParser,
|
||||||
|
monthDay,
|
||||||
|
numberIn,
|
||||||
|
report,
|
||||||
|
type Scope,
|
||||||
|
} from './parse-helpers';
|
||||||
|
import type { PhenologyConfig } from './rule-config.types';
|
||||||
|
|
||||||
|
/** Upper limit of a growing-degree-day threshold; far above any phase. */
|
||||||
|
const MAX_GTS_THRESHOLD = 5000;
|
||||||
|
const MAX_GAP_SHARE = 0.5;
|
||||||
|
|
||||||
|
export const DEFAULT_PHENOLOGY_CONFIG: PhenologyConfig = {
|
||||||
|
useGts: true,
|
||||||
|
gtsThresholds: { SALWEIDE: 200 },
|
||||||
|
calibrateFromReports: true,
|
||||||
|
calibratePhases: ['KIRSCHE', 'APFEL'],
|
||||||
|
calendarFallback: {
|
||||||
|
SALWEIDE: '03-20',
|
||||||
|
KIRSCHE: '04-15',
|
||||||
|
APFEL: '04-28',
|
||||||
|
HOLUNDER: '05-30',
|
||||||
|
LINDE: '06-20',
|
||||||
|
TRACHTENDE: '08-01',
|
||||||
|
HERBST: '09-15',
|
||||||
|
WINTER: '11-15',
|
||||||
|
},
|
||||||
|
swarmSeason: { from: 'KIRSCHE', until: 'LINDE' },
|
||||||
|
maxGapShare: 0.1,
|
||||||
|
};
|
||||||
|
|
||||||
|
function readThresholds(scope: Scope): PhenologyConfig['gtsThresholds'] {
|
||||||
|
// Without the group the defaults apply; with it, only the phases given
|
||||||
|
// have a threshold - leaving one out removes it.
|
||||||
|
if (!has(scope, 'gtsThresholds')) {
|
||||||
|
return { ...DEFAULT_PHENOLOGY_CONFIG.gtsThresholds };
|
||||||
|
}
|
||||||
|
const thresholds = childScope(scope, 'gtsThresholds');
|
||||||
|
const result: PhenologyConfig['gtsThresholds'] = {};
|
||||||
|
for (const phase of PHENOLOGY_PHASES) {
|
||||||
|
if (isCleared(thresholds, phase)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const value = numberIn(
|
||||||
|
thresholds,
|
||||||
|
phase,
|
||||||
|
Number.NaN,
|
||||||
|
0,
|
||||||
|
MAX_GTS_THRESHOLD,
|
||||||
|
{ exclusiveMin: true },
|
||||||
|
);
|
||||||
|
if (!Number.isNaN(value)) {
|
||||||
|
result[phase] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calendar dates of the phases must not run backwards in phase order.
|
||||||
|
* One violation sends the whole map back to the defaults, so it stays
|
||||||
|
* consistent; the issue names a key the caller actually sent.
|
||||||
|
*/
|
||||||
|
function readCalendar(scope: Scope): PhenologyConfig['calendarFallback'] {
|
||||||
|
const calendar = childScope(scope, 'calendarFallback');
|
||||||
|
const defaults = DEFAULT_PHENOLOGY_CONFIG.calendarFallback;
|
||||||
|
const before = scope.issues.length;
|
||||||
|
const result = { ...defaults };
|
||||||
|
for (const phase of PHENOLOGY_PHASES) {
|
||||||
|
result[phase] = monthDay(calendar, phase, defaults[phase]);
|
||||||
|
}
|
||||||
|
if (scope.issues.length > before) {
|
||||||
|
return { ...defaults };
|
||||||
|
}
|
||||||
|
for (let index = 1; index < PHENOLOGY_PHASES.length; index += 1) {
|
||||||
|
const previous = PHENOLOGY_PHASES[index - 1];
|
||||||
|
const phase = PHENOLOGY_PHASES[index];
|
||||||
|
if (result[phase] < result[previous]) {
|
||||||
|
const culprit = has(calendar, phase) ? phase : previous;
|
||||||
|
report(
|
||||||
|
calendar,
|
||||||
|
culprit,
|
||||||
|
`Das Datum darf nicht vor dem der vorherigen Phase liegen (${previous}).`,
|
||||||
|
);
|
||||||
|
return { ...defaults };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSwarmSeason(scope: Scope): PhenologyConfig['swarmSeason'] {
|
||||||
|
const season = childScope(scope, 'swarmSeason');
|
||||||
|
const defaults = DEFAULT_PHENOLOGY_CONFIG.swarmSeason;
|
||||||
|
const from: PhenologyPhaseKey = enumValue(
|
||||||
|
season,
|
||||||
|
'from',
|
||||||
|
defaults.from,
|
||||||
|
PHENOLOGY_PHASES,
|
||||||
|
);
|
||||||
|
const until: PhenologyPhaseKey = enumValue(
|
||||||
|
season,
|
||||||
|
'until',
|
||||||
|
defaults.until,
|
||||||
|
PHENOLOGY_PHASES,
|
||||||
|
);
|
||||||
|
if (PHENOLOGY_PHASES.indexOf(from) >= PHENOLOGY_PHASES.indexOf(until)) {
|
||||||
|
report(
|
||||||
|
season,
|
||||||
|
has(season, 'from') ? 'from' : 'until',
|
||||||
|
'Der Beginn der Schwarmzeit muss vor ihrem Ende liegen.',
|
||||||
|
);
|
||||||
|
return { ...defaults };
|
||||||
|
}
|
||||||
|
return { from, until };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const phenologyParser = makeParser<PhenologyConfig>(
|
||||||
|
'PHENOLOGY',
|
||||||
|
DEFAULT_PHENOLOGY_CONFIG,
|
||||||
|
(scope) => {
|
||||||
|
const d = DEFAULT_PHENOLOGY_CONFIG;
|
||||||
|
return {
|
||||||
|
useGts: bool(scope, 'useGts', d.useGts),
|
||||||
|
gtsThresholds: readThresholds(scope),
|
||||||
|
calibrateFromReports: bool(
|
||||||
|
scope,
|
||||||
|
'calibrateFromReports',
|
||||||
|
d.calibrateFromReports,
|
||||||
|
),
|
||||||
|
calibratePhases: enumList(
|
||||||
|
scope,
|
||||||
|
'calibratePhases',
|
||||||
|
d.calibratePhases,
|
||||||
|
PHENOLOGY_PHASES,
|
||||||
|
),
|
||||||
|
calendarFallback: readCalendar(scope),
|
||||||
|
swarmSeason: readSwarmSeason(scope),
|
||||||
|
maxGapShare: numberIn(
|
||||||
|
scope,
|
||||||
|
'maxGapShare',
|
||||||
|
d.maxGapShare,
|
||||||
|
0,
|
||||||
|
MAX_GAP_SHARE,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Prisma } from '../../../generated/prisma';
|
||||||
|
import type { AutomationTriggerType } from '../../../generated/prisma';
|
||||||
|
import { assertTransactionClient, type DbClient } from '../../prisma/db-client';
|
||||||
|
import { lockRowForUpdate } from '../../prisma/row-lock';
|
||||||
|
import { DEFAULT_RULES } from './default-rules';
|
||||||
|
import { ruleConfigOf } from './rule-config.registry';
|
||||||
|
import type { RuleConfigMap } from './rule-config.types';
|
||||||
|
|
||||||
|
export interface ActiveRule<K extends AutomationTriggerType> {
|
||||||
|
/** null while no row exists yet: the built-in setting applies. */
|
||||||
|
id: string | null;
|
||||||
|
active: boolean;
|
||||||
|
config: RuleConfigMap[K];
|
||||||
|
actionTemplate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuleRow {
|
||||||
|
id: string;
|
||||||
|
active: boolean;
|
||||||
|
config: Prisma.JsonValue;
|
||||||
|
actionTemplate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RULE_COLUMNS = {
|
||||||
|
id: true,
|
||||||
|
active: true,
|
||||||
|
config: true,
|
||||||
|
actionTemplate: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function toActiveRule<K extends AutomationTriggerType>(
|
||||||
|
trigger: K,
|
||||||
|
row: RuleRow | null,
|
||||||
|
): ActiveRule<K> {
|
||||||
|
if (row === null) {
|
||||||
|
const defaults = DEFAULT_RULES[trigger];
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
|
active: defaults.active ?? true,
|
||||||
|
config: ruleConfigOf(trigger, defaults.config),
|
||||||
|
actionTemplate: defaults.actionTemplate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
active: row.active,
|
||||||
|
config: ruleConfigOf(trigger, row.config),
|
||||||
|
actionTemplate: row.actionTemplate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rule as it is in force: its row, or the built-in setting while there is
|
||||||
|
* none. The config is parsed, so every key is valid.
|
||||||
|
*/
|
||||||
|
export async function readRule<K extends AutomationTriggerType>(
|
||||||
|
db: DbClient,
|
||||||
|
trigger: K,
|
||||||
|
): Promise<ActiveRule<K>> {
|
||||||
|
const row = await db.automationRule.findUnique({
|
||||||
|
where: { triggerType: trigger },
|
||||||
|
select: RULE_COLUMNS,
|
||||||
|
});
|
||||||
|
return toActiveRule(trigger, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the rule row if it is missing (without touching an existing one),
|
||||||
|
* locks it until the transaction ends and reads it. Serialises everything
|
||||||
|
* that decides "is there already an open task/suggestion" under the rule.
|
||||||
|
*/
|
||||||
|
export async function lockRuleIn<K extends AutomationTriggerType>(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
trigger: K,
|
||||||
|
): Promise<ActiveRule<K> & { id: string }> {
|
||||||
|
assertTransactionClient(tx, 'lockRuleIn');
|
||||||
|
const defaults = DEFAULT_RULES[trigger];
|
||||||
|
await tx.automationRule.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
triggerType: trigger,
|
||||||
|
active: defaults.active ?? true,
|
||||||
|
config: defaults.config as Prisma.InputJsonValue,
|
||||||
|
actionTemplate: defaults.actionTemplate,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
const { id } = await tx.automationRule.findUniqueOrThrow({
|
||||||
|
where: { triggerType: trigger },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
await lockRowForUpdate(tx, 'AutomationRule', id);
|
||||||
|
// Read after the lock: a concurrent edit of the rule is visible now.
|
||||||
|
const row = await tx.automationRule.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
select: RULE_COLUMNS,
|
||||||
|
});
|
||||||
|
return { ...toActiveRule(trigger, row), id };
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { AutomationTriggerType } from '../../../generated/prisma';
|
||||||
|
import {
|
||||||
|
chainParser,
|
||||||
|
colonyEndedCleanupParser,
|
||||||
|
DEFAULT_CHAIN_CONFIG,
|
||||||
|
withdrawalEndedParser,
|
||||||
|
} from './chain.config';
|
||||||
|
import { inspectionIntervalParser } from './inspection-interval.config';
|
||||||
|
import { phenologyParser } from './phenology.config';
|
||||||
|
import { seasonAutopilotParser } from './season-autopilot.config';
|
||||||
|
import { numberFormatParser, simpleParser } from './simple.config';
|
||||||
|
import { splitScheduleParser } from './split-schedule.config';
|
||||||
|
import { varroaThresholdParser } from './varroa-threshold.config';
|
||||||
|
import { waterContentParser } from './water-content.config';
|
||||||
|
import type {
|
||||||
|
RuleConfigIssue,
|
||||||
|
RuleConfigMap,
|
||||||
|
RuleConfigParser,
|
||||||
|
} from './rule-config.types';
|
||||||
|
|
||||||
|
/** The number-format rules keep the defaults they always had. */
|
||||||
|
const DEFAULT_BATCH_FORMAT = 'WH-{YYYY}-{SEQ:4}';
|
||||||
|
|
||||||
|
export const RULE_CONFIG_PARSERS: {
|
||||||
|
[K in AutomationTriggerType]: RuleConfigParser<RuleConfigMap[K]>;
|
||||||
|
} = {
|
||||||
|
VARROA_THRESHOLD: varroaThresholdParser,
|
||||||
|
INSPECTION_INTERVAL: inspectionIntervalParser,
|
||||||
|
QUEEN_CELLS_SWARM_CHECK: chainParser(
|
||||||
|
'QUEEN_CELLS_SWARM_CHECK',
|
||||||
|
DEFAULT_CHAIN_CONFIG,
|
||||||
|
),
|
||||||
|
QUEEN_ACCEPTANCE_CHECK: chainParser(
|
||||||
|
'QUEEN_ACCEPTANCE_CHECK',
|
||||||
|
DEFAULT_CHAIN_CONFIG,
|
||||||
|
),
|
||||||
|
SPLIT_SCHEDULE: splitScheduleParser,
|
||||||
|
COLONY_ENDED_CLEANUP: colonyEndedCleanupParser,
|
||||||
|
WITHDRAWAL_ENDED: withdrawalEndedParser,
|
||||||
|
HONEY_WATER_CONTENT: waterContentParser,
|
||||||
|
SEASON_AUTOPILOT: seasonAutopilotParser,
|
||||||
|
PHENOLOGY: phenologyParser,
|
||||||
|
HONEY_BATCH_NUMBER_FORMAT: numberFormatParser(
|
||||||
|
'HONEY_BATCH_NUMBER_FORMAT',
|
||||||
|
DEFAULT_BATCH_FORMAT,
|
||||||
|
),
|
||||||
|
BOTTLING_LOT_NUMBER_FORMAT: numberFormatParser(
|
||||||
|
'BOTTLING_LOT_NUMBER_FORMAT',
|
||||||
|
DEFAULT_BATCH_FORMAT,
|
||||||
|
),
|
||||||
|
// {ART} (WA/PR) gives wax and propolis a counter each.
|
||||||
|
RAW_MATERIAL_LOT_NUMBER_FORMAT: numberFormatParser(
|
||||||
|
'RAW_MATERIAL_LOT_NUMBER_FORMAT',
|
||||||
|
'{ART}-{YYYY}-{SEQ:4}',
|
||||||
|
),
|
||||||
|
PRODUCTION_BATCH_NUMBER_FORMAT: numberFormatParser(
|
||||||
|
'PRODUCTION_BATCH_NUMBER_FORMAT',
|
||||||
|
'HS-{YYYY}-{SEQ:4}',
|
||||||
|
),
|
||||||
|
QUEENLESS_NO_EGGS: simpleParser('QUEENLESS_NO_EGGS', { notify: true }),
|
||||||
|
FEED_STATUS_CRITICAL: simpleParser('FEED_STATUS_CRITICAL', {
|
||||||
|
notify: false,
|
||||||
|
}),
|
||||||
|
SWARM_MOOD_HIGH: simpleParser('SWARM_MOOD_HIGH', { notify: false }),
|
||||||
|
COLONY_HEALTH_CRITICAL: simpleParser('COLONY_HEALTH_CRITICAL', {
|
||||||
|
notify: true,
|
||||||
|
}),
|
||||||
|
STOCK_ITEM_LOW: simpleParser('STOCK_ITEM_LOW', { notify: false }),
|
||||||
|
RAW_MATERIAL_LOW: simpleParser('RAW_MATERIAL_LOW', {
|
||||||
|
notify: false,
|
||||||
|
withMinGrams: true,
|
||||||
|
}),
|
||||||
|
TREATMENT_FOLLOW_UP: simpleParser('TREATMENT_FOLLOW_UP', { notify: false }),
|
||||||
|
FEEDING_FOLLOW_UP: simpleParser('FEEDING_FOLLOW_UP', { notify: false }),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The effective setting of a rule: valid keys as stored, the rest default. */
|
||||||
|
export function ruleConfigOf<K extends AutomationTriggerType>(
|
||||||
|
trigger: K,
|
||||||
|
json: unknown,
|
||||||
|
): RuleConfigMap[K] {
|
||||||
|
return RULE_CONFIG_PARSERS[trigger].parse(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The invalid known keys of a config a client wants to save. */
|
||||||
|
export function validateRuleConfig(
|
||||||
|
trigger: AutomationTriggerType,
|
||||||
|
json: unknown,
|
||||||
|
): RuleConfigIssue[] {
|
||||||
|
return RULE_CONFIG_PARSERS[trigger].validate(json);
|
||||||
|
}
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { AutomationTriggerType } from '../../../generated/prisma';
|
||||||
|
import { DEFAULT_RULES } from './default-rules';
|
||||||
|
import { ruleConfigOf, RULE_CONFIG_PARSERS, validateRuleConfig } from './index';
|
||||||
|
|
||||||
|
const ALL_TRIGGERS = Object.values(AutomationTriggerType);
|
||||||
|
|
||||||
|
function fieldsOf(trigger: AutomationTriggerType, config: unknown): string[] {
|
||||||
|
return validateRuleConfig(trigger, config).map((issue) => issue.field);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('rule config', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
|
});
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
it('every trigger has defaults and a parser', () => {
|
||||||
|
for (const t of ALL_TRIGGERS) {
|
||||||
|
expect(DEFAULT_RULES[t]).toBeDefined();
|
||||||
|
expect(RULE_CONFIG_PARSERS[t]).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(ALL_TRIGGERS)(
|
||||||
|
'the default config of %s parses to itself and validates without issues',
|
||||||
|
(trigger) => {
|
||||||
|
const { config } = DEFAULT_RULES[trigger];
|
||||||
|
expect(RULE_CONFIG_PARSERS[trigger].parse(config)).toEqual(config);
|
||||||
|
expect(RULE_CONFIG_PARSERS[trigger].defaults).toEqual(config);
|
||||||
|
expect(validateRuleConfig(trigger, config)).toEqual([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(ALL_TRIGGERS)(
|
||||||
|
'%s falls back to its defaults for null, arrays and scalars',
|
||||||
|
(trigger) => {
|
||||||
|
const { config } = DEFAULT_RULES[trigger];
|
||||||
|
for (const junk of [null, undefined, [], 'x', 5]) {
|
||||||
|
expect(ruleConfigOf(trigger, junk)).toEqual(config);
|
||||||
|
expect(validateRuleConfig(trigger, junk)).toEqual([]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('reads a legacy thresholdMitesPerDay as naturalFall.redAbove', () =>
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 7 }).naturalFall,
|
||||||
|
).toEqual({
|
||||||
|
seasonMonths: [3, 4, 5, 6, 7, 8, 9, 10],
|
||||||
|
yellowFrom: 5,
|
||||||
|
redAbove: 7,
|
||||||
|
winterRedAbove: 0.5,
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('lets the band format win over the legacy key', () =>
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('VARROA_THRESHOLD', {
|
||||||
|
thresholdMitesPerDay: 7,
|
||||||
|
naturalFall: { redAbove: 9 },
|
||||||
|
}).naturalFall.redAbove,
|
||||||
|
).toBe(9));
|
||||||
|
|
||||||
|
it('ignores an invalid legacy threshold', () =>
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 0 }).naturalFall
|
||||||
|
.redAbove,
|
||||||
|
).toBe(10));
|
||||||
|
|
||||||
|
it('reports an invalid legacy threshold with field and message', () => {
|
||||||
|
const issues = validateRuleConfig('VARROA_THRESHOLD', {
|
||||||
|
thresholdMitesPerDay: 0,
|
||||||
|
});
|
||||||
|
expect(issues).toEqual([
|
||||||
|
{
|
||||||
|
field: 'thresholdMitesPerDay',
|
||||||
|
message: expect.any(String) as string,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(fieldsOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 7 })).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps natural-fall redAbove above zero and bounded, percent bands up to 100', () => {
|
||||||
|
const fields = (config: unknown) => fieldsOf('VARROA_THRESHOLD', config);
|
||||||
|
expect(fields({ naturalFall: { redAbove: 0 } })).toEqual([
|
||||||
|
'naturalFall.redAbove',
|
||||||
|
]);
|
||||||
|
expect(fields({ naturalFall: { redAbove: 1001 } })).toEqual([
|
||||||
|
'naturalFall.redAbove',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
fields({ infestationPercent: { summer: { redAbove: 101 } } }),
|
||||||
|
).toEqual(['infestationPercent.summer.redAbove']);
|
||||||
|
expect(
|
||||||
|
fields({ infestationPercent: { autumn: { redAbove: 100 } } }),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an explicit null on a known key', () => {
|
||||||
|
expect(fieldsOf('SEASON_AUTOPILOT', { queenMaxAgeYears: null })).toEqual([
|
||||||
|
'queenMaxAgeYears',
|
||||||
|
]);
|
||||||
|
expect(fieldsOf('PHENOLOGY', { swarmSeason: null })).toEqual([
|
||||||
|
'swarmSeason',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts null on optional keys as clearing them', () => {
|
||||||
|
expect(fieldsOf('TREATMENT_FOLLOW_UP', { dueInDays: null })).toEqual([]);
|
||||||
|
expect(ruleConfigOf('TREATMENT_FOLLOW_UP', { dueInDays: null })).toEqual({
|
||||||
|
notify: false,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('RAW_MATERIAL_LOW', {
|
||||||
|
minGrams: { BEESWAX: null, PROPOLIS: 50 },
|
||||||
|
}),
|
||||||
|
).toEqual({ notify: false, minGrams: { PROPOLIS: 50 } });
|
||||||
|
expect(ruleConfigOf('RAW_MATERIAL_LOW', { minGrams: null })).toEqual({
|
||||||
|
notify: false,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('SPLIT_SCHEDULE', {
|
||||||
|
QUEEN: [{ step: 'ACCEPTANCE_CHECK', offsetDays: 7, windowDays: null }],
|
||||||
|
}).QUEEN,
|
||||||
|
).toEqual([{ step: 'ACCEPTANCE_CHECK', offsetDays: 7 }]);
|
||||||
|
expect(
|
||||||
|
fieldsOf('PHENOLOGY', { gtsThresholds: { SALWEIDE: null } }),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('PHENOLOGY', { gtsThresholds: { SALWEIDE: null } })
|
||||||
|
.gtsThresholds,
|
||||||
|
).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a client remove a gts threshold by leaving it out', () => {
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('PHENOLOGY', { gtsThresholds: { KIRSCHE: 400 } })
|
||||||
|
.gtsThresholds,
|
||||||
|
).toEqual({ KIRSCHE: 400 });
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('PHENOLOGY', { gtsThresholds: {} }).gtsThresholds,
|
||||||
|
).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps yellowFrom at or below a legacy threshold under five', () => {
|
||||||
|
const { naturalFall } = ruleConfigOf('VARROA_THRESHOLD', {
|
||||||
|
thresholdMitesPerDay: 3,
|
||||||
|
});
|
||||||
|
expect(naturalFall.redAbove).toBe(3);
|
||||||
|
expect(naturalFall.yellowFrom).toBeLessThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back per invalid key and keeps the valid ones', () =>
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('SEASON_AUTOPILOT', {
|
||||||
|
queenMaxAgeYears: 'x',
|
||||||
|
honeySuperMinFrames: 10,
|
||||||
|
}),
|
||||||
|
).toMatchObject({ queenMaxAgeYears: 2, honeySuperMinFrames: 10 }));
|
||||||
|
|
||||||
|
it('logs a warning with trigger and field but without the value', () => {
|
||||||
|
const warn = jest.spyOn(Logger.prototype, 'warn');
|
||||||
|
ruleConfigOf('SEASON_AUTOPILOT', { queenMaxAgeYears: 'secret-value' });
|
||||||
|
const logged = warn.mock.calls.map((call) => String(call[0])).join('\n');
|
||||||
|
expect(logged).toContain('SEASON_AUTOPILOT');
|
||||||
|
expect(logged).toContain('queenMaxAgeYears');
|
||||||
|
expect(logged).not.toContain('secret-value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects SCHWARMZEIT 10 days with the field name', () =>
|
||||||
|
expect(
|
||||||
|
validateRuleConfig('INSPECTION_INTERVAL', {
|
||||||
|
intervalDays: { SCHWARMZEIT: 10 },
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
field: 'intervalDays.SCHWARMZEIT',
|
||||||
|
message: expect.stringContaining('7') as string,
|
||||||
|
},
|
||||||
|
]));
|
||||||
|
|
||||||
|
it('allows 7 to 35 days outside the swarm season and 7 to 9 inside', () => {
|
||||||
|
expect(
|
||||||
|
validateRuleConfig('INSPECTION_INTERVAL', {
|
||||||
|
intervalDays: { SOMMER: 35, FRUEHJAHR: 7, SCHWARMZEIT: 9 },
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
fieldsOf('INSPECTION_INTERVAL', { intervalDays: { SOMMER: 36 } }),
|
||||||
|
).toEqual(['intervalDays.SOMMER']);
|
||||||
|
expect(
|
||||||
|
fieldsOf('INSPECTION_INTERVAL', { intervalDays: { HERBST: 6.5 } }),
|
||||||
|
).toEqual(['intervalDays.HERBST']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges a partial interval config over the defaults', () =>
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('INSPECTION_INTERVAL', { intervalDays: { SOMMER: 10 } }),
|
||||||
|
).toEqual({
|
||||||
|
intervalDays: {
|
||||||
|
FRUEHJAHR: 14,
|
||||||
|
SCHWARMZEIT: 7,
|
||||||
|
SOMMER: 10,
|
||||||
|
SPAETSOMMER: 21,
|
||||||
|
HERBST: 21,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('rejects yellowFrom above redAbove', () =>
|
||||||
|
expect(
|
||||||
|
fieldsOf('VARROA_THRESHOLD', {
|
||||||
|
naturalFall: { yellowFrom: 12, redAbove: 8 },
|
||||||
|
}),
|
||||||
|
).toEqual(['naturalFall.yellowFrom']));
|
||||||
|
|
||||||
|
it('rejects percent bands where yellowFrom is above redAbove', () =>
|
||||||
|
expect(
|
||||||
|
fieldsOf('VARROA_THRESHOLD', {
|
||||||
|
infestationPercent: { summer: { yellowFrom: 6, redAbove: 5 } },
|
||||||
|
}),
|
||||||
|
).toEqual(['infestationPercent.summer.yellowFrom']));
|
||||||
|
|
||||||
|
it('rejects duplicate or out-of-range months', () => {
|
||||||
|
expect(
|
||||||
|
fieldsOf('VARROA_THRESHOLD', { naturalFall: { seasonMonths: [3, 3] } }),
|
||||||
|
).toEqual(['naturalFall.seasonMonths']);
|
||||||
|
expect(
|
||||||
|
fieldsOf('VARROA_THRESHOLD', {
|
||||||
|
infestationPercent: { summerMonths: [0, 13] },
|
||||||
|
}),
|
||||||
|
).toEqual(['infestationPercent.summerMonths']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks the varroa limits', () => {
|
||||||
|
const fields = (config: unknown) => fieldsOf('VARROA_THRESHOLD', config);
|
||||||
|
expect(fields({ youngColonyFactor: 0 })).toEqual(['youngColonyFactor']);
|
||||||
|
expect(fields({ youngColonyFactor: 1.5 })).toEqual(['youngColonyFactor']);
|
||||||
|
expect(fields({ youngColonyFactor: 1 })).toEqual([]);
|
||||||
|
expect(fields({ recheckAfterDays: 61 })).toEqual(['recheckAfterDays']);
|
||||||
|
expect(fields({ recheckAfterDays: 0 })).toEqual([]);
|
||||||
|
expect(fields({ youngColonyTypes: ['NOPE'] })).toEqual([
|
||||||
|
'youngColonyTypes',
|
||||||
|
]);
|
||||||
|
expect(fields({ naturalFall: { redAbove: -1 } })).toEqual([
|
||||||
|
'naturalFall.redAbove',
|
||||||
|
]);
|
||||||
|
expect(fields({ naturalFall: { redAbove: Infinity } })).toEqual([
|
||||||
|
'naturalFall.redAbove',
|
||||||
|
]);
|
||||||
|
expect(fields({ notify: 'yes' })).toEqual(['notify']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports no issue for keys it does not know', () =>
|
||||||
|
expect(
|
||||||
|
validateRuleConfig('SEASON_AUTOPILOT', { somethingElse: 'x' }),
|
||||||
|
).toEqual([]));
|
||||||
|
|
||||||
|
it('rejects a calendar fallback that runs backwards', () => {
|
||||||
|
expect(
|
||||||
|
fieldsOf('PHENOLOGY', { calendarFallback: { KIRSCHE: '03-01' } }),
|
||||||
|
).toEqual(['calendarFallback.KIRSCHE']);
|
||||||
|
// The whole map falls back so the phases stay in order.
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('PHENOLOGY', { calendarFallback: { KIRSCHE: '03-01' } })
|
||||||
|
.calendarFallback,
|
||||||
|
).toEqual(DEFAULT_RULES.PHENOLOGY.config.calendarFallback);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid month-day', () => {
|
||||||
|
expect(
|
||||||
|
fieldsOf('PHENOLOGY', { calendarFallback: { SALWEIDE: '13-01' } }),
|
||||||
|
).toEqual(['calendarFallback.SALWEIDE']);
|
||||||
|
expect(
|
||||||
|
fieldsOf('PHENOLOGY', { calendarFallback: { SALWEIDE: '02-30' } }),
|
||||||
|
).toEqual(['calendarFallback.SALWEIDE']);
|
||||||
|
expect(
|
||||||
|
fieldsOf('PHENOLOGY', { calendarFallback: { SALWEIDE: '03-20' } }),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks the phenology limits', () => {
|
||||||
|
const fields = (config: unknown) => fieldsOf('PHENOLOGY', config);
|
||||||
|
expect(fields({ maxGapShare: 0.6 })).toEqual(['maxGapShare']);
|
||||||
|
expect(fields({ maxGapShare: 0.5 })).toEqual([]);
|
||||||
|
expect(fields({ gtsThresholds: { SALWEIDE: 0 } })).toEqual([
|
||||||
|
'gtsThresholds.SALWEIDE',
|
||||||
|
]);
|
||||||
|
expect(fields({ gtsThresholds: { NOPE: 5 } })).toEqual([]);
|
||||||
|
expect(fields({ calibratePhases: ['KIRSCHE', 'X'] })).toEqual([
|
||||||
|
'calibratePhases',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
fields({ swarmSeason: { from: 'LINDE', until: 'KIRSCHE' } }),
|
||||||
|
).toEqual(['swarmSeason.from']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults gtsThresholds to SALWEIDE 200 only', () =>
|
||||||
|
expect(ruleConfigOf('PHENOLOGY', {}).gtsThresholds).toEqual({
|
||||||
|
SALWEIDE: 200,
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('checks the season autopilot limits', () => {
|
||||||
|
const fields = (config: unknown) => fieldsOf('SEASON_AUTOPILOT', config);
|
||||||
|
expect(fields({ honeySuperMinFrames: 0 })).toEqual(['honeySuperMinFrames']);
|
||||||
|
expect(fields({ honeySuperMinFrames: 41 })).toEqual([
|
||||||
|
'honeySuperMinFrames',
|
||||||
|
]);
|
||||||
|
expect(fields({ varroaMeasureIntervalDays: 6 })).toEqual([
|
||||||
|
'varroaMeasureIntervalDays',
|
||||||
|
]);
|
||||||
|
expect(fields({ queenMaxAgeYears: 0 })).toEqual(['queenMaxAgeYears']);
|
||||||
|
expect(fields({ queenMaxAgeYears: 11 })).toEqual(['queenMaxAgeYears']);
|
||||||
|
expect(fields({ suggestionTtlDays: 366 })).toEqual(['suggestionTtlDays']);
|
||||||
|
expect(fields({ maxStepsPerColony: 4 })).toEqual(['maxStepsPerColony']);
|
||||||
|
expect(
|
||||||
|
fields({ winterVarroaWindow: { from: '12-20', to: '11-15' } }),
|
||||||
|
).toEqual(['winterVarroaWindow.from']);
|
||||||
|
expect(fields({ rules: { A3: 'no' } })).toEqual(['rules.A3']);
|
||||||
|
expect(fields({ rules: { A3: false } })).toEqual([]);
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('SEASON_AUTOPILOT', { rules: { A3: false } }).rules,
|
||||||
|
).toEqual(expect.objectContaining({ A1: true, A3: false, A10: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks the water content limits', () => {
|
||||||
|
const fields = (config: unknown) => fieldsOf('HONEY_WATER_CONTENT', config);
|
||||||
|
expect(fields({ warnAbovePercent: 9 })).toEqual(['warnAbovePercent']);
|
||||||
|
expect(fields({ confirmAbovePercent: 31 })).toEqual([
|
||||||
|
'confirmAbovePercent',
|
||||||
|
]);
|
||||||
|
expect(fields({ warnAbovePercent: 21 })).toEqual(['warnAbovePercent']);
|
||||||
|
expect(
|
||||||
|
fields({ byHoneyType: { Heidehonig: { confirmAbovePercent: 23 } } }),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
fields({ byHoneyType: { Heidehonig: { confirmAbovePercent: 5 } } }),
|
||||||
|
).toEqual(['byHoneyType.Heidehonig.confirmAbovePercent']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks the split schedule steps', () => {
|
||||||
|
const fields = (config: unknown) => fieldsOf('SPLIT_SCHEDULE', config);
|
||||||
|
expect(
|
||||||
|
fields({ QUEEN: [{ step: 'ACCEPTANCE_CHECK', offsetDays: 121 }] }),
|
||||||
|
).toEqual(['QUEEN[0].offsetDays']);
|
||||||
|
expect(
|
||||||
|
fields({
|
||||||
|
QUEEN_CELL: [{ step: 'EGGS_CHECK', offsetDays: 21, windowDays: 31 }],
|
||||||
|
}),
|
||||||
|
).toEqual(['QUEEN_CELL[0].windowDays']);
|
||||||
|
expect(fields({ RAISE_OWN: [{ offsetDays: 3 }] })).toEqual([
|
||||||
|
'RAISE_OWN[0].step',
|
||||||
|
]);
|
||||||
|
expect(fields({ RAISE_OWN: 'nope' })).toEqual(['RAISE_OWN']);
|
||||||
|
expect(
|
||||||
|
fields({
|
||||||
|
RAISE_OWN: [{ step: 'MATING_CHECK', offsetDays: 21, windowDays: 3 }],
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks dueInDays of chains and simple rules', () => {
|
||||||
|
expect(fieldsOf('QUEEN_CELLS_SWARM_CHECK', { dueInDays: 366 })).toEqual([
|
||||||
|
'dueInDays',
|
||||||
|
]);
|
||||||
|
expect(fieldsOf('TREATMENT_FOLLOW_UP', { dueInDays: -1 })).toEqual([
|
||||||
|
'dueInDays',
|
||||||
|
]);
|
||||||
|
expect(ruleConfigOf('TREATMENT_FOLLOW_UP', { dueInDays: 14 })).toEqual({
|
||||||
|
notify: false,
|
||||||
|
dueInDays: 14,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads minGrams per raw material kind', () => {
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('RAW_MATERIAL_LOW', { minGrams: { BEESWAX: 500 } }),
|
||||||
|
).toEqual({ notify: false, minGrams: { BEESWAX: 500 } });
|
||||||
|
expect(
|
||||||
|
fieldsOf('RAW_MATERIAL_LOW', {
|
||||||
|
minGrams: { BEESWAX: -5, PROPOLIS: 'x' },
|
||||||
|
}),
|
||||||
|
).toEqual(['minGrams.BEESWAX', 'minGrams.PROPOLIS']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a number format and rejects an empty or oversized one', () => {
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('HONEY_BATCH_NUMBER_FORMAT', { format: 'HB-{SEQ:3}' }),
|
||||||
|
).toEqual({ format: 'HB-{SEQ:3}' });
|
||||||
|
expect(fieldsOf('HONEY_BATCH_NUMBER_FORMAT', { format: '' })).toEqual([
|
||||||
|
'format',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
fieldsOf('HONEY_BATCH_NUMBER_FORMAT', { format: 'x'.repeat(101) }),
|
||||||
|
).toEqual(['format']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults notify to true only for QUEENLESS_NO_EGGS, VARROA_THRESHOLD and COLONY_HEALTH_CRITICAL (and WITHDRAWAL_ENDED)', () => {
|
||||||
|
const withNotify = ALL_TRIGGERS.filter(
|
||||||
|
(trigger) =>
|
||||||
|
(ruleConfigOf(trigger, {}) as { notify?: boolean }).notify === true,
|
||||||
|
);
|
||||||
|
expect([...withNotify].sort()).toEqual(
|
||||||
|
[
|
||||||
|
'COLONY_HEALTH_CRITICAL',
|
||||||
|
'QUEENLESS_NO_EGGS',
|
||||||
|
'VARROA_THRESHOLD',
|
||||||
|
'WITHDRAWAL_ENDED',
|
||||||
|
].sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts the optional rules switched off', () => {
|
||||||
|
const off = ALL_TRIGGERS.filter(
|
||||||
|
(trigger) => DEFAULT_RULES[trigger].active === false,
|
||||||
|
).sort();
|
||||||
|
expect(off).toEqual([
|
||||||
|
'FEEDING_FOLLOW_UP',
|
||||||
|
'RAW_MATERIAL_LOW',
|
||||||
|
'TREATMENT_FOLLOW_UP',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives every new rule an empty action template', () => {
|
||||||
|
for (const trigger of [
|
||||||
|
'INSPECTION_INTERVAL',
|
||||||
|
'QUEEN_CELLS_SWARM_CHECK',
|
||||||
|
'QUEEN_ACCEPTANCE_CHECK',
|
||||||
|
'SPLIT_SCHEDULE',
|
||||||
|
'COLONY_ENDED_CLEANUP',
|
||||||
|
'WITHDRAWAL_ENDED',
|
||||||
|
'HONEY_WATER_CONTENT',
|
||||||
|
'SEASON_AUTOPILOT',
|
||||||
|
'PHENOLOGY',
|
||||||
|
] as const) {
|
||||||
|
expect(DEFAULT_RULES[trigger].actionTemplate).toBe('');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import type { ColonyType, RawMaterialKind } from '../../../generated/prisma';
|
||||||
|
import type {
|
||||||
|
PhenologyPhaseKey,
|
||||||
|
SeasonSegmentKey,
|
||||||
|
} from '../../season/season-types';
|
||||||
|
|
||||||
|
/** One invalid setting of a rule: the field path and a German message. */
|
||||||
|
export interface RuleConfigIssue {
|
||||||
|
field: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleConfigParser<T> {
|
||||||
|
/** The built-in setting; also what an empty or unusable config reads as. */
|
||||||
|
readonly defaults: T;
|
||||||
|
/** Never throws: each invalid key falls back to its default (and is logged). */
|
||||||
|
parse(json: unknown): T;
|
||||||
|
/** Reports the known keys that are present and invalid; nothing else. */
|
||||||
|
validate(json: unknown): RuleConfigIssue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimpleRuleConfig {
|
||||||
|
notify: boolean;
|
||||||
|
dueInDays?: number;
|
||||||
|
minGrams?: Partial<Record<RawMaterialKind, number>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberFormatConfig {
|
||||||
|
format: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VarroaThresholdConfig {
|
||||||
|
naturalFall: {
|
||||||
|
seasonMonths: number[];
|
||||||
|
yellowFrom: number;
|
||||||
|
redAbove: number;
|
||||||
|
winterRedAbove: number;
|
||||||
|
};
|
||||||
|
infestationPercent: {
|
||||||
|
summerMonths: number[];
|
||||||
|
summer: { yellowFrom: number; redAbove: number };
|
||||||
|
autumnMonths: number[];
|
||||||
|
autumn: { yellowFrom: number; redAbove: number };
|
||||||
|
};
|
||||||
|
youngColonyFactor: number;
|
||||||
|
youngColonyTypes: ColonyType[];
|
||||||
|
recheckAfterDays: number;
|
||||||
|
notify: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InspectionIntervalConfig {
|
||||||
|
intervalDays: Record<Exclude<SeasonSegmentKey, 'WINTERRUHE'>, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChainConfig {
|
||||||
|
dueInDays: number;
|
||||||
|
notify: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanStepConfig {
|
||||||
|
step: string;
|
||||||
|
offsetDays: number;
|
||||||
|
windowDays?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SplitScheduleConfig {
|
||||||
|
RAISE_OWN: PlanStepConfig[];
|
||||||
|
QUEEN_CELL: PlanStepConfig[];
|
||||||
|
QUEEN: PlanStepConfig[];
|
||||||
|
notify: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WaterContentConfig {
|
||||||
|
warnAbovePercent: number;
|
||||||
|
confirmAbovePercent: number;
|
||||||
|
byHoneyType: Record<
|
||||||
|
string,
|
||||||
|
{ warnAbovePercent?: number; confirmAbovePercent?: number }
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AUTOPILOT_RULE_IDS = [
|
||||||
|
'A1',
|
||||||
|
'A2',
|
||||||
|
'A3',
|
||||||
|
'A4',
|
||||||
|
'A5',
|
||||||
|
'A6',
|
||||||
|
'A7',
|
||||||
|
'A8',
|
||||||
|
'A9',
|
||||||
|
'A10',
|
||||||
|
] as const;
|
||||||
|
export type AutopilotRuleId = (typeof AUTOPILOT_RULE_IDS)[number];
|
||||||
|
|
||||||
|
export interface SeasonAutopilotConfig {
|
||||||
|
honeySuperMinFrames: number;
|
||||||
|
varroaMeasureIntervalDays: number;
|
||||||
|
winterVarroaWindow: { from: string; to: string };
|
||||||
|
queenMaxAgeYears: number;
|
||||||
|
suggestionTtlDays: number;
|
||||||
|
maxStepsPerColony: number;
|
||||||
|
rules: Record<AutopilotRuleId, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PhenologyConfig {
|
||||||
|
useGts: boolean;
|
||||||
|
gtsThresholds: Partial<Record<PhenologyPhaseKey, number>>;
|
||||||
|
calibrateFromReports: boolean;
|
||||||
|
calibratePhases: PhenologyPhaseKey[];
|
||||||
|
calendarFallback: Record<PhenologyPhaseKey, string>;
|
||||||
|
swarmSeason: { from: PhenologyPhaseKey; until: PhenologyPhaseKey };
|
||||||
|
maxGapShare: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One entry per AutomationTriggerType. */
|
||||||
|
export interface RuleConfigMap {
|
||||||
|
VARROA_THRESHOLD: VarroaThresholdConfig;
|
||||||
|
INSPECTION_INTERVAL: InspectionIntervalConfig;
|
||||||
|
QUEEN_CELLS_SWARM_CHECK: ChainConfig;
|
||||||
|
QUEEN_ACCEPTANCE_CHECK: ChainConfig;
|
||||||
|
SPLIT_SCHEDULE: SplitScheduleConfig;
|
||||||
|
COLONY_ENDED_CLEANUP: Record<string, never>;
|
||||||
|
WITHDRAWAL_ENDED: { notify: boolean };
|
||||||
|
HONEY_WATER_CONTENT: WaterContentConfig;
|
||||||
|
SEASON_AUTOPILOT: SeasonAutopilotConfig;
|
||||||
|
PHENOLOGY: PhenologyConfig;
|
||||||
|
HONEY_BATCH_NUMBER_FORMAT: NumberFormatConfig;
|
||||||
|
BOTTLING_LOT_NUMBER_FORMAT: NumberFormatConfig;
|
||||||
|
RAW_MATERIAL_LOT_NUMBER_FORMAT: NumberFormatConfig;
|
||||||
|
PRODUCTION_BATCH_NUMBER_FORMAT: NumberFormatConfig;
|
||||||
|
QUEENLESS_NO_EGGS: SimpleRuleConfig;
|
||||||
|
FEED_STATUS_CRITICAL: SimpleRuleConfig;
|
||||||
|
SWARM_MOOD_HIGH: SimpleRuleConfig;
|
||||||
|
COLONY_HEALTH_CRITICAL: SimpleRuleConfig;
|
||||||
|
STOCK_ITEM_LOW: SimpleRuleConfig;
|
||||||
|
RAW_MATERIAL_LOW: SimpleRuleConfig;
|
||||||
|
TREATMENT_FOLLOW_UP: SimpleRuleConfig;
|
||||||
|
FEEDING_FOLLOW_UP: SimpleRuleConfig;
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import {
|
||||||
|
bool,
|
||||||
|
childScope,
|
||||||
|
has,
|
||||||
|
intIn,
|
||||||
|
makeParser,
|
||||||
|
monthDay,
|
||||||
|
report,
|
||||||
|
} from './parse-helpers';
|
||||||
|
import {
|
||||||
|
AUTOPILOT_RULE_IDS,
|
||||||
|
type SeasonAutopilotConfig,
|
||||||
|
} from './rule-config.types';
|
||||||
|
|
||||||
|
export const DEFAULT_SEASON_AUTOPILOT_CONFIG: SeasonAutopilotConfig = {
|
||||||
|
honeySuperMinFrames: 8,
|
||||||
|
varroaMeasureIntervalDays: 28,
|
||||||
|
winterVarroaWindow: { from: '11-15', to: '12-20' },
|
||||||
|
queenMaxAgeYears: 2,
|
||||||
|
suggestionTtlDays: 30,
|
||||||
|
maxStepsPerColony: 3,
|
||||||
|
rules: {
|
||||||
|
A1: true,
|
||||||
|
A2: true,
|
||||||
|
A3: true,
|
||||||
|
A4: true,
|
||||||
|
A5: true,
|
||||||
|
A6: true,
|
||||||
|
A7: true,
|
||||||
|
A8: true,
|
||||||
|
A9: true,
|
||||||
|
A10: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const seasonAutopilotParser = makeParser<SeasonAutopilotConfig>(
|
||||||
|
'SEASON_AUTOPILOT',
|
||||||
|
DEFAULT_SEASON_AUTOPILOT_CONFIG,
|
||||||
|
(scope) => {
|
||||||
|
const d = DEFAULT_SEASON_AUTOPILOT_CONFIG;
|
||||||
|
|
||||||
|
const windowScope = childScope(scope, 'winterVarroaWindow');
|
||||||
|
let winterVarroaWindow = {
|
||||||
|
from: monthDay(windowScope, 'from', d.winterVarroaWindow.from),
|
||||||
|
to: monthDay(windowScope, 'to', d.winterVarroaWindow.to),
|
||||||
|
};
|
||||||
|
if (winterVarroaWindow.from > winterVarroaWindow.to) {
|
||||||
|
report(
|
||||||
|
windowScope,
|
||||||
|
has(windowScope, 'from') ? 'from' : 'to',
|
||||||
|
'Das Fenster muss innerhalb eines Jahres liegen: Beginn vor Ende.',
|
||||||
|
);
|
||||||
|
winterVarroaWindow = { ...d.winterVarroaWindow };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ruleScope = childScope(scope, 'rules');
|
||||||
|
const rules = { ...d.rules };
|
||||||
|
for (const id of AUTOPILOT_RULE_IDS) {
|
||||||
|
rules[id] = bool(ruleScope, id, d.rules[id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
honeySuperMinFrames: intIn(
|
||||||
|
scope,
|
||||||
|
'honeySuperMinFrames',
|
||||||
|
d.honeySuperMinFrames,
|
||||||
|
1,
|
||||||
|
40,
|
||||||
|
),
|
||||||
|
varroaMeasureIntervalDays: intIn(
|
||||||
|
scope,
|
||||||
|
'varroaMeasureIntervalDays',
|
||||||
|
d.varroaMeasureIntervalDays,
|
||||||
|
7,
|
||||||
|
90,
|
||||||
|
),
|
||||||
|
winterVarroaWindow,
|
||||||
|
queenMaxAgeYears: intIn(
|
||||||
|
scope,
|
||||||
|
'queenMaxAgeYears',
|
||||||
|
d.queenMaxAgeYears,
|
||||||
|
1,
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
suggestionTtlDays: intIn(
|
||||||
|
scope,
|
||||||
|
'suggestionTtlDays',
|
||||||
|
d.suggestionTtlDays,
|
||||||
|
1,
|
||||||
|
365,
|
||||||
|
),
|
||||||
|
maxStepsPerColony: intIn(
|
||||||
|
scope,
|
||||||
|
'maxStepsPerColony',
|
||||||
|
d.maxStepsPerColony,
|
||||||
|
1,
|
||||||
|
3,
|
||||||
|
),
|
||||||
|
rules,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { RawMaterialKind } from '../../../generated/prisma';
|
||||||
|
import {
|
||||||
|
bool,
|
||||||
|
childScope,
|
||||||
|
has,
|
||||||
|
isCleared,
|
||||||
|
makeParser,
|
||||||
|
numberIn,
|
||||||
|
optionalIntIn,
|
||||||
|
stringIn,
|
||||||
|
} from './parse-helpers';
|
||||||
|
import type {
|
||||||
|
NumberFormatConfig,
|
||||||
|
RuleConfigParser,
|
||||||
|
SimpleRuleConfig,
|
||||||
|
} from './rule-config.types';
|
||||||
|
|
||||||
|
const MAX_DUE_IN_DAYS = 365;
|
||||||
|
/** Far above any minimum stock of wax or propolis. */
|
||||||
|
const MAX_MIN_GRAMS = 1_000_000_000;
|
||||||
|
const MAX_NUMBER_FORMAT_LENGTH = 100;
|
||||||
|
const RAW_MATERIAL_KINDS = Object.values(RawMaterialKind);
|
||||||
|
|
||||||
|
export interface SimpleRuleOptions {
|
||||||
|
/** Whether the rule notifies by default. */
|
||||||
|
notify: boolean;
|
||||||
|
/** RAW_MATERIAL_LOW reads a minimum per kind. */
|
||||||
|
withMinGrams?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rules that propose one suggestion: notify, an optional follow-up interval
|
||||||
|
* (dueInDays) and, for the raw material rule, minGrams per kind.
|
||||||
|
*/
|
||||||
|
export function simpleParser(
|
||||||
|
trigger: string,
|
||||||
|
options: SimpleRuleOptions,
|
||||||
|
): RuleConfigParser<SimpleRuleConfig> {
|
||||||
|
return makeParser<SimpleRuleConfig>(
|
||||||
|
trigger,
|
||||||
|
{ notify: options.notify },
|
||||||
|
(scope) => {
|
||||||
|
const config: SimpleRuleConfig = {
|
||||||
|
notify: bool(scope, 'notify', options.notify),
|
||||||
|
};
|
||||||
|
const dueInDays = optionalIntIn(scope, 'dueInDays', 0, MAX_DUE_IN_DAYS);
|
||||||
|
if (dueInDays !== undefined) {
|
||||||
|
config.dueInDays = dueInDays;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
options.withMinGrams &&
|
||||||
|
has(scope, 'minGrams') &&
|
||||||
|
!isCleared(scope, 'minGrams')
|
||||||
|
) {
|
||||||
|
const minScope = childScope(scope, 'minGrams');
|
||||||
|
const minGrams: Partial<Record<RawMaterialKind, number>> = {};
|
||||||
|
for (const kind of RAW_MATERIAL_KINDS) {
|
||||||
|
if (isCleared(minScope, kind)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const grams = numberIn(minScope, kind, Number.NaN, 0, MAX_MIN_GRAMS);
|
||||||
|
if (!Number.isNaN(grams)) {
|
||||||
|
minGrams[kind] = grams;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(minGrams).length > 0) {
|
||||||
|
config.minGrams = minGrams;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The batch/lot number rules: one format string with placeholders. */
|
||||||
|
export function numberFormatParser(
|
||||||
|
trigger: string,
|
||||||
|
defaultFormat: string,
|
||||||
|
): RuleConfigParser<NumberFormatConfig> {
|
||||||
|
return makeParser<NumberFormatConfig>(
|
||||||
|
trigger,
|
||||||
|
{ format: defaultFormat },
|
||||||
|
(scope) => ({
|
||||||
|
format: stringIn(
|
||||||
|
scope,
|
||||||
|
'format',
|
||||||
|
defaultFormat,
|
||||||
|
1,
|
||||||
|
MAX_NUMBER_FORMAT_LENGTH,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import {
|
||||||
|
asRecord,
|
||||||
|
bool,
|
||||||
|
fieldPath,
|
||||||
|
has,
|
||||||
|
intIn,
|
||||||
|
makeParser,
|
||||||
|
optionalIntIn,
|
||||||
|
report,
|
||||||
|
stringIn,
|
||||||
|
type Scope,
|
||||||
|
} from './parse-helpers';
|
||||||
|
import type { PlanStepConfig, SplitScheduleConfig } from './rule-config.types';
|
||||||
|
|
||||||
|
type SplitVariant = 'RAISE_OWN' | 'QUEEN_CELL' | 'QUEEN';
|
||||||
|
|
||||||
|
const MAX_STEPS_PER_VARIANT = 10;
|
||||||
|
const MAX_OFFSET_DAYS = 120;
|
||||||
|
const MAX_WINDOW_DAYS = 30;
|
||||||
|
const STEP_NAME = /^[A-Z][A-Z_]{1,39}$/;
|
||||||
|
|
||||||
|
export const DEFAULT_SPLIT_SCHEDULE_CONFIG: SplitScheduleConfig = {
|
||||||
|
RAISE_OWN: [
|
||||||
|
{ step: 'RAISE_CELLS_CHECK', offsetDays: 9 },
|
||||||
|
{ step: 'MATING_CHECK', offsetDays: 21, windowDays: 3 },
|
||||||
|
{ step: 'EGGS_CHECK', offsetDays: 28 },
|
||||||
|
],
|
||||||
|
QUEEN_CELL: [{ step: 'EGGS_CHECK', offsetDays: 21 }],
|
||||||
|
QUEEN: [{ step: 'ACCEPTANCE_CHECK', offsetDays: 7 }],
|
||||||
|
notify: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function clonedSteps(variant: SplitVariant): PlanStepConfig[] {
|
||||||
|
return DEFAULT_SPLIT_SCHEDULE_CONFIG[variant].map((step) => ({ ...step }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scope of one list entry: its path is `VARIANT[index]`. */
|
||||||
|
function stepScope(
|
||||||
|
scope: Scope,
|
||||||
|
variant: SplitVariant,
|
||||||
|
index: number,
|
||||||
|
raw: unknown,
|
||||||
|
): Scope | null {
|
||||||
|
const record = asRecord(raw);
|
||||||
|
const path = `${fieldPath(scope, variant)}[${index}]`;
|
||||||
|
if (record === null) {
|
||||||
|
scope.issues.push({
|
||||||
|
field: path,
|
||||||
|
message: 'Bitte jeden Schritt als Gruppe senden.',
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { raw: record, path, issues: scope.issues };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSteps(scope: Scope, variant: SplitVariant): PlanStepConfig[] {
|
||||||
|
if (!has(scope, variant)) {
|
||||||
|
return clonedSteps(variant);
|
||||||
|
}
|
||||||
|
const list = scope.raw[variant];
|
||||||
|
if (!Array.isArray(list) || list.length > MAX_STEPS_PER_VARIANT) {
|
||||||
|
report(
|
||||||
|
scope,
|
||||||
|
variant,
|
||||||
|
`Bitte eine Liste mit höchstens ${MAX_STEPS_PER_VARIANT} Schritten senden.`,
|
||||||
|
);
|
||||||
|
return clonedSteps(variant);
|
||||||
|
}
|
||||||
|
const before = scope.issues.length;
|
||||||
|
const steps: PlanStepConfig[] = [];
|
||||||
|
list.forEach((entry: unknown, index) => {
|
||||||
|
const item = stepScope(scope, variant, index, entry);
|
||||||
|
if (item === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!has(item, 'step')) {
|
||||||
|
scope.issues.push({
|
||||||
|
field: `${item.path}.step`,
|
||||||
|
message: 'Bitte den Schritt angeben.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const step: PlanStepConfig = {
|
||||||
|
step: stringIn(item, 'step', '', 2, 40, STEP_NAME),
|
||||||
|
offsetDays: intIn(item, 'offsetDays', 0, 0, MAX_OFFSET_DAYS),
|
||||||
|
};
|
||||||
|
const windowDays = optionalIntIn(item, 'windowDays', 0, MAX_WINDOW_DAYS);
|
||||||
|
if (windowDays !== undefined) {
|
||||||
|
step.windowDays = windowDays;
|
||||||
|
}
|
||||||
|
steps.push(step);
|
||||||
|
});
|
||||||
|
// One bad entry makes the whole list unreliable: back to the default plan.
|
||||||
|
return scope.issues.length > before ? clonedSteps(variant) : steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const splitScheduleParser = makeParser<SplitScheduleConfig>(
|
||||||
|
'SPLIT_SCHEDULE',
|
||||||
|
DEFAULT_SPLIT_SCHEDULE_CONFIG,
|
||||||
|
(scope) => ({
|
||||||
|
RAISE_OWN: readSteps(scope, 'RAISE_OWN'),
|
||||||
|
QUEEN_CELL: readSteps(scope, 'QUEEN_CELL'),
|
||||||
|
QUEEN: readSteps(scope, 'QUEEN'),
|
||||||
|
notify: bool(scope, 'notify', DEFAULT_SPLIT_SCHEDULE_CONFIG.notify),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { ColonyType } from '../../../generated/prisma';
|
||||||
|
import {
|
||||||
|
bool,
|
||||||
|
childScope,
|
||||||
|
enumList,
|
||||||
|
has,
|
||||||
|
intIn,
|
||||||
|
makeParser,
|
||||||
|
months,
|
||||||
|
numberIn,
|
||||||
|
report,
|
||||||
|
type Scope,
|
||||||
|
} from './parse-helpers';
|
||||||
|
import type { VarroaThresholdConfig } from './rule-config.types';
|
||||||
|
|
||||||
|
const MAX_RECHECK_DAYS = 60;
|
||||||
|
|
||||||
|
export const DEFAULT_VARROA_THRESHOLD_CONFIG: VarroaThresholdConfig = {
|
||||||
|
naturalFall: {
|
||||||
|
seasonMonths: [3, 4, 5, 6, 7, 8, 9, 10],
|
||||||
|
yellowFrom: 5,
|
||||||
|
redAbove: 10,
|
||||||
|
winterRedAbove: 0.5,
|
||||||
|
},
|
||||||
|
infestationPercent: {
|
||||||
|
summerMonths: [5, 6, 7],
|
||||||
|
summer: { yellowFrom: 1, redAbove: 5 },
|
||||||
|
autumnMonths: [8, 9, 10],
|
||||||
|
autumn: { yellowFrom: 2, redAbove: 5 },
|
||||||
|
},
|
||||||
|
youngColonyFactor: 0.5,
|
||||||
|
youngColonyTypes: ['YOUNG_COLONY', 'NUCLEUS_COLONY', 'MATING_NUCLEUS'],
|
||||||
|
recheckAfterDays: 14,
|
||||||
|
notify: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Upper limits: mites per day cannot get near 1000; a percentage stops at 100. */
|
||||||
|
const MAX_MITES_PER_DAY = 1000;
|
||||||
|
const MAX_PERCENT = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key before the bands existed: natural mite fall that counts as red.
|
||||||
|
* A present but unusable value is reported.
|
||||||
|
*/
|
||||||
|
function legacyThreshold(scope: Scope): number | undefined {
|
||||||
|
if (!has(scope, 'thresholdMitesPerDay')) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const value = scope.raw.thresholdMitesPerDay;
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
report(
|
||||||
|
scope,
|
||||||
|
'thresholdMitesPerDay',
|
||||||
|
'Bitte eine Zahl größer als 0 angeben.',
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Band {
|
||||||
|
yellowFrom: number;
|
||||||
|
redAbove: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BandLimits {
|
||||||
|
max: number;
|
||||||
|
/** The red limit itself must be above zero. */
|
||||||
|
redAboveZero?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* yellowFrom and redAbove of one band. An absent yellowFrom never ends above
|
||||||
|
* redAbove; a given one that does is reported and replaced.
|
||||||
|
*/
|
||||||
|
function readBand(
|
||||||
|
scope: Scope,
|
||||||
|
defaults: Band,
|
||||||
|
limits: BandLimits,
|
||||||
|
legacyRed?: number,
|
||||||
|
): Band {
|
||||||
|
const redAbove = numberIn(
|
||||||
|
scope,
|
||||||
|
'redAbove',
|
||||||
|
legacyRed ?? defaults.redAbove,
|
||||||
|
0,
|
||||||
|
limits.max,
|
||||||
|
{ exclusiveMin: limits.redAboveZero },
|
||||||
|
);
|
||||||
|
const yellowDefault = Math.min(defaults.yellowFrom, redAbove);
|
||||||
|
const yellowFrom = numberIn(
|
||||||
|
scope,
|
||||||
|
'yellowFrom',
|
||||||
|
yellowDefault,
|
||||||
|
0,
|
||||||
|
limits.max,
|
||||||
|
);
|
||||||
|
if (yellowFrom > redAbove) {
|
||||||
|
report(
|
||||||
|
scope,
|
||||||
|
'yellowFrom',
|
||||||
|
'Der gelbe Wert darf nicht über dem roten Wert liegen.',
|
||||||
|
);
|
||||||
|
return { yellowFrom: yellowDefault, redAbove };
|
||||||
|
}
|
||||||
|
return { yellowFrom, redAbove };
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLONY_TYPES = Object.values(ColonyType);
|
||||||
|
|
||||||
|
export const varroaThresholdParser = makeParser<VarroaThresholdConfig>(
|
||||||
|
'VARROA_THRESHOLD',
|
||||||
|
DEFAULT_VARROA_THRESHOLD_CONFIG,
|
||||||
|
(scope) => {
|
||||||
|
const d = DEFAULT_VARROA_THRESHOLD_CONFIG;
|
||||||
|
|
||||||
|
const legacy = legacyThreshold(scope);
|
||||||
|
const fall = childScope(scope, 'naturalFall');
|
||||||
|
const fallBand = readBand(
|
||||||
|
fall,
|
||||||
|
d.naturalFall,
|
||||||
|
{ max: MAX_MITES_PER_DAY, redAboveZero: true },
|
||||||
|
legacy,
|
||||||
|
);
|
||||||
|
const naturalFall = {
|
||||||
|
seasonMonths: months(fall, 'seasonMonths', d.naturalFall.seasonMonths),
|
||||||
|
...fallBand,
|
||||||
|
winterRedAbove: numberIn(
|
||||||
|
fall,
|
||||||
|
'winterRedAbove',
|
||||||
|
d.naturalFall.winterRedAbove,
|
||||||
|
0,
|
||||||
|
MAX_MITES_PER_DAY,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const percent = childScope(scope, 'infestationPercent');
|
||||||
|
const infestationPercent = {
|
||||||
|
summerMonths: months(
|
||||||
|
percent,
|
||||||
|
'summerMonths',
|
||||||
|
d.infestationPercent.summerMonths,
|
||||||
|
),
|
||||||
|
summer: readBand(
|
||||||
|
childScope(percent, 'summer'),
|
||||||
|
d.infestationPercent.summer,
|
||||||
|
{ max: MAX_PERCENT },
|
||||||
|
),
|
||||||
|
autumnMonths: months(
|
||||||
|
percent,
|
||||||
|
'autumnMonths',
|
||||||
|
d.infestationPercent.autumnMonths,
|
||||||
|
),
|
||||||
|
autumn: readBand(
|
||||||
|
childScope(percent, 'autumn'),
|
||||||
|
d.infestationPercent.autumn,
|
||||||
|
{ max: MAX_PERCENT },
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
naturalFall,
|
||||||
|
infestationPercent,
|
||||||
|
youngColonyFactor: numberIn(
|
||||||
|
scope,
|
||||||
|
'youngColonyFactor',
|
||||||
|
d.youngColonyFactor,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
{ exclusiveMin: true },
|
||||||
|
),
|
||||||
|
youngColonyTypes: enumList(
|
||||||
|
scope,
|
||||||
|
'youngColonyTypes',
|
||||||
|
d.youngColonyTypes,
|
||||||
|
COLONY_TYPES,
|
||||||
|
),
|
||||||
|
recheckAfterDays: intIn(
|
||||||
|
scope,
|
||||||
|
'recheckAfterDays',
|
||||||
|
d.recheckAfterDays,
|
||||||
|
0,
|
||||||
|
MAX_RECHECK_DAYS,
|
||||||
|
),
|
||||||
|
notify: bool(scope, 'notify', d.notify),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import {
|
||||||
|
childScope,
|
||||||
|
has,
|
||||||
|
makeParser,
|
||||||
|
numberIn,
|
||||||
|
optionalNumberIn,
|
||||||
|
report,
|
||||||
|
type Scope,
|
||||||
|
} from './parse-helpers';
|
||||||
|
import type { WaterContentConfig } from './rule-config.types';
|
||||||
|
|
||||||
|
const MIN_WATER_PERCENT = 10;
|
||||||
|
const MAX_WATER_PERCENT = 30;
|
||||||
|
const MAX_HONEY_TYPES = 50;
|
||||||
|
const MAX_HONEY_TYPE_LENGTH = 100;
|
||||||
|
|
||||||
|
export const DEFAULT_WATER_CONTENT_CONFIG: WaterContentConfig = {
|
||||||
|
warnAbovePercent: 18,
|
||||||
|
confirmAbovePercent: 20,
|
||||||
|
byHoneyType: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
function percent(scope: Scope, key: string, fallback: number): number {
|
||||||
|
return numberIn(scope, key, fallback, MIN_WATER_PERCENT, MAX_WATER_PERCENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORDER_MESSAGE =
|
||||||
|
'Die Warnschwelle darf nicht über der Bestätigungsschwelle liegen.';
|
||||||
|
|
||||||
|
function readHoneyTypes(scope: Scope): WaterContentConfig['byHoneyType'] {
|
||||||
|
const types = childScope(scope, 'byHoneyType');
|
||||||
|
const names = Object.keys(types.raw);
|
||||||
|
if (names.length > MAX_HONEY_TYPES) {
|
||||||
|
report(scope, 'byHoneyType', `Höchstens ${MAX_HONEY_TYPES} Sorten.`);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const entries: [
|
||||||
|
string,
|
||||||
|
{ warnAbovePercent?: number; confirmAbovePercent?: number },
|
||||||
|
][] = [];
|
||||||
|
for (const name of names) {
|
||||||
|
if (name.length === 0 || name.length > MAX_HONEY_TYPE_LENGTH) {
|
||||||
|
report(types, name.slice(0, 30), 'Der Sortenname ist ungültig.');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const entry = childScope(types, name);
|
||||||
|
const before = entry.issues.length;
|
||||||
|
const value: { warnAbovePercent?: number; confirmAbovePercent?: number } =
|
||||||
|
{};
|
||||||
|
const warn = optionalNumberIn(
|
||||||
|
entry,
|
||||||
|
'warnAbovePercent',
|
||||||
|
MIN_WATER_PERCENT,
|
||||||
|
MAX_WATER_PERCENT,
|
||||||
|
);
|
||||||
|
const confirm = optionalNumberIn(
|
||||||
|
entry,
|
||||||
|
'confirmAbovePercent',
|
||||||
|
MIN_WATER_PERCENT,
|
||||||
|
MAX_WATER_PERCENT,
|
||||||
|
);
|
||||||
|
if (warn !== undefined && confirm !== undefined && warn > confirm) {
|
||||||
|
report(entry, 'warnAbovePercent', ORDER_MESSAGE);
|
||||||
|
}
|
||||||
|
if (entry.issues.length > before) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (warn !== undefined) {
|
||||||
|
value.warnAbovePercent = warn;
|
||||||
|
}
|
||||||
|
if (confirm !== undefined) {
|
||||||
|
value.confirmAbovePercent = confirm;
|
||||||
|
}
|
||||||
|
entries.push([name, value]);
|
||||||
|
}
|
||||||
|
// fromEntries defines own properties, so a name like "__proto__" is harmless.
|
||||||
|
return Object.fromEntries(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const waterContentParser = makeParser<WaterContentConfig>(
|
||||||
|
'HONEY_WATER_CONTENT',
|
||||||
|
DEFAULT_WATER_CONTENT_CONFIG,
|
||||||
|
(scope) => {
|
||||||
|
const d = DEFAULT_WATER_CONTENT_CONFIG;
|
||||||
|
let warnAbovePercent = percent(
|
||||||
|
scope,
|
||||||
|
'warnAbovePercent',
|
||||||
|
d.warnAbovePercent,
|
||||||
|
);
|
||||||
|
let confirmAbovePercent = percent(
|
||||||
|
scope,
|
||||||
|
'confirmAbovePercent',
|
||||||
|
d.confirmAbovePercent,
|
||||||
|
);
|
||||||
|
if (warnAbovePercent > confirmAbovePercent) {
|
||||||
|
report(
|
||||||
|
scope,
|
||||||
|
has(scope, 'warnAbovePercent')
|
||||||
|
? 'warnAbovePercent'
|
||||||
|
: 'confirmAbovePercent',
|
||||||
|
ORDER_MESSAGE,
|
||||||
|
);
|
||||||
|
warnAbovePercent = d.warnAbovePercent;
|
||||||
|
confirmAbovePercent = d.confirmAbovePercent;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
warnAbovePercent,
|
||||||
|
confirmAbovePercent,
|
||||||
|
byHoneyType: readHoneyTypes(scope),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import type { ColonyType, VarroaMethod } from '../../generated/prisma';
|
||||||
|
import { bandsFor, classify, rateMeasurement } from './varroa-bands';
|
||||||
|
import { DEFAULT_VARROA_THRESHOLD_CONFIG } from './rule-config/varroa-threshold.config';
|
||||||
|
|
||||||
|
const DEFAULTS = DEFAULT_VARROA_THRESHOLD_CONFIG;
|
||||||
|
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||||
|
|
||||||
|
describe('varroa bands', () => {
|
||||||
|
it.each<
|
||||||
|
[
|
||||||
|
string,
|
||||||
|
ColonyType,
|
||||||
|
VarroaMethod,
|
||||||
|
number,
|
||||||
|
'green' | 'yellow' | 'red' | null,
|
||||||
|
]
|
||||||
|
>([
|
||||||
|
['2026-08-10', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 10.0, 'yellow'],
|
||||||
|
['2026-08-10', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 10.1, 'red'],
|
||||||
|
['2026-08-10', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 4.9, 'green'],
|
||||||
|
['2026-08-10', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 5, 'yellow'],
|
||||||
|
['2026-11-20', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 0.6, 'red'],
|
||||||
|
['2026-11-20', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 0.5, 'green'],
|
||||||
|
['2026-06-10', 'YOUNG_COLONY', 'NATURAL_MITE_FALL', 5.1, 'red'],
|
||||||
|
['2026-06-10', 'YOUNG_COLONY', 'NATURAL_MITE_FALL', 2.5, 'yellow'],
|
||||||
|
['2026-11-20', 'NUCLEUS_COLONY', 'NATURAL_MITE_FALL', 0.3, 'red'],
|
||||||
|
['2026-11-20', 'NUCLEUS_COLONY', 'NATURAL_MITE_FALL', 0.25, 'green'],
|
||||||
|
['2026-07-10', 'PRODUCTION_COLONY', 'POWDERED_SUGAR', 5.1, 'red'],
|
||||||
|
['2026-07-10', 'PRODUCTION_COLONY', 'POWDERED_SUGAR', 1, 'yellow'],
|
||||||
|
['2026-07-10', 'YOUNG_COLONY', 'POWDERED_SUGAR', 4, 'yellow'],
|
||||||
|
['2026-09-10', 'PRODUCTION_COLONY', 'WASHING_METHOD', 1.5, 'green'],
|
||||||
|
['2026-09-10', 'PRODUCTION_COLONY', 'WASHING_METHOD', 2, 'yellow'],
|
||||||
|
['2026-04-10', 'PRODUCTION_COLONY', 'POWDERED_SUGAR', 9, null],
|
||||||
|
['2026-11-10', 'PRODUCTION_COLONY', 'WASHING_METHOD', 9, null],
|
||||||
|
['2026-07-10', 'PRODUCTION_COLONY', 'DRONE_BROOD_CONTROL', 50, null],
|
||||||
|
])('%s %s %s %p -> %p', (day, type, method, value, light) =>
|
||||||
|
expect(
|
||||||
|
classify(value, bandsFor(day, type, DEFAULTS, []).bands[method]),
|
||||||
|
).toBe(light),
|
||||||
|
);
|
||||||
|
|
||||||
|
it('classifies a missing value or band as no light', () => {
|
||||||
|
const band = bandsFor('2026-08-10', null, DEFAULTS, []).bands
|
||||||
|
.NATURAL_MITE_FALL;
|
||||||
|
expect(classify(null, band)).toBeNull();
|
||||||
|
expect(classify(3, undefined)).toBeNull();
|
||||||
|
expect(classify(3, null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes the band with its reason, unit and colony kind', () => {
|
||||||
|
const autumn = bandsFor('2026-08-10', 'YOUNG_COLONY', DEFAULTS, []);
|
||||||
|
expect(autumn.day).toBe('2026-08-10');
|
||||||
|
expect(autumn.bands.NATURAL_MITE_FALL).toEqual({
|
||||||
|
method: 'NATURAL_MITE_FALL',
|
||||||
|
unit: 'MITES_PER_DAY',
|
||||||
|
yellowFrom: 2.5,
|
||||||
|
redAbove: 5,
|
||||||
|
reason: 'NATURAL_FALL_SEASON',
|
||||||
|
youngColony: true,
|
||||||
|
});
|
||||||
|
expect(autumn.bands.POWDERED_SUGAR).toMatchObject({
|
||||||
|
unit: 'PERCENT',
|
||||||
|
yellowFrom: 2,
|
||||||
|
redAbove: 5,
|
||||||
|
reason: 'INFESTATION_AUTUMN',
|
||||||
|
});
|
||||||
|
const winter = bandsFor('2026-01-10', 'PRODUCTION_COLONY', DEFAULTS, []);
|
||||||
|
expect(winter.bands.NATURAL_MITE_FALL).toMatchObject({
|
||||||
|
yellowFrom: null,
|
||||||
|
redAbove: 0.5,
|
||||||
|
reason: 'NATURAL_FALL_WINTER',
|
||||||
|
youngColony: false,
|
||||||
|
});
|
||||||
|
expect(winter.bands.POWDERED_SUGAR).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a colony without a type as a full colony', () =>
|
||||||
|
expect(
|
||||||
|
bandsFor('2026-08-10', null, DEFAULTS, []).bands.NATURAL_MITE_FALL,
|
||||||
|
).toMatchObject({ redAbove: 10, youngColony: false }));
|
||||||
|
|
||||||
|
it('names the recheck day while a treatment effect is in force', () => {
|
||||||
|
const treatments = [{ startDay: '2026-07-20', endDay: '2026-08-02' }];
|
||||||
|
const until = (day: string) =>
|
||||||
|
bandsFor(day, null, DEFAULTS, treatments).postTreatmentUntil;
|
||||||
|
expect(until('2026-07-25')).toBe('2026-08-16');
|
||||||
|
expect(until('2026-08-15')).toBe('2026-08-16');
|
||||||
|
expect(until('2026-08-16')).toBeNull();
|
||||||
|
expect(until('2026-07-19')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the latest recheck day of overlapping treatments', () =>
|
||||||
|
expect(
|
||||||
|
bandsFor('2026-08-01', null, DEFAULTS, [
|
||||||
|
{ startDay: '2026-07-01', endDay: '2026-07-20' },
|
||||||
|
{ startDay: '2026-07-25', endDay: '2026-08-10' },
|
||||||
|
]).postTreatmentUntil,
|
||||||
|
).toBe('2026-08-24'));
|
||||||
|
|
||||||
|
describe('month edges', () => {
|
||||||
|
const lightOn = (
|
||||||
|
day: string,
|
||||||
|
method: VarroaMethod,
|
||||||
|
value: number,
|
||||||
|
): string | null =>
|
||||||
|
classify(
|
||||||
|
value,
|
||||||
|
bandsFor(day, 'PRODUCTION_COLONY', DEFAULTS, []).bands[method],
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each<[string, VarroaMethod, number, string | null]>([
|
||||||
|
['2026-02-28', 'NATURAL_MITE_FALL', 3, 'red'],
|
||||||
|
['2026-03-01', 'NATURAL_MITE_FALL', 3, 'green'],
|
||||||
|
['2026-10-31', 'NATURAL_MITE_FALL', 3, 'green'],
|
||||||
|
['2026-11-01', 'NATURAL_MITE_FALL', 3, 'red'],
|
||||||
|
['2026-04-30', 'POWDERED_SUGAR', 3, null],
|
||||||
|
['2026-05-01', 'POWDERED_SUGAR', 3, 'yellow'],
|
||||||
|
['2026-07-31', 'POWDERED_SUGAR', 1.5, 'yellow'],
|
||||||
|
['2026-08-01', 'POWDERED_SUGAR', 1.5, 'green'],
|
||||||
|
['2026-10-31', 'POWDERED_SUGAR', 3, 'yellow'],
|
||||||
|
['2026-11-01', 'POWDERED_SUGAR', 3, null],
|
||||||
|
['2026-04-30', 'WASHING_METHOD', 3, null],
|
||||||
|
['2026-05-01', 'WASHING_METHOD', 3, 'yellow'],
|
||||||
|
['2026-07-31', 'WASHING_METHOD', 1.5, 'yellow'],
|
||||||
|
['2026-08-01', 'WASHING_METHOD', 1.5, 'green'],
|
||||||
|
['2026-10-31', 'WASHING_METHOD', 3, 'yellow'],
|
||||||
|
['2026-11-01', 'WASHING_METHOD', 3, null],
|
||||||
|
])('%s %s %p -> %p', (day, method, value, light) =>
|
||||||
|
expect(lightOn(day, method, value)).toBe(light),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the young factor off the percent bands', () => {
|
||||||
|
const { bands } = bandsFor('2026-07-10', 'YOUNG_COLONY', DEFAULTS, []);
|
||||||
|
expect(bands.POWDERED_SUGAR).toMatchObject({
|
||||||
|
youngColony: false,
|
||||||
|
yellowFrom: 1,
|
||||||
|
redAbove: 5,
|
||||||
|
});
|
||||||
|
expect(bands.NATURAL_MITE_FALL?.youngColony).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('treatment effect edges', () => {
|
||||||
|
const treatments = [{ startDay: '2026-07-20', endDay: '2026-08-02' }];
|
||||||
|
const ratingOn = (day: string, method: VarroaMethod) =>
|
||||||
|
rateMeasurement(
|
||||||
|
{ date: d(day), method, calculatedValue: 12 },
|
||||||
|
null,
|
||||||
|
DEFAULTS,
|
||||||
|
treatments,
|
||||||
|
);
|
||||||
|
|
||||||
|
it('starts on the start day and ends the day before the recheck day', () => {
|
||||||
|
expect(ratingOn('2026-07-19', 'NATURAL_MITE_FALL').light).toBe('red');
|
||||||
|
expect(ratingOn('2026-07-20', 'NATURAL_MITE_FALL').light).toBe(
|
||||||
|
'POST_TREATMENT',
|
||||||
|
);
|
||||||
|
expect(ratingOn('2026-08-15', 'NATURAL_MITE_FALL').light).toBe(
|
||||||
|
'POST_TREATMENT',
|
||||||
|
);
|
||||||
|
expect(ratingOn('2026-08-16', 'NATURAL_MITE_FALL').light).toBe('red');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('covers a single-day treatment', () => {
|
||||||
|
const single = [{ startDay: '2026-08-01', endDay: '2026-08-01' }];
|
||||||
|
const until = (day: string) =>
|
||||||
|
bandsFor(day, null, DEFAULTS, single).postTreatmentUntil;
|
||||||
|
expect(until('2026-08-01')).toBe('2026-08-15');
|
||||||
|
expect(until('2026-08-14')).toBe('2026-08-15');
|
||||||
|
expect(until('2026-08-15')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies to the percent methods and never to the drone brood check', () => {
|
||||||
|
expect(ratingOn('2026-07-25', 'POWDERED_SUGAR').light).toBe(
|
||||||
|
'POST_TREATMENT',
|
||||||
|
);
|
||||||
|
expect(ratingOn('2026-08-05', 'WASHING_METHOD').light).toBe(
|
||||||
|
'POST_TREATMENT',
|
||||||
|
);
|
||||||
|
expect(ratingOn('2026-07-25', 'DRONE_BROOD_CONTROL').light).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rates by the Berlin calendar day of the measurement', () => {
|
||||||
|
// 2026-10-31T23:30Z is 2026-11-01 00:30 in Berlin: winter band.
|
||||||
|
const rating = rateMeasurement(
|
||||||
|
{
|
||||||
|
date: new Date('2026-11-01T00:30:00+01:00'),
|
||||||
|
method: 'NATURAL_MITE_FALL',
|
||||||
|
calculatedValue: 3,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
DEFAULTS,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect(rating.reasonCode).toBe('NATURAL_FALL_WINTER');
|
||||||
|
expect(rating.light).toBe('red');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rateMeasurement', () => {
|
||||||
|
it('a measurement 5 days after treatment end has no light and names the recheck day', () =>
|
||||||
|
expect(
|
||||||
|
rateMeasurement(
|
||||||
|
{
|
||||||
|
date: d('2026-08-07'),
|
||||||
|
method: 'NATURAL_MITE_FALL',
|
||||||
|
calculatedValue: 12,
|
||||||
|
},
|
||||||
|
'PRODUCTION_COLONY',
|
||||||
|
DEFAULTS,
|
||||||
|
[{ startDay: '2026-07-20', endDay: '2026-08-02' }],
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
light: 'POST_TREATMENT',
|
||||||
|
reasonCode: 'POST_TREATMENT',
|
||||||
|
band: null,
|
||||||
|
postTreatmentUntil: '2026-08-16',
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('rates outside a treatment effect with the band and reason', () => {
|
||||||
|
const rating = rateMeasurement(
|
||||||
|
{
|
||||||
|
date: d('2026-08-20'),
|
||||||
|
method: 'NATURAL_MITE_FALL',
|
||||||
|
calculatedValue: 12,
|
||||||
|
},
|
||||||
|
'PRODUCTION_COLONY',
|
||||||
|
DEFAULTS,
|
||||||
|
[{ startDay: '2026-07-20', endDay: '2026-08-02' }],
|
||||||
|
);
|
||||||
|
expect(rating).toMatchObject({
|
||||||
|
light: 'red',
|
||||||
|
reasonCode: 'NATURAL_FALL_SEASON',
|
||||||
|
postTreatmentUntil: null,
|
||||||
|
});
|
||||||
|
expect(rating.band).toMatchObject({ redAbove: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no light for a method without a band or a missing value', () => {
|
||||||
|
expect(
|
||||||
|
rateMeasurement(
|
||||||
|
{
|
||||||
|
date: d('2026-04-10'),
|
||||||
|
method: 'POWDERED_SUGAR',
|
||||||
|
calculatedValue: 9,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
DEFAULTS,
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
light: null,
|
||||||
|
reasonCode: null,
|
||||||
|
band: null,
|
||||||
|
postTreatmentUntil: null,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
rateMeasurement(
|
||||||
|
{
|
||||||
|
date: d('2026-08-10'),
|
||||||
|
method: 'NATURAL_MITE_FALL',
|
||||||
|
calculatedValue: null,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
DEFAULTS,
|
||||||
|
[],
|
||||||
|
).light,
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import type {
|
||||||
|
ColonyType,
|
||||||
|
VarroaMeasurement,
|
||||||
|
VarroaMethod,
|
||||||
|
} from '../../generated/prisma';
|
||||||
|
import { addDays, berlinDayOf } from '../common/dates';
|
||||||
|
import type { TreatmentWindowLite } from '../treatments/withdrawal-window';
|
||||||
|
import type { VarroaThresholdConfig } from './rule-config/rule-config.types';
|
||||||
|
|
||||||
|
export type VarroaLightKey = 'green' | 'yellow' | 'red';
|
||||||
|
|
||||||
|
export type VarroaBandReason =
|
||||||
|
| 'NATURAL_FALL_SEASON'
|
||||||
|
| 'NATURAL_FALL_WINTER'
|
||||||
|
| 'INFESTATION_SUMMER'
|
||||||
|
| 'INFESTATION_AUTUMN';
|
||||||
|
|
||||||
|
export interface VarroaBand {
|
||||||
|
method: VarroaMethod;
|
||||||
|
unit: 'MITES_PER_DAY' | 'PERCENT';
|
||||||
|
/** null: no yellow stage (winter). */
|
||||||
|
yellowFrom: number | null;
|
||||||
|
redAbove: number;
|
||||||
|
reason: VarroaBandReason;
|
||||||
|
youngColony: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VarroaBands {
|
||||||
|
day: string;
|
||||||
|
bands: Partial<Record<VarroaMethod, VarroaBand>>;
|
||||||
|
/** Recheck day while a treatment effect is in force, otherwise null. */
|
||||||
|
postTreatmentUntil: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VarroaRating {
|
||||||
|
light: VarroaLightKey | 'POST_TREATMENT' | null;
|
||||||
|
reasonCode: VarroaBandReason | 'POST_TREATMENT' | null;
|
||||||
|
band: VarroaBand | null;
|
||||||
|
postTreatmentUntil: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERCENT_METHODS: readonly VarroaMethod[] = [
|
||||||
|
'POWDERED_SUGAR',
|
||||||
|
'WASHING_METHOD',
|
||||||
|
];
|
||||||
|
|
||||||
|
function naturalFallBand(
|
||||||
|
month: number,
|
||||||
|
factor: number,
|
||||||
|
young: boolean,
|
||||||
|
config: VarroaThresholdConfig['naturalFall'],
|
||||||
|
): VarroaBand {
|
||||||
|
const inSeason = config.seasonMonths.includes(month);
|
||||||
|
return {
|
||||||
|
method: 'NATURAL_MITE_FALL',
|
||||||
|
unit: 'MITES_PER_DAY',
|
||||||
|
yellowFrom: inSeason ? config.yellowFrom * factor : null,
|
||||||
|
redAbove: (inSeason ? config.redAbove : config.winterRedAbove) * factor,
|
||||||
|
reason: inSeason ? 'NATURAL_FALL_SEASON' : 'NATURAL_FALL_WINTER',
|
||||||
|
youngColony: young,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The percentage band of the month, absent outside summer and autumn. */
|
||||||
|
function percentBand(
|
||||||
|
method: VarroaMethod,
|
||||||
|
month: number,
|
||||||
|
config: VarroaThresholdConfig['infestationPercent'],
|
||||||
|
): VarroaBand | undefined {
|
||||||
|
if (config.summerMonths.includes(month)) {
|
||||||
|
return {
|
||||||
|
method,
|
||||||
|
unit: 'PERCENT',
|
||||||
|
...config.summer,
|
||||||
|
reason: 'INFESTATION_SUMMER',
|
||||||
|
youngColony: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (config.autumnMonths.includes(month)) {
|
||||||
|
return {
|
||||||
|
method,
|
||||||
|
unit: 'PERCENT',
|
||||||
|
...config.autumn,
|
||||||
|
reason: 'INFESTATION_AUTUMN',
|
||||||
|
youngColony: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Latest recheck day of the treatments whose effect covers `day`. */
|
||||||
|
function postTreatmentUntil(
|
||||||
|
day: string,
|
||||||
|
recheckAfterDays: number,
|
||||||
|
treatments: readonly TreatmentWindowLite[],
|
||||||
|
): string | null {
|
||||||
|
let until: string | null = null;
|
||||||
|
for (const { startDay, endDay } of treatments) {
|
||||||
|
const recheckDay = addDays(endDay, recheckAfterDays);
|
||||||
|
const covers = startDay <= day && day < recheckDay;
|
||||||
|
if (covers && (until === null || recheckDay > until)) {
|
||||||
|
until = recheckDay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return until;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bands in force on `day` for a colony of `colonyType`: natural mite fall
|
||||||
|
* by season (young colonies at the reduced factor), the percentage methods in
|
||||||
|
* summer and autumn only, no band for the drone brood check.
|
||||||
|
*/
|
||||||
|
export function bandsFor(
|
||||||
|
day: string,
|
||||||
|
colonyType: ColonyType | null,
|
||||||
|
config: VarroaThresholdConfig,
|
||||||
|
treatments: readonly TreatmentWindowLite[],
|
||||||
|
): VarroaBands {
|
||||||
|
const month = Number(day.slice(5, 7));
|
||||||
|
const young =
|
||||||
|
colonyType !== null && config.youngColonyTypes.includes(colonyType);
|
||||||
|
const factor = young ? config.youngColonyFactor : 1;
|
||||||
|
const bands: VarroaBands['bands'] = {
|
||||||
|
NATURAL_MITE_FALL: naturalFallBand(
|
||||||
|
month,
|
||||||
|
factor,
|
||||||
|
young,
|
||||||
|
config.naturalFall,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
for (const method of PERCENT_METHODS) {
|
||||||
|
const band = percentBand(method, month, config.infestationPercent);
|
||||||
|
if (band) {
|
||||||
|
bands[method] = band;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
day,
|
||||||
|
bands,
|
||||||
|
postTreatmentUntil: postTreatmentUntil(
|
||||||
|
day,
|
||||||
|
config.recheckAfterDays,
|
||||||
|
treatments,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Red above redAbove, yellow from yellowFrom, otherwise green. */
|
||||||
|
export function classify(
|
||||||
|
value: number | null,
|
||||||
|
band: VarroaBand | null | undefined,
|
||||||
|
): VarroaLightKey | null {
|
||||||
|
if (value === null || !band) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value > band.redAbove) {
|
||||||
|
return 'red';
|
||||||
|
}
|
||||||
|
if (band.yellowFrom !== null && value >= band.yellowFrom) {
|
||||||
|
return 'yellow';
|
||||||
|
}
|
||||||
|
return 'green';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The light of one measurement. A measurement taken from the start of a
|
||||||
|
* treatment until the recheck day has no light (treatment effect), except the
|
||||||
|
* drone brood check, which never has one.
|
||||||
|
*/
|
||||||
|
export function rateMeasurement(
|
||||||
|
m: Pick<VarroaMeasurement, 'date' | 'method' | 'calculatedValue'>,
|
||||||
|
colonyType: ColonyType | null,
|
||||||
|
config: VarroaThresholdConfig,
|
||||||
|
treatments: readonly TreatmentWindowLite[],
|
||||||
|
): VarroaRating {
|
||||||
|
const day = berlinDayOf(m.date);
|
||||||
|
const { bands, postTreatmentUntil: recheckDay } = bandsFor(
|
||||||
|
day,
|
||||||
|
colonyType,
|
||||||
|
config,
|
||||||
|
treatments,
|
||||||
|
);
|
||||||
|
const band = bands[m.method] ?? null;
|
||||||
|
if (recheckDay !== null && m.method !== 'DRONE_BROOD_CONTROL') {
|
||||||
|
return {
|
||||||
|
light: 'POST_TREATMENT',
|
||||||
|
reasonCode: 'POST_TREATMENT',
|
||||||
|
band: null,
|
||||||
|
postTreatmentUntil: recheckDay,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
light: classify(m.calculatedValue, band),
|
||||||
|
reasonCode: band?.reason ?? null,
|
||||||
|
band,
|
||||||
|
postTreatmentUntil: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import {
|
|
||||||
DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY,
|
|
||||||
varroaThresholdOf,
|
|
||||||
} from './varroa-threshold';
|
|
||||||
|
|
||||||
describe('varroaThresholdOf', () => {
|
|
||||||
it('uses the default when no rule exists yet (default rule is active)', () => {
|
|
||||||
expect(varroaThresholdOf(null)).toBe(
|
|
||||||
DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY,
|
|
||||||
);
|
|
||||||
expect(DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for an inactive rule', () => {
|
|
||||||
expect(
|
|
||||||
varroaThresholdOf({
|
|
||||||
active: false,
|
|
||||||
config: { thresholdMitesPerDay: 7 },
|
|
||||||
}),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the configured threshold of an active rule', () => {
|
|
||||||
expect(
|
|
||||||
varroaThresholdOf({ active: true, config: { thresholdMitesPerDay: 7 } }),
|
|
||||||
).toBe(7);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['a string', { thresholdMitesPerDay: 'x' }],
|
|
||||||
['zero', { thresholdMitesPerDay: 0 }],
|
|
||||||
['a negative number', { thresholdMitesPerDay: -1 }],
|
|
||||||
['NaN', { thresholdMitesPerDay: Number.NaN }],
|
|
||||||
['a missing key', {}],
|
|
||||||
['a null config', null],
|
|
||||||
])('falls back to the default for %s', (_label, config) => {
|
|
||||||
expect(varroaThresholdOf({ active: true, config })).toBe(
|
|
||||||
DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import type { AutomationRule } from '../../generated/prisma';
|
|
||||||
|
|
||||||
/** Default natural mite fall (mites per day) that triggers the varroa rule. */
|
|
||||||
export const DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY = 10;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Effective varroa threshold of the VARROA_THRESHOLD rule.
|
|
||||||
* - no rule row yet: the default rule applies (ensureDefaults creates it active)
|
|
||||||
* - inactive rule: null (no threshold in force)
|
|
||||||
* - active rule: the configured value, or the default if it is not a
|
|
||||||
* positive finite number (a hand-edited config must never disable the check).
|
|
||||||
*/
|
|
||||||
export function varroaThresholdOf(
|
|
||||||
rule: Pick<AutomationRule, 'active' | 'config'> | null,
|
|
||||||
): number | null {
|
|
||||||
if (rule === null) {
|
|
||||||
return DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY;
|
|
||||||
}
|
|
||||||
if (!rule.active) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const config = rule.config;
|
|
||||||
if (typeof config !== 'object' || config === null || Array.isArray(config)) {
|
|
||||||
return DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY;
|
|
||||||
}
|
|
||||||
const value = (config as Record<string, unknown>).thresholdMitesPerDay;
|
|
||||||
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
|
||||||
? value
|
|
||||||
: DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 1797 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user