Compare commits
100
Commits
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,110 @@
|
|||||||
|
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
|
||||||
|
run: npm run test:e2e -- --ci
|
||||||
@@ -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,16 +50,15 @@ 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: |
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
|
||||||
type=sha,format=short
|
type=sha,format=short
|
||||||
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,16 +50,15 @@ 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: |
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
|
||||||
type=sha,format=short
|
type=sha,format=short
|
||||||
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
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
name: Manifests
|
||||||
|
|
||||||
|
# Prueft deploy/k8s ohne Cluster-Zugriff: Overlay und jeden Rollout-Schritt
|
||||||
|
# rendern, gegen die Kubernetes-Schemas validieren und die Regeln testen
|
||||||
|
# (NetworkPolicies, Pod-Haertung, Ressourcen, CronJobs, Digests, Live-Selektoren).
|
||||||
|
#
|
||||||
|
# Bewusst kein pull_request-Trigger: der Runner laeuft im Cluster, und ein
|
||||||
|
# Fork-PR braechte eigenen Code (auch eine geaenderte Fassung dieser Datei,
|
||||||
|
# also ohne jeden if-Waechter) darauf zur Ausfuehrung. push setzt
|
||||||
|
# Schreibrechte am Repo voraus; jeder Branch-Push wird geprueft, damit auch
|
||||||
|
# Aenderungen vor dem Merge nach main durch die CI gehen.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths: ['deploy/k8s/**', '.gitea/workflows/manifests.yml']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
runs-on: docker-builder
|
||||||
|
env:
|
||||||
|
# Werkzeuge mit festen Versionen; jede Datei wird gegen ihre SHA-256 von
|
||||||
|
# der offiziellen Release-Seite geprueft, bevor sie laeuft:
|
||||||
|
# kubectl: https://dl.k8s.io/release/<v>/bin/linux/amd64/kubectl.sha256
|
||||||
|
# kubeconform: CHECKSUMS im GitHub-Release yannh/kubeconform
|
||||||
|
# yq: checksums (Spalte SHA-256) im GitHub-Release mikefarah/yq
|
||||||
|
# Ein Update aendert Version und Pruefsumme zusammen.
|
||||||
|
KUBECTL_VERSION: v1.36.5
|
||||||
|
KUBECTL_SHA256: 33bc88a24c3b09cf55bfd59e6ca977881b61fc3f25ab65bc80c65b430248648e
|
||||||
|
KUBECONFORM_VERSION: v0.8.0
|
||||||
|
KUBECONFORM_SHA256: 9bc2bffbf71f261128533edaf912153948b7ff238f9a531ae6d34466ec287883
|
||||||
|
YQ_VERSION: v4.53.6
|
||||||
|
YQ_SHA256: c5f056448f973ae7d39b5401949648a78f2dc1947d6a8eb65be60d5c504b9385
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
|
- name: Node einrichten
|
||||||
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: kubectl, kubeconform und yq laden
|
||||||
|
run: |
|
||||||
|
mkdir -p "$RUNNER_TEMP/bin"
|
||||||
|
cd "$RUNNER_TEMP"
|
||||||
|
curl -fsSLo kubectl "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl"
|
||||||
|
echo "${KUBECTL_SHA256} kubectl" | sha256sum -c -
|
||||||
|
curl -fsSLo kubeconform.tar.gz "https://github.com/yannh/kubeconform/releases/download/${KUBECONFORM_VERSION}/kubeconform-linux-amd64.tar.gz"
|
||||||
|
echo "${KUBECONFORM_SHA256} kubeconform.tar.gz" | sha256sum -c -
|
||||||
|
tar -xzf kubeconform.tar.gz kubeconform
|
||||||
|
curl -fsSLo yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64"
|
||||||
|
echo "${YQ_SHA256} yq" | sha256sum -c -
|
||||||
|
install -m 0755 kubectl kubeconform yq "$RUNNER_TEMP/bin/"
|
||||||
|
|
||||||
|
- name: Overlay rendern
|
||||||
|
run: |
|
||||||
|
"$RUNNER_TEMP/bin/kubectl" kustomize deploy/k8s/overlays/encry > "$RUNNER_TEMP/rendered.yaml"
|
||||||
|
"$RUNNER_TEMP/bin/yq" ea -o=json '[.]' "$RUNNER_TEMP/rendered.yaml" > "$RUNNER_TEMP/rendered.json"
|
||||||
|
|
||||||
|
- name: Rollout-Schritte rendern
|
||||||
|
# Runbook Schritte 1, 2, 3 und 5: Overlay mit den bis dahin
|
||||||
|
# freigegebenen Komponenten (siehe render-stages.mjs).
|
||||||
|
run: |
|
||||||
|
export KUBECTL="$RUNNER_TEMP/bin/kubectl" YQ="$RUNNER_TEMP/bin/yq"
|
||||||
|
node deploy/k8s/tests/render-stages.mjs "$RUNNER_TEMP/stages"
|
||||||
|
|
||||||
|
- name: Gegen Kubernetes-Schemas pruefen
|
||||||
|
run: |
|
||||||
|
"$RUNNER_TEMP/bin/kubeconform" -strict -summary "$RUNNER_TEMP/rendered.yaml" "$RUNNER_TEMP"/stages/*.yaml
|
||||||
|
|
||||||
|
- name: Regeln testen
|
||||||
|
run: |
|
||||||
|
export RENDERED_MANIFESTS="$RUNNER_TEMP/rendered.json" RENDERED_STAGES="$RUNNER_TEMP/stages/stages.json"
|
||||||
|
node --test deploy/k8s/tests/manifest-policy.test.mjs
|
||||||
@@ -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/
|
||||||
+1016
-26
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,8 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
# A stale build info makes `nest build` emit only changed files into the
|
||||||
|
# freshly deleted dist (tsconfig.json: incremental).
|
||||||
|
*.tsbuildinfo
|
||||||
generated
|
generated
|
||||||
coverage
|
coverage
|
||||||
.devdb
|
.devdb
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
DATABASE_URL="postgresql://user:password@localhost:5432/apicore?schema=public"
|
DATABASE_URL="postgresql://user:password@localhost:5432/apicore?schema=public"
|
||||||
|
|
||||||
# Mindestens 32 Zeichen (server-seitig erzwungen, siehe main.ts) - signiert
|
# Mindestens 32 Zeichen (server-seitig erzwungen, siehe
|
||||||
# jedes Access-/MFA-/SSO-Token UND verschluesselt gespeicherte TOTP-Secrets.
|
# src/runtime/startup-checks.ts) - signiert jedes Access-/MFA-/SSO-Token.
|
||||||
|
# TOTP- und Webhook-Secrets aus der Zeit vor den eigenen Schluesseln (unten)
|
||||||
|
# sind noch damit verschluesselt: erst nach dem rekey-Job rotieren.
|
||||||
# Erzeugen z.B. mit: openssl rand -hex 32
|
# Erzeugen z.B. mit: openssl rand -hex 32
|
||||||
JWT_ACCESS_SECRET="change-me-to-a-random-value-at-least-32-characters-long"
|
JWT_ACCESS_SECRET="change-me-to-a-random-value-at-least-32-characters-long"
|
||||||
JWT_REFRESH_SECRET="change-me-refresh-secret"
|
JWT_REFRESH_SECRET="change-me-refresh-secret"
|
||||||
@@ -22,7 +24,7 @@ FRONTEND_ORIGIN="http://localhost:3000"
|
|||||||
# trusted (client IP for rate limits and API-key IP allowlists). "1" for the
|
# trusted (client IP for rate limits and API-key IP allowlists). "1" for the
|
||||||
# single K8s ingress; a list such as "loopback, 10.0.0.0/8" also works.
|
# single K8s ingress; a list such as "loopback, 10.0.0.0/8" also works.
|
||||||
# Behind Cloudflare + Traefik use "loopback, <pod CIDR>, <Cloudflare ranges>"
|
# Behind Cloudflare + Traefik use "loopback, <pod CIDR>, <Cloudflare ranges>"
|
||||||
# (see deploy/k8s/backend/configmap.example.yaml); "1" would yield the
|
# (see deploy/k8s/base/backend/configmap.example.yaml); "1" would yield the
|
||||||
# Cloudflare edge IP that many clients share.
|
# Cloudflare edge IP that many clients share.
|
||||||
TRUST_PROXY=1
|
TRUST_PROXY=1
|
||||||
|
|
||||||
@@ -54,11 +56,38 @@ BACKUP_CRON_SCHEDULE="0 3 * * *"
|
|||||||
# Wie viele der neuesten Backup-Dateien behalten werden, aeltere werden nach
|
# Wie viele der neuesten Backup-Dateien behalten werden, aeltere werden nach
|
||||||
# jedem Lauf geloescht (Standard: 14).
|
# jedem Lauf geloescht (Standard: 14).
|
||||||
BACKUP_RETENTION_COUNT=14
|
BACKUP_RETENTION_COUNT=14
|
||||||
# Backups are encrypted (AES-256-GCM). Without this key they are sealed with a
|
# Obergrenze fuer eine einzuspielende Backup-Datei in Bytes (Standard 50 MB,
|
||||||
# key derived from JWT_ACCESS_SECRET and restore only into this deployment.
|
# hoechstens 256 MB, weil der Upload komplett im RAM verarbeitet wird).
|
||||||
# Set the same value on another deployment to restore there. At least 32
|
# Export und Zeitplan warnen, wenn eine Sicherung groesser wird.
|
||||||
# characters, e.g. "openssl rand -hex 32". Keep it outside the backup storage.
|
# 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
|
||||||
|
# HKDF): TOTP secrets, webhook signing secrets, backup files. Required in
|
||||||
|
# production: at least 32 characters each, all three different from each
|
||||||
|
# other and from JWT_ACCESS_SECRET/JWT_REFRESH_SECRET, e.g.
|
||||||
|
# "openssl rand -hex 32". Outside production a missing key falls back to one
|
||||||
|
# derived from JWT_ACCESS_SECRET.
|
||||||
|
# Rotation: move the old value to <NAME>_PREVIOUS (still opens), set a new
|
||||||
|
# <NAME>, run the rekey job, then drop <NAME>_PREVIOUS. Backup files are not
|
||||||
|
# resealed: keep BACKUP_ENCRYPTION_KEY_PREVIOUS while older files must restore.
|
||||||
|
# Set the same BACKUP_ENCRYPTION_KEY on another deployment to restore its
|
||||||
|
# backups there (MFA_/WEBHOOK_ENCRYPTION_KEY too, to keep MFA and webhook
|
||||||
|
# secrets). Keep the backup key outside the backup storage.
|
||||||
|
# MFA_ENCRYPTION_KEY=""
|
||||||
|
# WEBHOOK_ENCRYPTION_KEY=""
|
||||||
# BACKUP_ENCRYPTION_KEY=""
|
# BACKUP_ENCRYPTION_KEY=""
|
||||||
|
# Seals voice notes (audio, transcripts, suggestions); required in production.
|
||||||
|
# VOICE_ENCRYPTION_KEY=""
|
||||||
|
# MFA_ENCRYPTION_KEY_PREVIOUS=""
|
||||||
|
# WEBHOOK_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).
|
||||||
@@ -84,3 +113,39 @@ OIDC_REDIRECT_URI="http://localhost:3001/api/v1/auth/sso/callback"
|
|||||||
# gesamte API-Schema ungeschuetzt (kein JwtAuthGuard) preisgeben wuerde.
|
# gesamte API-Schema ungeschuetzt (kein JwtAuthGuard) preisgeben wuerde.
|
||||||
# Nur fuer lokale Entwicklung/Debugging explizit aktivieren.
|
# Nur fuer lokale Entwicklung/Debugging explizit aktivieren.
|
||||||
ENABLE_SWAGGER=false
|
ENABLE_SWAGGER=false
|
||||||
|
|
||||||
|
# Laufrolle des Prozesses: api (nur HTTP), worker, job (CLI) oder all (API +
|
||||||
|
# Worker in einem Prozess, nur fuer lokale Entwicklung und Tests). Ohne Wert
|
||||||
|
# gilt der Standard des Einstiegspunkts (main: all, worker: worker, cli: job).
|
||||||
|
# In Produktion Pflicht und "all" ist dort verboten.
|
||||||
|
# APICORE_ROLE=api
|
||||||
|
# Port des Health-Endpunkts des Worker-Prozesses.
|
||||||
|
# WORKER_HEALTH_PORT=3002
|
||||||
|
|
||||||
|
# Wetterquelle fuer die Pflanzenentwicklung (Gradtagszahl) und die
|
||||||
|
# Wetteranzeige. Einzige Quelle ist die freie Open-Meteo-API (bewusst
|
||||||
|
# uebernommenes Lizenzrisiko, nicht-kommerzielle Bedingungen); der Adapter
|
||||||
|
# (src/weather/history) erlaubt einen Wechsel. Nur https:-URLs; ein
|
||||||
|
# unbekannter Quellenname oder eine http:-URL stoppt den Start. Der Schluessel
|
||||||
|
# ist optional, gehoert in ein Secret und steht nie in Logs oder Fehlermeldungen.
|
||||||
|
# WEATHER_HISTORY_SOURCE=open-meteo
|
||||||
|
# OPEN_METEO_FORECAST_URL=https://api.open-meteo.com/v1/forecast
|
||||||
|
# OPEN_METEO_ARCHIVE_URL=https://archive-api.open-meteo.com/v1/archive
|
||||||
|
# OPEN_METEO_API_KEY=
|
||||||
|
# Optional: Kontakt (E-Mail oder URL) fuer den User-Agent-Header der
|
||||||
|
# Wetteranfragen ("ApiCore/<Version> (+<Kontakt>)"); ohne Wert
|
||||||
|
# "ApiCore/<Version> (self-hosted beekeeping app)".
|
||||||
|
# WEATHER_USER_AGENT_CONTACT=
|
||||||
|
|
||||||
|
# Telegram-Erinnerungen (optional, T4c). TELEGRAM_BOT_USERNAME (ohne @, 5-32
|
||||||
|
# Zeichen: Buchstaben, Ziffern, Unterstrich) steht in API und Worker und
|
||||||
|
# erzeugt den Deep Link. TELEGRAM_BOT_TOKEN (BotFather) gehoert NUR dem Worker,
|
||||||
|
# im Cluster in das eigene Secret apicore-telegram; der API-Pod darf es nicht
|
||||||
|
# einbinden. Ein fehlerhaftes Token stoppt nichts: Telegram bleibt aus, der
|
||||||
|
# Worker loggt einen Fehler (nie den Wert). Bei BotFather Gruppen ausschalten
|
||||||
|
# (/setjoingroups) und keinen Webhook setzen.
|
||||||
|
# Je Umgebung ein eigener Bot. Im Cluster steht der Name in der ConfigMap
|
||||||
|
# (TELEGRAM_BOT_USERNAME, leer = Telegram in der App nicht eingerichtet); den
|
||||||
|
# Zustand zeigt Verwaltung > System (Telegram-Karte, nur system:manage).
|
||||||
|
# TELEGRAM_BOT_USERNAME=
|
||||||
|
# TELEGRAM_BOT_TOKEN=
|
||||||
@@ -18,3 +18,6 @@ prisma/seed.d.ts
|
|||||||
# Locally uploaded documents/images (Modul 29) and their test-run counterpart
|
# Locally uploaded documents/images (Modul 29) and their test-run counterpart
|
||||||
/uploads/
|
/uploads/
|
||||||
/test-uploads/
|
/test-uploads/
|
||||||
|
|
||||||
|
# T4 migration test harness: scratch copies of the migration folders
|
||||||
|
/.tmp-migrations/
|
||||||
+24
-10
@@ -1,6 +1,10 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
FROM node:22-alpine AS builder
|
# Base-Image per Digest gepinnt (node:22-alpine, Multi-Arch-Index vom
|
||||||
|
# 2026-09-23): ein Rebuild zieht nie ungeprueft ein neues Image. Ein Update
|
||||||
|
# ist ein bewusster Commit mit neuem Digest, ermittelt per
|
||||||
|
# docker buildx imagetools inspect node:22-alpine
|
||||||
|
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
@@ -14,16 +18,16 @@ RUN npx prisma generate
|
|||||||
RUN npx eslint "{src,test}/**/*.ts" && npm run typecheck && npm test -- --ci
|
RUN npx eslint "{src,test}/**/*.ts" && npm run typecheck && npm test -- --ci
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:22-alpine AS runtime
|
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS runtime
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY --from=builder /app/generated ./generated
|
COPY --from=builder /app/generated ./generated
|
||||||
# schema.prisma, migrations/ und prisma.config.ts: noetig fuer
|
# schema.prisma, migrations/ und prisma.config.ts: noetig fuer den
|
||||||
# `npm run migrate:deploy`, das der Container beim Start selbst ausfuehrt
|
# initContainer `migrate` (siehe Kommentar ueber CMD unten).
|
||||||
# (siehe CMD unten). seed.ts selbst wird NICHT zur Laufzeit ausgefuehrt -
|
# seed.ts selbst wird NICHT zur Laufzeit ausgefuehrt -
|
||||||
# `npm run build` kompiliert es vorab zu dist/seed.js (siehe
|
# `npm run build` kompiliert es vorab zu dist/seed.js (siehe
|
||||||
# tsconfig.seed.json), das schon in ./dist enthalten ist. Frueher lief
|
# tsconfig.seed.json), das schon in ./dist enthalten ist. Frueher lief
|
||||||
# `ts-node prisma/seed.ts` direkt im Container: der TypeScript-Compiler
|
# `ts-node prisma/seed.ts` direkt im Container: der TypeScript-Compiler
|
||||||
@@ -31,9 +35,19 @@ COPY --from=builder /app/generated ./generated
|
|||||||
# fuehrte zu sporadischen OOM-Crashes beim Start.
|
# fuehrte zu sporadischen OOM-Crashes beim Start.
|
||||||
COPY --from=builder /app/prisma ./prisma
|
COPY --from=builder /app/prisma ./prisma
|
||||||
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
|
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
|
||||||
EXPOSE 3001
|
# 3001: HTTP der api-Rolle. 3002: /health der worker-Rolle.
|
||||||
|
EXPOSE 3001 3002
|
||||||
USER node
|
USER node
|
||||||
# migrate deploy ist idempotent und nutzt einen DB-seitigen Advisory-Lock,
|
# Migration und Seed laufen nicht mehr hier, sondern als initContainer im
|
||||||
# seed ist idempotent per Unique-Constraint auf User.email (siehe seed.ts) -
|
# Pod (deploy/k8s/components/backend-roles): `migrate` in api und worker
|
||||||
# beides unproblematisch bei mehreren gleichzeitig startenden Pod-Replicas.
|
# (`node node_modules/prisma/build/index.js migrate deploy`, idempotent mit
|
||||||
CMD ["sh", "-c", "npm run migrate:deploy && npm run seed && node dist/main.js"]
|
# DB-seitigem Advisory-Lock), `seed` nur in der api (`node dist/seed.js`,
|
||||||
|
# idempotent per Unique-Constraint auf User.email). Scheitert die Migration,
|
||||||
|
# startet der neue Pod nicht und der alte laeuft weiter. Der Hauptprozess
|
||||||
|
# startet ohne Prisma-CLI; worker und Jobs ueberschreiben das Kommando
|
||||||
|
# (dist/worker.js, dist/cli.js <job>), die Rolle waehlt APICORE_ROLE.
|
||||||
|
# Dieses Image darf darum nur zusammen mit components/backend-roles live
|
||||||
|
# gehen (Runbook Schritt 3). Fehlt APICORE_ROLE, bricht der Start in
|
||||||
|
# Produktion absichtlich ab. APICORE_ROLE nicht von Hand in ein Deployment
|
||||||
|
# ohne die initContainer setzen: dann liefe keine Migration.
|
||||||
|
CMD ["node", "dist/main.js"]
|
||||||
+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
+93
-35
@@ -9,13 +9,13 @@
|
|||||||
"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",
|
||||||
"@nestjs/jwt": "^11.0.2",
|
"@nestjs/jwt": "^11.0.2",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/schedule": "^12.0.1",
|
|
||||||
"@nestjs/swagger": "^11.4.7",
|
"@nestjs/swagger": "^11.4.7",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@prisma/adapter-pg": "^7.9.1",
|
"@prisma/adapter-pg": "^7.9.1",
|
||||||
@@ -24,12 +24,14 @@
|
|||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cron": "^4.4.0",
|
||||||
"helmet": "^8.3.0",
|
"helmet": "^8.3.0",
|
||||||
"openid-client": "^6.8.4",
|
"openid-client": "^6.8.4",
|
||||||
"otplib": "^13.5.0",
|
"otplib": "^13.5.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"pdfkit": "^0.19.1",
|
"pdfkit": "^0.19.1",
|
||||||
|
"pg": "^8.23.1",
|
||||||
"prisma": "^7.9.1",
|
"prisma": "^7.9.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/pdfkit": "^0.17.6",
|
"@types/pdfkit": "^0.17.6",
|
||||||
|
"@types/pg": "^8.23.1",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/supertest": "^7.0.0",
|
"@types/supertest": "^7.0.0",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
@@ -211,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",
|
||||||
@@ -672,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",
|
||||||
@@ -2478,22 +2511,6 @@
|
|||||||
"@nestjs/core": "^11.0.0"
|
"@nestjs/core": "^11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nestjs/schedule": {
|
|
||||||
"version": "12.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-12.0.1.tgz",
|
|
||||||
"integrity": "sha512-v0z/li9L8oxpvuIDAGm5uOLaZ8KzsfZRzgWgppvs5vClneSinZP5IFl3C1F1DCeTCkNLiGN9g/oLUvzg8x++RQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"cron": "4.4.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.19.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@nestjs/common": "^11.0.0 || ^12.0.0",
|
|
||||||
"@nestjs/core": "^11.0.0 || ^12.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nestjs/schematics": {
|
"node_modules/@nestjs/schematics": {
|
||||||
"version": "11.1.0",
|
"version": "11.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz",
|
||||||
@@ -3227,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",
|
||||||
@@ -3601,9 +3624,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/luxon": {
|
"node_modules/@types/luxon": {
|
||||||
"version": "3.7.5",
|
"version": "3.7.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.6.tgz",
|
||||||
"integrity": "sha512-jJ41Q4z6ZVO260MNDdHfW7+7a5iMiX8Mr6ZJHcmgrvhZha6dz5704o/lF2kKl6URjH6ivEL97w9xS/MgpJEphg==",
|
"integrity": "sha512-6KSjliQAXK8ZLgFQO4B7iE4Rpf/B3rItzCFuXezBY/XIAGxOdLd7vRNK9/StRPwiS/yJsVbB7HKpW46B8tcEuQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/methods": {
|
"node_modules/@types/methods": {
|
||||||
@@ -6939,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",
|
||||||
@@ -8794,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",
|
||||||
@@ -10087,14 +10129,14 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pg": {
|
"node_modules/pg": {
|
||||||
"version": "8.23.0",
|
"version": "8.23.1",
|
||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
|
||||||
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
|
"integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.14.0",
|
"pg-connection-string": "^2.14.1",
|
||||||
"pg-pool": "^3.14.0",
|
"pg-pool": "^3.14.0",
|
||||||
"pg-protocol": "^1.16.0",
|
"pg-protocol": "^1.16.1",
|
||||||
"pg-types": "2.2.0",
|
"pg-types": "2.2.0",
|
||||||
"pgpass": "1.0.5"
|
"pgpass": "1.0.5"
|
||||||
},
|
},
|
||||||
@@ -10102,7 +10144,7 @@
|
|||||||
"node": ">= 16.0.0"
|
"node": ">= 16.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"pg-cloudflare": "^1.4.0"
|
"pg-cloudflare": "^1.4.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"pg-native": ">=3.0.1"
|
"pg-native": ">=3.0.1"
|
||||||
@@ -10114,16 +10156,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pg-cloudflare": {
|
"node_modules/pg-cloudflare": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
|
||||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
"integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/pg-connection-string": {
|
"node_modules/pg-connection-string": {
|
||||||
"version": "2.14.0",
|
"version": "2.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
|
||||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
"integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pg-int8": {
|
"node_modules/pg-int8": {
|
||||||
@@ -10145,9 +10187,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pg-protocol": {
|
"node_modules/pg-protocol": {
|
||||||
"version": "1.16.0",
|
"version": "1.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
|
||||||
"integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
|
"integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pg-types": {
|
"node_modules/pg-types": {
|
||||||
@@ -11263,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",
|
||||||
@@ -11799,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",
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "node dist/main",
|
"start:prod": "node dist/main",
|
||||||
|
"worker": "node dist/worker.js",
|
||||||
|
"job": "node dist/cli.js",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
@@ -20,16 +22,18 @@
|
|||||||
"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",
|
||||||
"@nestjs/jwt": "^11.0.2",
|
"@nestjs/jwt": "^11.0.2",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/schedule": "^12.0.1",
|
|
||||||
"@nestjs/swagger": "^11.4.7",
|
"@nestjs/swagger": "^11.4.7",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@prisma/adapter-pg": "^7.9.1",
|
"@prisma/adapter-pg": "^7.9.1",
|
||||||
@@ -38,12 +42,14 @@
|
|||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cron": "^4.4.0",
|
||||||
"helmet": "^8.3.0",
|
"helmet": "^8.3.0",
|
||||||
"openid-client": "^6.8.4",
|
"openid-client": "^6.8.4",
|
||||||
"otplib": "^13.5.0",
|
"otplib": "^13.5.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"pdfkit": "^0.19.1",
|
"pdfkit": "^0.19.1",
|
||||||
|
"pg": "^8.23.1",
|
||||||
"prisma": "^7.9.1",
|
"prisma": "^7.9.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
@@ -63,6 +69,7 @@
|
|||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/pdfkit": "^0.17.6",
|
"@types/pdfkit": "^0.17.6",
|
||||||
|
"@types/pg": "^8.23.1",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/supertest": "^7.0.0",
|
"@types/supertest": "^7.0.0",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
@@ -92,7 +99,7 @@
|
|||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose|@nestjs/schedule|cron)/)"
|
"node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose|cron)/)"
|
||||||
],
|
],
|
||||||
"collectCoverageFrom": [
|
"collectCoverageFrom": [
|
||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
-- Platform backbone: domain-event outbox (DomainEvent + per-handler
|
||||||
|
-- DomainEventDelivery), webhook delivery queue, cron job run history and
|
||||||
|
-- throttler counters, plus two small columns (Apiary.publicLocationLabel,
|
||||||
|
-- SiteSettings.originCountry).
|
||||||
|
-- ThrottlerHit is UNLOGGED on purpose: no WAL, emptied after a crash - only
|
||||||
|
-- rate-limit counters get lost. DomainEvent.dedupeKey is a nullable UNIQUE
|
||||||
|
-- (not a partial index) so the schema and the database cannot drift apart.
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "DomainEventStatus" AS ENUM ('PENDING', 'PROCESSING', 'DONE', 'FAILED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "DomainEventDeliveryStatus" AS ENUM ('PENDING', 'DONE', 'FAILED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "WebhookDeliveryStatus" AS ENUM ('PENDING', 'PROCESSING', 'DELIVERED', 'FAILED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "JobRunStatus" AS ENUM ('RUNNING', 'SUCCEEDED', 'FAILED');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Apiary" ADD COLUMN "publicLocationLabel" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "SiteSettings" ADD COLUMN "originCountry" TEXT NOT NULL DEFAULT 'Deutschland';
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DomainEvent" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"aggregateType" TEXT NOT NULL,
|
||||||
|
"aggregateId" TEXT NOT NULL,
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
"actorUserId" TEXT,
|
||||||
|
"correlationId" TEXT NOT NULL,
|
||||||
|
"dedupeKey" TEXT,
|
||||||
|
"occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"status" "DomainEventStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"claimedAt" TIMESTAMP(3),
|
||||||
|
"processedAt" TIMESTAMP(3),
|
||||||
|
"lastError" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "DomainEvent_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DomainEventDelivery" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"eventId" TEXT NOT NULL,
|
||||||
|
"handler" TEXT NOT NULL,
|
||||||
|
"status" "DomainEventDeliveryStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"lastError" TEXT,
|
||||||
|
"completedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "DomainEventDelivery_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "WebhookDelivery" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"subscriptionId" TEXT NOT NULL,
|
||||||
|
"event" TEXT NOT NULL,
|
||||||
|
"body" TEXT NOT NULL,
|
||||||
|
"status" "WebhookDeliveryStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"claimedAt" TIMESTAMP(3),
|
||||||
|
"lastStatusCode" INTEGER,
|
||||||
|
"lastError" TEXT,
|
||||||
|
"deliveredAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "WebhookDelivery_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "JobRun" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"job" TEXT NOT NULL,
|
||||||
|
"status" "JobRunStatus" NOT NULL DEFAULT 'RUNNING',
|
||||||
|
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"finishedAt" TIMESTAMP(3),
|
||||||
|
"result" JSONB,
|
||||||
|
"error" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "JobRun_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE UNLOGGED TABLE "ThrottlerHit" (
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"throttlerName" TEXT NOT NULL,
|
||||||
|
"hits" INTEGER NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"blockedUntil" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "ThrottlerHit_pkey" PRIMARY KEY ("key","throttlerName")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "DomainEvent_dedupeKey_key" ON "DomainEvent"("dedupeKey");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DomainEvent_status_availableAt_idx" ON "DomainEvent"("status", "availableAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DomainEvent_correlationId_idx" ON "DomainEvent"("correlationId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DomainEvent_aggregateType_aggregateId_idx" ON "DomainEvent"("aggregateType", "aggregateId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "DomainEventDelivery_eventId_handler_key" ON "DomainEventDelivery"("eventId", "handler");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WebhookDelivery_status_availableAt_idx" ON "WebhookDelivery"("status", "availableAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WebhookDelivery_subscriptionId_createdAt_idx" ON "WebhookDelivery"("subscriptionId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "JobRun_job_startedAt_idx" ON "JobRun"("job", "startedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "JobRun_status_idx" ON "JobRun"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ThrottlerHit_expiresAt_idx" ON "ThrottlerHit"("expiresAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DomainEventDelivery" ADD CONSTRAINT "DomainEventDelivery_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "DomainEvent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "WebhookDelivery" ADD CONSTRAINT "WebhookDelivery_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "WebhookSubscription"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "Inspection" ADD COLUMN "broodFrames" INTEGER;
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
-- ERP-Export /bewegungen sortiert und blättert nach (createdAt, id).
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "BatchMovement_createdAt_id_idx" ON "BatchMovement"("createdAt", "id");
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- T4 migration 1/2: new enum values only. PostgreSQL cannot use a value in
|
||||||
|
-- the transaction that adds it, so everything that uses them (the Suggestion
|
||||||
|
-- table, rule rows) comes in later migrations.
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "AutomationTriggerType" ADD VALUE 'INSPECTION_INTERVAL';
|
||||||
|
ALTER TYPE "AutomationTriggerType" ADD VALUE 'QUEEN_CELLS_SWARM_CHECK';
|
||||||
|
ALTER TYPE "AutomationTriggerType" ADD VALUE 'QUEEN_ACCEPTANCE_CHECK';
|
||||||
|
ALTER TYPE "AutomationTriggerType" ADD VALUE 'SPLIT_SCHEDULE';
|
||||||
|
ALTER TYPE "AutomationTriggerType" ADD VALUE 'COLONY_ENDED_CLEANUP';
|
||||||
|
ALTER TYPE "AutomationTriggerType" ADD VALUE 'WITHDRAWAL_ENDED';
|
||||||
|
ALTER TYPE "AutomationTriggerType" ADD VALUE 'HONEY_WATER_CONTENT';
|
||||||
|
ALTER TYPE "AutomationTriggerType" ADD VALUE 'SEASON_AUTOPILOT';
|
||||||
|
ALTER TYPE "AutomationTriggerType" ADD VALUE 'PHENOLOGY';
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "BatchMovementType" ADD VALUE 'BOTTLING';
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "CalendarSyncSourceType" ADD VALUE 'WITHDRAWAL';
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
-- T4 migration 2/2: suggestions (rule, chain and autopilot proposals the
|
||||||
|
-- beekeeper decides on). dedupeKey is nullable and unique: Postgres allows many
|
||||||
|
-- NULLs, so EXPIRED/RESOLVED rows release their key.
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SuggestionStatus" AS ENUM ('OPEN', 'ACCEPTED', 'DISMISSED', 'RESOLVED', 'EXPIRED', 'OBSOLETE');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SuggestionMode" AS ENUM ('PLAN', 'DO');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SuggestionSource" AS ENUM ('RULE', 'CHAIN', 'AUTOPILOT');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SuggestionIntent" AS ENUM ('INSPECT', 'SWARM_CHECK', 'QUEEN_CHECK', 'QUEEN_ACCEPTANCE', 'QUEEN_REPLACE', 'SPLIT_PLAN', 'VARROA_MEASURE', 'VARROA_RECHECK', 'VARROA_TREAT', 'SUMMER_TREATMENT', 'FEED', 'HONEY_SUPER', 'WINTER_CHECK', 'HEALTH_CHECK', 'FOLLOW_UP', 'STOCK_REORDER', 'CLEANUP');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Task" ADD COLUMN "sourceSuggestionId" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Suggestion" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"ruleKey" "AutomationTriggerType" NOT NULL,
|
||||||
|
"source" "SuggestionSource" NOT NULL,
|
||||||
|
"mode" "SuggestionMode" NOT NULL,
|
||||||
|
"intent" "SuggestionIntent" NOT NULL,
|
||||||
|
"status" "SuggestionStatus" NOT NULL DEFAULT 'OPEN',
|
||||||
|
"priority" "TaskPriority" NOT NULL DEFAULT 'NORMAL',
|
||||||
|
"relatedType" "TaskRelatedType" NOT NULL,
|
||||||
|
"relatedId" TEXT,
|
||||||
|
"colonyId" TEXT,
|
||||||
|
"reasonCode" TEXT NOT NULL,
|
||||||
|
"evidence" JSONB NOT NULL,
|
||||||
|
"requiredPermission" TEXT,
|
||||||
|
"dueDate" TIMESTAMP(3),
|
||||||
|
"expiresAt" TIMESTAMP(3),
|
||||||
|
"plan" JSONB,
|
||||||
|
"dedupeKey" TEXT,
|
||||||
|
"sourceEventId" TEXT,
|
||||||
|
"decidedAt" TIMESTAMP(3),
|
||||||
|
"decidedByUserId" TEXT,
|
||||||
|
"decisionNote" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Suggestion_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Suggestion_dedupeKey_key" ON "Suggestion"("dedupeKey");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Suggestion_status_colonyId_idx" ON "Suggestion"("status", "colonyId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Suggestion_status_dueDate_idx" ON "Suggestion"("status", "dueDate");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Suggestion_ruleKey_relatedType_relatedId_status_idx" ON "Suggestion"("ruleKey", "relatedType", "relatedId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Task_sourceSuggestionId_idx" ON "Task"("sourceSuggestionId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Task" ADD CONSTRAINT "Task_sourceSuggestionId_fkey" FOREIGN KEY ("sourceSuggestionId") REFERENCES "Suggestion"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Suggestion" ADD CONSTRAINT "Suggestion_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Suggestion" ADD CONSTRAINT "Suggestion_decidedByUserId_fkey" FOREIGN KEY ("decidedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- T4 migration 3/n: one vocabulary for the feed condition. Inspection.feedStatus
|
||||||
|
-- was free text; it becomes ColonyCondition, like the colony card. Known words
|
||||||
|
-- (ausreichend, niedrig, kritisch; trimmed of spaces, tabs and line breaks like
|
||||||
|
-- JS trim, any case) map to the enum, any other text moves to the end of
|
||||||
|
-- "notes" as 'Futter (alt): <text>'. Nothing is lost.
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Inspection" ADD COLUMN "feedCondition" "ColonyCondition";
|
||||||
|
|
||||||
|
-- Known words map to the enum.
|
||||||
|
UPDATE "Inspection"
|
||||||
|
SET "feedCondition" = CASE lower(btrim("feedStatus", E' \t\r\n'))
|
||||||
|
WHEN 'ausreichend' THEN 'GOOD'::"ColonyCondition"
|
||||||
|
WHEN 'niedrig' THEN 'WEAK'::"ColonyCondition"
|
||||||
|
WHEN 'kritisch' THEN 'CRITICAL'::"ColonyCondition"
|
||||||
|
END
|
||||||
|
WHERE "feedStatus" IS NOT NULL;
|
||||||
|
|
||||||
|
-- Every other non-empty text goes to the notes.
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
moved integer;
|
||||||
|
BEGIN
|
||||||
|
UPDATE "Inspection"
|
||||||
|
SET "notes" = concat_ws(E'\n', NULLIF("notes", ''), 'Futter (alt): ' || btrim("feedStatus", E' \t\r\n'))
|
||||||
|
WHERE "feedCondition" IS NULL
|
||||||
|
AND btrim("feedStatus", E' \t\r\n') <> '';
|
||||||
|
GET DIAGNOSTICS moved = ROW_COUNT;
|
||||||
|
RAISE NOTICE 't4_feed_vocabulary: % free-text feed values moved into notes', moved;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Inspection" DROP COLUMN "feedStatus";
|
||||||
|
ALTER TABLE "Inspection" RENAME COLUMN "feedCondition" TO "feedStatus";
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- T4 migration 5/n: where the queen of a split colony comes from. Optional:
|
||||||
|
-- an observation is never filled in for old rows.
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SplitQueenSource" AS ENUM ('RAISE_OWN', 'QUEEN_CELL', 'QUEEN');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ColonyLineage" ADD COLUMN "queenSource" "SplitQueenSource";
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- T4 migration 7/n (part sampleBees): number of bees in a powdered-sugar or
|
||||||
|
-- washing sample, so that the infestation percentage can be computed.
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "VarroaMeasurement" ADD COLUMN "sampleBees" INTEGER;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- T4 migration 8/n: every notification belongs to exactly one user. A
|
||||||
|
-- broadcast (userId IS NULL) is copied once per active user with its read
|
||||||
|
-- flag and creation time, then the original is deleted. After that userId is
|
||||||
|
-- NOT NULL; dedupeKey ("<prefix>:<userId>") makes repeated writes harmless.
|
||||||
|
-- Check afterwards: SELECT count(*) FROM "Notification" WHERE "userId" IS NULL;
|
||||||
|
-- must be 0 (the SET NOT NULL below fails otherwise).
|
||||||
|
|
||||||
|
-- DropForeignKey (was ON DELETE SET NULL, impossible for a NOT NULL column)
|
||||||
|
ALTER TABLE "Notification" DROP CONSTRAINT "Notification_userId_fkey";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Notification" ADD COLUMN "dedupeKey" TEXT;
|
||||||
|
|
||||||
|
-- Fan-out of the broadcasts to ALL active users (isActive only, no role or
|
||||||
|
-- permission filter): a broadcast was visible to every user before, so the
|
||||||
|
-- copies keep exactly that visibility. With no active user, broadcasts are
|
||||||
|
-- dropped. The copies get no dedupeKey and the default requiredPermissions.
|
||||||
|
INSERT INTO "Notification" ("id", "userId", "priority", "message", "relatedType", "relatedId", "read", "createdAt")
|
||||||
|
SELECT gen_random_uuid()::text, u."id", n."priority", n."message", n."relatedType", n."relatedId", n."read", n."createdAt"
|
||||||
|
FROM "Notification" n
|
||||||
|
CROSS JOIN "User" u
|
||||||
|
WHERE n."userId" IS NULL AND u."isActive" = true;
|
||||||
|
|
||||||
|
DELETE FROM "Notification" WHERE "userId" IS NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Notification" ALTER COLUMN "userId" SET NOT NULL;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Notification_dedupeKey_key" ON "Notification"("dedupeKey");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- T4 migration 9/n: switch for the withdrawal-period entries of the calendar
|
||||||
|
-- sync (on by default, like the other sources).
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "CalendarSyncSettings" ADD COLUMN "syncWithdrawal" BOOLEAN NOT NULL DEFAULT true;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- T4 migration 10/n: Colony.occupiedFrames, the number of occupied frames
|
||||||
|
-- (0-40, same unit and bounds as Inspection.colonyStrength). Colony.strength
|
||||||
|
-- (scale 1-10) is left untouched, nothing is converted.
|
||||||
|
--
|
||||||
|
-- Check BEFORE the deploy (hits must be clarified with the owner, never cut):
|
||||||
|
-- SELECT count(*) FROM "Inspection" WHERE "colonyStrength" > 40;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Colony" ADD COLUMN "occupiedFrames" INTEGER;
|
||||||
|
|
||||||
|
ALTER TABLE "Colony" ADD CONSTRAINT "Colony_occupiedFrames_range" CHECK ("occupiedFrames" BETWEEN 0 AND 40);
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
-- T4 migration 11/n (part category, link, permissions, preferences). Runs
|
||||||
|
-- after migration 8. category of the old rows: job failures carry
|
||||||
|
-- relatedType 'JobRun' (and rows without relatedType are system messages) ->
|
||||||
|
-- SYSTEM, everything else -> SUGGESTION. Only then NOT NULL.
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "NotificationCategory" AS ENUM ('TASK_DUE', 'DIGEST', 'SUGGESTION', 'WITHDRAWAL', 'SEASON', 'SYSTEM');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Notification" ADD COLUMN "category" "NotificationCategory",
|
||||||
|
ADD COLUMN "link" TEXT,
|
||||||
|
ADD COLUMN "requiredPermissions" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||||
|
|
||||||
|
UPDATE "Notification"
|
||||||
|
SET "category" = CASE
|
||||||
|
WHEN "relatedType" = 'JobRun' OR "relatedType" IS NULL THEN 'SYSTEM'::"NotificationCategory"
|
||||||
|
ELSE 'SUGGESTION'::"NotificationCategory"
|
||||||
|
END;
|
||||||
|
|
||||||
|
ALTER TABLE "Notification" ALTER COLUMN "category" SET NOT NULL;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "NotificationPreference" (
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"taskDueEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"digestEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"digestTime" TEXT NOT NULL DEFAULT '06:30',
|
||||||
|
"quietFrom" TEXT DEFAULT '21:00',
|
||||||
|
"quietUntil" TEXT DEFAULT '06:30',
|
||||||
|
"telegramEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"telegramCategories" "NotificationCategory"[] DEFAULT ARRAY['TASK_DUE', 'DIGEST']::"NotificationCategory"[],
|
||||||
|
"telegramDetail" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "NotificationPreference_pkey" PRIMARY KEY ("userId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "NotificationPreference" ADD CONSTRAINT "NotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
-- T4 follow-up: notifications and their settings go with their user.
|
||||||
|
-- Users are deactivated, never hard-deleted, in production, so RESTRICT
|
||||||
|
-- protected nothing; but fan-out writes rows to users of other test files,
|
||||||
|
-- and deleting such a user must not fail on a foreign notification.
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Notification" DROP CONSTRAINT "Notification_userId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "NotificationPreference" DROP CONSTRAINT "NotificationPreference_userId_fkey";
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "NotificationPreference" ADD CONSTRAINT "NotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
-- T4 migration 4/n: plant development (phenology) and the season segment.
|
||||||
|
-- PhenologyReport holds the beekeeper's own reports; ApiaryPhenology and
|
||||||
|
-- ApiaryWeatherDay are derived/cached state written only by the
|
||||||
|
-- daily-evaluation step and the weather loop.
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PhenologyPhase" AS ENUM ('SALWEIDE', 'KIRSCHE', 'APFEL', 'HOLUNDER', 'LINDE', 'TRACHTENDE', 'HERBST', 'WINTER');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SeasonSegment" AS ENUM ('WINTERRUHE', 'FRUEHJAHR', 'SCHWARMZEIT', 'SOMMER', 'SPAETSOMMER', 'HERBST');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PhaseSource" AS ENUM ('REPORT', 'GTS', 'CALENDAR');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PhenologyReport" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"apiaryId" TEXT,
|
||||||
|
"phase" "PhenologyPhase" NOT NULL,
|
||||||
|
"date" TIMESTAMP(3) NOT NULL,
|
||||||
|
"notes" TEXT,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"revokedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "PhenologyReport_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ApiaryPhenology" (
|
||||||
|
"apiaryId" TEXT NOT NULL,
|
||||||
|
"year" INTEGER NOT NULL,
|
||||||
|
"gts" DOUBLE PRECISION,
|
||||||
|
"gtsThroughDay" TIMESTAMP(3),
|
||||||
|
"missingDays" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"estimatedPhase" "PhenologyPhase",
|
||||||
|
"reportedPhase" "PhenologyPhase",
|
||||||
|
"effectivePhase" "PhenologyPhase",
|
||||||
|
"phaseSource" "PhaseSource",
|
||||||
|
"segment" "SeasonSegment" NOT NULL,
|
||||||
|
"segmentSince" TIMESTAMP(3) NOT NULL,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ApiaryPhenology_pkey" PRIMARY KEY ("apiaryId","year")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ApiaryWeatherDay" (
|
||||||
|
"apiaryId" TEXT NOT NULL,
|
||||||
|
"day" TIMESTAMP(3) NOT NULL,
|
||||||
|
"meanTempC" DOUBLE PRECISION,
|
||||||
|
"source" TEXT NOT NULL,
|
||||||
|
"fetchedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ApiaryWeatherDay_pkey" PRIMARY KEY ("apiaryId","day")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PhenologyReport_date_idx" ON "PhenologyReport"("date");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PhenologyReport_apiaryId_phase_date_idx" ON "PhenologyReport"("apiaryId", "phase", "date");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PhenologyReport" ADD CONSTRAINT "PhenologyReport_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PhenologyReport" ADD CONSTRAINT "PhenologyReport_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ApiaryPhenology" ADD CONSTRAINT "ApiaryPhenology_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ApiaryWeatherDay" ADD CONSTRAINT "ApiaryWeatherDay_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Permissions: the boot-time catalog sync grants new permissions to system
|
||||||
|
-- roles only for rows it creates itself, and these rows exist before the
|
||||||
|
-- first boot. So system roles (ADMIN: full access) get all three here; this
|
||||||
|
-- mirrors what PermissionCatalogSyncService does for permissions it creates.
|
||||||
|
-- Phenology reports are everyday beekeeping records, so non-system roles get
|
||||||
|
-- them like winter-checks (assumption 16): read with colonies:read,
|
||||||
|
-- create/update with inspections:create.
|
||||||
|
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'phenology:read', 'phenology', 'read', 'Ansehen', 'Pflanzenentwicklung', 860),
|
||||||
|
(gen_random_uuid()::text, 'phenology:create', 'phenology', 'create', 'Melden', 'Pflanzenentwicklung', 861),
|
||||||
|
(gen_random_uuid()::text, 'phenology:update', 'phenology', 'update', 'Bearbeiten', 'Pflanzenentwicklung', 862)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||||
|
SELECT r.id, p.id, now()
|
||||||
|
FROM "Role" r, "Permission" p
|
||||||
|
WHERE r."isSystem" = true AND p.resource = 'phenology'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||||
|
SELECT DISTINCT rp."roleId", p.id, now()
|
||||||
|
FROM "RolePermission" rp
|
||||||
|
JOIN "Role" r ON r.id = rp."roleId" AND r."isSystem" = false
|
||||||
|
JOIN "Permission" have ON have.id = rp."permissionId"
|
||||||
|
JOIN "Permission" p ON p.resource = 'phenology'
|
||||||
|
WHERE (have.key = 'colonies:read' AND p.action = 'read')
|
||||||
|
OR (have.key = 'inspections:create' AND p.action IN ('create', 'update'))
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
-- T4 migration 6/n: ledger of the honey batch. HoneyBatch.amount (kg) stays
|
||||||
|
-- the running rest; initialAmount (kg) is the starting quantity, and
|
||||||
|
-- amount = initialAmount + sum(BatchMovement.quantity) / 1000
|
||||||
|
-- (quantity in grams, signed). Every bottling is booked as a BOTTLING
|
||||||
|
-- movement. Earlier hand corrections of amount are folded into initialAmount.
|
||||||
|
--
|
||||||
|
-- Invariant check afterwards (must return 0 rows):
|
||||||
|
-- SELECT hb."id" FROM "HoneyBatch" hb
|
||||||
|
-- LEFT JOIN (SELECT "honeyBatchId", sum("quantity") g FROM "BatchMovement"
|
||||||
|
-- WHERE "honeyBatchId" IS NOT NULL GROUP BY 1) m ON m."honeyBatchId" = hb."id"
|
||||||
|
-- WHERE hb."initialAmount" IS NOT NULL AND hb."amount" IS NOT NULL
|
||||||
|
-- AND abs(hb."amount" - (hb."initialAmount" + coalesce(m.g, 0) / 1000.0)) > 0.0005;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "BatchMovement" ADD COLUMN "honeyBatchId" TEXT,
|
||||||
|
ADD COLUMN "sourceBottlingBatchId" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "HoneyBatch" ADD COLUMN "initialAmount" DOUBLE PRECISION;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "BatchMovement_honeyBatchId_idx" ON "BatchMovement"("honeyBatchId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "BatchMovement_sourceBottlingBatchId_idx" ON "BatchMovement"("sourceBottlingBatchId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_honeyBatchId_fkey" FOREIGN KEY ("honeyBatchId") REFERENCES "HoneyBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_sourceBottlingBatchId_fkey" FOREIGN KEY ("sourceBottlingBatchId") REFERENCES "BottlingBatch"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- A movement now belongs to exactly one of four batch kinds (name kept from
|
||||||
|
-- 20261005100000_raw_materials_production).
|
||||||
|
ALTER TABLE "BatchMovement" DROP CONSTRAINT "BatchMovement_exactly_one_batch";
|
||||||
|
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_exactly_one_batch"
|
||||||
|
CHECK (num_nonnulls("bottlingBatchId", "productionBatchId", "rawMaterialLotId", "honeyBatchId") = 1);
|
||||||
|
|
||||||
|
-- Recalculation, only for batches with a recorded amount (a batch without
|
||||||
|
-- one is not booked, as before).
|
||||||
|
-- 1. initialAmount = amount + the bottlings rounded to the gram exactly like
|
||||||
|
-- the bookings below (kg), so the invariant holds without a rounding gap.
|
||||||
|
UPDATE "HoneyBatch" hb
|
||||||
|
SET "initialAmount" = hb."amount" + coalesce((
|
||||||
|
SELECT sum(floor(b."totalAmount" * 1000 + 0.5)) FROM "BottlingBatch" b WHERE b."honeyBatchId" = hb."id"
|
||||||
|
), 0) / 1000.0
|
||||||
|
WHERE hb."amount" IS NOT NULL;
|
||||||
|
|
||||||
|
-- 2. One BOTTLING movement per bottling, in grams, dated like the bottling.
|
||||||
|
-- floor(x + 0.5) rounds half up like the application (Math.round), whereas
|
||||||
|
-- round(double precision) may round half to even.
|
||||||
|
INSERT INTO "BatchMovement" ("id", "honeyBatchId", "sourceBottlingBatchId", "type", "quantity", "notes", "userId", "createdAt")
|
||||||
|
SELECT gen_random_uuid()::text, b."honeyBatchId", b."id", 'BOTTLING',
|
||||||
|
-(floor(b."totalAmount" * 1000 + 0.5))::integer,
|
||||||
|
'Übernahme aus Abfüllung (T4)', b."userId", b."createdAt"
|
||||||
|
FROM "BottlingBatch" b
|
||||||
|
JOIN "HoneyBatch" hb ON hb."id" = b."honeyBatchId"
|
||||||
|
WHERE hb."amount" IS NOT NULL;
|
||||||
|
|
||||||
|
-- Report and self-check: the counts go to the migration log; a broken
|
||||||
|
-- invariant aborts the whole migration (the file runs in one transaction).
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
batches integer;
|
||||||
|
bookings integer;
|
||||||
|
skipped integer;
|
||||||
|
broken integer;
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO batches FROM "HoneyBatch" WHERE "initialAmount" IS NOT NULL;
|
||||||
|
SELECT count(*) INTO bookings FROM "BatchMovement" WHERE "honeyBatchId" IS NOT NULL AND "type" = 'BOTTLING';
|
||||||
|
SELECT count(*) INTO skipped FROM "BottlingBatch" b
|
||||||
|
JOIN "HoneyBatch" hb ON hb."id" = b."honeyBatchId" WHERE hb."amount" IS NULL;
|
||||||
|
RAISE NOTICE 'honey ledger: % batches with initialAmount, % bookings created, % bottlings skipped (batch amount is NULL)',
|
||||||
|
batches, bookings, skipped;
|
||||||
|
|
||||||
|
SELECT count(*) INTO broken FROM "HoneyBatch" hb
|
||||||
|
LEFT JOIN (SELECT "honeyBatchId", sum("quantity") AS g FROM "BatchMovement"
|
||||||
|
WHERE "honeyBatchId" IS NOT NULL GROUP BY 1) m ON m."honeyBatchId" = hb."id"
|
||||||
|
WHERE hb."initialAmount" IS NOT NULL AND hb."amount" IS NOT NULL
|
||||||
|
AND abs(hb."amount" - (hb."initialAmount" + coalesce(m.g, 0) / 1000.0)) > 0.0005;
|
||||||
|
IF broken > 0 THEN
|
||||||
|
RAISE EXCEPTION 'honey ledger invariant broken for % batches', broken;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- T4 migration 7/n (part shelf life): Product.shelfLifeMonths, the shelf life
|
||||||
|
-- in months, used to pre-fill the best-before date. No default value.
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Product" ADD COLUMN "shelfLifeMonths" INTEGER;
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
-- T4 migration 11/n (part Telegram and delivery): chat binding, single-use
|
||||||
|
-- link tokens, the delivery outbox and the receiver's poll state. All four
|
||||||
|
-- tables are excluded from the backup (MODELS_EXCLUDED_FROM_BACKUP).
|
||||||
|
-- NotificationDelivery carries either a notification for a user or a bot
|
||||||
|
-- system reply for a chat, never both (CHECK below), and at most one
|
||||||
|
-- delivery per notification and channel. TelegramPollState holds exactly one
|
||||||
|
-- row (CHECK below), created here.
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "NotificationChannel" AS ENUM ('TELEGRAM');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "NotificationDeliveryStatus" AS ENUM ('PENDING', 'PROCESSING', 'DELIVERED', 'SKIPPED', 'FAILED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TelegramLink" (
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"chatId" BIGINT NOT NULL,
|
||||||
|
"telegramFirstName" TEXT,
|
||||||
|
"linkedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "TelegramLink_pkey" PRIMARY KEY ("userId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TelegramLinkToken" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"tokenHash" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"consumedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "TelegramLinkToken_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "NotificationDelivery" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"channel" "NotificationChannel" NOT NULL,
|
||||||
|
"userId" TEXT,
|
||||||
|
"chatId" BIGINT,
|
||||||
|
"notificationId" TEXT,
|
||||||
|
"templateCode" TEXT,
|
||||||
|
"status" "NotificationDeliveryStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"claimedAt" TIMESTAMP(3),
|
||||||
|
"lastStatusCode" INTEGER,
|
||||||
|
"lastError" TEXT,
|
||||||
|
"deliveredAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "NotificationDelivery_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TelegramPollState" (
|
||||||
|
"id" TEXT NOT NULL DEFAULT 'singleton',
|
||||||
|
"updateOffset" BIGINT NOT NULL DEFAULT 0,
|
||||||
|
"leaseOwner" TEXT,
|
||||||
|
"leaseUntil" TIMESTAMP(3),
|
||||||
|
"lastPollAt" TIMESTAMP(3),
|
||||||
|
"lastError" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "TelegramPollState_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TelegramLink_chatId_key" ON "TelegramLink"("chatId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TelegramLinkToken_tokenHash_key" ON "TelegramLinkToken"("tokenHash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TelegramLinkToken_userId_idx" ON "TelegramLinkToken"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NotificationDelivery_status_availableAt_idx" ON "NotificationDelivery"("status", "availableAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NotificationDelivery_userId_createdAt_idx" ON "NotificationDelivery"("userId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NotificationDelivery_notificationId_idx" ON "NotificationDelivery"("notificationId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "NotificationDelivery_notificationId_channel_key" ON "NotificationDelivery"("notificationId", "channel");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TelegramLink" ADD CONSTRAINT "TelegramLink_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TelegramLinkToken" ADD CONSTRAINT "TelegramLinkToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "NotificationDelivery" ADD CONSTRAINT "NotificationDelivery_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "NotificationDelivery" ADD CONSTRAINT "NotificationDelivery_notificationId_fkey" FOREIGN KEY ("notificationId") REFERENCES "Notification"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- A delivery is either a notification for a user (rendered at send time) or a
|
||||||
|
-- system reply for a chat (rendered from templateCode).
|
||||||
|
ALTER TABLE "NotificationDelivery" ADD CONSTRAINT "NotificationDelivery_target_check" CHECK (
|
||||||
|
("notificationId" IS NOT NULL AND "userId" IS NOT NULL AND "templateCode" IS NULL AND "chatId" IS NULL)
|
||||||
|
OR ("notificationId" IS NULL AND "templateCode" IS NOT NULL AND "chatId" IS NOT NULL)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The receiver's single state row.
|
||||||
|
ALTER TABLE "TelegramPollState" ADD CONSTRAINT "TelegramPollState_singleton_check" CHECK ("id" = 'singleton');
|
||||||
|
INSERT INTO "TelegramPollState" ("id") VALUES ('singleton');
|
||||||
+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");
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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.
|
||||||
console.error(error);
|
if (require.main === module) {
|
||||||
process.exit(1);
|
main().catch((error: unknown) => {
|
||||||
});
|
console.error(error);
|
||||||
|
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}`);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { WorkerRuntimeModule } from './worker/worker-runtime.module';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role "all": the API plus the worker loops in one process, for local
|
||||||
|
* development and tests. Production refuses it (runtime/role.ts) and runs
|
||||||
|
* api and worker as separate pods. No worker health server here - the
|
||||||
|
* API's /health endpoints serve the process.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [AppModule, WorkerRuntimeModule],
|
||||||
|
})
|
||||||
|
export class AllInOneModule {}
|
||||||
@@ -19,6 +19,24 @@ export const API_KEY_SCOPES = [
|
|||||||
|
|
||||||
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
|
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scopes that book (change data). Every other scope only reads. A new key
|
||||||
|
* never mixes both kinds: a leaked read key cannot book, a leaked booking
|
||||||
|
* key cannot read (ARCHITECTURE.md, Annahme 14).
|
||||||
|
*/
|
||||||
|
export const API_KEY_WRITE_SCOPES = [
|
||||||
|
'shop-sync:write',
|
||||||
|
] as const satisfies readonly ApiKeyScope[];
|
||||||
|
|
||||||
export function isApiKeyScope(value: string): value is ApiKeyScope {
|
export function isApiKeyScope(value: string): value is ApiKeyScope {
|
||||||
return (API_KEY_SCOPES as readonly string[]).includes(value);
|
return (API_KEY_SCOPES as readonly string[]).includes(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isWriteScope(scope: string): boolean {
|
||||||
|
return (API_KEY_WRITE_SCOPES as readonly string[]).includes(scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if the scopes hold at least one reading and one booking right. */
|
||||||
|
export function combinesReadAndWrite(scopes: readonly string[]): boolean {
|
||||||
|
return scopes.some(isWriteScope) && scopes.some((s) => !isWriteScope(s));
|
||||||
|
}
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { ThrottlerStorageModule } from '../common/throttler/throttler-storage.module';
|
||||||
import { ApiKeysController } from './api-keys.controller';
|
import { ApiKeysController } from './api-keys.controller';
|
||||||
import { ApiKeysService } from './api-keys.service';
|
import { ApiKeysService } from './api-keys.service';
|
||||||
|
import { FailedAttemptLimiter } from './failed-attempt-limiter';
|
||||||
|
|
||||||
// Global: the app-wide auth guards need ApiKeysService to recognise keys.
|
// Global: the app-wide auth guards need ApiKeysService to recognise keys.
|
||||||
|
// ThrottlerStorageModule: the failure counters share the rate-limit table,
|
||||||
|
// so the limit holds across all api pods.
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [ThrottlerStorageModule],
|
||||||
controllers: [ApiKeysController],
|
controllers: [ApiKeysController],
|
||||||
providers: [ApiKeysService],
|
providers: [ApiKeysService, FailedAttemptLimiter],
|
||||||
exports: [ApiKeysService],
|
exports: [ApiKeysService],
|
||||||
})
|
})
|
||||||
export class ApiKeysModule {}
|
export class ApiKeysModule {}
|
||||||
@@ -1,13 +1,19 @@
|
|||||||
import { ForbiddenException, HttpException, Logger } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
HttpException,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
|
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
|
||||||
|
import { ApiKeysService, extractApiKeyToken } from './api-keys.service';
|
||||||
import {
|
import {
|
||||||
API_KEY_FAILURE_LIMIT,
|
API_KEY_FAILURE_LIMIT,
|
||||||
ApiKeysService,
|
FailedAttemptLimiter,
|
||||||
extractApiKeyToken,
|
} from './failed-attempt-limiter';
|
||||||
} from './api-keys.service';
|
|
||||||
|
|
||||||
// Synthetischer Schlüssel im echten Format - kein echter Zugang.
|
// Synthetischer Schlüssel im echten Format - kein echter Zugang.
|
||||||
const TOKEN = `ack_abcd1234_${'A'.repeat(43)}`;
|
const TOKEN = `ack_abcd1234_${'A'.repeat(43)}`;
|
||||||
@@ -31,6 +37,38 @@ function storedKey(overrides: Record<string, unknown> = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stand-in for the shared counter table with the same counting rule as
|
||||||
|
* PrismaThrottlerStorage within one window: count up to the limit, the hit
|
||||||
|
* after it blocks, a blocked source keeps its count. Time and the database
|
||||||
|
* itself are covered by the e2e tests.
|
||||||
|
*/
|
||||||
|
function inMemoryStorage(): PrismaThrottlerStorage {
|
||||||
|
const rows = new Map<string, { hits: number; blocked: boolean }>();
|
||||||
|
return {
|
||||||
|
increment: (
|
||||||
|
key: string,
|
||||||
|
ttl: number,
|
||||||
|
limit: number,
|
||||||
|
blockDuration: number,
|
||||||
|
throttlerName: string,
|
||||||
|
) => {
|
||||||
|
const id = `${throttlerName}|${key}`;
|
||||||
|
const row = rows.get(id) ?? { hits: 0, blocked: false };
|
||||||
|
const next = row.blocked
|
||||||
|
? row
|
||||||
|
: { hits: row.hits + 1, blocked: row.hits + 1 > limit };
|
||||||
|
rows.set(id, next);
|
||||||
|
return Promise.resolve({
|
||||||
|
totalHits: next.hits,
|
||||||
|
timeToExpire: ttl / 1000,
|
||||||
|
isBlocked: next.blocked,
|
||||||
|
timeToBlockExpire: next.blocked ? blockDuration / 1000 : 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
} as unknown as PrismaThrottlerStorage;
|
||||||
|
}
|
||||||
|
|
||||||
describe('ApiKeysService.authenticate', () => {
|
describe('ApiKeysService.authenticate', () => {
|
||||||
let prisma: { apiKey: { findUnique: jest.Mock; update: jest.Mock } };
|
let prisma: { apiKey: { findUnique: jest.Mock; update: jest.Mock } };
|
||||||
let service: ApiKeysService;
|
let service: ApiKeysService;
|
||||||
@@ -47,6 +85,7 @@ describe('ApiKeysService.authenticate', () => {
|
|||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
{ record: jest.fn() } as unknown as AuditLogService,
|
{ record: jest.fn() } as unknown as AuditLogService,
|
||||||
{ getPermissions: jest.fn() } as unknown as PermissionResolverService,
|
{ getPermissions: jest.fn() } as unknown as PermissionResolverService,
|
||||||
|
new FailedAttemptLimiter(inMemoryStorage()),
|
||||||
);
|
);
|
||||||
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
||||||
});
|
});
|
||||||
@@ -166,6 +205,18 @@ describe('ApiKeysService.authenticate', () => {
|
|||||||
).resolves.toBe(null);
|
).resolves.toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('meldet das Erreichen der Grenze einmal im Log', async () => {
|
||||||
|
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
|
||||||
|
await expect(
|
||||||
|
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
|
||||||
|
).rejects.toMatchObject({ status: 429 });
|
||||||
|
|
||||||
|
const limitMessages = warn.mock.calls.filter(([message]) =>
|
||||||
|
String(message).includes('get 429'),
|
||||||
|
);
|
||||||
|
expect(limitMessages).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('weist einen gültigen Schlüssel nie wegen der Sperre ab - sonst könnte jeder ERPNext oder den Shop aussperren', async () => {
|
it('weist einen gültigen Schlüssel nie wegen der Sperre ab - sonst könnte jeder ERPNext oder den Shop aussperren', async () => {
|
||||||
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
|
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
|
||||||
await expect(
|
await expect(
|
||||||
@@ -210,6 +261,101 @@ describe('ApiKeysService.authenticate', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ApiKeysService.create', () => {
|
||||||
|
let service: ApiKeysService;
|
||||||
|
let prisma: { apiKey: { create: jest.Mock } };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma = {
|
||||||
|
apiKey: {
|
||||||
|
create: jest.fn((args: { data: Record<string, unknown> }) =>
|
||||||
|
Promise.resolve({ id: 'key-1', keyHash: 'h', ...args.data }),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const permissionResolver = {
|
||||||
|
getPermissions: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
new Set(['shop-sync:read', 'shop-sync:write', 'erp-inventory:read']),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
service = new ApiKeysService(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
{ record: jest.fn() } as unknown as AuditLogService,
|
||||||
|
permissionResolver as unknown as PermissionResolverService,
|
||||||
|
new FailedAttemptLimiter(inMemoryStorage()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a new key with both shop scopes', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create(
|
||||||
|
{
|
||||||
|
name: 'Shop',
|
||||||
|
scopes: ['shop-sync:read', 'shop-sync:write'],
|
||||||
|
},
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
).rejects.toThrow(
|
||||||
|
new BadRequestException(
|
||||||
|
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(prisma.apiKey.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses the read-only ERP scope together with booking', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create(
|
||||||
|
{
|
||||||
|
name: 'ERPNext',
|
||||||
|
scopes: ['erp-inventory:read', 'shop-sync:write'],
|
||||||
|
},
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
).rejects.toThrow(
|
||||||
|
new BadRequestException(
|
||||||
|
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(prisma.apiKey.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a new key with more than one scope, even if all of them read', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create(
|
||||||
|
{ name: 'Alles', scopes: ['shop-sync:read', 'erp-inventory:read'] },
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
).rejects.toThrow(
|
||||||
|
new BadRequestException(
|
||||||
|
'Je Schlüssel nur eine Berechtigung - bitte getrennte Schlüssel anlegen',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(prisma.apiKey.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats the same scope given twice as one', async () => {
|
||||||
|
const created = await service.create(
|
||||||
|
{ name: 'ERPNext', scopes: ['erp-inventory:read', 'erp-inventory:read'] },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
expect(created.apiKey.scopes).toEqual(['erp-inventory:read']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['shop-sync:read', 'shop-sync:write', 'erp-inventory:read'])(
|
||||||
|
'still creates a key with only %s',
|
||||||
|
async (scope) => {
|
||||||
|
const created = await service.create(
|
||||||
|
{ name: 'Shop', scopes: [scope] },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
expect(created.apiKey.scopes).toEqual([scope]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
describe('extractApiKeyToken', () => {
|
describe('extractApiKeyToken', () => {
|
||||||
it('erkennt nur Bearer-Schlüssel im ApiCore-Format', () => {
|
it('erkennt nur Bearer-Schlüssel im ApiCore-Format', () => {
|
||||||
expect(extractApiKeyToken(`Bearer ${TOKEN}`)).toBe(TOKEN);
|
expect(extractApiKeyToken(`Bearer ${TOKEN}`)).toBe(TOKEN);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
@@ -11,11 +12,18 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { ApiKey } from '../../generated/prisma';
|
import { ApiKey } from '../../generated/prisma';
|
||||||
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
||||||
import { ApiKeyScope, isApiKeyScope } from './api-key-scopes';
|
import {
|
||||||
|
ApiKeyScope,
|
||||||
|
combinesReadAndWrite,
|
||||||
|
isApiKeyScope,
|
||||||
|
} from './api-key-scopes';
|
||||||
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
|
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
|
||||||
import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
|
import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
|
||||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
import { FailedAttemptLimiter } from './failed-attempt-limiter';
|
import {
|
||||||
|
API_KEY_FAILURE_LIMIT,
|
||||||
|
FailedAttemptLimiter,
|
||||||
|
} from './failed-attempt-limiter';
|
||||||
import { rateLimitSubject } from '../common/throttler/rate-limit-subject';
|
import { rateLimitSubject } from '../common/throttler/rate-limit-subject';
|
||||||
|
|
||||||
function normalizeAllowlist(entries: readonly string[] | undefined): string[] {
|
function normalizeAllowlist(entries: readonly string[] | undefined): string[] {
|
||||||
@@ -38,21 +46,6 @@ const LAST_USED_RESOLUTION_MS = 5 * 60 * 1000;
|
|||||||
// revealing its secret part.
|
// revealing its secret part.
|
||||||
const LOGGABLE_PREFIX_LENGTH = 12;
|
const LOGGABLE_PREFIX_LENGTH = 12;
|
||||||
|
|
||||||
/**
|
|
||||||
* Unknown, revoked or expired keys per client network before that network
|
|
||||||
* gets 429 for further invalid keys for the rest of the window - the same
|
|
||||||
* budget as the shop's ERP export. It slows down guessing and a
|
|
||||||
* misconfigured client; a valid key is never refused by it. Otherwise
|
|
||||||
* anyone able to send requests from (or forge) a shared address - the
|
|
||||||
* cluster egress, a proxy - could lock ERPNext or the shop out, and a key
|
|
||||||
* holds 256 random bits, so letting it through costs nothing.
|
|
||||||
*/
|
|
||||||
export const API_KEY_FAILURE_LIMIT = {
|
|
||||||
maxFailures: 20,
|
|
||||||
windowMs: 15 * 60 * 1000,
|
|
||||||
maxTrackedSources: 10_000,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type RejectReason = 'unknown' | 'revoked' | 'expired';
|
type RejectReason = 'unknown' | 'revoked' | 'expired';
|
||||||
|
|
||||||
/** What a request authenticated by an API key may do - it is not a user. */
|
/** What a request authenticated by an API key may do - it is not a user. */
|
||||||
@@ -91,12 +84,13 @@ function toSummary(apiKey: ApiKey): ApiKeySummary {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApiKeysService {
|
export class ApiKeysService {
|
||||||
private readonly logger = new Logger(ApiKeysService.name);
|
private readonly logger = new Logger(ApiKeysService.name);
|
||||||
private readonly failures = new FailedAttemptLimiter(API_KEY_FAILURE_LIMIT);
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
private readonly permissionResolver: PermissionResolverService,
|
private readonly permissionResolver: PermissionResolverService,
|
||||||
|
// Counters shared by all api pods (ThrottlerHit), not process memory.
|
||||||
|
private readonly failures: FailedAttemptLimiter,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(): Promise<ApiKeySummary[]> {
|
async findAll(): Promise<ApiKeySummary[]> {
|
||||||
@@ -110,6 +104,19 @@ export class ApiKeysService {
|
|||||||
dto: CreateApiKeyDto,
|
dto: CreateApiKeyDto,
|
||||||
actingUserId: string,
|
actingUserId: string,
|
||||||
): Promise<CreatedApiKey> {
|
): Promise<CreatedApiKey> {
|
||||||
|
// One scope per new key: a leaked read key cannot book, a leaked booking
|
||||||
|
// key cannot read, and each connected system gets its own key.
|
||||||
|
// Existing multi-scope keys stay valid (ARCHITECTURE.md, Annahme 14).
|
||||||
|
if (combinesReadAndWrite(dto.scopes)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (new Set(dto.scopes).size > 1) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Je Schlüssel nur eine Berechtigung - bitte getrennte Schlüssel anlegen',
|
||||||
|
);
|
||||||
|
}
|
||||||
// Nobody hands a machine more than they may do themselves.
|
// Nobody hands a machine more than they may do themselves.
|
||||||
const callerPermissions =
|
const callerPermissions =
|
||||||
await this.permissionResolver.getPermissions(actingUserId);
|
await this.permissionResolver.getPermissions(actingUserId);
|
||||||
@@ -224,20 +231,21 @@ export class ApiKeysService {
|
|||||||
if (reason === null && apiKey) {
|
if (reason === null && apiKey) {
|
||||||
return this.accept(apiKey, clientIp, now);
|
return this.accept(apiKey, clientIp, now);
|
||||||
}
|
}
|
||||||
this.recordRejection(token, reason ?? 'unknown', clientIp);
|
await this.recordRejection(token, reason ?? 'unknown', clientIp);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Counts an invalid key; 429 once the client network is over the limit. */
|
/** Counts an invalid key; 429 once the client network is over the limit. */
|
||||||
private recordRejection(
|
private async recordRejection(
|
||||||
token: string,
|
token: string,
|
||||||
reason: RejectReason,
|
reason: RejectReason,
|
||||||
clientIp: string | undefined,
|
clientIp: string | undefined,
|
||||||
): void {
|
): Promise<void> {
|
||||||
// Counted per client network like the throttler (IPv6 /64), so rotating
|
// Counted per client network like the throttler (IPv6 /64), so rotating
|
||||||
// addresses inside one's own prefix does not reset the budget.
|
// addresses inside one's own prefix does not reset the budget.
|
||||||
const subject = clientIp ? rateLimitSubject(clientIp) : 'unknown';
|
const subject = clientIp ? rateLimitSubject(clientIp) : 'unknown';
|
||||||
if (this.failures.isBlocked(subject)) {
|
const verdict = await this.failures.recordFailure(subject);
|
||||||
|
if (verdict.blocked) {
|
||||||
// Same body shape as the built-in 4xx exceptions (JSON object, not a
|
// Same body shape as the built-in 4xx exceptions (JSON object, not a
|
||||||
// bare string), so clients can read `message` uniformly.
|
// bare string), so clients can read `message` uniformly.
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
@@ -254,9 +262,9 @@ export class ApiKeysService {
|
|||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`API key rejected (${reason}): ${token.slice(0, LOGGABLE_PREFIX_LENGTH)} from ${clientIp ?? 'unknown'}`,
|
`API key rejected (${reason}): ${token.slice(0, LOGGABLE_PREFIX_LENGTH)} from ${clientIp ?? 'unknown'}`,
|
||||||
);
|
);
|
||||||
if (this.failures.recordFailure(subject)) {
|
if (verdict.limitReached) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Invalid API keys from ${subject} refused for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes after ${API_KEY_FAILURE_LIMIT.maxFailures} failures`,
|
`Invalid API keys from ${subject} reached ${API_KEY_FAILURE_LIMIT.maxFailures} failures - further invalid keys get 429 for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,112 +1,91 @@
|
|||||||
import { FailedAttemptLimiter } from './failed-attempt-limiter';
|
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
|
||||||
|
import {
|
||||||
|
API_KEY_FAILURE_LIMIT,
|
||||||
|
API_KEY_FAILURE_THROTTLER,
|
||||||
|
FailedAttemptLimiter,
|
||||||
|
} from './failed-attempt-limiter';
|
||||||
|
|
||||||
describe('FailedAttemptLimiter', () => {
|
describe('FailedAttemptLimiter', () => {
|
||||||
let now: number;
|
const increment = jest.fn();
|
||||||
let limiter: FailedAttemptLimiter;
|
const limiter = new FailedAttemptLimiter({
|
||||||
|
increment,
|
||||||
|
} as unknown as PrismaThrottlerStorage);
|
||||||
|
|
||||||
|
function record(totalHits: number, isBlocked: boolean) {
|
||||||
|
return {
|
||||||
|
totalHits,
|
||||||
|
timeToExpire: 600,
|
||||||
|
isBlocked,
|
||||||
|
timeToBlockExpire: isBlocked ? 900 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
now = 1_000_000;
|
increment.mockReset();
|
||||||
limiter = new FailedAttemptLimiter({
|
increment.mockResolvedValue(record(1, false));
|
||||||
maxFailures: 3,
|
});
|
||||||
windowMs: 60_000,
|
|
||||||
maxTrackedSources: 100,
|
it('zählt in der gemeinsamen Zähler-Tabelle, getrennt vom Anfrage-Limit', async () => {
|
||||||
clock: () => now,
|
await limiter.recordFailure('203.0.113.7');
|
||||||
|
|
||||||
|
// Ein Aufruf = eine Upsert-Anweisung mit DB-Uhr (PrismaThrottlerStorage),
|
||||||
|
// unter eigenem Throttler-Namen und eigenem Schlüssel-Präfix.
|
||||||
|
expect(increment).toHaveBeenCalledTimes(1);
|
||||||
|
expect(increment).toHaveBeenCalledWith(
|
||||||
|
`${API_KEY_FAILURE_THROTTLER}:203.0.113.7`,
|
||||||
|
API_KEY_FAILURE_LIMIT.windowMs,
|
||||||
|
API_KEY_FAILURE_LIMIT.maxFailures,
|
||||||
|
API_KEY_FAILURE_LIMIT.windowMs,
|
||||||
|
API_KEY_FAILURE_THROTTLER,
|
||||||
|
);
|
||||||
|
expect(API_KEY_FAILURE_THROTTLER).toBe('api-key-failures');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('behält die vereinbarten Grenzen: 20 Fehlversuche in 15 Minuten', () => {
|
||||||
|
expect(API_KEY_FAILURE_LIMIT).toEqual({
|
||||||
|
maxFailures: 20,
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sperrt eine Quelle erst, wenn die Obergrenze erreicht ist', () => {
|
it('sperrt nicht unterhalb der Grenze', async () => {
|
||||||
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
|
increment.mockResolvedValueOnce(
|
||||||
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
|
record(API_KEY_FAILURE_LIMIT.maxFailures - 1, false),
|
||||||
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
|
);
|
||||||
|
|
||||||
// Der Fehlversuch, mit dem die Grenze erreicht wird, meldet die Sperre.
|
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
|
||||||
expect(limiter.recordFailure('203.0.113.7')).toBe(true);
|
blocked: false,
|
||||||
expect(limiter.isBlocked('203.0.113.7')).toBe(true);
|
limitReached: false,
|
||||||
});
|
|
||||||
|
|
||||||
it('meldet die Sperre nur einmal, nicht bei jedem weiteren Versuch', () => {
|
|
||||||
limiter.recordFailure('203.0.113.7');
|
|
||||||
limiter.recordFailure('203.0.113.7');
|
|
||||||
expect(limiter.recordFailure('203.0.113.7')).toBe(true);
|
|
||||||
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('trennt Quellen voneinander', () => {
|
|
||||||
for (let i = 0; i < 3; i++) limiter.recordFailure('203.0.113.7');
|
|
||||||
expect(limiter.isBlocked('203.0.113.7')).toBe(true);
|
|
||||||
expect(limiter.isBlocked('198.51.100.1')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gibt die Quelle nach Ablauf des Fensters wieder frei', () => {
|
|
||||||
for (let i = 0; i < 3; i++) limiter.recordFailure('203.0.113.7');
|
|
||||||
now += 60_001;
|
|
||||||
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
|
|
||||||
// Neues Fenster: der nächste Fehlversuch zählt wieder ab eins.
|
|
||||||
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
|
|
||||||
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('vergisst alte Quellen, statt unbegrenzt Speicher zu belegen', () => {
|
|
||||||
const small = new FailedAttemptLimiter({
|
|
||||||
maxFailures: 1,
|
|
||||||
windowMs: 60_000,
|
|
||||||
maxTrackedSources: 2,
|
|
||||||
clock: () => now,
|
|
||||||
});
|
});
|
||||||
small.recordFailure('a');
|
|
||||||
small.recordFailure('b');
|
|
||||||
now += 60_001;
|
|
||||||
// Beim dritten Eintrag wird aufgeräumt: a und b sind abgelaufen.
|
|
||||||
small.recordFailure('c');
|
|
||||||
expect(small.trackedSources()).toBe(1);
|
|
||||||
expect(small.isBlocked('c')).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('verdrängt bei voller Liste den ältesten Eintrag, auch wenn er noch läuft', () => {
|
it('meldet genau den Fehlversuch, mit dem die Grenze erreicht ist', async () => {
|
||||||
const small = new FailedAttemptLimiter({
|
increment.mockResolvedValueOnce(
|
||||||
maxFailures: 5,
|
record(API_KEY_FAILURE_LIMIT.maxFailures, false),
|
||||||
windowMs: 60_000,
|
);
|
||||||
maxTrackedSources: 2,
|
|
||||||
clock: () => now,
|
|
||||||
});
|
|
||||||
small.recordFailure('a');
|
|
||||||
small.recordFailure('b');
|
|
||||||
small.recordFailure('c');
|
|
||||||
expect(small.trackedSources()).toBe(2);
|
|
||||||
});
|
|
||||||
it('verdrängt bei voller Liste zuerst nicht gesperrte Quellen', () => {
|
|
||||||
const small = new FailedAttemptLimiter({
|
|
||||||
maxFailures: 2,
|
|
||||||
windowMs: 60_000,
|
|
||||||
maxTrackedSources: 2,
|
|
||||||
clock: () => now,
|
|
||||||
});
|
|
||||||
small.recordFailure('angreifer');
|
|
||||||
small.recordFailure('angreifer'); // gesperrt, ältester Eintrag
|
|
||||||
small.recordFailure('harmlos'); // ein Fehlversuch, nicht gesperrt
|
|
||||||
small.recordFailure('neu');
|
|
||||||
|
|
||||||
expect(small.trackedSources()).toBe(2);
|
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
|
||||||
// Die Sperre bleibt, obwohl sie der älteste Eintrag ist.
|
blocked: false,
|
||||||
expect(small.isBlocked('angreifer')).toBe(true);
|
limitReached: true,
|
||||||
// Verdrängt wurde der nicht gesperrte Eintrag.
|
});
|
||||||
expect(small.recordFailure('harmlos')).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('verdrängt gesperrte Quellen erst, wenn alle gesperrt sind (älteste zuerst)', () => {
|
it('sperrt, sobald die Datenbank die Quelle als gesperrt meldet', async () => {
|
||||||
const small = new FailedAttemptLimiter({
|
increment.mockResolvedValueOnce(
|
||||||
maxFailures: 1,
|
record(API_KEY_FAILURE_LIMIT.maxFailures + 1, true),
|
||||||
windowMs: 60_000,
|
);
|
||||||
maxTrackedSources: 2,
|
|
||||||
clock: () => now,
|
|
||||||
});
|
|
||||||
small.recordFailure('a');
|
|
||||||
small.recordFailure('b');
|
|
||||||
small.recordFailure('c');
|
|
||||||
|
|
||||||
expect(small.trackedSources()).toBe(2);
|
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
|
||||||
expect(small.isBlocked('a')).toBe(false);
|
blocked: true,
|
||||||
expect(small.isBlocked('b')).toBe(true);
|
limitReached: false,
|
||||||
expect(small.isBlocked('c')).toBe(true);
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gibt Datenbankfehler weiter, statt still durchzulassen', async () => {
|
||||||
|
increment.mockRejectedValueOnce(new Error('db down'));
|
||||||
|
|
||||||
|
await expect(limiter.recordFailure('203.0.113.7')).rejects.toThrow(
|
||||||
|
'db down',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,94 +1,57 @@
|
|||||||
export interface FailedAttemptLimiterOptions {
|
import { Injectable } from '@nestjs/common';
|
||||||
/** Ab so vielen Fehlversuchen im Fenster ist die Quelle gesperrt. */
|
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
|
||||||
maxFailures: number;
|
|
||||||
/** Länge des Fensters in Millisekunden, gezählt ab dem ersten Fehlversuch. */
|
|
||||||
windowMs: number;
|
|
||||||
/** Obergrenze gemerkter Quellen - schützt den Speicher bei verteilten Angriffen. */
|
|
||||||
maxTrackedSources: number;
|
|
||||||
clock?: () => number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Window {
|
/**
|
||||||
failures: number;
|
* Unknown, revoked or expired keys per client network before that network
|
||||||
startedAt: number;
|
* gets 429 for further invalid keys - the same budget the ERP export
|
||||||
|
* contract names (docs/erp-inventar-export.md). It slows down guessing and a
|
||||||
|
* misconfigured client; a valid key is never refused by it. Otherwise
|
||||||
|
* anyone able to send requests from (or forge) a shared address - the
|
||||||
|
* cluster egress, a proxy - could lock ERPNext or the shop out, and a key
|
||||||
|
* holds 256 random bits, so letting it through costs nothing.
|
||||||
|
*/
|
||||||
|
export const API_KEY_FAILURE_LIMIT = {
|
||||||
|
maxFailures: 20,
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throttler name and key prefix of these counters in "ThrottlerHit", so
|
||||||
|
* they never mix with the request rate limits stored in the same table.
|
||||||
|
*/
|
||||||
|
export const API_KEY_FAILURE_THROTTLER = 'api-key-failures';
|
||||||
|
|
||||||
|
export interface FailedAttemptVerdict {
|
||||||
|
/** The source is over the limit: refuse the request with 429. */
|
||||||
|
blocked: boolean;
|
||||||
|
/** This failure is the one that reached the limit (once per window). */
|
||||||
|
limitReached: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zählt fehlgeschlagene Anmeldungen je Quelle (Client-IP) in einem festen
|
* Counts invalid API keys per source (client network) in the shared,
|
||||||
* Zeitfenster und sperrt die Quelle, sobald die Obergrenze erreicht ist.
|
* database-backed rate-limit storage, so the limit holds across all api
|
||||||
*
|
* pods. One upsert per failure, every time comparison on the database
|
||||||
* Bewusst im Speicher des Prozesses, wie der globale Throttler: ApiCore läuft
|
* clock (PrismaThrottlerStorage). The window starts with the first failure;
|
||||||
* mit einem Backend-Pod. Nach einem Neustart beginnt die Zählung von vorn -
|
* the failure after the limit blocks the source for one window length.
|
||||||
* das ist für eine Bremse gegen Durchprobieren ausreichend.
|
* A database error propagates: the request fails instead of passing.
|
||||||
*/
|
*/
|
||||||
|
@Injectable()
|
||||||
export class FailedAttemptLimiter {
|
export class FailedAttemptLimiter {
|
||||||
private readonly windows = new Map<string, Window>();
|
constructor(private readonly storage: PrismaThrottlerStorage) {}
|
||||||
private readonly clock: () => number;
|
|
||||||
|
|
||||||
constructor(private readonly options: FailedAttemptLimiterOptions) {
|
async recordFailure(source: string): Promise<FailedAttemptVerdict> {
|
||||||
this.clock = options.clock ?? Date.now;
|
const { maxFailures, windowMs } = API_KEY_FAILURE_LIMIT;
|
||||||
}
|
const record = await this.storage.increment(
|
||||||
|
`${API_KEY_FAILURE_THROTTLER}:${source}`,
|
||||||
isBlocked(source: string): boolean {
|
windowMs,
|
||||||
const window = this.activeWindow(source);
|
maxFailures,
|
||||||
return window !== undefined && window.failures >= this.options.maxFailures;
|
windowMs,
|
||||||
}
|
API_KEY_FAILURE_THROTTLER,
|
||||||
|
);
|
||||||
/** Zählt einen Fehlversuch; true genau dann, wenn er die Sperre auslöst. */
|
return {
|
||||||
recordFailure(source: string): boolean {
|
blocked: record.isBlocked,
|
||||||
const now = this.clock();
|
limitReached: !record.isBlocked && record.totalHits === maxFailures,
|
||||||
const current = this.activeWindow(source);
|
};
|
||||||
if (!current) {
|
|
||||||
this.makeRoom(now);
|
|
||||||
}
|
|
||||||
const failures = (current?.failures ?? 0) + 1;
|
|
||||||
this.windows.set(source, {
|
|
||||||
failures,
|
|
||||||
startedAt: current?.startedAt ?? now,
|
|
||||||
});
|
|
||||||
return failures === this.options.maxFailures;
|
|
||||||
}
|
|
||||||
|
|
||||||
trackedSources(): number {
|
|
||||||
return this.windows.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
private activeWindow(source: string): Window | undefined {
|
|
||||||
const window = this.windows.get(source);
|
|
||||||
if (window && this.clock() - window.startedAt > this.options.windowMs) {
|
|
||||||
this.windows.delete(source);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return window;
|
|
||||||
}
|
|
||||||
|
|
||||||
private makeRoom(now: number): void {
|
|
||||||
if (this.windows.size < this.options.maxTrackedSources) return;
|
|
||||||
for (const [source, window] of this.windows) {
|
|
||||||
if (now - window.startedAt > this.options.windowMs) {
|
|
||||||
this.windows.delete(source);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Immer noch voll: lieber eine Zählung verlieren als unbegrenzt wachsen.
|
|
||||||
// Zuerst den ältesten nicht gesperrten Eintrag verdrängen - sonst könnte
|
|
||||||
// ein Angreifer mit vielen Adressen seine eigene Sperre hinausschieben.
|
|
||||||
// Nur wenn alle gesperrt sind, geht der älteste gesperrte (die Map behält
|
|
||||||
// die Einfügereihenfolge).
|
|
||||||
if (this.windows.size >= this.options.maxTrackedSources) {
|
|
||||||
const victim =
|
|
||||||
this.oldestWhere(
|
|
||||||
(window) => window.failures < this.options.maxFailures,
|
|
||||||
) ?? this.oldestWhere(() => true);
|
|
||||||
if (victim !== undefined) this.windows.delete(victim);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private oldestWhere(
|
|
||||||
matches: (window: Window) => boolean,
|
|
||||||
): string | undefined {
|
|
||||||
for (const [source, window] of this.windows) {
|
|
||||||
if (matches(window)) return source;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,12 +15,16 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
|||||||
import { CreateApiaryDto } from './dto/create-apiary.dto';
|
import { CreateApiaryDto } from './dto/create-apiary.dto';
|
||||||
import { UpdateApiaryDto } from './dto/update-apiary.dto';
|
import { UpdateApiaryDto } from './dto/update-apiary.dto';
|
||||||
import { ApiariesService } from './apiaries.service';
|
import { ApiariesService } from './apiaries.service';
|
||||||
|
import { ApiaryFieldService } from './apiary-field.service';
|
||||||
|
|
||||||
@ApiTags('apiaries')
|
@ApiTags('apiaries')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('apiaries')
|
@Controller('apiaries')
|
||||||
export class ApiariesController {
|
export class ApiariesController {
|
||||||
constructor(private readonly apiariesService: ApiariesService) {}
|
constructor(
|
||||||
|
private readonly apiariesService: ApiariesService,
|
||||||
|
private readonly apiaryFieldService: ApiaryFieldService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@RequirePermission('apiaries:read')
|
@RequirePermission('apiaries:read')
|
||||||
@Get()
|
@Get()
|
||||||
@@ -31,6 +35,24 @@ export class ApiariesController {
|
|||||||
return this.apiariesService.findAll({ status, location });
|
return this.apiariesService.findAll({ status, location });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequirePermission('colonies:read')
|
||||||
|
@Get(':id/colony-overview')
|
||||||
|
colonyOverview(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.apiaryFieldService.colonyOverview(id, actor.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('colonies:read')
|
||||||
|
@Get(':id/offline-pack')
|
||||||
|
offlinePack(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.apiaryFieldService.offlinePack(id, actor.id);
|
||||||
|
}
|
||||||
|
|
||||||
@RequirePermission('apiaries:read')
|
@RequirePermission('apiaries:read')
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
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';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [AutopilotModule, DomainEventsModule, HiveModule],
|
||||||
controllers: [ApiariesController],
|
controllers: [ApiariesController],
|
||||||
providers: [ApiariesService],
|
providers: [ApiariesService, ApiaryFieldService],
|
||||||
exports: [ApiariesService],
|
exports: [ApiariesService],
|
||||||
})
|
})
|
||||||
export class ApiariesModule {}
|
export class ApiariesModule {}
|
||||||
@@ -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 } });
|
||||||
entityType: 'Apiary',
|
await this.auditLog.record(
|
||||||
entityId: apiary.id,
|
{
|
||||||
action: 'CREATE',
|
entityType: 'Apiary',
|
||||||
userId: actingUserId,
|
entityId: apiary.id,
|
||||||
afterData: apiary,
|
action: 'CREATE',
|
||||||
|
userId: actingUserId,
|
||||||
|
afterData: apiary,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
await this.recordChange(tx, apiary.id, 'CREATED', actingUserId);
|
||||||
|
return apiary;
|
||||||
});
|
});
|
||||||
return apiary;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(
|
async update(
|
||||||
@@ -59,69 +79,120 @@ 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);
|
||||||
where: { id },
|
const apiary = await tx.apiary.update({
|
||||||
data: { ...dto },
|
where: { id },
|
||||||
|
data: { ...dto },
|
||||||
|
});
|
||||||
|
await this.auditLog.record(
|
||||||
|
{
|
||||||
|
entityType: 'Apiary',
|
||||||
|
entityId: id,
|
||||||
|
action: 'UPDATE',
|
||||||
|
userId: actingUserId,
|
||||||
|
beforeData: before,
|
||||||
|
afterData: apiary,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
if (LOCATION_FIELDS.some((field) => before[field] !== apiary[field])) {
|
||||||
|
await this.recordChange(tx, id, 'LOCATION_CHANGED', actingUserId);
|
||||||
|
}
|
||||||
|
return apiary;
|
||||||
});
|
});
|
||||||
await this.auditLog.record({
|
|
||||||
entityType: 'Apiary',
|
|
||||||
entityId: id,
|
|
||||||
action: 'UPDATE',
|
|
||||||
userId: actingUserId,
|
|
||||||
beforeData: before,
|
|
||||||
afterData: 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) => {
|
||||||
// Colony.apiaryId is a plain FK, not enforced against apiary status -
|
const before = await this.lockApiary(tx, id);
|
||||||
// without this guard a colony can silently keep pointing at an
|
// Colony.apiaryId is a plain FK, not enforced against apiary status -
|
||||||
// inactive apiary (still shows ACTIVE, still countable, but its
|
// without this guard a colony can silently keep pointing at an
|
||||||
// "location" has vanished from the active apiary list). Move the
|
// inactive apiary (still shows ACTIVE, still countable, but its
|
||||||
// colonies elsewhere or dissolve them first instead.
|
// "location" has vanished from the active apiary list). Move the
|
||||||
const activeColonyCount = await this.prisma.colony.count({
|
// colonies elsewhere or dissolve them first instead. Counted under the
|
||||||
where: { apiaryId: id, ...LIVING_COLONY_WHERE },
|
// apiary lock: the colony paths hold the apiary FOR SHARE, so a move
|
||||||
});
|
// into it that is still running finishes first.
|
||||||
if (activeColonyCount > 0) {
|
const activeColonyCount = await tx.colony.count({
|
||||||
throw new ConflictException(
|
where: { apiaryId: id, ...LIVING_COLONY_WHERE },
|
||||||
`Der Bienenstand hat noch ${activeColonyCount} aktive(s) Volk/Völker - zuerst verlegen oder auflösen`,
|
});
|
||||||
|
if (activeColonyCount > 0) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Der Bienenstand hat noch ${activeColonyCount} aktive(s) Volk/Völker - zuerst verlegen oder auflösen`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const apiary = await tx.apiary.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'INACTIVE', deactivatedAt: new Date() },
|
||||||
|
});
|
||||||
|
await this.auditLog.record(
|
||||||
|
{
|
||||||
|
entityType: 'Apiary',
|
||||||
|
entityId: id,
|
||||||
|
action: 'DEACTIVATE',
|
||||||
|
userId: actingUserId,
|
||||||
|
beforeData: before,
|
||||||
|
afterData: apiary,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
);
|
);
|
||||||
}
|
await this.recordChange(tx, id, 'DEACTIVATED', actingUserId);
|
||||||
const apiary = await this.prisma.apiary.update({
|
return apiary;
|
||||||
where: { id },
|
|
||||||
data: { status: 'INACTIVE', deactivatedAt: new Date() },
|
|
||||||
});
|
});
|
||||||
await this.auditLog.record({
|
|
||||||
entityType: 'Apiary',
|
|
||||||
entityId: id,
|
|
||||||
action: 'DEACTIVATE',
|
|
||||||
userId: actingUserId,
|
|
||||||
beforeData: before,
|
|
||||||
afterData: 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) => {
|
||||||
if (before.status === 'ACTIVE') {
|
const before = await this.lockApiary(tx, id);
|
||||||
throw new ConflictException('Apiary is already active');
|
if (before.status === 'ACTIVE') {
|
||||||
|
throw new ConflictException('Apiary is already active');
|
||||||
|
}
|
||||||
|
const apiary = await tx.apiary.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'ACTIVE', deactivatedAt: null },
|
||||||
|
});
|
||||||
|
await this.auditLog.record(
|
||||||
|
{
|
||||||
|
entityType: 'Apiary',
|
||||||
|
entityId: id,
|
||||||
|
action: 'UPDATE',
|
||||||
|
userId: actingUserId,
|
||||||
|
beforeData: before,
|
||||||
|
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');
|
||||||
}
|
}
|
||||||
const apiary = await this.prisma.apiary.update({
|
|
||||||
where: { id },
|
|
||||||
data: { status: 'ACTIVE', deactivatedAt: null },
|
|
||||||
});
|
|
||||||
await this.auditLog.record({
|
|
||||||
entityType: 'Apiary',
|
|
||||||
entityId: id,
|
|
||||||
action: 'UPDATE',
|
|
||||||
userId: actingUserId,
|
|
||||||
beforeData: before,
|
|
||||||
afterData: apiary,
|
|
||||||
});
|
|
||||||
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
|
import { ApiaryFieldService } from './apiary-field.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
|
import type { ColonyNextSteps } from '../autopilot/next-step-ranking';
|
||||||
|
import { NextStepsService } from '../autopilot/next-steps.service';
|
||||||
|
import { HiveService } from '../hive/hive.service';
|
||||||
|
import { HiveCatalogService } from '../hive/hive-catalog.service';
|
||||||
|
|
||||||
|
describe('ApiaryFieldService', () => {
|
||||||
|
let service: ApiaryFieldService;
|
||||||
|
let prisma: {
|
||||||
|
apiary: { findUnique: jest.Mock };
|
||||||
|
colony: { findMany: jest.Mock };
|
||||||
|
inspection: { groupBy: jest.Mock; findMany: jest.Mock };
|
||||||
|
$queryRaw: jest.Mock;
|
||||||
|
queen: { findMany: jest.Mock };
|
||||||
|
task: { findMany: jest.Mock };
|
||||||
|
};
|
||||||
|
let permissionResolver: { getPermissions: jest.Mock };
|
||||||
|
let nextSteps: { forColonies: jest.Mock; warningCounts: jest.Mock };
|
||||||
|
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) => ({
|
||||||
|
id,
|
||||||
|
code,
|
||||||
|
apiaryId: 'apiary-1',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
|
hiveType: null,
|
||||||
|
strength: 5,
|
||||||
|
occupiedFrames: 12,
|
||||||
|
broodStatus: null,
|
||||||
|
feedStatus: null,
|
||||||
|
healthStatus: null,
|
||||||
|
currentQueenId: queenId,
|
||||||
|
notes: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const grant = (...keys: string[]) =>
|
||||||
|
permissionResolver.getPermissions.mockResolvedValue(new Set(keys));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma = {
|
||||||
|
apiary: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue({
|
||||||
|
id: 'apiary-1',
|
||||||
|
name: 'Heimstand',
|
||||||
|
location: 'Garten',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
colony: {
|
||||||
|
findMany: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
|
colonyRow('c10', 'V-10', 'q1'),
|
||||||
|
colonyRow('c2', 'V-2', null),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
inspection: {
|
||||||
|
groupBy: jest.fn().mockResolvedValue([]),
|
||||||
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||||
|
queen: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
task: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
};
|
||||||
|
permissionResolver = { getPermissions: jest.fn() };
|
||||||
|
nextSteps = {
|
||||||
|
forColonies: jest.fn().mockResolvedValue(
|
||||||
|
new Map([
|
||||||
|
['c10', stepsOf('c10')],
|
||||||
|
['c2', stepsOf('c2')],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
warningCounts: jest.fn().mockResolvedValue(new Map()),
|
||||||
|
};
|
||||||
|
hives = { getMany: jest.fn().mockResolvedValue({}) };
|
||||||
|
hiveCatalogs = { listSystems: jest.fn().mockResolvedValue([]) };
|
||||||
|
service = new ApiaryFieldService(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
permissionResolver as unknown as PermissionResolverService,
|
||||||
|
nextSteps as unknown as NextStepsService,
|
||||||
|
hives as unknown as HiveService,
|
||||||
|
hiveCatalogs as unknown as HiveCatalogService,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundException for an unknown apiary', async () => {
|
||||||
|
grant('colonies:read');
|
||||||
|
prisma.apiary.findUnique.mockResolvedValue(null);
|
||||||
|
await expect(service.colonyOverview('x', 'u1')).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts living colonies by code with numeric collation', async () => {
|
||||||
|
grant('colonies:read');
|
||||||
|
const overview = await service.colonyOverview('apiary-1', 'u1');
|
||||||
|
expect(overview.rows.map((row) => row.code)).toEqual(['V-2', 'V-10']);
|
||||||
|
expect(prisma.colony.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({ apiaryId: 'apiary-1' }) as unknown,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves out inspections, queens and tasks without their read permission', async () => {
|
||||||
|
grant('colonies:read');
|
||||||
|
const pack = await service.offlinePack('apiary-1', 'u1');
|
||||||
|
expect(pack.inspections).toBeNull();
|
||||||
|
expect(pack.queens).toBeNull();
|
||||||
|
expect(pack.tasks).toBeNull();
|
||||||
|
expect(pack.overview.inspectionsVisible).toBe(false);
|
||||||
|
expect(pack.overview.rows.every((r) => r.lastInspectionDate === null)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(prisma.inspection.groupBy).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.inspection.findMany).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.queen.findMany).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.task.findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('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 () => {
|
||||||
|
grant('colonies:read', 'inspections:read', 'queens:read', 'tasks:read');
|
||||||
|
const date = new Date('2026-05-01T10:00:00.000Z');
|
||||||
|
prisma.inspection.groupBy.mockResolvedValue([
|
||||||
|
{ colonyId: 'c2', _max: { date } },
|
||||||
|
]);
|
||||||
|
prisma.queen.findMany.mockResolvedValue([{ id: 'q1', code: 'K-1' }]);
|
||||||
|
prisma.task.findMany.mockResolvedValue([{ id: 't1', title: 'Futter' }]);
|
||||||
|
|
||||||
|
const pack = await service.offlinePack('apiary-1', 'u1');
|
||||||
|
|
||||||
|
expect(pack.overview.inspectionsVisible).toBe(true);
|
||||||
|
expect(pack.overview.rows[0].lastInspectionDate).toBe(date.toISOString());
|
||||||
|
expect(pack.overview.rows[1].lastInspectionDate).toBeNull();
|
||||||
|
expect(pack.queens).toEqual([{ id: 'q1', code: 'K-1' }]);
|
||||||
|
expect(prisma.queen.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: { id: { in: ['q1'] } } }),
|
||||||
|
);
|
||||||
|
expect(pack.tasks).toEqual([{ id: 't1', title: 'Futter' }]);
|
||||||
|
expect(prisma.task.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: {
|
||||||
|
status: { in: ['OPEN', 'IN_PROGRESS'] },
|
||||||
|
OR: [
|
||||||
|
{ relatedType: 'APIARY', relatedId: 'apiary-1' },
|
||||||
|
{ relatedType: 'COLONY', relatedId: { in: ['c2', 'c10'] } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps at most three inspections per colony, newest first', async () => {
|
||||||
|
grant('colonies:read', 'inspections:read');
|
||||||
|
prisma.inspection.findMany.mockResolvedValue([{ id: 'i1' }]);
|
||||||
|
|
||||||
|
const pack = await service.offlinePack('apiary-1', 'u1');
|
||||||
|
|
||||||
|
expect(prisma.inspection.findMany).toHaveBeenCalledTimes(2);
|
||||||
|
expect(prisma.inspection.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { colonyId: 'c2' },
|
||||||
|
take: 3,
|
||||||
|
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }],
|
||||||
|
omit: { userId: true },
|
||||||
|
});
|
||||||
|
expect(pack.inspections).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never returns coordinates, address, apiary notes or inspection userId', async () => {
|
||||||
|
grant('colonies:read', 'inspections:read');
|
||||||
|
const pack = await service.offlinePack('apiary-1', 'u1');
|
||||||
|
|
||||||
|
expect(Object.keys(pack.apiary).sort()).toEqual([
|
||||||
|
'id',
|
||||||
|
'location',
|
||||||
|
'name',
|
||||||
|
'status',
|
||||||
|
]);
|
||||||
|
const apiarySelect = (
|
||||||
|
prisma.apiary.findUnique.mock.calls[0] as [{ select: object }]
|
||||||
|
)[0].select;
|
||||||
|
expect(apiarySelect).not.toHaveProperty('latitude');
|
||||||
|
expect(apiarySelect).not.toHaveProperty('longitude');
|
||||||
|
expect(apiarySelect).not.toHaveProperty('address');
|
||||||
|
expect(apiarySelect).not.toHaveProperty('notes');
|
||||||
|
const inspectionArgs = (
|
||||||
|
prisma.inspection.findMany.mock.calls[0] as [{ omit: object }]
|
||||||
|
)[0];
|
||||||
|
expect(inspectionArgs.omit).toEqual({ userId: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes the warning counts from the open suggestions the caller may see', async () => {
|
||||||
|
grant('colonies:read', 'tasks:read');
|
||||||
|
nextSteps.warningCounts.mockResolvedValue(
|
||||||
|
new Map([['c2', { warnings: 2, critical: 1 }]]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const overview = await service.colonyOverview('apiary-1', 'u1');
|
||||||
|
|
||||||
|
expect(nextSteps.warningCounts).toHaveBeenCalledWith(
|
||||||
|
['c2', 'c10'],
|
||||||
|
'u1',
|
||||||
|
new Set(['colonies:read', 'tasks:read']),
|
||||||
|
);
|
||||||
|
expect(overview.rows[0]).toMatchObject({
|
||||||
|
code: 'V-2',
|
||||||
|
openWarnings: 2,
|
||||||
|
openCritical: 1,
|
||||||
|
});
|
||||||
|
expect(overview.rows[1]).toMatchObject({
|
||||||
|
openWarnings: 0,
|
||||||
|
openCritical: 0,
|
||||||
|
});
|
||||||
|
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries inspectionDueOn, segment and the title of the first next step', async () => {
|
||||||
|
grant('colonies:read', 'tasks:read');
|
||||||
|
nextSteps.forColonies.mockResolvedValue(
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
'c2',
|
||||||
|
stepsOf('c2', {
|
||||||
|
segment: 'SOMMER',
|
||||||
|
inspectionDueOn: '2026-05-10',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
key: 'suggestion:s1',
|
||||||
|
kind: 'SUGGESTION',
|
||||||
|
intent: 'INSPECT',
|
||||||
|
title: 'Durchsicht mit Schwarmkontrolle',
|
||||||
|
reasons: [],
|
||||||
|
suggestionIds: ['s1'],
|
||||||
|
dueOn: '2026-05-10',
|
||||||
|
urgency: 'OVERDUE',
|
||||||
|
action: { type: 'ACCEPT', suggestionId: 's1' },
|
||||||
|
dismissible: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
['c10', stepsOf('c10', { segment: 'WINTERRUHE' })],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const overview = await service.colonyOverview('apiary-1', 'u1');
|
||||||
|
|
||||||
|
expect(overview.rows[0]).toMatchObject({
|
||||||
|
inspectionDueOn: '2026-05-10',
|
||||||
|
segment: 'SOMMER',
|
||||||
|
nextStepTitle: 'Durchsicht mit Schwarmkontrolle',
|
||||||
|
});
|
||||||
|
expect(overview.rows[1]).toMatchObject({
|
||||||
|
inspectionDueOn: null,
|
||||||
|
segment: 'WINTERRUHE',
|
||||||
|
nextStepTitle: null,
|
||||||
|
});
|
||||||
|
expect(nextSteps.forColonies).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the segment null when the colony has no season state', async () => {
|
||||||
|
grant('colonies:read');
|
||||||
|
nextSteps.forColonies.mockResolvedValue(new Map());
|
||||||
|
|
||||||
|
const overview = await service.colonyOverview('apiary-1', 'u1');
|
||||||
|
|
||||||
|
expect(overview.rows.every((row) => row.segment === null)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds the next steps per colony to the offline pack', async () => {
|
||||||
|
grant('colonies:read');
|
||||||
|
|
||||||
|
const pack = await service.offlinePack('apiary-1', 'u1');
|
||||||
|
|
||||||
|
expect(pack.version).toBe(1);
|
||||||
|
expect(pack.nextSteps).toEqual({ c2: [], c10: [] });
|
||||||
|
expect(nextSteps.forColonies).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
|
import type { ColonyNextSteps } from '../autopilot/next-step-ranking';
|
||||||
|
import { NextStepsService } from '../autopilot/next-steps.service';
|
||||||
|
import { HiveCatalogService } from '../hive/hive-catalog.service';
|
||||||
|
import { HiveService } from '../hive/hive.service';
|
||||||
|
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import type {
|
||||||
|
ColonyOverview,
|
||||||
|
ColonyOverviewRow,
|
||||||
|
OfflinePackV1,
|
||||||
|
} from './apiary-field.types';
|
||||||
|
|
||||||
|
const INSPECTIONS_PER_COLONY = 3;
|
||||||
|
|
||||||
|
/** 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];
|
||||||
|
|
||||||
|
interface FieldData {
|
||||||
|
apiary: OfflinePackV1['apiary'];
|
||||||
|
colonies: PackColony[];
|
||||||
|
permissions: ReadonlySet<string>;
|
||||||
|
overview: ColonyOverview;
|
||||||
|
nextSteps: ReadonlyMap<string, ColonyNextSteps>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read models for work at the apiary: the colony traffic-light overview and
|
||||||
|
* the offline pack the PWA caches before the beekeeper loses reception.
|
||||||
|
* Sections beyond the colonies are only included for callers who may read them.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ApiaryFieldService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissionResolver: PermissionResolverService,
|
||||||
|
private readonly nextSteps: NextStepsService,
|
||||||
|
private readonly hives: HiveService,
|
||||||
|
private readonly hiveCatalogs: HiveCatalogService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async colonyOverview(
|
||||||
|
apiaryId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<ColonyOverview> {
|
||||||
|
return (await this.load(apiaryId, userId)).overview;
|
||||||
|
}
|
||||||
|
|
||||||
|
async offlinePack(apiaryId: string, userId: string): Promise<OfflinePackV1> {
|
||||||
|
const { apiary, colonies, permissions, overview, nextSteps } =
|
||||||
|
await this.load(apiaryId, userId);
|
||||||
|
const [queens, inspections, tasks, hives, hiveCatalog] = await Promise.all([
|
||||||
|
permissions.has('queens:read') ? this.loadQueens(colonies) : null,
|
||||||
|
permissions.has('inspections:read')
|
||||||
|
? this.loadInspections(colonies)
|
||||||
|
: null,
|
||||||
|
permissions.has('tasks:read') ? this.loadTasks(apiaryId, colonies) : null,
|
||||||
|
this.hives.getMany(colonies.map((colony) => colony.id)),
|
||||||
|
this.hiveCatalogs.listSystems(),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
apiary,
|
||||||
|
overview,
|
||||||
|
colonies,
|
||||||
|
queens,
|
||||||
|
inspections,
|
||||||
|
tasks,
|
||||||
|
hives,
|
||||||
|
hiveCatalog: activeOrUsed(hiveCatalog, hives),
|
||||||
|
nextSteps: Object.fromEntries(
|
||||||
|
colonies.map((colony) => [
|
||||||
|
colony.id,
|
||||||
|
nextSteps.get(colony.id)?.steps ?? [],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(apiaryId: string, userId: string): Promise<FieldData> {
|
||||||
|
// Explicit select: coordinates, address and notes of an apiary are
|
||||||
|
// theft-sensitive and must never reach the field payloads.
|
||||||
|
const apiary = await this.prisma.apiary.findUnique({
|
||||||
|
where: { id: apiaryId },
|
||||||
|
select: { id: true, name: true, location: true, status: true },
|
||||||
|
});
|
||||||
|
if (!apiary) {
|
||||||
|
throw new NotFoundException('Apiary not found');
|
||||||
|
}
|
||||||
|
const permissions = await this.permissionResolver.getPermissions(userId);
|
||||||
|
const found = await this.prisma.colony.findMany({
|
||||||
|
where: { ...LIVING_COLONY_WHERE, apiaryId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
apiaryId: true,
|
||||||
|
status: true,
|
||||||
|
colonyType: true,
|
||||||
|
hiveType: true,
|
||||||
|
strength: true,
|
||||||
|
occupiedFrames: true,
|
||||||
|
broodStatus: true,
|
||||||
|
feedStatus: true,
|
||||||
|
healthStatus: true,
|
||||||
|
currentQueenId: true,
|
||||||
|
notes: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const colonies = [...found].sort((a, b) =>
|
||||||
|
a.code.localeCompare(b.code, 'de', { numeric: true }),
|
||||||
|
);
|
||||||
|
const inspectionsVisible = permissions.has('inspections:read');
|
||||||
|
const colonyIds = colonies.map((colony) => colony.id);
|
||||||
|
const [lastInspections, warnings, nextSteps] = await Promise.all([
|
||||||
|
inspectionsVisible ? this.lastInspectionDates(colonies) : null,
|
||||||
|
this.nextSteps.warningCounts(colonyIds, userId, permissions),
|
||||||
|
this.nextSteps.forColonies(colonyIds, userId, undefined, permissions),
|
||||||
|
]);
|
||||||
|
const rows = colonies.map((colony): ColonyOverviewRow => ({
|
||||||
|
id: colony.id,
|
||||||
|
code: colony.code,
|
||||||
|
status: colony.status,
|
||||||
|
strength: colony.strength,
|
||||||
|
occupiedFrames: colony.occupiedFrames,
|
||||||
|
broodStatus: colony.broodStatus,
|
||||||
|
feedStatus: colony.feedStatus,
|
||||||
|
healthStatus: colony.healthStatus,
|
||||||
|
lastInspectionDate: lastInspections?.get(colony.id) ?? null,
|
||||||
|
inspectionDueOn: nextSteps.get(colony.id)?.inspectionDueOn ?? null,
|
||||||
|
segment: nextSteps.get(colony.id)?.segment ?? null,
|
||||||
|
nextStepTitle: nextSteps.get(colony.id)?.steps.at(0)?.title ?? null,
|
||||||
|
openWarnings: warnings.get(colony.id)?.warnings ?? 0,
|
||||||
|
openCritical: warnings.get(colony.id)?.critical ?? 0,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
apiary,
|
||||||
|
colonies,
|
||||||
|
permissions,
|
||||||
|
overview: { apiaryId, inspectionsVisible, rows },
|
||||||
|
nextSteps,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lastInspectionDates(
|
||||||
|
colonies: PackColony[],
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
const groups = await this.prisma.inspection.groupBy({
|
||||||
|
by: ['colonyId'],
|
||||||
|
where: { colonyId: { in: colonies.map((colony) => colony.id) } },
|
||||||
|
_max: { date: true },
|
||||||
|
});
|
||||||
|
const dates = new Map<string, string>();
|
||||||
|
for (const group of groups) {
|
||||||
|
if (group._max.date) {
|
||||||
|
dates.set(group.colonyId, group._max.date.toISOString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadQueens(colonies: PackColony[]): Promise<OfflinePackV1['queens']> {
|
||||||
|
const queenIds = colonies.flatMap((colony) =>
|
||||||
|
colony.currentQueenId ? [colony.currentQueenId] : [],
|
||||||
|
);
|
||||||
|
return this.prisma.queen.findMany({
|
||||||
|
where: { id: { in: queenIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
hatchYear: true,
|
||||||
|
breed: true,
|
||||||
|
markingColor: true,
|
||||||
|
markingNumber: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadInspections(
|
||||||
|
colonies: PackColony[],
|
||||||
|
): Promise<OfflinePackV1['inspections']> {
|
||||||
|
const perColony = await Promise.all(
|
||||||
|
colonies.map((colony) =>
|
||||||
|
this.prisma.inspection.findMany({
|
||||||
|
where: { colonyId: colony.id },
|
||||||
|
take: INSPECTIONS_PER_COLONY,
|
||||||
|
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }],
|
||||||
|
omit: { userId: true },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return perColony.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadTasks(
|
||||||
|
apiaryId: string,
|
||||||
|
colonies: PackColony[],
|
||||||
|
): Promise<OfflinePackV1['tasks']> {
|
||||||
|
return this.prisma.task.findMany({
|
||||||
|
where: {
|
||||||
|
status: { in: ['OPEN', 'IN_PROGRESS'] },
|
||||||
|
OR: [
|
||||||
|
{ relatedType: 'APIARY', relatedId: apiaryId },
|
||||||
|
{
|
||||||
|
relatedType: 'COLONY',
|
||||||
|
relatedId: { in: colonies.map((colony) => colony.id) },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
dueDate: true,
|
||||||
|
priority: true,
|
||||||
|
status: true,
|
||||||
|
relatedType: true,
|
||||||
|
relatedId: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ dueDate: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import type {
|
||||||
|
ApiaryStatus,
|
||||||
|
Colony,
|
||||||
|
ColonyCondition,
|
||||||
|
ColonyStatus,
|
||||||
|
Inspection,
|
||||||
|
Queen,
|
||||||
|
Task,
|
||||||
|
} 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. */
|
||||||
|
export interface ColonyOverviewRow {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
status: ColonyStatus;
|
||||||
|
strength: number | null;
|
||||||
|
occupiedFrames: number | null;
|
||||||
|
broodStatus: ColonyCondition | null;
|
||||||
|
feedStatus: ColonyCondition | null;
|
||||||
|
healthStatus: ColonyCondition | null;
|
||||||
|
/** ISO timestamp; always null when the caller may not read inspections. */
|
||||||
|
lastInspectionDate: string | null;
|
||||||
|
/**
|
||||||
|
* The day the next inspection is due (YYYY-MM-DD); null in the winter
|
||||||
|
* rest, when the interval rule is off and without `inspections:read`.
|
||||||
|
*/
|
||||||
|
inspectionDueOn: string | null;
|
||||||
|
/** The season segment of the apiary today; null when it is unknown. */
|
||||||
|
segment: SeasonSegmentKey | null;
|
||||||
|
/** Title of the first next step; null without one or without `tasks:read`. */
|
||||||
|
nextStepTitle: string | null;
|
||||||
|
/** Open suggestions of priority HIGH the caller may see. */
|
||||||
|
openWarnings: number;
|
||||||
|
/** Open suggestions of priority URGENT the caller may see. */
|
||||||
|
openCritical: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColonyOverview {
|
||||||
|
apiaryId: string;
|
||||||
|
inspectionsVisible: boolean;
|
||||||
|
rows: ColonyOverviewRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the offline apiary mode needs. It deliberately carries no
|
||||||
|
* coordinates, address or apiary notes (apiary locations are theft-sensitive)
|
||||||
|
* and no inspection userId. Sections the caller has no read permission for
|
||||||
|
* are null.
|
||||||
|
*/
|
||||||
|
export interface OfflinePackV1 {
|
||||||
|
version: 1;
|
||||||
|
generatedAt: string;
|
||||||
|
apiary: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
location: string | null;
|
||||||
|
status: ApiaryStatus;
|
||||||
|
};
|
||||||
|
overview: ColonyOverview;
|
||||||
|
colonies: Pick<
|
||||||
|
Colony,
|
||||||
|
| 'id'
|
||||||
|
| 'code'
|
||||||
|
| 'apiaryId'
|
||||||
|
| 'status'
|
||||||
|
| 'colonyType'
|
||||||
|
| 'hiveType'
|
||||||
|
| 'strength'
|
||||||
|
| 'occupiedFrames'
|
||||||
|
| 'broodStatus'
|
||||||
|
| 'feedStatus'
|
||||||
|
| 'healthStatus'
|
||||||
|
| 'currentQueenId'
|
||||||
|
| 'notes'
|
||||||
|
>[];
|
||||||
|
queens:
|
||||||
|
| Pick<
|
||||||
|
Queen,
|
||||||
|
| 'id'
|
||||||
|
| 'code'
|
||||||
|
| 'hatchYear'
|
||||||
|
| 'breed'
|
||||||
|
| 'markingColor'
|
||||||
|
| 'markingNumber'
|
||||||
|
| 'status'
|
||||||
|
>[]
|
||||||
|
| null;
|
||||||
|
inspections: Omit<Inspection, 'userId'>[] | null;
|
||||||
|
tasks:
|
||||||
|
| Pick<
|
||||||
|
Task,
|
||||||
|
| 'id'
|
||||||
|
| 'title'
|
||||||
|
| 'description'
|
||||||
|
| 'dueDate'
|
||||||
|
| 'priority'
|
||||||
|
| 'status'
|
||||||
|
| 'relatedType'
|
||||||
|
| 'relatedId'
|
||||||
|
>[]
|
||||||
|
| null;
|
||||||
|
/**
|
||||||
|
* The next steps per colony id (what `GET /colonies/:id/next-steps`
|
||||||
|
* returns as `steps`). Absent in packs cached before T4.
|
||||||
|
*/
|
||||||
|
nextSteps?: Record<string, NextStep[]>;
|
||||||
|
/**
|
||||||
|
* 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[];
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger';
|
import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsLatitude, IsLongitude, IsOptional, IsString } from 'class-validator';
|
import {
|
||||||
|
IsLatitude,
|
||||||
|
IsLongitude,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class CreateApiaryDto {
|
export class CreateApiaryDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -35,4 +41,13 @@ export class CreateApiaryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Ort für die öffentliche Herkunftsseite, z. B. "Nordhessen". Nie die genaue Adresse.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
publicLocationLabel?: string;
|
||||||
}
|
}
|
||||||
@@ -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 },
|
||||||
|
) {}
|
||||||
@@ -19,7 +19,7 @@ import { noStoreMiddleware } from './common/middleware/no-store.middleware';
|
|||||||
* Production runs client -> Cloudflare -> Traefik -> backend, so the hop
|
* Production runs client -> Cloudflare -> Traefik -> backend, so the hop
|
||||||
* count is not stable; TRUST_PROXY is an Express list there ("loopback,
|
* count is not stable; TRUST_PROXY is an Express list there ("loopback,
|
||||||
* 10.42.0.0/16, <Cloudflare IPv4+IPv6 ranges>", see
|
* 10.42.0.0/16, <Cloudflare IPv4+IPv6 ranges>", see
|
||||||
* deploy/k8s/backend/configmap.example.yaml). A plain number ("1") is a hop
|
* deploy/k8s/base/backend/configmap.example.yaml). A plain number ("1") is a hop
|
||||||
* count and stays the default when the variable is unset.
|
* count and stays the default when the variable is unset.
|
||||||
*/
|
*/
|
||||||
export function trustProxySetting(): number | string {
|
export function trustProxySetting(): number | string {
|
||||||
@@ -58,7 +58,7 @@ export function configureApp(app: NestExpressApplication): void {
|
|||||||
// proxy, so rate limits, API-key failure limits and IP allowlists act
|
// proxy, so rate limits, API-key failure limits and IP allowlists act
|
||||||
// on a shared address instead of the client.
|
// on a shared address instead of the client.
|
||||||
bootstrapLogger.warn(
|
bootstrapLogger.warn(
|
||||||
'TRUST_PROXY is not set in production - client IPs for rate limits and API-key allowlists are likely the proxy address; set it as in deploy/k8s/backend/configmap.example.yaml',
|
'TRUST_PROXY is not set in production - client IPs for rate limits and API-key allowlists are likely the proxy address; set it as in deploy/k8s/base/backend/configmap.example.yaml',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// No ETags: they let caches and proxies revalidate (and keep) personal
|
// No ETags: they let caches and proxies revalidate (and keep) personal
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { ConfigModule } from '@nestjs/config';
|
|||||||
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
import { ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerModule } from '@nestjs/throttler';
|
||||||
import { ClientIpThrottlerGuard } from './common/throttler/client-ip-throttler.guard';
|
import { ClientIpThrottlerGuard } from './common/throttler/client-ip-throttler.guard';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { PrismaThrottlerStorage } from './common/throttler/prisma-throttler.storage';
|
||||||
|
import { ThrottlerStorageModule } from './common/throttler/throttler-storage.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { AuditModule } from './audit/audit.module';
|
import { AuditModule } from './audit/audit.module';
|
||||||
|
import { DomainEventsModule } from './domain-events/domain-events.module';
|
||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { RolesModule } from './roles/roles.module';
|
import { RolesModule } from './roles/roles.module';
|
||||||
@@ -26,6 +28,8 @@ import { DashboardModule } from './dashboard/dashboard.module';
|
|||||||
import { SearchModule } from './search/search.module';
|
import { SearchModule } from './search/search.module';
|
||||||
import { ExportModule } from './export/export.module';
|
import { ExportModule } from './export/export.module';
|
||||||
import { QrModule } from './qr/qr.module';
|
import { QrModule } from './qr/qr.module';
|
||||||
|
import { PhenologyModule } from './phenology/phenology.module';
|
||||||
|
import { RemindersModule } from './reminders/reminders.module';
|
||||||
import { ProductsModule } from './products/products.module';
|
import { ProductsModule } from './products/products.module';
|
||||||
import { BottlingModule } from './bottling/bottling.module';
|
import { BottlingModule } from './bottling/bottling.module';
|
||||||
import { ProductionBatchesModule } from './production-batches/production-batches.module';
|
import { ProductionBatchesModule } from './production-batches/production-batches.module';
|
||||||
@@ -51,20 +55,36 @@ import { BackupModule } from './backup/backup.module';
|
|||||||
import { ShopSyncModule } from './shop-sync/shop-sync.module';
|
import { ShopSyncModule } from './shop-sync/shop-sync.module';
|
||||||
import { ErpExportModule } from './erp-export/erp-export.module';
|
import { ErpExportModule } from './erp-export/erp-export.module';
|
||||||
import { ApiKeysModule } from './api-keys/api-keys.module';
|
import { ApiKeysModule } from './api-keys/api-keys.module';
|
||||||
|
import { CleanupModule } from './suggestions/cleanup.module';
|
||||||
|
import { SuggestionsModule } from './suggestions/suggestions.module';
|
||||||
|
import { AutopilotModule } from './autopilot/autopilot.module';
|
||||||
|
import { SystemModule } from './system/system.module';
|
||||||
|
import { WithdrawalModule } from './withdrawal/withdrawal.module';
|
||||||
|
import { 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';
|
||||||
import { IdempotencyInterceptor } from './common/idempotency/idempotency.interceptor';
|
import { IdempotencyInterceptor } from './common/idempotency/idempotency.interceptor';
|
||||||
import { IdempotencyCleanupService } from './common/idempotency/idempotency-cleanup.service';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
ScheduleModule.forRoot(),
|
ThrottlerModule.forRootAsync({
|
||||||
ThrottlerModule.forRoot([{ name: 'default', ttl: 60000, limit: 100 }]),
|
imports: [ThrottlerStorageModule],
|
||||||
|
inject: [PrismaThrottlerStorage],
|
||||||
|
useFactory: (storage: PrismaThrottlerStorage) => ({
|
||||||
|
throttlers: [{ name: 'default', ttl: 60000, limit: 100 }],
|
||||||
|
storage,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PermissionsModule,
|
PermissionsModule,
|
||||||
AuditModule,
|
AuditModule,
|
||||||
|
DomainEventsModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
@@ -97,6 +117,12 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
|
|||||||
WebhooksModule,
|
WebhooksModule,
|
||||||
DocumentsModule,
|
DocumentsModule,
|
||||||
AutomationModule,
|
AutomationModule,
|
||||||
|
SuggestionsModule,
|
||||||
|
CleanupModule,
|
||||||
|
AutopilotModule,
|
||||||
|
PhenologyModule,
|
||||||
|
WithdrawalModule,
|
||||||
|
RemindersModule,
|
||||||
LabelTemplatesModule,
|
LabelTemplatesModule,
|
||||||
ImportModule,
|
ImportModule,
|
||||||
PublicModule,
|
PublicModule,
|
||||||
@@ -110,6 +136,12 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
|
|||||||
ShopSyncModule,
|
ShopSyncModule,
|
||||||
ErpExportModule,
|
ErpExportModule,
|
||||||
ApiKeysModule,
|
ApiKeysModule,
|
||||||
|
SystemModule,
|
||||||
|
VeterinaryMedicinesModule,
|
||||||
|
HiveModule,
|
||||||
|
ComplianceModule,
|
||||||
|
ReadinessModule,
|
||||||
|
VoiceModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -119,7 +151,6 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
|
|||||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||||
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
||||||
{ provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
|
{ provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
|
||||||
IdempotencyCleanupService,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
@@ -43,6 +43,18 @@ describe('AuditLogService', () => {
|
|||||||
expect(createArg.data.userId).toBe('user-1');
|
expect(createArg.data.userId).toBe('user-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('writes through the transaction client when one is given', async () => {
|
||||||
|
const tx = { auditLog: { create: jest.fn() } };
|
||||||
|
|
||||||
|
await service.record(
|
||||||
|
{ entityType: 'Inspection', entityId: 'i1', action: 'CREATE' },
|
||||||
|
tx as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tx.auditLog.create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('findAll filters by entity type and id', async () => {
|
it('findAll filters by entity type and id', async () => {
|
||||||
await service.findAll({ entityType: 'Colony', entityId: 'colony-1' });
|
await service.findAll({ entityType: 'Colony', entityId: 'colony-1' });
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { DbClient } from '../prisma/db-client';
|
||||||
import { AuditAction, AuditLog, Prisma } from '../../generated/prisma';
|
import { AuditAction, AuditLog, Prisma } from '../../generated/prisma';
|
||||||
|
|
||||||
interface RecordAuditLogInput {
|
export interface RecordAuditLogInput {
|
||||||
entityType: string;
|
entityType: string;
|
||||||
entityId: string;
|
entityId: string;
|
||||||
action: AuditAction;
|
action: AuditAction;
|
||||||
@@ -20,8 +21,17 @@ export interface AuditLogFilter {
|
|||||||
export class AuditLogService {
|
export class AuditLogService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async record(input: RecordAuditLogInput): Promise<void> {
|
/**
|
||||||
await this.prisma.auditLog.create({
|
* With `tx` the entry commits or rolls back together with the change it
|
||||||
|
* describes - no audited change without its entry, and no entry for a
|
||||||
|
* change that never happened.
|
||||||
|
*/
|
||||||
|
async record(
|
||||||
|
input: RecordAuditLogInput,
|
||||||
|
tx?: Prisma.TransactionClient,
|
||||||
|
): Promise<void> {
|
||||||
|
const db: DbClient = tx ?? this.prisma;
|
||||||
|
await db.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
entityType: input.entityType,
|
entityType: input.entityType,
|
||||||
entityId: input.entityId,
|
entityId: input.entityId,
|
||||||
|
|||||||
@@ -1,37 +1,114 @@
|
|||||||
import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util';
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { KEY_ID_PATTERN } from '../../common/crypto/key-ring';
|
||||||
|
import {
|
||||||
|
decryptMfaSecret,
|
||||||
|
encryptMfaSecret,
|
||||||
|
mfaSecretNeedsRekey,
|
||||||
|
} from './mfa-crypto.util';
|
||||||
|
|
||||||
|
// Written by the code before the key ring (AES key sha256(JWT_ACCESS_SECRET),
|
||||||
|
// no key id) with JWT_ACCESS_SECRET = 'x' * 40.
|
||||||
|
const LEGACY_SECRET =
|
||||||
|
'zJD2lAy1b/wslLyQ.hn8scDeaxIADXlRFtcGIyg==.3+5qbYM52lKKN/VXpInUOQ==';
|
||||||
|
const PLAINTEXT = 'JBSWY3DPEHPK3PXP';
|
||||||
|
|
||||||
describe('mfa-crypto.util', () => {
|
describe('mfa-crypto.util', () => {
|
||||||
const originalSecret = process.env.JWT_ACCESS_SECRET;
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret';
|
process.env.JWT_ACCESS_SECRET = 'x'.repeat(40);
|
||||||
|
process.env.MFA_ENCRYPTION_KEY = 'm'.repeat(40);
|
||||||
|
delete process.env.MFA_ENCRYPTION_KEY_PREVIOUS;
|
||||||
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
process.env.JWT_ACCESS_SECRET = originalSecret;
|
process.env = { ...originalEnv };
|
||||||
|
jest.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('round-trips a TOTP secret through encrypt/decrypt', () => {
|
it('round-trips a TOTP secret and prefixes the key id', () => {
|
||||||
const plaintext = 'JBSWY3DPEHPK3PXP';
|
const ciphertext = encryptMfaSecret(PLAINTEXT);
|
||||||
const ciphertext = encryptMfaSecret(plaintext);
|
|
||||||
expect(ciphertext).not.toContain(plaintext);
|
expect(ciphertext).toMatch(KEY_ID_PATTERN);
|
||||||
expect(decryptMfaSecret(ciphertext)).toBe(plaintext);
|
expect(ciphertext).not.toContain(PLAINTEXT);
|
||||||
|
expect(decryptMfaSecret(ciphertext)).toBe(PLAINTEXT);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('produces a different ciphertext every time (random IV)', () => {
|
it('produces a different ciphertext every time (random IV)', () => {
|
||||||
const plaintext = 'JBSWY3DPEHPK3PXP';
|
expect(encryptMfaSecret(PLAINTEXT)).not.toBe(encryptMfaSecret(PLAINTEXT));
|
||||||
expect(encryptMfaSecret(plaintext)).not.toBe(encryptMfaSecret(plaintext));
|
});
|
||||||
|
|
||||||
|
it('no longer depends on JWT_ACCESS_SECRET', () => {
|
||||||
|
const ciphertext = encryptMfaSecret(PLAINTEXT);
|
||||||
|
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
|
||||||
|
|
||||||
|
expect(decryptMfaSecret(ciphertext)).toBe(PLAINTEXT);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails to decrypt with the wrong key', () => {
|
it('fails to decrypt with the wrong key', () => {
|
||||||
const ciphertext = encryptMfaSecret('JBSWY3DPEHPK3PXP');
|
const ciphertext = encryptMfaSecret(PLAINTEXT);
|
||||||
process.env.JWT_ACCESS_SECRET = 'a-different-secret';
|
process.env.MFA_ENCRYPTION_KEY = 'n'.repeat(40);
|
||||||
expect(() => decryptMfaSecret(ciphertext)).toThrow();
|
|
||||||
|
expect(() => decryptMfaSecret(ciphertext)).toThrow('unknown key id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens a secret sealed with MFA_ENCRYPTION_KEY_PREVIOUS and flags it for rekey', () => {
|
||||||
|
const ciphertext = encryptMfaSecret(PLAINTEXT);
|
||||||
|
process.env.MFA_ENCRYPTION_KEY_PREVIOUS = process.env.MFA_ENCRYPTION_KEY;
|
||||||
|
process.env.MFA_ENCRYPTION_KEY = 'n'.repeat(40);
|
||||||
|
|
||||||
|
expect(decryptMfaSecret(ciphertext)).toBe(PLAINTEXT);
|
||||||
|
expect(mfaSecretNeedsRekey(ciphertext)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not flag a secret sealed with the current key', () => {
|
||||||
|
expect(mfaSecretNeedsRekey(encryptMfaSecret(PLAINTEXT))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still opens a legacy MFA secret encrypted with the JWT-derived key', () => {
|
||||||
|
expect(decryptMfaSecret(LEGACY_SECRET)).toBe(PLAINTEXT);
|
||||||
|
expect(mfaSecretNeedsRekey(LEGACY_SECRET)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed on a legacy secret once JWT_ACCESS_SECRET changed', () => {
|
||||||
|
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
|
||||||
|
|
||||||
|
expect(() => decryptMfaSecret(LEGACY_SECRET)).toThrow(
|
||||||
|
'ciphertext failed authentication',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never retries a k…: value that fails authentication with the legacy key', () => {
|
||||||
|
const warn = jest.spyOn(Logger.prototype, 'warn');
|
||||||
|
const currentKeyId = encryptMfaSecret(PLAINTEXT).split(':')[0];
|
||||||
|
// A valid legacy ciphertext behind the current key id: only a retry with
|
||||||
|
// sha256(JWT_ACCESS_SECRET) could open it.
|
||||||
|
const disguised = `${currentKeyId}:${LEGACY_SECRET}`;
|
||||||
|
|
||||||
|
expect(() => decryptMfaSecret(disguised)).toThrow(
|
||||||
|
'ciphertext failed authentication',
|
||||||
|
);
|
||||||
|
expect(() => decryptMfaSecret(`k00000000:${LEGACY_SECRET}`)).toThrow(
|
||||||
|
'unknown key id',
|
||||||
|
);
|
||||||
|
expect(warn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails on a tampered ciphertext', () => {
|
||||||
|
const [keyId, body] = encryptMfaSecret(PLAINTEXT).split(':');
|
||||||
|
const [iv, tag, data] = body.split('.');
|
||||||
|
const bytes = Buffer.from(data, 'base64');
|
||||||
|
bytes[0] ^= 0xff;
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
decryptMfaSecret(`${keyId}:${iv}.${tag}.${bytes.toString('base64')}`),
|
||||||
|
).toThrow('ciphertext failed authentication');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a malformed ciphertext', () => {
|
it('rejects a malformed ciphertext', () => {
|
||||||
expect(() => decryptMfaSecret('not-a-valid-ciphertext')).toThrow(
|
expect(() => decryptMfaSecret('not-a-valid-ciphertext')).toThrow(
|
||||||
'Invalid MFA secret ciphertext',
|
'malformed ciphertext',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,53 +1,31 @@
|
|||||||
import {
|
import {
|
||||||
createCipheriv,
|
KEY_ID_PATTERN,
|
||||||
createDecipheriv,
|
isSealedWithCurrentKey,
|
||||||
createHash,
|
loadKeyRing,
|
||||||
randomBytes,
|
openLegacyCiphertext,
|
||||||
} from 'node:crypto';
|
openWithRing,
|
||||||
|
sealWithRing,
|
||||||
const ALGORITHM = 'aes-256-gcm';
|
} from '../../common/crypto/key-ring';
|
||||||
const IV_BYTES = 12;
|
|
||||||
|
|
||||||
function getKey(): Buffer {
|
|
||||||
const secret = process.env.JWT_ACCESS_SECRET;
|
|
||||||
if (!secret) {
|
|
||||||
throw new Error('JWT_ACCESS_SECRET is not set');
|
|
||||||
}
|
|
||||||
return createHash('sha256').update(secret).digest();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TOTP secrets must be reversible (unlike passwords/refresh tokens, which
|
* TOTP secrets must be reversible (unlike passwords/refresh tokens, which
|
||||||
* are one-way hashed) so the server can compute the expected code on every
|
* are one-way hashed) so the server can compute the expected code on every
|
||||||
* login. AES-256-GCM at rest, keyed off JWT_ACCESS_SECRET, keeps the secret
|
* login. They are sealed with the MFA key ring (MFA_ENCRYPTION_KEY, see
|
||||||
* out of the DB in plaintext without introducing a new secret to manage.
|
* common/crypto/key-ring.ts). Secrets stored before the key ring have no key
|
||||||
|
* id and were encrypted with sha256(JWT_ACCESS_SECRET); they stay readable
|
||||||
|
* until the rekey job reseals them.
|
||||||
*/
|
*/
|
||||||
export function encryptMfaSecret(plaintext: string): string {
|
export function encryptMfaSecret(plaintext: string): string {
|
||||||
const iv = randomBytes(IV_BYTES);
|
return sealWithRing(loadKeyRing('mfa'), plaintext);
|
||||||
const cipher = createCipheriv(ALGORITHM, getKey(), iv);
|
|
||||||
const encrypted = Buffer.concat([
|
|
||||||
cipher.update(plaintext, 'utf8'),
|
|
||||||
cipher.final(),
|
|
||||||
]);
|
|
||||||
const authTag = cipher.getAuthTag();
|
|
||||||
return [iv, authTag, encrypted]
|
|
||||||
.map((buffer) => buffer.toString('base64'))
|
|
||||||
.join('.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decryptMfaSecret(ciphertext: string): string {
|
export function decryptMfaSecret(stored: string): string {
|
||||||
const [ivB64, authTagB64, dataB64] = ciphertext.split('.');
|
return KEY_ID_PATTERN.test(stored)
|
||||||
if (!ivB64 || !authTagB64 || !dataB64) {
|
? openWithRing(loadKeyRing('mfa'), stored).plaintext
|
||||||
throw new Error('Invalid MFA secret ciphertext');
|
: openLegacyCiphertext('mfa', stored);
|
||||||
}
|
}
|
||||||
const decipher = createDecipheriv(
|
|
||||||
ALGORITHM,
|
/** True for secrets the rekey job should reseal with the current key. */
|
||||||
getKey(),
|
export function mfaSecretNeedsRekey(stored: string): boolean {
|
||||||
Buffer.from(ivB64, 'base64'),
|
return !isSealedWithCurrentKey(loadKeyRing('mfa'), stored);
|
||||||
);
|
|
||||||
decipher.setAuthTag(Buffer.from(authTagB64, 'base64'));
|
|
||||||
return Buffer.concat([
|
|
||||||
decipher.update(Buffer.from(dataB64, 'base64')),
|
|
||||||
decipher.final(),
|
|
||||||
]).toString('utf8');
|
|
||||||
}
|
}
|
||||||
@@ -123,6 +123,18 @@ export const PERMISSION_CATALOG = [
|
|||||||
],
|
],
|
||||||
850,
|
850,
|
||||||
),
|
),
|
||||||
|
// Seeded with role grants by the t4_phenology migration (read with
|
||||||
|
// colonies:read, create/update with inspections:create).
|
||||||
|
...group(
|
||||||
|
'Pflanzenentwicklung',
|
||||||
|
'phenology',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['create', 'Melden'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
],
|
||||||
|
860,
|
||||||
|
),
|
||||||
...group(
|
...group(
|
||||||
'Behandlungen',
|
'Behandlungen',
|
||||||
'treatments',
|
'treatments',
|
||||||
@@ -361,6 +373,15 @@ export const PERMISSION_CATALOG = [
|
|||||||
[['manage', 'Anlegen, ansehen und widerrufen']],
|
[['manage', 'Anlegen, ansehen und widerrufen']],
|
||||||
3400,
|
3400,
|
||||||
),
|
),
|
||||||
|
...group(
|
||||||
|
'System',
|
||||||
|
'system',
|
||||||
|
[
|
||||||
|
['read', 'Hintergrundjobs und Warteschlangen ansehen'],
|
||||||
|
['manage', 'Fehlgeschlagene Ereignisse erneut anstoßen'],
|
||||||
|
],
|
||||||
|
3500,
|
||||||
|
),
|
||||||
// Like shop-sync: a narrow read-only allowlist for one machine consumer
|
// Like shop-sync: a narrow read-only allowlist for one machine consumer
|
||||||
// (ERPNext's nightly inventory mirror), separate from products:read etc.,
|
// (ERPNext's nightly inventory mirror), separate from products:read etc.,
|
||||||
// which return descriptions and full records.
|
// which return descriptions and full records.
|
||||||
@@ -368,8 +389,34 @@ export const PERMISSION_CATALOG = [
|
|||||||
'ERP-Anbindung',
|
'ERP-Anbindung',
|
||||||
'erp-inventory',
|
'erp-inventory',
|
||||||
[['read', 'Inventar-Spiegel für ERPNext lesen']],
|
[['read', 'Inventar-Spiegel für ERPNext lesen']],
|
||||||
3500,
|
3600,
|
||||||
),
|
),
|
||||||
|
// 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);
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import { Prisma } from '../../generated/prisma';
|
||||||
|
import type {
|
||||||
|
AnyDomainEventEnvelope,
|
||||||
|
DomainEventEnvelope,
|
||||||
|
DomainEventType,
|
||||||
|
} from '../domain-events/domain-event-types';
|
||||||
|
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
|
||||||
|
import type { StockService } from '../stock/stock.service';
|
||||||
|
import { AutomationEventHandlers } from './automation.handlers';
|
||||||
|
import type { AutomationService } from './automation.service';
|
||||||
|
|
||||||
|
type RecordModel =
|
||||||
|
'inspection' | 'varroaMeasurement' | 'treatment' | 'feeding' | 'colony';
|
||||||
|
|
||||||
|
const RECORD_MODELS: readonly RecordModel[] = [
|
||||||
|
'inspection',
|
||||||
|
'varroaMeasurement',
|
||||||
|
'treatment',
|
||||||
|
'feeding',
|
||||||
|
'colony',
|
||||||
|
];
|
||||||
|
|
||||||
|
function envelope<T extends DomainEventType>(
|
||||||
|
type: T,
|
||||||
|
aggregateId: string,
|
||||||
|
payload: DomainEventEnvelope<T>['payload'],
|
||||||
|
): AnyDomainEventEnvelope<T> {
|
||||||
|
return {
|
||||||
|
id: 'e1',
|
||||||
|
type,
|
||||||
|
aggregateType: 'Inspection',
|
||||||
|
aggregateId,
|
||||||
|
payload,
|
||||||
|
actorUserId: 'u1',
|
||||||
|
correlationId: 'corr-1',
|
||||||
|
occurredAt: new Date('2026-10-13T08:00:00.000Z'),
|
||||||
|
attempts: 0,
|
||||||
|
} as unknown as AnyDomainEventEnvelope<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AutomationEventHandlers', () => {
|
||||||
|
let tx: Record<RecordModel, { findUnique: jest.Mock }>;
|
||||||
|
let automation: Record<
|
||||||
|
| 'evaluateInspectionIn'
|
||||||
|
| 'evaluateInspectionCorrectionIn'
|
||||||
|
| 'evaluateVarroaMeasurementIn'
|
||||||
|
| 'evaluateTreatmentIn'
|
||||||
|
| 'evaluateFeedingIn'
|
||||||
|
| 'evaluateColonyHealthIn'
|
||||||
|
| 'evaluateRawMaterialLowIn',
|
||||||
|
jest.Mock
|
||||||
|
>;
|
||||||
|
let stock: { remainingByRawMaterialKind: jest.Mock };
|
||||||
|
let handlers: AutomationEventHandlers;
|
||||||
|
|
||||||
|
const asTx = () => tx as unknown as Prisma.TransactionClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tx = Object.fromEntries(
|
||||||
|
RECORD_MODELS.map((model) => [
|
||||||
|
model,
|
||||||
|
{ findUnique: jest.fn().mockResolvedValue(null) },
|
||||||
|
]),
|
||||||
|
) as Record<RecordModel, { findUnique: jest.Mock }>;
|
||||||
|
automation = {
|
||||||
|
evaluateInspectionIn: jest.fn().mockResolvedValue(undefined),
|
||||||
|
evaluateInspectionCorrectionIn: jest.fn().mockResolvedValue(undefined),
|
||||||
|
evaluateVarroaMeasurementIn: jest.fn().mockResolvedValue(undefined),
|
||||||
|
evaluateTreatmentIn: jest.fn().mockResolvedValue(undefined),
|
||||||
|
evaluateFeedingIn: jest.fn().mockResolvedValue(undefined),
|
||||||
|
evaluateColonyHealthIn: jest.fn().mockResolvedValue(undefined),
|
||||||
|
evaluateRawMaterialLowIn: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
stock = { remainingByRawMaterialKind: jest.fn() };
|
||||||
|
handlers = new AutomationEventHandlers(
|
||||||
|
automation as unknown as AutomationService,
|
||||||
|
stock as unknown as StockService,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers the stable handler ids and event types', () => {
|
||||||
|
const registered = (method: keyof AutomationEventHandlers) =>
|
||||||
|
domainEventHandlerMetadata(
|
||||||
|
Reflect.get(AutomationEventHandlers.prototype, method),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(registered('inspectionRules')).toEqual([
|
||||||
|
{
|
||||||
|
types: ['InspectionRecorded', 'InspectionCorrected'],
|
||||||
|
handlerId: 'automation.inspection-rules',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(registered('varroaThreshold')).toEqual([
|
||||||
|
{
|
||||||
|
types: ['VarroaMeasured', 'VarroaCorrected'],
|
||||||
|
handlerId: 'automation.varroa-threshold',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(registered('treatmentFollowUp')).toEqual([
|
||||||
|
{
|
||||||
|
types: ['TreatmentRecorded'],
|
||||||
|
handlerId: 'automation.treatment-follow-up',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(registered('feedingFollowUp')).toEqual([
|
||||||
|
{ types: ['FeedingRecorded'], handlerId: 'automation.feeding-follow-up' },
|
||||||
|
]);
|
||||||
|
expect(registered('colonyHealth')).toEqual([
|
||||||
|
{ types: ['ColonyStatusChanged'], handlerId: 'automation.colony-health' },
|
||||||
|
]);
|
||||||
|
expect(registered('rawMaterialLow')).toEqual([
|
||||||
|
{ types: ['StockChanged'], handlerId: 'automation.raw-material-low' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('record handlers', () => {
|
||||||
|
const cases = [
|
||||||
|
[
|
||||||
|
'inspectionRules',
|
||||||
|
'InspectionRecorded',
|
||||||
|
'inspection',
|
||||||
|
'evaluateInspectionIn',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'varroaThreshold',
|
||||||
|
'VarroaMeasured',
|
||||||
|
'varroaMeasurement',
|
||||||
|
'evaluateVarroaMeasurementIn',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'varroaThreshold',
|
||||||
|
'VarroaCorrected',
|
||||||
|
'varroaMeasurement',
|
||||||
|
'evaluateVarroaMeasurementIn',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'treatmentFollowUp',
|
||||||
|
'TreatmentRecorded',
|
||||||
|
'treatment',
|
||||||
|
'evaluateTreatmentIn',
|
||||||
|
],
|
||||||
|
['feedingFollowUp', 'FeedingRecorded', 'feeding', 'evaluateFeedingIn'],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const run = (
|
||||||
|
method: (typeof cases)[number][0],
|
||||||
|
type: (typeof cases)[number][1],
|
||||||
|
) =>
|
||||||
|
handlers[method](
|
||||||
|
envelope(type, 'record-1', { colonyId: 'colony-1' }) as never,
|
||||||
|
asTx(),
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(cases)(
|
||||||
|
'handlers load the record via tx and evaluate it: %s on %s',
|
||||||
|
async (method, type, model, core) => {
|
||||||
|
const record = { id: 'record-1', colonyId: 'colony-1' };
|
||||||
|
tx[model].findUnique.mockResolvedValueOnce(record);
|
||||||
|
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
|
||||||
|
|
||||||
|
await run(method, type);
|
||||||
|
|
||||||
|
expect(tx[model].findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'record-1' },
|
||||||
|
});
|
||||||
|
expect(tx.colony.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'colony-1' },
|
||||||
|
select: { status: true },
|
||||||
|
});
|
||||||
|
expect(automation[core]).toHaveBeenCalledWith(tx, record);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(cases)(
|
||||||
|
'handlers load the record via tx and skip a missing one: %s on %s',
|
||||||
|
async (method, type, model, core) => {
|
||||||
|
await run(method, type);
|
||||||
|
|
||||||
|
expect(tx[model].findUnique).toHaveBeenCalledTimes(1);
|
||||||
|
expect(automation[core]).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(cases)(
|
||||||
|
'rule handlers write nothing for an ended colony: %s on %s',
|
||||||
|
async (method, type, model, core) => {
|
||||||
|
tx[model].findUnique.mockResolvedValueOnce({
|
||||||
|
id: 'record-1',
|
||||||
|
colonyId: 'colony-1',
|
||||||
|
});
|
||||||
|
tx.colony.findUnique.mockResolvedValueOnce({ status: 'DISSOLVED' });
|
||||||
|
|
||||||
|
await run(method, type);
|
||||||
|
|
||||||
|
expect(automation[core]).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(cases)(
|
||||||
|
'rule handlers write nothing for a colony that is gone: %s on %s',
|
||||||
|
async (method, type, model, core) => {
|
||||||
|
tx[model].findUnique.mockResolvedValueOnce({
|
||||||
|
id: 'record-1',
|
||||||
|
colonyId: 'colony-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
await run(method, type);
|
||||||
|
|
||||||
|
expect(automation[core]).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('a corrected inspection only follows open suggestions', async () => {
|
||||||
|
tx.inspection.findUnique.mockResolvedValueOnce({
|
||||||
|
id: 'record-1',
|
||||||
|
colonyId: 'colony-1',
|
||||||
|
});
|
||||||
|
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
|
||||||
|
|
||||||
|
await run('inspectionRules', 'InspectionCorrected' as never);
|
||||||
|
|
||||||
|
expect(automation.evaluateInspectionCorrectionIn).toHaveBeenCalledTimes(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(automation.evaluateInspectionIn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets an evaluation failure propagate, so the delivery is retried', async () => {
|
||||||
|
tx.inspection.findUnique.mockResolvedValueOnce({
|
||||||
|
id: 'record-1',
|
||||||
|
colonyId: 'colony-1',
|
||||||
|
});
|
||||||
|
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
|
||||||
|
automation.evaluateInspectionIn.mockRejectedValue(
|
||||||
|
new Error('notifications down'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
run('inspectionRules', 'InspectionRecorded'),
|
||||||
|
).rejects.toThrow('notifications down');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('colony-health', () => {
|
||||||
|
const from = { status: 'ACTIVE', healthStatus: 'AVERAGE' } as const;
|
||||||
|
const to = { status: 'ACTIVE', healthStatus: 'CRITICAL' } as const;
|
||||||
|
const event = () =>
|
||||||
|
envelope('ColonyStatusChanged', 'colony-1', { from, to });
|
||||||
|
|
||||||
|
it('hands the transition and the event to evaluateColonyHealthIn', async () => {
|
||||||
|
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
|
||||||
|
|
||||||
|
await handlers.colonyHealth(event(), asTx());
|
||||||
|
|
||||||
|
expect(tx.colony.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'colony-1' },
|
||||||
|
select: { status: true },
|
||||||
|
});
|
||||||
|
// The rule decides on the snapshots of the event, not on the colony
|
||||||
|
// as it is now: a later change must not hide the transition. The
|
||||||
|
// event is the anchor of the suggestion.
|
||||||
|
expect(automation.evaluateColonyHealthIn).toHaveBeenCalledWith(
|
||||||
|
tx,
|
||||||
|
'colony-1',
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
{ id: 'e1', occurredAt: new Date('2026-10-13T08:00:00.000Z') },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a colony that no longer exists', async () => {
|
||||||
|
await handlers.colonyHealth(event(), asTx());
|
||||||
|
|
||||||
|
expect(automation.evaluateColonyHealthIn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a colony that ended', async () => {
|
||||||
|
tx.colony.findUnique.mockResolvedValueOnce({ status: 'SOLD' });
|
||||||
|
|
||||||
|
await handlers.colonyHealth(event(), asTx());
|
||||||
|
|
||||||
|
expect(automation.evaluateColonyHealthIn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('raw-material-low', () => {
|
||||||
|
const stockChanged = (rawMaterialKinds: ('BEESWAX' | 'PROPOLIS')[]) =>
|
||||||
|
envelope('StockChanged', 'movement-1', {
|
||||||
|
bottlingBatchIds: [],
|
||||||
|
productIds: [],
|
||||||
|
rawMaterialKinds,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('evaluates every touched kind once with what is left of it', async () => {
|
||||||
|
stock.remainingByRawMaterialKind.mockImplementation((kind: string) =>
|
||||||
|
Promise.resolve(kind === 'BEESWAX' ? 400 : 75),
|
||||||
|
);
|
||||||
|
|
||||||
|
await handlers.rawMaterialLow(
|
||||||
|
stockChanged(['BEESWAX', 'PROPOLIS', 'BEESWAX']),
|
||||||
|
asTx(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(stock.remainingByRawMaterialKind).toHaveBeenCalledTimes(2);
|
||||||
|
expect(automation.evaluateRawMaterialLowIn.mock.calls).toEqual([
|
||||||
|
[tx, 'BEESWAX', 400],
|
||||||
|
[tx, 'PROPOLIS', 75],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing for a change without raw material', async () => {
|
||||||
|
await handlers.rawMaterialLow(stockChanged([]), asTx());
|
||||||
|
|
||||||
|
expect(stock.remainingByRawMaterialKind).not.toHaveBeenCalled();
|
||||||
|
expect(automation.evaluateRawMaterialLowIn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
|
||||||
|
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
|
||||||
|
import { isLivingColonyStatus } from '../colonies/colony-status';
|
||||||
|
import { Prisma } from '../../generated/prisma';
|
||||||
|
import { StockService } from '../stock/stock.service';
|
||||||
|
import { AutomationService } from './automation.service';
|
||||||
|
|
||||||
|
type Tx = Prisma.TransactionClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates the automation rules on domain events. Pure DB writer: it reads
|
||||||
|
* the record the event is about through the delivery transaction and lets
|
||||||
|
* AutomationService write suggestion, notification and audit entries in it.
|
||||||
|
* A record deleted before the event was dispatched, or of a colony that
|
||||||
|
* ended, is skipped. Errors propagate, so the delivery is rolled back as a
|
||||||
|
* whole and retried.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AutomationEventHandlers {
|
||||||
|
constructor(
|
||||||
|
private readonly automation: AutomationService,
|
||||||
|
private readonly stock: StockService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fast path only: proposeForRuleIn re-checks the colony under its lock.
|
||||||
|
* Whether the colony still lives (a colony that ended takes no proposals).
|
||||||
|
*/
|
||||||
|
private async isLivingColonyIn(tx: Tx, colonyId: string): Promise<boolean> {
|
||||||
|
const colony = await tx.colony.findUnique({
|
||||||
|
where: { id: colonyId },
|
||||||
|
select: { status: true },
|
||||||
|
});
|
||||||
|
return colony !== null && isLivingColonyStatus(colony.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A correction proposes nothing new; it only lets an open suggestion
|
||||||
|
// follow the corrected values.
|
||||||
|
@OnDomainEvent(
|
||||||
|
['InspectionRecorded', 'InspectionCorrected'],
|
||||||
|
'automation.inspection-rules',
|
||||||
|
)
|
||||||
|
async inspectionRules(
|
||||||
|
event: AnyDomainEventEnvelope<'InspectionRecorded' | 'InspectionCorrected'>,
|
||||||
|
tx: Tx,
|
||||||
|
): Promise<void> {
|
||||||
|
const inspection = await tx.inspection.findUnique({
|
||||||
|
where: { id: event.aggregateId },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
inspection === null ||
|
||||||
|
!(await this.isLivingColonyIn(tx, inspection.colonyId))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.type === 'InspectionRecorded') {
|
||||||
|
await this.automation.evaluateInspectionIn(tx, inspection);
|
||||||
|
} else {
|
||||||
|
await this.automation.evaluateInspectionCorrectionIn(tx, inspection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A correction can lift a count over the threshold, so it is evaluated
|
||||||
|
// like a new measurement; the open-suggestion check keeps it to one.
|
||||||
|
@OnDomainEvent(
|
||||||
|
['VarroaMeasured', 'VarroaCorrected'],
|
||||||
|
'automation.varroa-threshold',
|
||||||
|
)
|
||||||
|
async varroaThreshold(
|
||||||
|
event: AnyDomainEventEnvelope<'VarroaMeasured' | 'VarroaCorrected'>,
|
||||||
|
tx: Tx,
|
||||||
|
): Promise<void> {
|
||||||
|
const measurement = await tx.varroaMeasurement.findUnique({
|
||||||
|
where: { id: event.aggregateId },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
measurement === null ||
|
||||||
|
!(await this.isLivingColonyIn(tx, measurement.colonyId))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.automation.evaluateVarroaMeasurementIn(tx, measurement);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnDomainEvent('TreatmentRecorded', 'automation.treatment-follow-up')
|
||||||
|
async treatmentFollowUp(
|
||||||
|
event: AnyDomainEventEnvelope<'TreatmentRecorded'>,
|
||||||
|
tx: Tx,
|
||||||
|
): Promise<void> {
|
||||||
|
const treatment = await tx.treatment.findUnique({
|
||||||
|
where: { id: event.aggregateId },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
treatment === null ||
|
||||||
|
!(await this.isLivingColonyIn(tx, treatment.colonyId))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.automation.evaluateTreatmentIn(tx, treatment);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnDomainEvent('FeedingRecorded', 'automation.feeding-follow-up')
|
||||||
|
async feedingFollowUp(
|
||||||
|
event: AnyDomainEventEnvelope<'FeedingRecorded'>,
|
||||||
|
tx: Tx,
|
||||||
|
): Promise<void> {
|
||||||
|
const feeding = await tx.feeding.findUnique({
|
||||||
|
where: { id: event.aggregateId },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
feeding === null ||
|
||||||
|
!(await this.isLivingColonyIn(tx, feeding.colonyId))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.automation.evaluateFeedingIn(tx, feeding);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decides on the snapshots the event carries, not on the colony as it is
|
||||||
|
// now: a later edit must not hide the transition into CRITICAL.
|
||||||
|
@OnDomainEvent('ColonyStatusChanged', 'automation.colony-health')
|
||||||
|
async colonyHealth(
|
||||||
|
event: AnyDomainEventEnvelope<'ColonyStatusChanged'>,
|
||||||
|
tx: Tx,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!(await this.isLivingColonyIn(tx, event.aggregateId))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.automation.evaluateColonyHealthIn(
|
||||||
|
tx,
|
||||||
|
event.aggregateId,
|
||||||
|
event.payload.from,
|
||||||
|
event.payload.to,
|
||||||
|
{ id: event.id, occurredAt: event.occurredAt },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per kind the change touched, what is left of it across all its lots.
|
||||||
|
@OnDomainEvent('StockChanged', 'automation.raw-material-low')
|
||||||
|
async rawMaterialLow(
|
||||||
|
event: AnyDomainEventEnvelope<'StockChanged'>,
|
||||||
|
tx: Tx,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const kind of new Set(event.payload.rawMaterialKinds)) {
|
||||||
|
const remaining = await this.stock.remainingByRawMaterialKind(kind);
|
||||||
|
await this.automation.evaluateRawMaterialLowIn(tx, kind, remaining);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { StockModule } from '../stock/stock.module';
|
||||||
|
import { SuggestionsModule } from '../suggestions/suggestions.module';
|
||||||
import { AutomationController } from './automation.controller';
|
import { AutomationController } from './automation.controller';
|
||||||
|
import { AutomationEventHandlers } from './automation.handlers';
|
||||||
import { AutomationService } from './automation.service';
|
import { AutomationService } from './automation.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automation rules and the handlers that evaluate them on domain events.
|
||||||
|
* The rules propose through SuggestionsService; the task follows when the
|
||||||
|
* beekeeper accepts (TasksService, with calendar entry and task.created
|
||||||
|
* webhook), so neither a TasksModule nor a WebhooksService is needed here.
|
||||||
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [NotificationsModule],
|
imports: [SuggestionsModule, StockModule],
|
||||||
controllers: [AutomationController],
|
controllers: [AutomationController],
|
||||||
providers: [AutomationService],
|
providers: [AutomationService, AutomationEventHandlers],
|
||||||
exports: [AutomationService],
|
exports: [AutomationService],
|
||||||
})
|
})
|
||||||
export class AutomationModule {}
|
export class AutomationModule {}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,40 @@
|
|||||||
|
import { bool, intIn, makeParser } from './parse-helpers';
|
||||||
|
import type { ChainConfig } from './rule-config.types';
|
||||||
|
|
||||||
|
const MAX_DUE_IN_DAYS = 365;
|
||||||
|
|
||||||
|
/** Follow-up chains (queen cells, queen acceptance): one check some days later. */
|
||||||
|
export function chainParser(trigger: string, defaults: ChainConfig) {
|
||||||
|
return makeParser<ChainConfig>(trigger, defaults, (scope) => ({
|
||||||
|
dueInDays: intIn(
|
||||||
|
scope,
|
||||||
|
'dueInDays',
|
||||||
|
defaults.dueInDays,
|
||||||
|
0,
|
||||||
|
MAX_DUE_IN_DAYS,
|
||||||
|
),
|
||||||
|
notify: bool(scope, 'notify', defaults.notify),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_CHAIN_CONFIG: ChainConfig = {
|
||||||
|
dueInDays: 7,
|
||||||
|
notify: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_WITHDRAWAL_ENDED_CONFIG = { notify: true };
|
||||||
|
|
||||||
|
export const withdrawalEndedParser = makeParser<{ notify: boolean }>(
|
||||||
|
'WITHDRAWAL_ENDED',
|
||||||
|
DEFAULT_WITHDRAWAL_ENDED_CONFIG,
|
||||||
|
(scope) => ({
|
||||||
|
notify: bool(scope, 'notify', DEFAULT_WITHDRAWAL_ENDED_CONFIG.notify),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The cleanup rule has no setting; it is only switched on or off. */
|
||||||
|
export const colonyEndedCleanupParser = makeParser<Record<string, never>>(
|
||||||
|
'COLONY_ENDED_CLEANUP',
|
||||||
|
{},
|
||||||
|
() => ({}),
|
||||||
|
);
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { AutomationTriggerType } from '../../../generated/prisma';
|
||||||
|
import { RULE_CONFIG_PARSERS } from './rule-config.registry';
|
||||||
|
|
||||||
|
export interface RuleDefaults {
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
actionTemplate: string;
|
||||||
|
/** Rules without a sensible built-in setting start switched off. */
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Texts of the rules that create a task; the new rules have none yet. */
|
||||||
|
const ACTION_TEMPLATES: Record<AutomationTriggerType, string> = {
|
||||||
|
QUEENLESS_NO_EGGS:
|
||||||
|
'Kontrolle empfohlen: Königin nicht gesehen und keine Stifte vorhanden.',
|
||||||
|
VARROA_THRESHOLD: 'Behandlung prüfen: Varroa-Schwellenwert überschritten.',
|
||||||
|
FEED_STATUS_CRITICAL: 'Fütterung erforderlich: Futterstatus kritisch.',
|
||||||
|
SWARM_MOOD_HIGH:
|
||||||
|
'Schwarmkontrolle dringend: hohe Schwarmstimmung festgestellt.',
|
||||||
|
COLONY_HEALTH_CRITICAL: 'Kontrolle erforderlich: Gesundheitsstatus kritisch.',
|
||||||
|
STOCK_ITEM_LOW: 'Nachbestellung prüfen: Lagerartikel unter Mindestbestand.',
|
||||||
|
// Not task-generating rules like the ones above - `active` here toggles
|
||||||
|
// whether HoneyBatchesService/BottlingService auto-fill the batch/lot
|
||||||
|
// number from `config.format` instead of requiring manual entry.
|
||||||
|
HONEY_BATCH_NUMBER_FORMAT: '',
|
||||||
|
BOTTLING_LOT_NUMBER_FORMAT: '',
|
||||||
|
RAW_MATERIAL_LOT_NUMBER_FORMAT: '',
|
||||||
|
PRODUCTION_BATCH_NUMBER_FORMAT: '',
|
||||||
|
RAW_MATERIAL_LOW: 'Rohstoff unter Mindestmenge: Nachschub einplanen.',
|
||||||
|
TREATMENT_FOLLOW_UP: 'Behandlungserfolg kontrollieren',
|
||||||
|
FEEDING_FOLLOW_UP: 'Futterabnahme und Futtervorrat kontrollieren',
|
||||||
|
INSPECTION_INTERVAL: '',
|
||||||
|
QUEEN_CELLS_SWARM_CHECK: '',
|
||||||
|
QUEEN_ACCEPTANCE_CHECK: '',
|
||||||
|
SPLIT_SCHEDULE: '',
|
||||||
|
COLONY_ENDED_CLEANUP: '',
|
||||||
|
WITHDRAWAL_ENDED: '',
|
||||||
|
HONEY_WATER_CONTENT: '',
|
||||||
|
SEASON_AUTOPILOT: '',
|
||||||
|
PHENOLOGY: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// - RAW_MATERIAL_LOW: raw material lots carry no minimum of their own, so
|
||||||
|
// the minimum per kind (config.minGrams) lives on the rule - and without
|
||||||
|
// one there is nothing to compare, hence off by default.
|
||||||
|
// - Follow-up checks: how many days after a treatment or feeding a check
|
||||||
|
// makes sense depends on the product and the season, so there is no
|
||||||
|
// built-in interval - the beekeeper switches the rule on and sets
|
||||||
|
// config.dueInDays.
|
||||||
|
const INACTIVE_BY_DEFAULT: ReadonlySet<AutomationTriggerType> = new Set([
|
||||||
|
'RAW_MATERIAL_LOW',
|
||||||
|
'TREATMENT_FOLLOW_UP',
|
||||||
|
'FEEDING_FOLLOW_UP',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function defaultsOf(trigger: AutomationTriggerType): RuleDefaults {
|
||||||
|
return {
|
||||||
|
config: structuredClone(RULE_CONFIG_PARSERS[trigger].defaults) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>,
|
||||||
|
actionTemplate: ACTION_TEMPLATES[trigger],
|
||||||
|
...(INACTIVE_BY_DEFAULT.has(trigger) ? { active: false } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The rule rows created on first use; the config is the parser's default. */
|
||||||
|
export const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> =
|
||||||
|
Object.fromEntries(
|
||||||
|
(Object.keys(ACTION_TEMPLATES) as AutomationTriggerType[]).map(
|
||||||
|
(trigger) => [trigger, defaultsOf(trigger)],
|
||||||
|
),
|
||||||
|
) as Record<AutomationTriggerType, RuleDefaults>;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export * from './rule-config.types';
|
||||||
|
export {
|
||||||
|
RULE_CONFIG_PARSERS,
|
||||||
|
ruleConfigOf,
|
||||||
|
validateRuleConfig,
|
||||||
|
} from './rule-config.registry';
|
||||||
|
export { DEFAULT_RULES, type RuleDefaults } from './default-rules';
|
||||||
|
export { lockRuleIn, readRule, type ActiveRule } from './rule-config.reader';
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { SeasonSegmentKey } from '../../season/season-types';
|
||||||
|
import { childScope, intIn, makeParser } from './parse-helpers';
|
||||||
|
import type { InspectionIntervalConfig } from './rule-config.types';
|
||||||
|
|
||||||
|
type IntervalSegment = Exclude<SeasonSegmentKey, 'WINTERRUHE'>;
|
||||||
|
|
||||||
|
const MIN_INTERVAL_DAYS = 7;
|
||||||
|
const MAX_INTERVAL_DAYS = 35;
|
||||||
|
/** In the swarm season a colony is looked at weekly, at the latest every 9 days. */
|
||||||
|
const MAX_SWARM_INTERVAL_DAYS = 9;
|
||||||
|
|
||||||
|
export const DEFAULT_INSPECTION_INTERVAL_CONFIG: InspectionIntervalConfig = {
|
||||||
|
intervalDays: {
|
||||||
|
FRUEHJAHR: 14,
|
||||||
|
SCHWARMZEIT: 7,
|
||||||
|
SOMMER: 14,
|
||||||
|
SPAETSOMMER: 21,
|
||||||
|
HERBST: 21,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const SEGMENTS = Object.keys(
|
||||||
|
DEFAULT_INSPECTION_INTERVAL_CONFIG.intervalDays,
|
||||||
|
) as IntervalSegment[];
|
||||||
|
|
||||||
|
export const inspectionIntervalParser = makeParser<InspectionIntervalConfig>(
|
||||||
|
'INSPECTION_INTERVAL',
|
||||||
|
DEFAULT_INSPECTION_INTERVAL_CONFIG,
|
||||||
|
(scope) => {
|
||||||
|
const days = childScope(scope, 'intervalDays');
|
||||||
|
const defaults = DEFAULT_INSPECTION_INTERVAL_CONFIG.intervalDays;
|
||||||
|
const intervalDays = { ...defaults };
|
||||||
|
for (const segment of SEGMENTS) {
|
||||||
|
intervalDays[segment] = intIn(
|
||||||
|
days,
|
||||||
|
segment,
|
||||||
|
defaults[segment],
|
||||||
|
MIN_INTERVAL_DAYS,
|
||||||
|
segment === 'SCHWARMZEIT' ? MAX_SWARM_INTERVAL_DAYS : MAX_INTERVAL_DAYS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { intervalDays };
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import type { RuleConfigIssue, RuleConfigParser } from './rule-config.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a value is read from: the object, its field path (for messages) and
|
||||||
|
* the list the issues go to. Helpers never throw - an invalid or missing key
|
||||||
|
* gives the fallback, and an invalid one that is present is reported.
|
||||||
|
*/
|
||||||
|
export interface Scope {
|
||||||
|
readonly raw: Readonly<Record<string, unknown>>;
|
||||||
|
readonly path: string;
|
||||||
|
readonly issues: RuleConfigIssue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_VALUES: Readonly<Record<string, unknown>> = Object.freeze({});
|
||||||
|
|
||||||
|
export function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rootScope(json: unknown, issues: RuleConfigIssue[]): Scope {
|
||||||
|
return { raw: asRecord(json) ?? NO_VALUES, path: '', issues };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fieldPath(scope: Scope, key: string): string {
|
||||||
|
return scope.path === '' ? key : `${scope.path}.${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function report(scope: Scope, key: string, message: string): void {
|
||||||
|
scope.issues.push({ field: fieldPath(scope, key), message });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key counts as given when it is present - an explicit null too, which the
|
||||||
|
* readers then report as invalid instead of silently using the default.
|
||||||
|
*/
|
||||||
|
export function has(scope: Scope, key: string): boolean {
|
||||||
|
if (!Object.hasOwn(scope.raw, key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return scope.raw[key] !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An explicit null: on an optional key it clears the value. */
|
||||||
|
export function isCleared(scope: Scope, key: string): boolean {
|
||||||
|
return Object.hasOwn(scope.raw, key) && scope.raw[key] === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The nested object under `key`; a non-object is reported and read as empty. */
|
||||||
|
export function childScope(scope: Scope, key: string): Scope {
|
||||||
|
const path = fieldPath(scope, key);
|
||||||
|
if (!has(scope, key)) {
|
||||||
|
return { raw: NO_VALUES, path, issues: scope.issues };
|
||||||
|
}
|
||||||
|
const nested = asRecord(scope.raw[key]);
|
||||||
|
if (nested === null) {
|
||||||
|
report(scope, key, 'Bitte die Angaben als Gruppe senden.');
|
||||||
|
return { raw: NO_VALUES, path, issues: scope.issues };
|
||||||
|
}
|
||||||
|
return { raw: nested, path, issues: scope.issues };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs `read`; if it reported anything, `fallback()` replaces its result. */
|
||||||
|
export function orFallback<T>(
|
||||||
|
scope: Scope,
|
||||||
|
read: () => T,
|
||||||
|
fallback: () => T,
|
||||||
|
): T {
|
||||||
|
const before = scope.issues.length;
|
||||||
|
const value = read();
|
||||||
|
return scope.issues.length > before ? fallback() : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberFormat = new Intl.NumberFormat('de-DE', {
|
||||||
|
maximumFractionDigits: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function formatDe(value: number): string {
|
||||||
|
return numberFormat.format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function intIn(
|
||||||
|
scope: Scope,
|
||||||
|
key: string,
|
||||||
|
fallback: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
): number {
|
||||||
|
if (!has(scope, key)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const value = scope.raw[key];
|
||||||
|
if (
|
||||||
|
typeof value === 'number' &&
|
||||||
|
Number.isInteger(value) &&
|
||||||
|
value >= min &&
|
||||||
|
value <= max
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
report(
|
||||||
|
scope,
|
||||||
|
key,
|
||||||
|
`Bitte eine ganze Zahl zwischen ${formatDe(min)} und ${formatDe(max)} wählen.`,
|
||||||
|
);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Like intIn, but an absent key stays undefined. */
|
||||||
|
export function optionalIntIn(
|
||||||
|
scope: Scope,
|
||||||
|
key: string,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
): number | undefined {
|
||||||
|
if (!has(scope, key) || isCleared(scope, key)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const value = intIn(scope, key, Number.NaN, min, max);
|
||||||
|
return Number.isNaN(value) ? undefined : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberOptions {
|
||||||
|
/** The lower limit itself is not allowed (min < x). */
|
||||||
|
exclusiveMin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function numberIn(
|
||||||
|
scope: Scope,
|
||||||
|
key: string,
|
||||||
|
fallback: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
options: NumberOptions = {},
|
||||||
|
): number {
|
||||||
|
if (!has(scope, key)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const value = scope.raw[key];
|
||||||
|
if (
|
||||||
|
typeof value === 'number' &&
|
||||||
|
Number.isFinite(value) &&
|
||||||
|
(options.exclusiveMin ? value > min : value >= min) &&
|
||||||
|
value <= max
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const lower = options.exclusiveMin
|
||||||
|
? `größer als ${formatDe(min)}`
|
||||||
|
: `mindestens ${formatDe(min)}`;
|
||||||
|
report(
|
||||||
|
scope,
|
||||||
|
key,
|
||||||
|
Number.isFinite(max)
|
||||||
|
? `Bitte eine Zahl ${lower} und höchstens ${formatDe(max)} angeben.`
|
||||||
|
: `Bitte eine Zahl ${lower} angeben.`,
|
||||||
|
);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Like numberIn, but an absent key stays undefined. */
|
||||||
|
export function optionalNumberIn(
|
||||||
|
scope: Scope,
|
||||||
|
key: string,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
options: NumberOptions = {},
|
||||||
|
): number | undefined {
|
||||||
|
if (!has(scope, key) || isCleared(scope, key)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const value = numberIn(scope, key, Number.NaN, min, max, options);
|
||||||
|
return Number.isNaN(value) ? undefined : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bool(scope: Scope, key: string, fallback: boolean): boolean {
|
||||||
|
if (!has(scope, key)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const value = scope.raw[key];
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
report(scope, key, 'Bitte Ja oder Nein wählen.');
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stringIn(
|
||||||
|
scope: Scope,
|
||||||
|
key: string,
|
||||||
|
fallback: string,
|
||||||
|
minLength: number,
|
||||||
|
maxLength: number,
|
||||||
|
pattern?: RegExp,
|
||||||
|
): string {
|
||||||
|
if (!has(scope, key)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const value = scope.raw[key];
|
||||||
|
if (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
value.length >= minLength &&
|
||||||
|
value.length <= maxLength &&
|
||||||
|
(pattern === undefined || pattern.test(value))
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
report(
|
||||||
|
scope,
|
||||||
|
key,
|
||||||
|
`Bitte einen Text mit ${minLength} bis ${maxLength} Zeichen angeben.`,
|
||||||
|
);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTH_DAY = /^(\d{2})-(\d{2})$/;
|
||||||
|
|
||||||
|
/** 'MM-DD' that exists in a leap year (02-29 is allowed). */
|
||||||
|
export function isMonthDay(value: unknown): value is string {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const match = MONTH_DAY.exec(value);
|
||||||
|
if (!match) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const month = Number(match[1]);
|
||||||
|
const day = Number(match[2]);
|
||||||
|
const date = new Date(Date.UTC(2024, month - 1, day));
|
||||||
|
return date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function monthDay(scope: Scope, key: string, fallback: string): string {
|
||||||
|
if (!has(scope, key)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const value = scope.raw[key];
|
||||||
|
if (isMonthDay(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
report(scope, key, 'Bitte ein Datum als MM-TT angeben, zum Beispiel 03-20.');
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function months(
|
||||||
|
scope: Scope,
|
||||||
|
key: string,
|
||||||
|
fallback: readonly number[],
|
||||||
|
): number[] {
|
||||||
|
if (!has(scope, key)) {
|
||||||
|
return [...fallback];
|
||||||
|
}
|
||||||
|
const value = scope.raw[key];
|
||||||
|
if (
|
||||||
|
Array.isArray(value) &&
|
||||||
|
value.every(
|
||||||
|
(month): month is number =>
|
||||||
|
typeof month === 'number' &&
|
||||||
|
Number.isInteger(month) &&
|
||||||
|
month >= 1 &&
|
||||||
|
month <= 12,
|
||||||
|
) &&
|
||||||
|
new Set(value).size === value.length
|
||||||
|
) {
|
||||||
|
return [...value];
|
||||||
|
}
|
||||||
|
report(scope, key, 'Bitte Monate von 1 bis 12 angeben, jeden nur einmal.');
|
||||||
|
return [...fallback];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enumValue<T extends string>(
|
||||||
|
scope: Scope,
|
||||||
|
key: string,
|
||||||
|
fallback: T,
|
||||||
|
allowed: readonly T[],
|
||||||
|
): T {
|
||||||
|
if (!has(scope, key)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const value = scope.raw[key];
|
||||||
|
if (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
(allowed as readonly string[]).includes(value)
|
||||||
|
) {
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
|
report(scope, key, 'Bitte einen gültigen Wert wählen.');
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enumList<T extends string>(
|
||||||
|
scope: Scope,
|
||||||
|
key: string,
|
||||||
|
fallback: readonly T[],
|
||||||
|
allowed: readonly T[],
|
||||||
|
): T[] {
|
||||||
|
if (!has(scope, key)) {
|
||||||
|
return [...fallback];
|
||||||
|
}
|
||||||
|
const value = scope.raw[key];
|
||||||
|
if (
|
||||||
|
Array.isArray(value) &&
|
||||||
|
value.every(
|
||||||
|
(entry): entry is T =>
|
||||||
|
typeof entry === 'string' &&
|
||||||
|
(allowed as readonly string[]).includes(entry),
|
||||||
|
) &&
|
||||||
|
new Set(value).size === value.length
|
||||||
|
) {
|
||||||
|
return [...value];
|
||||||
|
}
|
||||||
|
report(scope, key, 'Bitte nur gültige Werte wählen, jeden nur einmal.');
|
||||||
|
return [...fallback];
|
||||||
|
}
|
||||||
|
|
||||||
|
const logger = new Logger('RuleConfig');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the parser of one rule from its reader. The reader turns the scope
|
||||||
|
* into a complete config and reports every invalid key; `parse` logs those
|
||||||
|
* (trigger and field, never the value) and returns the config, `validate`
|
||||||
|
* returns them.
|
||||||
|
*/
|
||||||
|
export function makeParser<T>(
|
||||||
|
trigger: string,
|
||||||
|
defaults: T,
|
||||||
|
read: (scope: Scope) => T,
|
||||||
|
): RuleConfigParser<T> {
|
||||||
|
return {
|
||||||
|
defaults,
|
||||||
|
parse(json) {
|
||||||
|
const issues: RuleConfigIssue[] = [];
|
||||||
|
const value = read(rootScope(json, issues));
|
||||||
|
for (const issue of issues) {
|
||||||
|
logger.warn(
|
||||||
|
`Invalid setting ${trigger}.${issue.field}; using the default`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
validate(json) {
|
||||||
|
const issues: RuleConfigIssue[] = [];
|
||||||
|
read(rootScope(json, issues));
|
||||||
|
return issues;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import {
|
||||||
|
PHENOLOGY_PHASES,
|
||||||
|
type PhenologyPhaseKey,
|
||||||
|
} from '../../season/season-types';
|
||||||
|
import {
|
||||||
|
bool,
|
||||||
|
childScope,
|
||||||
|
enumList,
|
||||||
|
enumValue,
|
||||||
|
has,
|
||||||
|
isCleared,
|
||||||
|
makeParser,
|
||||||
|
monthDay,
|
||||||
|
numberIn,
|
||||||
|
report,
|
||||||
|
type Scope,
|
||||||
|
} from './parse-helpers';
|
||||||
|
import type { PhenologyConfig } from './rule-config.types';
|
||||||
|
|
||||||
|
/** Upper limit of a growing-degree-day threshold; far above any phase. */
|
||||||
|
const MAX_GTS_THRESHOLD = 5000;
|
||||||
|
const MAX_GAP_SHARE = 0.5;
|
||||||
|
|
||||||
|
export const DEFAULT_PHENOLOGY_CONFIG: PhenologyConfig = {
|
||||||
|
useGts: true,
|
||||||
|
gtsThresholds: { SALWEIDE: 200 },
|
||||||
|
calibrateFromReports: true,
|
||||||
|
calibratePhases: ['KIRSCHE', 'APFEL'],
|
||||||
|
calendarFallback: {
|
||||||
|
SALWEIDE: '03-20',
|
||||||
|
KIRSCHE: '04-15',
|
||||||
|
APFEL: '04-28',
|
||||||
|
HOLUNDER: '05-30',
|
||||||
|
LINDE: '06-20',
|
||||||
|
TRACHTENDE: '08-01',
|
||||||
|
HERBST: '09-15',
|
||||||
|
WINTER: '11-15',
|
||||||
|
},
|
||||||
|
swarmSeason: { from: 'KIRSCHE', until: 'LINDE' },
|
||||||
|
maxGapShare: 0.1,
|
||||||
|
};
|
||||||
|
|
||||||
|
function readThresholds(scope: Scope): PhenologyConfig['gtsThresholds'] {
|
||||||
|
// Without the group the defaults apply; with it, only the phases given
|
||||||
|
// have a threshold - leaving one out removes it.
|
||||||
|
if (!has(scope, 'gtsThresholds')) {
|
||||||
|
return { ...DEFAULT_PHENOLOGY_CONFIG.gtsThresholds };
|
||||||
|
}
|
||||||
|
const thresholds = childScope(scope, 'gtsThresholds');
|
||||||
|
const result: PhenologyConfig['gtsThresholds'] = {};
|
||||||
|
for (const phase of PHENOLOGY_PHASES) {
|
||||||
|
if (isCleared(thresholds, phase)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const value = numberIn(
|
||||||
|
thresholds,
|
||||||
|
phase,
|
||||||
|
Number.NaN,
|
||||||
|
0,
|
||||||
|
MAX_GTS_THRESHOLD,
|
||||||
|
{ exclusiveMin: true },
|
||||||
|
);
|
||||||
|
if (!Number.isNaN(value)) {
|
||||||
|
result[phase] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calendar dates of the phases must not run backwards in phase order.
|
||||||
|
* One violation sends the whole map back to the defaults, so it stays
|
||||||
|
* consistent; the issue names a key the caller actually sent.
|
||||||
|
*/
|
||||||
|
function readCalendar(scope: Scope): PhenologyConfig['calendarFallback'] {
|
||||||
|
const calendar = childScope(scope, 'calendarFallback');
|
||||||
|
const defaults = DEFAULT_PHENOLOGY_CONFIG.calendarFallback;
|
||||||
|
const before = scope.issues.length;
|
||||||
|
const result = { ...defaults };
|
||||||
|
for (const phase of PHENOLOGY_PHASES) {
|
||||||
|
result[phase] = monthDay(calendar, phase, defaults[phase]);
|
||||||
|
}
|
||||||
|
if (scope.issues.length > before) {
|
||||||
|
return { ...defaults };
|
||||||
|
}
|
||||||
|
for (let index = 1; index < PHENOLOGY_PHASES.length; index += 1) {
|
||||||
|
const previous = PHENOLOGY_PHASES[index - 1];
|
||||||
|
const phase = PHENOLOGY_PHASES[index];
|
||||||
|
if (result[phase] < result[previous]) {
|
||||||
|
const culprit = has(calendar, phase) ? phase : previous;
|
||||||
|
report(
|
||||||
|
calendar,
|
||||||
|
culprit,
|
||||||
|
`Das Datum darf nicht vor dem der vorherigen Phase liegen (${previous}).`,
|
||||||
|
);
|
||||||
|
return { ...defaults };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSwarmSeason(scope: Scope): PhenologyConfig['swarmSeason'] {
|
||||||
|
const season = childScope(scope, 'swarmSeason');
|
||||||
|
const defaults = DEFAULT_PHENOLOGY_CONFIG.swarmSeason;
|
||||||
|
const from: PhenologyPhaseKey = enumValue(
|
||||||
|
season,
|
||||||
|
'from',
|
||||||
|
defaults.from,
|
||||||
|
PHENOLOGY_PHASES,
|
||||||
|
);
|
||||||
|
const until: PhenologyPhaseKey = enumValue(
|
||||||
|
season,
|
||||||
|
'until',
|
||||||
|
defaults.until,
|
||||||
|
PHENOLOGY_PHASES,
|
||||||
|
);
|
||||||
|
if (PHENOLOGY_PHASES.indexOf(from) >= PHENOLOGY_PHASES.indexOf(until)) {
|
||||||
|
report(
|
||||||
|
season,
|
||||||
|
has(season, 'from') ? 'from' : 'until',
|
||||||
|
'Der Beginn der Schwarmzeit muss vor ihrem Ende liegen.',
|
||||||
|
);
|
||||||
|
return { ...defaults };
|
||||||
|
}
|
||||||
|
return { from, until };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const phenologyParser = makeParser<PhenologyConfig>(
|
||||||
|
'PHENOLOGY',
|
||||||
|
DEFAULT_PHENOLOGY_CONFIG,
|
||||||
|
(scope) => {
|
||||||
|
const d = DEFAULT_PHENOLOGY_CONFIG;
|
||||||
|
return {
|
||||||
|
useGts: bool(scope, 'useGts', d.useGts),
|
||||||
|
gtsThresholds: readThresholds(scope),
|
||||||
|
calibrateFromReports: bool(
|
||||||
|
scope,
|
||||||
|
'calibrateFromReports',
|
||||||
|
d.calibrateFromReports,
|
||||||
|
),
|
||||||
|
calibratePhases: enumList(
|
||||||
|
scope,
|
||||||
|
'calibratePhases',
|
||||||
|
d.calibratePhases,
|
||||||
|
PHENOLOGY_PHASES,
|
||||||
|
),
|
||||||
|
calendarFallback: readCalendar(scope),
|
||||||
|
swarmSeason: readSwarmSeason(scope),
|
||||||
|
maxGapShare: numberIn(
|
||||||
|
scope,
|
||||||
|
'maxGapShare',
|
||||||
|
d.maxGapShare,
|
||||||
|
0,
|
||||||
|
MAX_GAP_SHARE,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Prisma } from '../../../generated/prisma';
|
||||||
|
import type { AutomationTriggerType } from '../../../generated/prisma';
|
||||||
|
import { assertTransactionClient, type DbClient } from '../../prisma/db-client';
|
||||||
|
import { lockRowForUpdate } from '../../prisma/row-lock';
|
||||||
|
import { DEFAULT_RULES } from './default-rules';
|
||||||
|
import { ruleConfigOf } from './rule-config.registry';
|
||||||
|
import type { RuleConfigMap } from './rule-config.types';
|
||||||
|
|
||||||
|
export interface ActiveRule<K extends AutomationTriggerType> {
|
||||||
|
/** null while no row exists yet: the built-in setting applies. */
|
||||||
|
id: string | null;
|
||||||
|
active: boolean;
|
||||||
|
config: RuleConfigMap[K];
|
||||||
|
actionTemplate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuleRow {
|
||||||
|
id: string;
|
||||||
|
active: boolean;
|
||||||
|
config: Prisma.JsonValue;
|
||||||
|
actionTemplate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RULE_COLUMNS = {
|
||||||
|
id: true,
|
||||||
|
active: true,
|
||||||
|
config: true,
|
||||||
|
actionTemplate: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function toActiveRule<K extends AutomationTriggerType>(
|
||||||
|
trigger: K,
|
||||||
|
row: RuleRow | null,
|
||||||
|
): ActiveRule<K> {
|
||||||
|
if (row === null) {
|
||||||
|
const defaults = DEFAULT_RULES[trigger];
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
|
active: defaults.active ?? true,
|
||||||
|
config: ruleConfigOf(trigger, defaults.config),
|
||||||
|
actionTemplate: defaults.actionTemplate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
active: row.active,
|
||||||
|
config: ruleConfigOf(trigger, row.config),
|
||||||
|
actionTemplate: row.actionTemplate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rule as it is in force: its row, or the built-in setting while there is
|
||||||
|
* none. The config is parsed, so every key is valid.
|
||||||
|
*/
|
||||||
|
export async function readRule<K extends AutomationTriggerType>(
|
||||||
|
db: DbClient,
|
||||||
|
trigger: K,
|
||||||
|
): Promise<ActiveRule<K>> {
|
||||||
|
const row = await db.automationRule.findUnique({
|
||||||
|
where: { triggerType: trigger },
|
||||||
|
select: RULE_COLUMNS,
|
||||||
|
});
|
||||||
|
return toActiveRule(trigger, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the rule row if it is missing (without touching an existing one),
|
||||||
|
* locks it until the transaction ends and reads it. Serialises everything
|
||||||
|
* that decides "is there already an open task/suggestion" under the rule.
|
||||||
|
*/
|
||||||
|
export async function lockRuleIn<K extends AutomationTriggerType>(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
trigger: K,
|
||||||
|
): Promise<ActiveRule<K> & { id: string }> {
|
||||||
|
assertTransactionClient(tx, 'lockRuleIn');
|
||||||
|
const defaults = DEFAULT_RULES[trigger];
|
||||||
|
await tx.automationRule.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
triggerType: trigger,
|
||||||
|
active: defaults.active ?? true,
|
||||||
|
config: defaults.config as Prisma.InputJsonValue,
|
||||||
|
actionTemplate: defaults.actionTemplate,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
const { id } = await tx.automationRule.findUniqueOrThrow({
|
||||||
|
where: { triggerType: trigger },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
await lockRowForUpdate(tx, 'AutomationRule', id);
|
||||||
|
// Read after the lock: a concurrent edit of the rule is visible now.
|
||||||
|
const row = await tx.automationRule.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
select: RULE_COLUMNS,
|
||||||
|
});
|
||||||
|
return { ...toActiveRule(trigger, row), id };
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { AutomationTriggerType } from '../../../generated/prisma';
|
||||||
|
import {
|
||||||
|
chainParser,
|
||||||
|
colonyEndedCleanupParser,
|
||||||
|
DEFAULT_CHAIN_CONFIG,
|
||||||
|
withdrawalEndedParser,
|
||||||
|
} from './chain.config';
|
||||||
|
import { inspectionIntervalParser } from './inspection-interval.config';
|
||||||
|
import { phenologyParser } from './phenology.config';
|
||||||
|
import { seasonAutopilotParser } from './season-autopilot.config';
|
||||||
|
import { numberFormatParser, simpleParser } from './simple.config';
|
||||||
|
import { splitScheduleParser } from './split-schedule.config';
|
||||||
|
import { varroaThresholdParser } from './varroa-threshold.config';
|
||||||
|
import { waterContentParser } from './water-content.config';
|
||||||
|
import type {
|
||||||
|
RuleConfigIssue,
|
||||||
|
RuleConfigMap,
|
||||||
|
RuleConfigParser,
|
||||||
|
} from './rule-config.types';
|
||||||
|
|
||||||
|
/** The number-format rules keep the defaults they always had. */
|
||||||
|
const DEFAULT_BATCH_FORMAT = 'WH-{YYYY}-{SEQ:4}';
|
||||||
|
|
||||||
|
export const RULE_CONFIG_PARSERS: {
|
||||||
|
[K in AutomationTriggerType]: RuleConfigParser<RuleConfigMap[K]>;
|
||||||
|
} = {
|
||||||
|
VARROA_THRESHOLD: varroaThresholdParser,
|
||||||
|
INSPECTION_INTERVAL: inspectionIntervalParser,
|
||||||
|
QUEEN_CELLS_SWARM_CHECK: chainParser(
|
||||||
|
'QUEEN_CELLS_SWARM_CHECK',
|
||||||
|
DEFAULT_CHAIN_CONFIG,
|
||||||
|
),
|
||||||
|
QUEEN_ACCEPTANCE_CHECK: chainParser(
|
||||||
|
'QUEEN_ACCEPTANCE_CHECK',
|
||||||
|
DEFAULT_CHAIN_CONFIG,
|
||||||
|
),
|
||||||
|
SPLIT_SCHEDULE: splitScheduleParser,
|
||||||
|
COLONY_ENDED_CLEANUP: colonyEndedCleanupParser,
|
||||||
|
WITHDRAWAL_ENDED: withdrawalEndedParser,
|
||||||
|
HONEY_WATER_CONTENT: waterContentParser,
|
||||||
|
SEASON_AUTOPILOT: seasonAutopilotParser,
|
||||||
|
PHENOLOGY: phenologyParser,
|
||||||
|
HONEY_BATCH_NUMBER_FORMAT: numberFormatParser(
|
||||||
|
'HONEY_BATCH_NUMBER_FORMAT',
|
||||||
|
DEFAULT_BATCH_FORMAT,
|
||||||
|
),
|
||||||
|
BOTTLING_LOT_NUMBER_FORMAT: numberFormatParser(
|
||||||
|
'BOTTLING_LOT_NUMBER_FORMAT',
|
||||||
|
DEFAULT_BATCH_FORMAT,
|
||||||
|
),
|
||||||
|
// {ART} (WA/PR) gives wax and propolis a counter each.
|
||||||
|
RAW_MATERIAL_LOT_NUMBER_FORMAT: numberFormatParser(
|
||||||
|
'RAW_MATERIAL_LOT_NUMBER_FORMAT',
|
||||||
|
'{ART}-{YYYY}-{SEQ:4}',
|
||||||
|
),
|
||||||
|
PRODUCTION_BATCH_NUMBER_FORMAT: numberFormatParser(
|
||||||
|
'PRODUCTION_BATCH_NUMBER_FORMAT',
|
||||||
|
'HS-{YYYY}-{SEQ:4}',
|
||||||
|
),
|
||||||
|
QUEENLESS_NO_EGGS: simpleParser('QUEENLESS_NO_EGGS', { notify: true }),
|
||||||
|
FEED_STATUS_CRITICAL: simpleParser('FEED_STATUS_CRITICAL', {
|
||||||
|
notify: false,
|
||||||
|
}),
|
||||||
|
SWARM_MOOD_HIGH: simpleParser('SWARM_MOOD_HIGH', { notify: false }),
|
||||||
|
COLONY_HEALTH_CRITICAL: simpleParser('COLONY_HEALTH_CRITICAL', {
|
||||||
|
notify: true,
|
||||||
|
}),
|
||||||
|
STOCK_ITEM_LOW: simpleParser('STOCK_ITEM_LOW', { notify: false }),
|
||||||
|
RAW_MATERIAL_LOW: simpleParser('RAW_MATERIAL_LOW', {
|
||||||
|
notify: false,
|
||||||
|
withMinGrams: true,
|
||||||
|
}),
|
||||||
|
TREATMENT_FOLLOW_UP: simpleParser('TREATMENT_FOLLOW_UP', { notify: false }),
|
||||||
|
FEEDING_FOLLOW_UP: simpleParser('FEEDING_FOLLOW_UP', { notify: false }),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The effective setting of a rule: valid keys as stored, the rest default. */
|
||||||
|
export function ruleConfigOf<K extends AutomationTriggerType>(
|
||||||
|
trigger: K,
|
||||||
|
json: unknown,
|
||||||
|
): RuleConfigMap[K] {
|
||||||
|
return RULE_CONFIG_PARSERS[trigger].parse(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The invalid known keys of a config a client wants to save. */
|
||||||
|
export function validateRuleConfig(
|
||||||
|
trigger: AutomationTriggerType,
|
||||||
|
json: unknown,
|
||||||
|
): RuleConfigIssue[] {
|
||||||
|
return RULE_CONFIG_PARSERS[trigger].validate(json);
|
||||||
|
}
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { AutomationTriggerType } from '../../../generated/prisma';
|
||||||
|
import { DEFAULT_RULES } from './default-rules';
|
||||||
|
import { ruleConfigOf, RULE_CONFIG_PARSERS, validateRuleConfig } from './index';
|
||||||
|
|
||||||
|
const ALL_TRIGGERS = Object.values(AutomationTriggerType);
|
||||||
|
|
||||||
|
function fieldsOf(trigger: AutomationTriggerType, config: unknown): string[] {
|
||||||
|
return validateRuleConfig(trigger, config).map((issue) => issue.field);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('rule config', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
|
});
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
it('every trigger has defaults and a parser', () => {
|
||||||
|
for (const t of ALL_TRIGGERS) {
|
||||||
|
expect(DEFAULT_RULES[t]).toBeDefined();
|
||||||
|
expect(RULE_CONFIG_PARSERS[t]).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(ALL_TRIGGERS)(
|
||||||
|
'the default config of %s parses to itself and validates without issues',
|
||||||
|
(trigger) => {
|
||||||
|
const { config } = DEFAULT_RULES[trigger];
|
||||||
|
expect(RULE_CONFIG_PARSERS[trigger].parse(config)).toEqual(config);
|
||||||
|
expect(RULE_CONFIG_PARSERS[trigger].defaults).toEqual(config);
|
||||||
|
expect(validateRuleConfig(trigger, config)).toEqual([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(ALL_TRIGGERS)(
|
||||||
|
'%s falls back to its defaults for null, arrays and scalars',
|
||||||
|
(trigger) => {
|
||||||
|
const { config } = DEFAULT_RULES[trigger];
|
||||||
|
for (const junk of [null, undefined, [], 'x', 5]) {
|
||||||
|
expect(ruleConfigOf(trigger, junk)).toEqual(config);
|
||||||
|
expect(validateRuleConfig(trigger, junk)).toEqual([]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('reads a legacy thresholdMitesPerDay as naturalFall.redAbove', () =>
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 7 }).naturalFall,
|
||||||
|
).toEqual({
|
||||||
|
seasonMonths: [3, 4, 5, 6, 7, 8, 9, 10],
|
||||||
|
yellowFrom: 5,
|
||||||
|
redAbove: 7,
|
||||||
|
winterRedAbove: 0.5,
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('lets the band format win over the legacy key', () =>
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('VARROA_THRESHOLD', {
|
||||||
|
thresholdMitesPerDay: 7,
|
||||||
|
naturalFall: { redAbove: 9 },
|
||||||
|
}).naturalFall.redAbove,
|
||||||
|
).toBe(9));
|
||||||
|
|
||||||
|
it('ignores an invalid legacy threshold', () =>
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 0 }).naturalFall
|
||||||
|
.redAbove,
|
||||||
|
).toBe(10));
|
||||||
|
|
||||||
|
it('reports an invalid legacy threshold with field and message', () => {
|
||||||
|
const issues = validateRuleConfig('VARROA_THRESHOLD', {
|
||||||
|
thresholdMitesPerDay: 0,
|
||||||
|
});
|
||||||
|
expect(issues).toEqual([
|
||||||
|
{
|
||||||
|
field: 'thresholdMitesPerDay',
|
||||||
|
message: expect.any(String) as string,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(fieldsOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 7 })).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps natural-fall redAbove above zero and bounded, percent bands up to 100', () => {
|
||||||
|
const fields = (config: unknown) => fieldsOf('VARROA_THRESHOLD', config);
|
||||||
|
expect(fields({ naturalFall: { redAbove: 0 } })).toEqual([
|
||||||
|
'naturalFall.redAbove',
|
||||||
|
]);
|
||||||
|
expect(fields({ naturalFall: { redAbove: 1001 } })).toEqual([
|
||||||
|
'naturalFall.redAbove',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
fields({ infestationPercent: { summer: { redAbove: 101 } } }),
|
||||||
|
).toEqual(['infestationPercent.summer.redAbove']);
|
||||||
|
expect(
|
||||||
|
fields({ infestationPercent: { autumn: { redAbove: 100 } } }),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an explicit null on a known key', () => {
|
||||||
|
expect(fieldsOf('SEASON_AUTOPILOT', { queenMaxAgeYears: null })).toEqual([
|
||||||
|
'queenMaxAgeYears',
|
||||||
|
]);
|
||||||
|
expect(fieldsOf('PHENOLOGY', { swarmSeason: null })).toEqual([
|
||||||
|
'swarmSeason',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts null on optional keys as clearing them', () => {
|
||||||
|
expect(fieldsOf('TREATMENT_FOLLOW_UP', { dueInDays: null })).toEqual([]);
|
||||||
|
expect(ruleConfigOf('TREATMENT_FOLLOW_UP', { dueInDays: null })).toEqual({
|
||||||
|
notify: false,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('RAW_MATERIAL_LOW', {
|
||||||
|
minGrams: { BEESWAX: null, PROPOLIS: 50 },
|
||||||
|
}),
|
||||||
|
).toEqual({ notify: false, minGrams: { PROPOLIS: 50 } });
|
||||||
|
expect(ruleConfigOf('RAW_MATERIAL_LOW', { minGrams: null })).toEqual({
|
||||||
|
notify: false,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('SPLIT_SCHEDULE', {
|
||||||
|
QUEEN: [{ step: 'ACCEPTANCE_CHECK', offsetDays: 7, windowDays: null }],
|
||||||
|
}).QUEEN,
|
||||||
|
).toEqual([{ step: 'ACCEPTANCE_CHECK', offsetDays: 7 }]);
|
||||||
|
expect(
|
||||||
|
fieldsOf('PHENOLOGY', { gtsThresholds: { SALWEIDE: null } }),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('PHENOLOGY', { gtsThresholds: { SALWEIDE: null } })
|
||||||
|
.gtsThresholds,
|
||||||
|
).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a client remove a gts threshold by leaving it out', () => {
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('PHENOLOGY', { gtsThresholds: { KIRSCHE: 400 } })
|
||||||
|
.gtsThresholds,
|
||||||
|
).toEqual({ KIRSCHE: 400 });
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('PHENOLOGY', { gtsThresholds: {} }).gtsThresholds,
|
||||||
|
).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps yellowFrom at or below a legacy threshold under five', () => {
|
||||||
|
const { naturalFall } = ruleConfigOf('VARROA_THRESHOLD', {
|
||||||
|
thresholdMitesPerDay: 3,
|
||||||
|
});
|
||||||
|
expect(naturalFall.redAbove).toBe(3);
|
||||||
|
expect(naturalFall.yellowFrom).toBeLessThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back per invalid key and keeps the valid ones', () =>
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('SEASON_AUTOPILOT', {
|
||||||
|
queenMaxAgeYears: 'x',
|
||||||
|
honeySuperMinFrames: 10,
|
||||||
|
}),
|
||||||
|
).toMatchObject({ queenMaxAgeYears: 2, honeySuperMinFrames: 10 }));
|
||||||
|
|
||||||
|
it('logs a warning with trigger and field but without the value', () => {
|
||||||
|
const warn = jest.spyOn(Logger.prototype, 'warn');
|
||||||
|
ruleConfigOf('SEASON_AUTOPILOT', { queenMaxAgeYears: 'secret-value' });
|
||||||
|
const logged = warn.mock.calls.map((call) => String(call[0])).join('\n');
|
||||||
|
expect(logged).toContain('SEASON_AUTOPILOT');
|
||||||
|
expect(logged).toContain('queenMaxAgeYears');
|
||||||
|
expect(logged).not.toContain('secret-value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects SCHWARMZEIT 10 days with the field name', () =>
|
||||||
|
expect(
|
||||||
|
validateRuleConfig('INSPECTION_INTERVAL', {
|
||||||
|
intervalDays: { SCHWARMZEIT: 10 },
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
field: 'intervalDays.SCHWARMZEIT',
|
||||||
|
message: expect.stringContaining('7') as string,
|
||||||
|
},
|
||||||
|
]));
|
||||||
|
|
||||||
|
it('allows 7 to 35 days outside the swarm season and 7 to 9 inside', () => {
|
||||||
|
expect(
|
||||||
|
validateRuleConfig('INSPECTION_INTERVAL', {
|
||||||
|
intervalDays: { SOMMER: 35, FRUEHJAHR: 7, SCHWARMZEIT: 9 },
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
fieldsOf('INSPECTION_INTERVAL', { intervalDays: { SOMMER: 36 } }),
|
||||||
|
).toEqual(['intervalDays.SOMMER']);
|
||||||
|
expect(
|
||||||
|
fieldsOf('INSPECTION_INTERVAL', { intervalDays: { HERBST: 6.5 } }),
|
||||||
|
).toEqual(['intervalDays.HERBST']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges a partial interval config over the defaults', () =>
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('INSPECTION_INTERVAL', { intervalDays: { SOMMER: 10 } }),
|
||||||
|
).toEqual({
|
||||||
|
intervalDays: {
|
||||||
|
FRUEHJAHR: 14,
|
||||||
|
SCHWARMZEIT: 7,
|
||||||
|
SOMMER: 10,
|
||||||
|
SPAETSOMMER: 21,
|
||||||
|
HERBST: 21,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('rejects yellowFrom above redAbove', () =>
|
||||||
|
expect(
|
||||||
|
fieldsOf('VARROA_THRESHOLD', {
|
||||||
|
naturalFall: { yellowFrom: 12, redAbove: 8 },
|
||||||
|
}),
|
||||||
|
).toEqual(['naturalFall.yellowFrom']));
|
||||||
|
|
||||||
|
it('rejects percent bands where yellowFrom is above redAbove', () =>
|
||||||
|
expect(
|
||||||
|
fieldsOf('VARROA_THRESHOLD', {
|
||||||
|
infestationPercent: { summer: { yellowFrom: 6, redAbove: 5 } },
|
||||||
|
}),
|
||||||
|
).toEqual(['infestationPercent.summer.yellowFrom']));
|
||||||
|
|
||||||
|
it('rejects duplicate or out-of-range months', () => {
|
||||||
|
expect(
|
||||||
|
fieldsOf('VARROA_THRESHOLD', { naturalFall: { seasonMonths: [3, 3] } }),
|
||||||
|
).toEqual(['naturalFall.seasonMonths']);
|
||||||
|
expect(
|
||||||
|
fieldsOf('VARROA_THRESHOLD', {
|
||||||
|
infestationPercent: { summerMonths: [0, 13] },
|
||||||
|
}),
|
||||||
|
).toEqual(['infestationPercent.summerMonths']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks the varroa limits', () => {
|
||||||
|
const fields = (config: unknown) => fieldsOf('VARROA_THRESHOLD', config);
|
||||||
|
expect(fields({ youngColonyFactor: 0 })).toEqual(['youngColonyFactor']);
|
||||||
|
expect(fields({ youngColonyFactor: 1.5 })).toEqual(['youngColonyFactor']);
|
||||||
|
expect(fields({ youngColonyFactor: 1 })).toEqual([]);
|
||||||
|
expect(fields({ recheckAfterDays: 61 })).toEqual(['recheckAfterDays']);
|
||||||
|
expect(fields({ recheckAfterDays: 0 })).toEqual([]);
|
||||||
|
expect(fields({ youngColonyTypes: ['NOPE'] })).toEqual([
|
||||||
|
'youngColonyTypes',
|
||||||
|
]);
|
||||||
|
expect(fields({ naturalFall: { redAbove: -1 } })).toEqual([
|
||||||
|
'naturalFall.redAbove',
|
||||||
|
]);
|
||||||
|
expect(fields({ naturalFall: { redAbove: Infinity } })).toEqual([
|
||||||
|
'naturalFall.redAbove',
|
||||||
|
]);
|
||||||
|
expect(fields({ notify: 'yes' })).toEqual(['notify']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports no issue for keys it does not know', () =>
|
||||||
|
expect(
|
||||||
|
validateRuleConfig('SEASON_AUTOPILOT', { somethingElse: 'x' }),
|
||||||
|
).toEqual([]));
|
||||||
|
|
||||||
|
it('rejects a calendar fallback that runs backwards', () => {
|
||||||
|
expect(
|
||||||
|
fieldsOf('PHENOLOGY', { calendarFallback: { KIRSCHE: '03-01' } }),
|
||||||
|
).toEqual(['calendarFallback.KIRSCHE']);
|
||||||
|
// The whole map falls back so the phases stay in order.
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('PHENOLOGY', { calendarFallback: { KIRSCHE: '03-01' } })
|
||||||
|
.calendarFallback,
|
||||||
|
).toEqual(DEFAULT_RULES.PHENOLOGY.config.calendarFallback);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid month-day', () => {
|
||||||
|
expect(
|
||||||
|
fieldsOf('PHENOLOGY', { calendarFallback: { SALWEIDE: '13-01' } }),
|
||||||
|
).toEqual(['calendarFallback.SALWEIDE']);
|
||||||
|
expect(
|
||||||
|
fieldsOf('PHENOLOGY', { calendarFallback: { SALWEIDE: '02-30' } }),
|
||||||
|
).toEqual(['calendarFallback.SALWEIDE']);
|
||||||
|
expect(
|
||||||
|
fieldsOf('PHENOLOGY', { calendarFallback: { SALWEIDE: '03-20' } }),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks the phenology limits', () => {
|
||||||
|
const fields = (config: unknown) => fieldsOf('PHENOLOGY', config);
|
||||||
|
expect(fields({ maxGapShare: 0.6 })).toEqual(['maxGapShare']);
|
||||||
|
expect(fields({ maxGapShare: 0.5 })).toEqual([]);
|
||||||
|
expect(fields({ gtsThresholds: { SALWEIDE: 0 } })).toEqual([
|
||||||
|
'gtsThresholds.SALWEIDE',
|
||||||
|
]);
|
||||||
|
expect(fields({ gtsThresholds: { NOPE: 5 } })).toEqual([]);
|
||||||
|
expect(fields({ calibratePhases: ['KIRSCHE', 'X'] })).toEqual([
|
||||||
|
'calibratePhases',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
fields({ swarmSeason: { from: 'LINDE', until: 'KIRSCHE' } }),
|
||||||
|
).toEqual(['swarmSeason.from']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults gtsThresholds to SALWEIDE 200 only', () =>
|
||||||
|
expect(ruleConfigOf('PHENOLOGY', {}).gtsThresholds).toEqual({
|
||||||
|
SALWEIDE: 200,
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('checks the season autopilot limits', () => {
|
||||||
|
const fields = (config: unknown) => fieldsOf('SEASON_AUTOPILOT', config);
|
||||||
|
expect(fields({ honeySuperMinFrames: 0 })).toEqual(['honeySuperMinFrames']);
|
||||||
|
expect(fields({ honeySuperMinFrames: 41 })).toEqual([
|
||||||
|
'honeySuperMinFrames',
|
||||||
|
]);
|
||||||
|
expect(fields({ varroaMeasureIntervalDays: 6 })).toEqual([
|
||||||
|
'varroaMeasureIntervalDays',
|
||||||
|
]);
|
||||||
|
expect(fields({ queenMaxAgeYears: 0 })).toEqual(['queenMaxAgeYears']);
|
||||||
|
expect(fields({ queenMaxAgeYears: 11 })).toEqual(['queenMaxAgeYears']);
|
||||||
|
expect(fields({ suggestionTtlDays: 366 })).toEqual(['suggestionTtlDays']);
|
||||||
|
expect(fields({ maxStepsPerColony: 4 })).toEqual(['maxStepsPerColony']);
|
||||||
|
expect(
|
||||||
|
fields({ winterVarroaWindow: { from: '12-20', to: '11-15' } }),
|
||||||
|
).toEqual(['winterVarroaWindow.from']);
|
||||||
|
expect(fields({ rules: { A3: 'no' } })).toEqual(['rules.A3']);
|
||||||
|
expect(fields({ rules: { A3: false } })).toEqual([]);
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('SEASON_AUTOPILOT', { rules: { A3: false } }).rules,
|
||||||
|
).toEqual(expect.objectContaining({ A1: true, A3: false, A10: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks the water content limits', () => {
|
||||||
|
const fields = (config: unknown) => fieldsOf('HONEY_WATER_CONTENT', config);
|
||||||
|
expect(fields({ warnAbovePercent: 9 })).toEqual(['warnAbovePercent']);
|
||||||
|
expect(fields({ confirmAbovePercent: 31 })).toEqual([
|
||||||
|
'confirmAbovePercent',
|
||||||
|
]);
|
||||||
|
expect(fields({ warnAbovePercent: 21 })).toEqual(['warnAbovePercent']);
|
||||||
|
expect(
|
||||||
|
fields({ byHoneyType: { Heidehonig: { confirmAbovePercent: 23 } } }),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
fields({ byHoneyType: { Heidehonig: { confirmAbovePercent: 5 } } }),
|
||||||
|
).toEqual(['byHoneyType.Heidehonig.confirmAbovePercent']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks the split schedule steps', () => {
|
||||||
|
const fields = (config: unknown) => fieldsOf('SPLIT_SCHEDULE', config);
|
||||||
|
expect(
|
||||||
|
fields({ QUEEN: [{ step: 'ACCEPTANCE_CHECK', offsetDays: 121 }] }),
|
||||||
|
).toEqual(['QUEEN[0].offsetDays']);
|
||||||
|
expect(
|
||||||
|
fields({
|
||||||
|
QUEEN_CELL: [{ step: 'EGGS_CHECK', offsetDays: 21, windowDays: 31 }],
|
||||||
|
}),
|
||||||
|
).toEqual(['QUEEN_CELL[0].windowDays']);
|
||||||
|
expect(fields({ RAISE_OWN: [{ offsetDays: 3 }] })).toEqual([
|
||||||
|
'RAISE_OWN[0].step',
|
||||||
|
]);
|
||||||
|
expect(fields({ RAISE_OWN: 'nope' })).toEqual(['RAISE_OWN']);
|
||||||
|
expect(
|
||||||
|
fields({
|
||||||
|
RAISE_OWN: [{ step: 'MATING_CHECK', offsetDays: 21, windowDays: 3 }],
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks dueInDays of chains and simple rules', () => {
|
||||||
|
expect(fieldsOf('QUEEN_CELLS_SWARM_CHECK', { dueInDays: 366 })).toEqual([
|
||||||
|
'dueInDays',
|
||||||
|
]);
|
||||||
|
expect(fieldsOf('TREATMENT_FOLLOW_UP', { dueInDays: -1 })).toEqual([
|
||||||
|
'dueInDays',
|
||||||
|
]);
|
||||||
|
expect(ruleConfigOf('TREATMENT_FOLLOW_UP', { dueInDays: 14 })).toEqual({
|
||||||
|
notify: false,
|
||||||
|
dueInDays: 14,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads minGrams per raw material kind', () => {
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('RAW_MATERIAL_LOW', { minGrams: { BEESWAX: 500 } }),
|
||||||
|
).toEqual({ notify: false, minGrams: { BEESWAX: 500 } });
|
||||||
|
expect(
|
||||||
|
fieldsOf('RAW_MATERIAL_LOW', {
|
||||||
|
minGrams: { BEESWAX: -5, PROPOLIS: 'x' },
|
||||||
|
}),
|
||||||
|
).toEqual(['minGrams.BEESWAX', 'minGrams.PROPOLIS']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a number format and rejects an empty or oversized one', () => {
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('HONEY_BATCH_NUMBER_FORMAT', { format: 'HB-{SEQ:3}' }),
|
||||||
|
).toEqual({ format: 'HB-{SEQ:3}' });
|
||||||
|
expect(fieldsOf('HONEY_BATCH_NUMBER_FORMAT', { format: '' })).toEqual([
|
||||||
|
'format',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
fieldsOf('HONEY_BATCH_NUMBER_FORMAT', { format: 'x'.repeat(101) }),
|
||||||
|
).toEqual(['format']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults notify to true only for QUEENLESS_NO_EGGS, VARROA_THRESHOLD and COLONY_HEALTH_CRITICAL (and WITHDRAWAL_ENDED)', () => {
|
||||||
|
const withNotify = ALL_TRIGGERS.filter(
|
||||||
|
(trigger) =>
|
||||||
|
(ruleConfigOf(trigger, {}) as { notify?: boolean }).notify === true,
|
||||||
|
);
|
||||||
|
expect([...withNotify].sort()).toEqual(
|
||||||
|
[
|
||||||
|
'COLONY_HEALTH_CRITICAL',
|
||||||
|
'QUEENLESS_NO_EGGS',
|
||||||
|
'VARROA_THRESHOLD',
|
||||||
|
'WITHDRAWAL_ENDED',
|
||||||
|
].sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts the optional rules switched off', () => {
|
||||||
|
const off = ALL_TRIGGERS.filter(
|
||||||
|
(trigger) => DEFAULT_RULES[trigger].active === false,
|
||||||
|
).sort();
|
||||||
|
expect(off).toEqual([
|
||||||
|
'FEEDING_FOLLOW_UP',
|
||||||
|
'RAW_MATERIAL_LOW',
|
||||||
|
'TREATMENT_FOLLOW_UP',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives every new rule an empty action template', () => {
|
||||||
|
for (const trigger of [
|
||||||
|
'INSPECTION_INTERVAL',
|
||||||
|
'QUEEN_CELLS_SWARM_CHECK',
|
||||||
|
'QUEEN_ACCEPTANCE_CHECK',
|
||||||
|
'SPLIT_SCHEDULE',
|
||||||
|
'COLONY_ENDED_CLEANUP',
|
||||||
|
'WITHDRAWAL_ENDED',
|
||||||
|
'HONEY_WATER_CONTENT',
|
||||||
|
'SEASON_AUTOPILOT',
|
||||||
|
'PHENOLOGY',
|
||||||
|
] as const) {
|
||||||
|
expect(DEFAULT_RULES[trigger].actionTemplate).toBe('');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import type { ColonyType, RawMaterialKind } from '../../../generated/prisma';
|
||||||
|
import type {
|
||||||
|
PhenologyPhaseKey,
|
||||||
|
SeasonSegmentKey,
|
||||||
|
} from '../../season/season-types';
|
||||||
|
|
||||||
|
/** One invalid setting of a rule: the field path and a German message. */
|
||||||
|
export interface RuleConfigIssue {
|
||||||
|
field: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleConfigParser<T> {
|
||||||
|
/** The built-in setting; also what an empty or unusable config reads as. */
|
||||||
|
readonly defaults: T;
|
||||||
|
/** Never throws: each invalid key falls back to its default (and is logged). */
|
||||||
|
parse(json: unknown): T;
|
||||||
|
/** Reports the known keys that are present and invalid; nothing else. */
|
||||||
|
validate(json: unknown): RuleConfigIssue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimpleRuleConfig {
|
||||||
|
notify: boolean;
|
||||||
|
dueInDays?: number;
|
||||||
|
minGrams?: Partial<Record<RawMaterialKind, number>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberFormatConfig {
|
||||||
|
format: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VarroaThresholdConfig {
|
||||||
|
naturalFall: {
|
||||||
|
seasonMonths: number[];
|
||||||
|
yellowFrom: number;
|
||||||
|
redAbove: number;
|
||||||
|
winterRedAbove: number;
|
||||||
|
};
|
||||||
|
infestationPercent: {
|
||||||
|
summerMonths: number[];
|
||||||
|
summer: { yellowFrom: number; redAbove: number };
|
||||||
|
autumnMonths: number[];
|
||||||
|
autumn: { yellowFrom: number; redAbove: number };
|
||||||
|
};
|
||||||
|
youngColonyFactor: number;
|
||||||
|
youngColonyTypes: ColonyType[];
|
||||||
|
recheckAfterDays: number;
|
||||||
|
notify: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InspectionIntervalConfig {
|
||||||
|
intervalDays: Record<Exclude<SeasonSegmentKey, 'WINTERRUHE'>, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChainConfig {
|
||||||
|
dueInDays: number;
|
||||||
|
notify: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanStepConfig {
|
||||||
|
step: string;
|
||||||
|
offsetDays: number;
|
||||||
|
windowDays?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SplitScheduleConfig {
|
||||||
|
RAISE_OWN: PlanStepConfig[];
|
||||||
|
QUEEN_CELL: PlanStepConfig[];
|
||||||
|
QUEEN: PlanStepConfig[];
|
||||||
|
notify: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WaterContentConfig {
|
||||||
|
warnAbovePercent: number;
|
||||||
|
confirmAbovePercent: number;
|
||||||
|
byHoneyType: Record<
|
||||||
|
string,
|
||||||
|
{ warnAbovePercent?: number; confirmAbovePercent?: number }
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AUTOPILOT_RULE_IDS = [
|
||||||
|
'A1',
|
||||||
|
'A2',
|
||||||
|
'A3',
|
||||||
|
'A4',
|
||||||
|
'A5',
|
||||||
|
'A6',
|
||||||
|
'A7',
|
||||||
|
'A8',
|
||||||
|
'A9',
|
||||||
|
'A10',
|
||||||
|
] as const;
|
||||||
|
export type AutopilotRuleId = (typeof AUTOPILOT_RULE_IDS)[number];
|
||||||
|
|
||||||
|
export interface SeasonAutopilotConfig {
|
||||||
|
honeySuperMinFrames: number;
|
||||||
|
varroaMeasureIntervalDays: number;
|
||||||
|
winterVarroaWindow: { from: string; to: string };
|
||||||
|
queenMaxAgeYears: number;
|
||||||
|
suggestionTtlDays: number;
|
||||||
|
maxStepsPerColony: number;
|
||||||
|
rules: Record<AutopilotRuleId, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PhenologyConfig {
|
||||||
|
useGts: boolean;
|
||||||
|
gtsThresholds: Partial<Record<PhenologyPhaseKey, number>>;
|
||||||
|
calibrateFromReports: boolean;
|
||||||
|
calibratePhases: PhenologyPhaseKey[];
|
||||||
|
calendarFallback: Record<PhenologyPhaseKey, string>;
|
||||||
|
swarmSeason: { from: PhenologyPhaseKey; until: PhenologyPhaseKey };
|
||||||
|
maxGapShare: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One entry per AutomationTriggerType. */
|
||||||
|
export interface RuleConfigMap {
|
||||||
|
VARROA_THRESHOLD: VarroaThresholdConfig;
|
||||||
|
INSPECTION_INTERVAL: InspectionIntervalConfig;
|
||||||
|
QUEEN_CELLS_SWARM_CHECK: ChainConfig;
|
||||||
|
QUEEN_ACCEPTANCE_CHECK: ChainConfig;
|
||||||
|
SPLIT_SCHEDULE: SplitScheduleConfig;
|
||||||
|
COLONY_ENDED_CLEANUP: Record<string, never>;
|
||||||
|
WITHDRAWAL_ENDED: { notify: boolean };
|
||||||
|
HONEY_WATER_CONTENT: WaterContentConfig;
|
||||||
|
SEASON_AUTOPILOT: SeasonAutopilotConfig;
|
||||||
|
PHENOLOGY: PhenologyConfig;
|
||||||
|
HONEY_BATCH_NUMBER_FORMAT: NumberFormatConfig;
|
||||||
|
BOTTLING_LOT_NUMBER_FORMAT: NumberFormatConfig;
|
||||||
|
RAW_MATERIAL_LOT_NUMBER_FORMAT: NumberFormatConfig;
|
||||||
|
PRODUCTION_BATCH_NUMBER_FORMAT: NumberFormatConfig;
|
||||||
|
QUEENLESS_NO_EGGS: SimpleRuleConfig;
|
||||||
|
FEED_STATUS_CRITICAL: SimpleRuleConfig;
|
||||||
|
SWARM_MOOD_HIGH: SimpleRuleConfig;
|
||||||
|
COLONY_HEALTH_CRITICAL: SimpleRuleConfig;
|
||||||
|
STOCK_ITEM_LOW: SimpleRuleConfig;
|
||||||
|
RAW_MATERIAL_LOW: SimpleRuleConfig;
|
||||||
|
TREATMENT_FOLLOW_UP: SimpleRuleConfig;
|
||||||
|
FEEDING_FOLLOW_UP: SimpleRuleConfig;
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import {
|
||||||
|
bool,
|
||||||
|
childScope,
|
||||||
|
has,
|
||||||
|
intIn,
|
||||||
|
makeParser,
|
||||||
|
monthDay,
|
||||||
|
report,
|
||||||
|
} from './parse-helpers';
|
||||||
|
import {
|
||||||
|
AUTOPILOT_RULE_IDS,
|
||||||
|
type SeasonAutopilotConfig,
|
||||||
|
} from './rule-config.types';
|
||||||
|
|
||||||
|
export const DEFAULT_SEASON_AUTOPILOT_CONFIG: SeasonAutopilotConfig = {
|
||||||
|
honeySuperMinFrames: 8,
|
||||||
|
varroaMeasureIntervalDays: 28,
|
||||||
|
winterVarroaWindow: { from: '11-15', to: '12-20' },
|
||||||
|
queenMaxAgeYears: 2,
|
||||||
|
suggestionTtlDays: 30,
|
||||||
|
maxStepsPerColony: 3,
|
||||||
|
rules: {
|
||||||
|
A1: true,
|
||||||
|
A2: true,
|
||||||
|
A3: true,
|
||||||
|
A4: true,
|
||||||
|
A5: true,
|
||||||
|
A6: true,
|
||||||
|
A7: true,
|
||||||
|
A8: true,
|
||||||
|
A9: true,
|
||||||
|
A10: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const seasonAutopilotParser = makeParser<SeasonAutopilotConfig>(
|
||||||
|
'SEASON_AUTOPILOT',
|
||||||
|
DEFAULT_SEASON_AUTOPILOT_CONFIG,
|
||||||
|
(scope) => {
|
||||||
|
const d = DEFAULT_SEASON_AUTOPILOT_CONFIG;
|
||||||
|
|
||||||
|
const windowScope = childScope(scope, 'winterVarroaWindow');
|
||||||
|
let winterVarroaWindow = {
|
||||||
|
from: monthDay(windowScope, 'from', d.winterVarroaWindow.from),
|
||||||
|
to: monthDay(windowScope, 'to', d.winterVarroaWindow.to),
|
||||||
|
};
|
||||||
|
if (winterVarroaWindow.from > winterVarroaWindow.to) {
|
||||||
|
report(
|
||||||
|
windowScope,
|
||||||
|
has(windowScope, 'from') ? 'from' : 'to',
|
||||||
|
'Das Fenster muss innerhalb eines Jahres liegen: Beginn vor Ende.',
|
||||||
|
);
|
||||||
|
winterVarroaWindow = { ...d.winterVarroaWindow };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ruleScope = childScope(scope, 'rules');
|
||||||
|
const rules = { ...d.rules };
|
||||||
|
for (const id of AUTOPILOT_RULE_IDS) {
|
||||||
|
rules[id] = bool(ruleScope, id, d.rules[id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
honeySuperMinFrames: intIn(
|
||||||
|
scope,
|
||||||
|
'honeySuperMinFrames',
|
||||||
|
d.honeySuperMinFrames,
|
||||||
|
1,
|
||||||
|
40,
|
||||||
|
),
|
||||||
|
varroaMeasureIntervalDays: intIn(
|
||||||
|
scope,
|
||||||
|
'varroaMeasureIntervalDays',
|
||||||
|
d.varroaMeasureIntervalDays,
|
||||||
|
7,
|
||||||
|
90,
|
||||||
|
),
|
||||||
|
winterVarroaWindow,
|
||||||
|
queenMaxAgeYears: intIn(
|
||||||
|
scope,
|
||||||
|
'queenMaxAgeYears',
|
||||||
|
d.queenMaxAgeYears,
|
||||||
|
1,
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
suggestionTtlDays: intIn(
|
||||||
|
scope,
|
||||||
|
'suggestionTtlDays',
|
||||||
|
d.suggestionTtlDays,
|
||||||
|
1,
|
||||||
|
365,
|
||||||
|
),
|
||||||
|
maxStepsPerColony: intIn(
|
||||||
|
scope,
|
||||||
|
'maxStepsPerColony',
|
||||||
|
d.maxStepsPerColony,
|
||||||
|
1,
|
||||||
|
3,
|
||||||
|
),
|
||||||
|
rules,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { RawMaterialKind } from '../../../generated/prisma';
|
||||||
|
import {
|
||||||
|
bool,
|
||||||
|
childScope,
|
||||||
|
has,
|
||||||
|
isCleared,
|
||||||
|
makeParser,
|
||||||
|
numberIn,
|
||||||
|
optionalIntIn,
|
||||||
|
stringIn,
|
||||||
|
} from './parse-helpers';
|
||||||
|
import type {
|
||||||
|
NumberFormatConfig,
|
||||||
|
RuleConfigParser,
|
||||||
|
SimpleRuleConfig,
|
||||||
|
} from './rule-config.types';
|
||||||
|
|
||||||
|
const MAX_DUE_IN_DAYS = 365;
|
||||||
|
/** Far above any minimum stock of wax or propolis. */
|
||||||
|
const MAX_MIN_GRAMS = 1_000_000_000;
|
||||||
|
const MAX_NUMBER_FORMAT_LENGTH = 100;
|
||||||
|
const RAW_MATERIAL_KINDS = Object.values(RawMaterialKind);
|
||||||
|
|
||||||
|
export interface SimpleRuleOptions {
|
||||||
|
/** Whether the rule notifies by default. */
|
||||||
|
notify: boolean;
|
||||||
|
/** RAW_MATERIAL_LOW reads a minimum per kind. */
|
||||||
|
withMinGrams?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rules that propose one suggestion: notify, an optional follow-up interval
|
||||||
|
* (dueInDays) and, for the raw material rule, minGrams per kind.
|
||||||
|
*/
|
||||||
|
export function simpleParser(
|
||||||
|
trigger: string,
|
||||||
|
options: SimpleRuleOptions,
|
||||||
|
): RuleConfigParser<SimpleRuleConfig> {
|
||||||
|
return makeParser<SimpleRuleConfig>(
|
||||||
|
trigger,
|
||||||
|
{ notify: options.notify },
|
||||||
|
(scope) => {
|
||||||
|
const config: SimpleRuleConfig = {
|
||||||
|
notify: bool(scope, 'notify', options.notify),
|
||||||
|
};
|
||||||
|
const dueInDays = optionalIntIn(scope, 'dueInDays', 0, MAX_DUE_IN_DAYS);
|
||||||
|
if (dueInDays !== undefined) {
|
||||||
|
config.dueInDays = dueInDays;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
options.withMinGrams &&
|
||||||
|
has(scope, 'minGrams') &&
|
||||||
|
!isCleared(scope, 'minGrams')
|
||||||
|
) {
|
||||||
|
const minScope = childScope(scope, 'minGrams');
|
||||||
|
const minGrams: Partial<Record<RawMaterialKind, number>> = {};
|
||||||
|
for (const kind of RAW_MATERIAL_KINDS) {
|
||||||
|
if (isCleared(minScope, kind)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const grams = numberIn(minScope, kind, Number.NaN, 0, MAX_MIN_GRAMS);
|
||||||
|
if (!Number.isNaN(grams)) {
|
||||||
|
minGrams[kind] = grams;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(minGrams).length > 0) {
|
||||||
|
config.minGrams = minGrams;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The batch/lot number rules: one format string with placeholders. */
|
||||||
|
export function numberFormatParser(
|
||||||
|
trigger: string,
|
||||||
|
defaultFormat: string,
|
||||||
|
): RuleConfigParser<NumberFormatConfig> {
|
||||||
|
return makeParser<NumberFormatConfig>(
|
||||||
|
trigger,
|
||||||
|
{ format: defaultFormat },
|
||||||
|
(scope) => ({
|
||||||
|
format: stringIn(
|
||||||
|
scope,
|
||||||
|
'format',
|
||||||
|
defaultFormat,
|
||||||
|
1,
|
||||||
|
MAX_NUMBER_FORMAT_LENGTH,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
Loaded 100 of 2018 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user