Compare commits
113
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,112 @@
|
||||
name: Backend E2E
|
||||
|
||||
# Fuehrt `npm run test:e2e` (Jest, maxWorkers 4, siehe test/jest-e2e.json)
|
||||
# gegen einen frischen Postgres-Service-Container aus. Das Backend-Image
|
||||
# (backend-image.yml) fuehrt nur Lint, Typecheck und Unit-Tests aus, weil der
|
||||
# Docker-Build keine Datenbank hat; diese Pipeline schliesst die Luecke.
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "ApiCore-Backend/**"
|
||||
- ".gitea/workflows/backend-e2e.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
# Ein neuer Push auf denselben Ref ersetzt den laufenden Lauf.
|
||||
concurrency:
|
||||
group: backend-e2e-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: docker-builder
|
||||
timeout-minutes: 30
|
||||
# Gitea act_runner unterstuetzt `services:`. Annahme (beim ersten Lauf
|
||||
# pruefen): der Job-Container teilt das Netz der Services und erreicht
|
||||
# Postgres unter dem Service-Namen `postgres`. Laeuft der Runner
|
||||
# stattdessen im Host-Modus, ist der Port auf localhost veroeffentlicht;
|
||||
# der Schritt "Datenbank-Host ermitteln" probiert beide Wege.
|
||||
services:
|
||||
postgres:
|
||||
# Gleiche Major-Version wie Produktion (deploy/k8s/base/postgres).
|
||||
# Per Digest gepinnt (Index-Digest von 18.6-alpine, nachgeschlagen
|
||||
# 2026-10-16); die k8s-Manifeste pinnen Postgres bisher nur per Tag.
|
||||
image: postgres:18.6-alpine@sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873
|
||||
env:
|
||||
# Wegwerf-Datenbank im Service-Container, nur im Job-Netz erreichbar
|
||||
# und nach dem Lauf verworfen: kein echtes Secret.
|
||||
POSTGRES_USER: apicore
|
||||
POSTGRES_PASSWORD: apicore-ci-only
|
||||
POSTGRES_DB: apicore_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U apicore -d apicore_test"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
env:
|
||||
NODE_ENV: test
|
||||
# Das Wetter-e2e ruft die echte Open-Meteo-API; in CI ueberspringen.
|
||||
SKIP_EXTERNAL: "1"
|
||||
JWT_ACCESS_EXPIRES_IN: 15m
|
||||
JWT_REFRESH_EXPIRES_IN: 7d
|
||||
PORT: "3001"
|
||||
FRONTEND_ORIGIN: http://localhost:3000
|
||||
DOCUMENTS_STORAGE_PATH: ./test-uploads
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ApiCore-Backend
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Node einrichten
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Datenbank-Host ermitteln und Testumgebung erzeugen
|
||||
# jest-e2e.json laedt .env.test per dotenv, das vorhandene Variablen
|
||||
# nicht ueberschreibt: alles kommt aus der Job-Umgebung, .env.test
|
||||
# existiert in CI nicht (gitignored). Die JWT-Secrets sind frisch
|
||||
# erzeugte Wegwerfwerte (mind. 32 Zeichen, voneinander verschieden),
|
||||
# nie echte Secrets.
|
||||
run: |
|
||||
set -eu
|
||||
host=""
|
||||
for candidate in postgres localhost; do
|
||||
if node -e "const s=require('net').connect(5432,process.argv[1]);s.on('connect',()=>process.exit(0));s.on('error',()=>process.exit(1));setTimeout(()=>process.exit(1),3000)" "$candidate"; then
|
||||
host="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$host" ]; then
|
||||
echo "Postgres weder unter 'postgres' noch unter 'localhost' erreichbar" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Postgres-Host: $host"
|
||||
access="$(openssl rand -hex 32)"
|
||||
refresh="$(openssl rand -hex 32)"
|
||||
echo "::add-mask::$access"
|
||||
echo "::add-mask::$refresh"
|
||||
{
|
||||
echo "DATABASE_URL=postgresql://apicore:apicore-ci-only@${host}:5432/apicore_test?schema=public"
|
||||
echo "JWT_ACCESS_SECRET=$access"
|
||||
echo "JWT_REFRESH_SECRET=$refresh"
|
||||
} >> "$GITEA_ENV"
|
||||
|
||||
- name: Abhaengigkeiten installieren
|
||||
run: npm ci
|
||||
|
||||
- name: Prisma-Client erzeugen
|
||||
run: npx prisma generate
|
||||
|
||||
- name: Migrationen anwenden
|
||||
# Die Rollen (z. B. BEEKEEPER) kommen aus den Migrationen, ein Seed
|
||||
# ist fuer die e2e-Tests nicht noetig.
|
||||
run: npx prisma migrate deploy
|
||||
|
||||
- name: E2E-Tests
|
||||
# Auf dem geteilten Runner brauchten App-Start plus bcrypt-Logins in
|
||||
# beforeAll mehr als die lokalen 20 s (Laeufe 826, 836); hier 60 s.
|
||||
run: npm run test:e2e -- --ci --testTimeout=60000
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
REGISTRY_PUSH: gitea:80
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Image-Namen in Kleinbuchstaben ermitteln
|
||||
# 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"
|
||||
|
||||
- name: Docker Buildx einrichten
|
||||
uses: docker/setup-buildx-action@v3
|
||||
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.
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
http = true
|
||||
|
||||
- name: Bei der Gitea-Container-Registry anmelden
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY_PUSH }}
|
||||
username: ${{ gitea.actor }}
|
||||
@@ -50,16 +50,15 @@ jobs:
|
||||
|
||||
- name: Metadaten (Tags/Labels) ermitteln
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,format=short
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Image bauen und pushen
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: ./ApiCore-Backend
|
||||
push: true
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
REGISTRY_PUSH: gitea:80
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Image-Namen in Kleinbuchstaben ermitteln
|
||||
# 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"
|
||||
|
||||
- name: Docker Buildx einrichten
|
||||
uses: docker/setup-buildx-action@v3
|
||||
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.
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
http = true
|
||||
|
||||
- name: Bei der Gitea-Container-Registry anmelden
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY_PUSH }}
|
||||
username: ${{ gitea.actor }}
|
||||
@@ -50,16 +50,15 @@ jobs:
|
||||
|
||||
- name: Metadaten (Tags/Labels) ermitteln
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,format=short
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Image bauen und pushen
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: ./ApiCore-Frontend
|
||||
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
|
||||
dist
|
||||
# A stale build info makes `nest build` emit only changed files into the
|
||||
# freshly deleted dist (tsconfig.json: incremental).
|
||||
*.tsbuildinfo
|
||||
generated
|
||||
coverage
|
||||
.devdb
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
DATABASE_URL="postgresql://user:password@localhost:5432/apicore?schema=public"
|
||||
|
||||
# Mindestens 32 Zeichen (server-seitig erzwungen, siehe main.ts) - signiert
|
||||
# jedes Access-/MFA-/SSO-Token UND verschluesselt gespeicherte TOTP-Secrets.
|
||||
# Mindestens 32 Zeichen (server-seitig erzwungen, siehe
|
||||
# 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
|
||||
JWT_ACCESS_SECRET="change-me-to-a-random-value-at-least-32-characters-long"
|
||||
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
|
||||
# single K8s ingress; a list such as "loopback, 10.0.0.0/8" also works.
|
||||
# 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.
|
||||
TRUST_PROXY=1
|
||||
|
||||
@@ -54,11 +56,38 @@ BACKUP_CRON_SCHEDULE="0 3 * * *"
|
||||
# Wie viele der neuesten Backup-Dateien behalten werden, aeltere werden nach
|
||||
# jedem Lauf geloescht (Standard: 14).
|
||||
BACKUP_RETENTION_COUNT=14
|
||||
# Backups are encrypted (AES-256-GCM). Without this key they are sealed with a
|
||||
# key derived from JWT_ACCESS_SECRET and restore only into this deployment.
|
||||
# Set the same value on another deployment to restore there. At least 32
|
||||
# characters, e.g. "openssl rand -hex 32". Keep it outside the backup storage.
|
||||
# Obergrenze fuer eine einzuspielende Backup-Datei in Bytes (Standard 50 MB,
|
||||
# hoechstens 256 MB, weil der Upload komplett im RAM verarbeitet wird).
|
||||
# Export und Zeitplan warnen, wenn eine Sicherung groesser wird.
|
||||
# BACKUP_MAX_IMPORT_BYTES=52428800
|
||||
|
||||
# Erster Administrator (prisma/seed.ts): wird angelegt, wenn es noch keinen
|
||||
# Benutzer mit dieser Adresse gibt. Ohne Passwort erzeugt der Seed ein
|
||||
# zufaelliges und gibt es einmalig im Log aus.
|
||||
# [email protected]
|
||||
# SEED_ADMIN_PASSWORD=
|
||||
|
||||
# Encryption keys, one per purpose (AES-256-GCM, key derived per purpose with
|
||||
# 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=""
|
||||
# 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
|
||||
# (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.
|
||||
# Nur fuer lokale Entwicklung/Debugging explizit aktivieren.
|
||||
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
|
||||
/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
|
||||
|
||||
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
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
@@ -14,16 +18,16 @@ RUN npx prisma generate
|
||||
RUN npx eslint "{src,test}/**/*.ts" && npm run typecheck && npm test -- --ci
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runtime
|
||||
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS runtime
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/generated ./generated
|
||||
# schema.prisma, migrations/ und prisma.config.ts: noetig fuer
|
||||
# `npm run migrate:deploy`, das der Container beim Start selbst ausfuehrt
|
||||
# (siehe CMD unten). seed.ts selbst wird NICHT zur Laufzeit ausgefuehrt -
|
||||
# schema.prisma, migrations/ und prisma.config.ts: noetig fuer den
|
||||
# initContainer `migrate` (siehe Kommentar ueber CMD unten).
|
||||
# seed.ts selbst wird NICHT zur Laufzeit ausgefuehrt -
|
||||
# `npm run build` kompiliert es vorab zu dist/seed.js (siehe
|
||||
# tsconfig.seed.json), das schon in ./dist enthalten ist. Frueher lief
|
||||
# `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.
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
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
|
||||
# migrate deploy ist idempotent und nutzt einen DB-seitigen Advisory-Lock,
|
||||
# seed ist idempotent per Unique-Constraint auf User.email (siehe seed.ts) -
|
||||
# beides unproblematisch bei mehreren gleichzeitig startenden Pod-Replicas.
|
||||
CMD ["sh", "-c", "npm run migrate:deploy && npm run seed && node dist/main.js"]
|
||||
# Migration und Seed laufen nicht mehr hier, sondern als initContainer im
|
||||
# Pod (deploy/k8s/components/backend-roles): `migrate` in api und worker
|
||||
# (`node node_modules/prisma/build/index.js migrate deploy`, idempotent mit
|
||||
# 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">
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
# ApiCore-Backend
|
||||
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||
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).
|
||||
|
||||
<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>
|
||||
<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
|
||||
## Einrichten
|
||||
|
||||
```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
|
||||
# development
|
||||
$ npm run start
|
||||
|
||||
# watch mode
|
||||
$ npm run start:dev
|
||||
|
||||
# production mode
|
||||
$ npm run start:prod
|
||||
npm run lint # eslint mit --fix
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm test # Jest-Unit-Tests
|
||||
npm run test:e2e # Jest-e2e gegen die Datenbank aus .env.test (vorher migrieren)
|
||||
npm run build # dist/main.js, dist/worker.js, dist/cli.js, dist/seed.js
|
||||
```
|
||||
|
||||
## 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
|
||||
# unit tests
|
||||
$ npm run test
|
||||
## Aufbau
|
||||
|
||||
# e2e tests
|
||||
$ npm run test:e2e
|
||||
|
||||
# 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).
|
||||
- `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`.
|
||||
- `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).
|
||||
Generated
+93
-35
@@ -9,13 +9,13 @@
|
||||
"version": "0.0.1",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.131.0",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/schedule": "^12.0.1",
|
||||
"@nestjs/swagger": "^11.4.7",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@prisma/adapter-pg": "^7.9.1",
|
||||
@@ -24,12 +24,14 @@
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cron": "^4.4.0",
|
||||
"helmet": "^8.3.0",
|
||||
"openid-client": "^6.8.4",
|
||||
"otplib": "^13.5.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pdfkit": "^0.19.1",
|
||||
"pg": "^8.23.1",
|
||||
"prisma": "^7.9.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
@@ -49,6 +51,7 @@
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/pdfkit": "^0.17.6",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
@@ -211,6 +214,27 @@
|
||||
"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": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
@@ -672,6 +696,15 @@
|
||||
"@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": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||
@@ -2478,22 +2511,6 @@
|
||||
"@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": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz",
|
||||
@@ -3227,6 +3244,12 @@
|
||||
"@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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
@@ -3601,9 +3624,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/luxon": {
|
||||
"version": "3.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.5.tgz",
|
||||
"integrity": "sha512-jJ41Q4z6ZVO260MNDdHfW7+7a5iMiX8Mr6ZJHcmgrvhZha6dz5704o/lF2kKl6URjH6ivEL97w9xS/MgpJEphg==",
|
||||
"version": "3.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.6.tgz",
|
||||
"integrity": "sha512-6KSjliQAXK8ZLgFQO4B7iE4Rpf/B3rItzCFuXezBY/XIAGxOdLd7vRNK9/StRPwiS/yJsVbB7HKpW46B8tcEuQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/methods": {
|
||||
@@ -6939,6 +6962,12 @@
|
||||
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||
"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": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
|
||||
@@ -8794,6 +8823,19 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
@@ -10087,14 +10129,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.23.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
|
||||
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
|
||||
"version": "8.23.1",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
|
||||
"integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-connection-string": "^2.14.1",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.16.0",
|
||||
"pg-protocol": "^1.16.1",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
@@ -10102,7 +10144,7 @@
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.4.0"
|
||||
"pg-cloudflare": "^1.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
@@ -10114,16 +10156,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
|
||||
"integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||
"version": "2.14.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
|
||||
"integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
@@ -10145,9 +10187,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
|
||||
"integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
|
||||
"integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
@@ -11263,6 +11305,16 @@
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -11799,6 +11851,12 @@
|
||||
"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": {
|
||||
"version": "2.5.0",
|
||||
"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:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"worker": "node dist/worker.js",
|
||||
"job": "node dist/cli.js",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"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:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"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": {
|
||||
"@anthropic-ai/sdk": "0.131.0",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/schedule": "^12.0.1",
|
||||
"@nestjs/swagger": "^11.4.7",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@prisma/adapter-pg": "^7.9.1",
|
||||
@@ -38,12 +42,14 @@
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cron": "^4.4.0",
|
||||
"helmet": "^8.3.0",
|
||||
"openid-client": "^6.8.4",
|
||||
"otplib": "^13.5.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pdfkit": "^0.19.1",
|
||||
"pg": "^8.23.1",
|
||||
"prisma": "^7.9.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
@@ -63,6 +69,7 @@
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/pdfkit": "^0.17.6",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
@@ -92,12 +99,13 @@
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"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": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
"testEnvironment": "node",
|
||||
"testTimeout": 20000
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// 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
|
||||
// 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 line = '='.repeat(60);
|
||||
console.log(line);
|
||||
console.log('Seed: Admin-Zugang eingerichtet.');
|
||||
console.log(` URL: ${url}`);
|
||||
console.log(` Benutzer: ${email}`);
|
||||
console.log(` Passwort: ${password}`);
|
||||
console.log(line);
|
||||
log(line);
|
||||
log('Seed: Admin-Zugang eingerichtet.');
|
||||
log(` URL: ${url}`);
|
||||
log(` Benutzer: ${email}`);
|
||||
log(
|
||||
password === null
|
||||
? ' Passwort: wie in SEED_ADMIN_PASSWORD gesetzt'
|
||||
: ` Passwort: ${password}`,
|
||||
);
|
||||
log(line);
|
||||
}
|
||||
|
||||
// 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> {
|
||||
const prisma = new PrismaClient({
|
||||
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
|
||||
});
|
||||
type AdminSeedClient = Pick<PrismaClient, 'user' | 'role'>;
|
||||
|
||||
await seedDefaultTaskTemplates(prisma);
|
||||
|
||||
const email = process.env.SEED_ADMIN_EMAIL ?? DEFAULT_ADMIN_EMAIL;
|
||||
const password = process.env.SEED_ADMIN_PASSWORD ?? generateRandomPassword();
|
||||
const existing = await prisma.user.findUnique({ where: { email } });
|
||||
if (existing) {
|
||||
console.log(`Seed: admin user ${email} already exists, skipping.`);
|
||||
await prisma.$disconnect();
|
||||
return;
|
||||
/**
|
||||
* Creates the first administrator, but only while the user table is still
|
||||
* empty. Checking SEED_ADMIN_EMAIL instead would add a second ADMIN (and log
|
||||
* its password) on every start after the real admin changed their address.
|
||||
*/
|
||||
export async function seedAdmin(
|
||||
prisma: AdminSeedClient,
|
||||
env: NodeJS.ProcessEnv,
|
||||
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 adminRole = await prisma.role.findUniqueOrThrow({
|
||||
where: { key: 'ADMIN' },
|
||||
@@ -218,27 +232,42 @@ async function main(): Promise<void> {
|
||||
roleId: adminRole.id,
|
||||
},
|
||||
});
|
||||
printLoginBanner(email, password);
|
||||
} catch (error: unknown) {
|
||||
// P2002 = unique constraint violation on email: another replica's
|
||||
// startup won this race between the existence check above and this
|
||||
// create() call. The user now exists either way, so this is a no-op,
|
||||
// not a failure - container startup must not crash on it.
|
||||
// startup won this race between the count above and this create()
|
||||
// call. The user now exists either way, so this is a no-op, not a
|
||||
// failure - container startup must not crash on it.
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2002'
|
||||
) {
|
||||
console.log(
|
||||
`Seed: admin user ${email} was created concurrently, skipping.`,
|
||||
);
|
||||
} else {
|
||||
log(`Seed: admin user ${email} was created concurrently, skipping.`);
|
||||
return 'skipped';
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
// Only when run as `node dist/seed.js`, not when a test imports seedAdmin.
|
||||
if (require.main === module) {
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
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];
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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 { ThrottlerStorageModule } from '../common/throttler/throttler-storage.module';
|
||||
import { ApiKeysController } from './api-keys.controller';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
import { FailedAttemptLimiter } from './failed-attempt-limiter';
|
||||
|
||||
// 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()
|
||||
@Module({
|
||||
imports: [ThrottlerStorageModule],
|
||||
controllers: [ApiKeysController],
|
||||
providers: [ApiKeysService],
|
||||
providers: [ApiKeysService, FailedAttemptLimiter],
|
||||
exports: [ApiKeysService],
|
||||
})
|
||||
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 { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.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 {
|
||||
API_KEY_FAILURE_LIMIT,
|
||||
ApiKeysService,
|
||||
extractApiKeyToken,
|
||||
} from './api-keys.service';
|
||||
FailedAttemptLimiter,
|
||||
} from './failed-attempt-limiter';
|
||||
|
||||
// Synthetischer Schlüssel im echten Format - kein echter Zugang.
|
||||
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', () => {
|
||||
let prisma: { apiKey: { findUnique: jest.Mock; update: jest.Mock } };
|
||||
let service: ApiKeysService;
|
||||
@@ -47,6 +85,7 @@ describe('ApiKeysService.authenticate', () => {
|
||||
prisma as unknown as PrismaService,
|
||||
{ record: jest.fn() } as unknown as AuditLogService,
|
||||
{ getPermissions: jest.fn() } as unknown as PermissionResolverService,
|
||||
new FailedAttemptLimiter(inMemoryStorage()),
|
||||
);
|
||||
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
@@ -166,6 +205,18 @@ describe('ApiKeysService.authenticate', () => {
|
||||
).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 () => {
|
||||
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
|
||||
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', () => {
|
||||
it('erkennt nur Bearer-Schlüssel im ApiCore-Format', () => {
|
||||
expect(extractApiKeyToken(`Bearer ${TOKEN}`)).toBe(TOKEN);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
@@ -11,11 +12,18 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { ApiKey } from '../../generated/prisma';
|
||||
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 { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
|
||||
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';
|
||||
|
||||
function normalizeAllowlist(entries: readonly string[] | undefined): string[] {
|
||||
@@ -38,21 +46,6 @@ const LAST_USED_RESOLUTION_MS = 5 * 60 * 1000;
|
||||
// revealing its secret part.
|
||||
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';
|
||||
|
||||
/** 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()
|
||||
export class ApiKeysService {
|
||||
private readonly logger = new Logger(ApiKeysService.name);
|
||||
private readonly failures = new FailedAttemptLimiter(API_KEY_FAILURE_LIMIT);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly permissionResolver: PermissionResolverService,
|
||||
// Counters shared by all api pods (ThrottlerHit), not process memory.
|
||||
private readonly failures: FailedAttemptLimiter,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<ApiKeySummary[]> {
|
||||
@@ -110,6 +104,19 @@ export class ApiKeysService {
|
||||
dto: CreateApiKeyDto,
|
||||
actingUserId: string,
|
||||
): 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.
|
||||
const callerPermissions =
|
||||
await this.permissionResolver.getPermissions(actingUserId);
|
||||
@@ -224,20 +231,21 @@ export class ApiKeysService {
|
||||
if (reason === null && apiKey) {
|
||||
return this.accept(apiKey, clientIp, now);
|
||||
}
|
||||
this.recordRejection(token, reason ?? 'unknown', clientIp);
|
||||
await this.recordRejection(token, reason ?? 'unknown', clientIp);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Counts an invalid key; 429 once the client network is over the limit. */
|
||||
private recordRejection(
|
||||
private async recordRejection(
|
||||
token: string,
|
||||
reason: RejectReason,
|
||||
clientIp: string | undefined,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
// Counted per client network like the throttler (IPv6 /64), so rotating
|
||||
// addresses inside one's own prefix does not reset the budget.
|
||||
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
|
||||
// bare string), so clients can read `message` uniformly.
|
||||
throw new HttpException(
|
||||
@@ -254,9 +262,9 @@ export class ApiKeysService {
|
||||
this.logger.warn(
|
||||
`API key rejected (${reason}): ${token.slice(0, LOGGABLE_PREFIX_LENGTH)} from ${clientIp ?? 'unknown'}`,
|
||||
);
|
||||
if (this.failures.recordFailure(subject)) {
|
||||
if (verdict.limitReached) {
|
||||
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', () => {
|
||||
let now: number;
|
||||
let limiter: FailedAttemptLimiter;
|
||||
const increment = jest.fn();
|
||||
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(() => {
|
||||
now = 1_000_000;
|
||||
limiter = new FailedAttemptLimiter({
|
||||
maxFailures: 3,
|
||||
windowMs: 60_000,
|
||||
maxTrackedSources: 100,
|
||||
clock: () => now,
|
||||
increment.mockReset();
|
||||
increment.mockResolvedValue(record(1, false));
|
||||
});
|
||||
|
||||
it('zählt in der gemeinsamen Zähler-Tabelle, getrennt vom Anfrage-Limit', async () => {
|
||||
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', () => {
|
||||
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
|
||||
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
|
||||
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
|
||||
it('sperrt nicht unterhalb der Grenze', async () => {
|
||||
increment.mockResolvedValueOnce(
|
||||
record(API_KEY_FAILURE_LIMIT.maxFailures - 1, false),
|
||||
);
|
||||
|
||||
// Der Fehlversuch, mit dem die Grenze erreicht wird, meldet die Sperre.
|
||||
expect(limiter.recordFailure('203.0.113.7')).toBe(true);
|
||||
expect(limiter.isBlocked('203.0.113.7')).toBe(true);
|
||||
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
|
||||
blocked: false,
|
||||
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('meldet genau den Fehlversuch, mit dem die Grenze erreicht ist', async () => {
|
||||
increment.mockResolvedValueOnce(
|
||||
record(API_KEY_FAILURE_LIMIT.maxFailures, false),
|
||||
);
|
||||
|
||||
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
|
||||
blocked: false,
|
||||
limitReached: true,
|
||||
});
|
||||
});
|
||||
|
||||
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('sperrt, sobald die Datenbank die Quelle als gesperrt meldet', async () => {
|
||||
increment.mockResolvedValueOnce(
|
||||
record(API_KEY_FAILURE_LIMIT.maxFailures + 1, true),
|
||||
);
|
||||
|
||||
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
|
||||
blocked: true,
|
||||
limitReached: 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('gibt Datenbankfehler weiter, statt still durchzulassen', async () => {
|
||||
increment.mockRejectedValueOnce(new Error('db down'));
|
||||
|
||||
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', () => {
|
||||
const small = new FailedAttemptLimiter({
|
||||
maxFailures: 5,
|
||||
windowMs: 60_000,
|
||||
maxTrackedSources: 2,
|
||||
clock: () => now,
|
||||
});
|
||||
small.recordFailure('a');
|
||||
small.recordFailure('b');
|
||||
small.recordFailure('c');
|
||||
expect(small.trackedSources()).toBe(2);
|
||||
});
|
||||
it('verdrängt bei voller Liste zuerst nicht gesperrte Quellen', () => {
|
||||
const small = new FailedAttemptLimiter({
|
||||
maxFailures: 2,
|
||||
windowMs: 60_000,
|
||||
maxTrackedSources: 2,
|
||||
clock: () => now,
|
||||
});
|
||||
small.recordFailure('angreifer');
|
||||
small.recordFailure('angreifer'); // gesperrt, ältester Eintrag
|
||||
small.recordFailure('harmlos'); // ein Fehlversuch, nicht gesperrt
|
||||
small.recordFailure('neu');
|
||||
|
||||
expect(small.trackedSources()).toBe(2);
|
||||
// Die Sperre bleibt, obwohl sie der älteste Eintrag ist.
|
||||
expect(small.isBlocked('angreifer')).toBe(true);
|
||||
// Verdrängt wurde der nicht gesperrte Eintrag.
|
||||
expect(small.recordFailure('harmlos')).toBe(false);
|
||||
});
|
||||
|
||||
it('verdrängt gesperrte Quellen erst, wenn alle gesperrt sind (älteste zuerst)', () => {
|
||||
const small = new FailedAttemptLimiter({
|
||||
maxFailures: 1,
|
||||
windowMs: 60_000,
|
||||
maxTrackedSources: 2,
|
||||
clock: () => now,
|
||||
});
|
||||
small.recordFailure('a');
|
||||
small.recordFailure('b');
|
||||
small.recordFailure('c');
|
||||
|
||||
expect(small.trackedSources()).toBe(2);
|
||||
expect(small.isBlocked('a')).toBe(false);
|
||||
expect(small.isBlocked('b')).toBe(true);
|
||||
expect(small.isBlocked('c')).toBe(true);
|
||||
await expect(limiter.recordFailure('203.0.113.7')).rejects.toThrow(
|
||||
'db down',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,94 +1,57 @@
|
||||
export interface FailedAttemptLimiterOptions {
|
||||
/** Ab so vielen Fehlversuchen im Fenster ist die Quelle gesperrt. */
|
||||
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;
|
||||
}
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
|
||||
|
||||
interface Window {
|
||||
failures: number;
|
||||
startedAt: number;
|
||||
/**
|
||||
* Unknown, revoked or expired keys per client network before that network
|
||||
* 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
|
||||
* Zeitfenster und sperrt die Quelle, sobald die Obergrenze erreicht ist.
|
||||
*
|
||||
* Bewusst im Speicher des Prozesses, wie der globale Throttler: ApiCore läuft
|
||||
* mit einem Backend-Pod. Nach einem Neustart beginnt die Zählung von vorn -
|
||||
* das ist für eine Bremse gegen Durchprobieren ausreichend.
|
||||
* Counts invalid API keys per source (client network) in the shared,
|
||||
* database-backed rate-limit storage, so the limit holds across all api
|
||||
* pods. One upsert per failure, every time comparison on the database
|
||||
* clock (PrismaThrottlerStorage). The window starts with the first failure;
|
||||
* the failure after the limit blocks the source for one window length.
|
||||
* A database error propagates: the request fails instead of passing.
|
||||
*/
|
||||
@Injectable()
|
||||
export class FailedAttemptLimiter {
|
||||
private readonly windows = new Map<string, Window>();
|
||||
private readonly clock: () => number;
|
||||
constructor(private readonly storage: PrismaThrottlerStorage) {}
|
||||
|
||||
constructor(private readonly options: FailedAttemptLimiterOptions) {
|
||||
this.clock = options.clock ?? Date.now;
|
||||
}
|
||||
|
||||
isBlocked(source: string): boolean {
|
||||
const window = this.activeWindow(source);
|
||||
return window !== undefined && window.failures >= this.options.maxFailures;
|
||||
}
|
||||
|
||||
/** Zählt einen Fehlversuch; true genau dann, wenn er die Sperre auslöst. */
|
||||
recordFailure(source: string): boolean {
|
||||
const now = this.clock();
|
||||
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;
|
||||
async recordFailure(source: string): Promise<FailedAttemptVerdict> {
|
||||
const { maxFailures, windowMs } = API_KEY_FAILURE_LIMIT;
|
||||
const record = await this.storage.increment(
|
||||
`${API_KEY_FAILURE_THROTTLER}:${source}`,
|
||||
windowMs,
|
||||
maxFailures,
|
||||
windowMs,
|
||||
API_KEY_FAILURE_THROTTLER,
|
||||
);
|
||||
return {
|
||||
blocked: record.isBlocked,
|
||||
limitReached: !record.isBlocked && record.totalHits === maxFailures,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -15,12 +15,16 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { CreateApiaryDto } from './dto/create-apiary.dto';
|
||||
import { UpdateApiaryDto } from './dto/update-apiary.dto';
|
||||
import { ApiariesService } from './apiaries.service';
|
||||
import { ApiaryFieldService } from './apiary-field.service';
|
||||
|
||||
@ApiTags('apiaries')
|
||||
@ApiBearerAuth()
|
||||
@Controller('apiaries')
|
||||
export class ApiariesController {
|
||||
constructor(private readonly apiariesService: ApiariesService) {}
|
||||
constructor(
|
||||
private readonly apiariesService: ApiariesService,
|
||||
private readonly apiaryFieldService: ApiaryFieldService,
|
||||
) {}
|
||||
|
||||
@RequirePermission('apiaries:read')
|
||||
@Get()
|
||||
@@ -31,6 +35,24 @@ export class ApiariesController {
|
||||
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')
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
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 { ApiariesService } from './apiaries.service';
|
||||
import { ApiaryFieldService } from './apiary-field.service';
|
||||
|
||||
@Module({
|
||||
imports: [AutopilotModule, DomainEventsModule, HiveModule],
|
||||
controllers: [ApiariesController],
|
||||
providers: [ApiariesService],
|
||||
providers: [ApiariesService, ApiaryFieldService],
|
||||
exports: [ApiariesService],
|
||||
})
|
||||
export class ApiariesModule {}
|
||||
@@ -2,44 +2,78 @@ import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { ApiariesService } from './apiaries.service';
|
||||
import { PrismaService } from '../prisma/prisma.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', () => {
|
||||
let service: ApiariesService;
|
||||
let prisma: {
|
||||
apiary: {
|
||||
findMany: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
let order: string[];
|
||||
let tx: {
|
||||
$queryRaw: jest.Mock;
|
||||
apiary: { findUnique: jest.Mock; create: jest.Mock; update: jest.Mock };
|
||||
colony: { count: jest.Mock };
|
||||
};
|
||||
let prisma: {
|
||||
apiary: { findMany: jest.Mock; findUnique: jest.Mock };
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let events: { record: jest.Mock };
|
||||
|
||||
const existingApiary = {
|
||||
id: 'apiary-1',
|
||||
name: 'Heimstand',
|
||||
status: 'ACTIVE',
|
||||
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(() => {
|
||||
prisma = {
|
||||
order = [];
|
||||
tx = {
|
||||
$queryRaw: jest.fn().mockImplementation(() => {
|
||||
order.push('lock');
|
||||
return Promise.resolve([{ id: 'apiary-1' }]);
|
||||
}),
|
||||
apiary: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
findUnique: jest.fn().mockImplementation(() => {
|
||||
order.push('read');
|
||||
return Promise.resolve(existingApiary);
|
||||
}),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
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) };
|
||||
events = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new ApiariesService(
|
||||
prisma as unknown as PrismaService,
|
||||
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 () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue(null);
|
||||
await expect(service.findById('missing')).rejects.toBeInstanceOf(
|
||||
@@ -47,89 +81,152 @@ describe('ApiariesService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('create persists the apiary and records an audit entry', async () => {
|
||||
prisma.apiary.create.mockResolvedValue(existingApiary);
|
||||
it('create writes record, audit and ApiaryChanged CREATED in one transaction', async () => {
|
||||
tx.apiary.create.mockResolvedValue(existingApiary);
|
||||
|
||||
const result = await service.create({ name: 'Heimstand' }, 'user-1');
|
||||
|
||||
expect(result).toEqual(existingApiary);
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'Apiary',
|
||||
action: 'CREATE',
|
||||
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 () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
||||
prisma.apiary.update.mockResolvedValue({
|
||||
it('update of a missing apiary answers 404', async () => {
|
||||
tx.apiary.findUnique.mockResolvedValue(null);
|
||||
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,
|
||||
status: 'INACTIVE',
|
||||
deactivatedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.deactivate('apiary-1', 'user-1');
|
||||
|
||||
expect(result.status).toBe('INACTIVE');
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ entityType: 'Apiary', action: 'DEACTIVATE' }),
|
||||
);
|
||||
});
|
||||
|
||||
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({
|
||||
expect(order).toEqual(['lock', 'read']);
|
||||
expect(tx.colony.count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
apiaryId: 'apiary-1',
|
||||
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 () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
||||
prisma.colony.count.mockResolvedValue(2);
|
||||
tx.colony.count.mockResolvedValue(2);
|
||||
|
||||
await expect(
|
||||
service.deactivate('apiary-1', 'user-1'),
|
||||
).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 () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
||||
await expect(
|
||||
service.reactivate('apiary-1', 'user-1'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(events.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reactivate sets status to ACTIVE and clears deactivatedAt', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue({
|
||||
it('reactivate records REACTIVATED and clears deactivatedAt', async () => {
|
||||
tx.apiary.findUnique.mockResolvedValue({
|
||||
...existingApiary,
|
||||
status: 'INACTIVE',
|
||||
deactivatedAt: new Date(),
|
||||
});
|
||||
prisma.apiary.update.mockResolvedValue({
|
||||
...existingApiary,
|
||||
status: 'ACTIVE',
|
||||
deactivatedAt: null,
|
||||
});
|
||||
tx.apiary.update.mockResolvedValue(existingApiary);
|
||||
|
||||
const result = await service.reactivate('apiary-1', 'user-1');
|
||||
|
||||
expect(result.status).toBe('ACTIVE');
|
||||
expect(prisma.apiary.update).toHaveBeenCalledWith({
|
||||
expect(tx.apiary.update).toHaveBeenCalledWith({
|
||||
where: { id: 'apiary-1' },
|
||||
data: { status: 'ACTIVE', deactivatedAt: null },
|
||||
});
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ entityType: 'Apiary', action: 'UPDATE' }),
|
||||
tx,
|
||||
);
|
||||
expect(changes()).toEqual(['REACTIVATED']);
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,11 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.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 { Apiary, ApiaryStatus } from '../../generated/prisma';
|
||||
import { Apiary, ApiaryStatus, Prisma } from '../../generated/prisma';
|
||||
import { CreateApiaryDto } from './dto/create-apiary.dto';
|
||||
import { UpdateApiaryDto } from './dto/update-apiary.dto';
|
||||
|
||||
@@ -15,11 +18,22 @@ export interface ApiaryFilter {
|
||||
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()
|
||||
export class ApiariesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly events: DomainEventsService,
|
||||
) {}
|
||||
|
||||
async findAll(filter: ApiaryFilter = {}): Promise<Apiary[]> {
|
||||
@@ -43,15 +57,21 @@ export class ApiariesService {
|
||||
}
|
||||
|
||||
async create(dto: CreateApiaryDto, actingUserId: string): Promise<Apiary> {
|
||||
const apiary = await this.prisma.apiary.create({ data: { ...dto } });
|
||||
await this.auditLog.record({
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const apiary = await tx.apiary.create({ data: { ...dto } });
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Apiary',
|
||||
entityId: apiary.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: apiary,
|
||||
});
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await this.recordChange(tx, apiary.id, 'CREATED', actingUserId);
|
||||
return apiary;
|
||||
});
|
||||
}
|
||||
|
||||
async update(
|
||||
@@ -59,30 +79,41 @@ export class ApiariesService {
|
||||
dto: UpdateApiaryDto,
|
||||
actingUserId: string,
|
||||
): Promise<Apiary> {
|
||||
const before = await this.findById(id);
|
||||
const apiary = await this.prisma.apiary.update({
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const before = await this.lockApiary(tx, id);
|
||||
const apiary = await tx.apiary.update({
|
||||
where: { id },
|
||||
data: { ...dto },
|
||||
});
|
||||
await this.auditLog.record({
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
async deactivate(id: string, actingUserId: string): Promise<Apiary> {
|
||||
const before = await this.findById(id);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const before = await this.lockApiary(tx, id);
|
||||
// Colony.apiaryId is a plain FK, not enforced against apiary status -
|
||||
// without this guard a colony can silently keep pointing at an
|
||||
// inactive apiary (still shows ACTIVE, still countable, but its
|
||||
// "location" has vanished from the active apiary list). Move the
|
||||
// colonies elsewhere or dissolve them first instead.
|
||||
const activeColonyCount = await this.prisma.colony.count({
|
||||
// colonies elsewhere or dissolve them first instead. Counted under the
|
||||
// apiary lock: the colony paths hold the apiary FOR SHARE, so a move
|
||||
// into it that is still running finishes first.
|
||||
const activeColonyCount = await tx.colony.count({
|
||||
where: { apiaryId: id, ...LIVING_COLONY_WHERE },
|
||||
});
|
||||
if (activeColonyCount > 0) {
|
||||
@@ -90,38 +121,78 @@ export class ApiariesService {
|
||||
`Der Bienenstand hat noch ${activeColonyCount} aktive(s) Volk/Völker - zuerst verlegen oder auflösen`,
|
||||
);
|
||||
}
|
||||
const apiary = await this.prisma.apiary.update({
|
||||
const apiary = await tx.apiary.update({
|
||||
where: { id },
|
||||
data: { status: 'INACTIVE', deactivatedAt: new Date() },
|
||||
});
|
||||
await this.auditLog.record({
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Apiary',
|
||||
entityId: id,
|
||||
action: 'DEACTIVATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: apiary,
|
||||
});
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await this.recordChange(tx, id, 'DEACTIVATED', actingUserId);
|
||||
return apiary;
|
||||
});
|
||||
}
|
||||
|
||||
async reactivate(id: string, actingUserId: string): Promise<Apiary> {
|
||||
const before = await this.findById(id);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const before = await this.lockApiary(tx, id);
|
||||
if (before.status === 'ACTIVE') {
|
||||
throw new ConflictException('Apiary is already active');
|
||||
}
|
||||
const apiary = await this.prisma.apiary.update({
|
||||
const apiary = await tx.apiary.update({
|
||||
where: { id },
|
||||
data: { status: 'ACTIVE', deactivatedAt: null },
|
||||
});
|
||||
await this.auditLog.record({
|
||||
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');
|
||||
}
|
||||
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 { IsLatitude, IsLongitude, IsOptional, IsString } from 'class-validator';
|
||||
import {
|
||||
IsLatitude,
|
||||
IsLongitude,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateApiaryDto {
|
||||
@ApiProperty()
|
||||
@@ -35,4 +41,13 @@ export class CreateApiaryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
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
|
||||
* count is not stable; TRUST_PROXY is an Express list there ("loopback,
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
// on a shared address instead of the client.
|
||||
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
|
||||
|
||||
@@ -3,10 +3,12 @@ import { ConfigModule } from '@nestjs/config';
|
||||
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
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 { HealthModule } from './health/health.module';
|
||||
import { AuditModule } from './audit/audit.module';
|
||||
import { DomainEventsModule } from './domain-events/domain-events.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { RolesModule } from './roles/roles.module';
|
||||
@@ -26,6 +28,8 @@ import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { SearchModule } from './search/search.module';
|
||||
import { ExportModule } from './export/export.module';
|
||||
import { QrModule } from './qr/qr.module';
|
||||
import { PhenologyModule } from './phenology/phenology.module';
|
||||
import { RemindersModule } from './reminders/reminders.module';
|
||||
import { ProductsModule } from './products/products.module';
|
||||
import { BottlingModule } from './bottling/bottling.module';
|
||||
import { ProductionBatchesModule } from './production-batches/production-batches.module';
|
||||
@@ -51,20 +55,36 @@ import { BackupModule } from './backup/backup.module';
|
||||
import { ShopSyncModule } from './shop-sync/shop-sync.module';
|
||||
import { ErpExportModule } from './erp-export/erp-export.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 { PermissionsGuard } from './auth/guards/permissions.guard';
|
||||
import { PermissionsModule } from './auth/permissions/permissions.module';
|
||||
import { IdempotencyInterceptor } from './common/idempotency/idempotency.interceptor';
|
||||
import { IdempotencyCleanupService } from './common/idempotency/idempotency-cleanup.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
ScheduleModule.forRoot(),
|
||||
ThrottlerModule.forRoot([{ name: 'default', ttl: 60000, limit: 100 }]),
|
||||
ThrottlerModule.forRootAsync({
|
||||
imports: [ThrottlerStorageModule],
|
||||
inject: [PrismaThrottlerStorage],
|
||||
useFactory: (storage: PrismaThrottlerStorage) => ({
|
||||
throttlers: [{ name: 'default', ttl: 60000, limit: 100 }],
|
||||
storage,
|
||||
}),
|
||||
}),
|
||||
PrismaModule,
|
||||
PermissionsModule,
|
||||
AuditModule,
|
||||
DomainEventsModule,
|
||||
HealthModule,
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
@@ -97,6 +117,12 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
|
||||
WebhooksModule,
|
||||
DocumentsModule,
|
||||
AutomationModule,
|
||||
SuggestionsModule,
|
||||
CleanupModule,
|
||||
AutopilotModule,
|
||||
PhenologyModule,
|
||||
WithdrawalModule,
|
||||
RemindersModule,
|
||||
LabelTemplatesModule,
|
||||
ImportModule,
|
||||
PublicModule,
|
||||
@@ -110,6 +136,12 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
|
||||
ShopSyncModule,
|
||||
ErpExportModule,
|
||||
ApiKeysModule,
|
||||
SystemModule,
|
||||
VeterinaryMedicinesModule,
|
||||
HiveModule,
|
||||
ComplianceModule,
|
||||
ReadinessModule,
|
||||
VoiceModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [
|
||||
@@ -119,7 +151,6 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
|
||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
||||
{ provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
|
||||
IdempotencyCleanupService,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -43,6 +43,18 @@ describe('AuditLogService', () => {
|
||||
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 () => {
|
||||
await service.findAll({ entityType: 'Colony', entityId: 'colony-1' });
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { DbClient } from '../prisma/db-client';
|
||||
import { AuditAction, AuditLog, Prisma } from '../../generated/prisma';
|
||||
|
||||
interface RecordAuditLogInput {
|
||||
export interface RecordAuditLogInput {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
action: AuditAction;
|
||||
@@ -20,8 +21,17 @@ export interface AuditLogFilter {
|
||||
export class AuditLogService {
|
||||
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: {
|
||||
entityType: input.entityType,
|
||||
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', () => {
|
||||
const originalSecret = process.env.JWT_ACCESS_SECRET;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
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(() => {
|
||||
process.env.JWT_ACCESS_SECRET = originalSecret;
|
||||
process.env = { ...originalEnv };
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('round-trips a TOTP secret through encrypt/decrypt', () => {
|
||||
const plaintext = 'JBSWY3DPEHPK3PXP';
|
||||
const ciphertext = encryptMfaSecret(plaintext);
|
||||
expect(ciphertext).not.toContain(plaintext);
|
||||
expect(decryptMfaSecret(ciphertext)).toBe(plaintext);
|
||||
it('round-trips a TOTP secret and prefixes the key id', () => {
|
||||
const ciphertext = encryptMfaSecret(PLAINTEXT);
|
||||
|
||||
expect(ciphertext).toMatch(KEY_ID_PATTERN);
|
||||
expect(ciphertext).not.toContain(PLAINTEXT);
|
||||
expect(decryptMfaSecret(ciphertext)).toBe(PLAINTEXT);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const ciphertext = encryptMfaSecret('JBSWY3DPEHPK3PXP');
|
||||
process.env.JWT_ACCESS_SECRET = 'a-different-secret';
|
||||
expect(() => decryptMfaSecret(ciphertext)).toThrow();
|
||||
const ciphertext = encryptMfaSecret(PLAINTEXT);
|
||||
process.env.MFA_ENCRYPTION_KEY = 'n'.repeat(40);
|
||||
|
||||
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', () => {
|
||||
expect(() => decryptMfaSecret('not-a-valid-ciphertext')).toThrow(
|
||||
'Invalid MFA secret ciphertext',
|
||||
'malformed ciphertext',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,53 +1,31 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
randomBytes,
|
||||
} from 'node:crypto';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
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();
|
||||
}
|
||||
KEY_ID_PATTERN,
|
||||
isSealedWithCurrentKey,
|
||||
loadKeyRing,
|
||||
openLegacyCiphertext,
|
||||
openWithRing,
|
||||
sealWithRing,
|
||||
} from '../../common/crypto/key-ring';
|
||||
|
||||
/**
|
||||
* TOTP secrets must be reversible (unlike passwords/refresh tokens, which
|
||||
* 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
|
||||
* out of the DB in plaintext without introducing a new secret to manage.
|
||||
* login. They are sealed with the MFA key ring (MFA_ENCRYPTION_KEY, see
|
||||
* 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 {
|
||||
const iv = randomBytes(IV_BYTES);
|
||||
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('.');
|
||||
return sealWithRing(loadKeyRing('mfa'), plaintext);
|
||||
}
|
||||
|
||||
export function decryptMfaSecret(ciphertext: string): string {
|
||||
const [ivB64, authTagB64, dataB64] = ciphertext.split('.');
|
||||
if (!ivB64 || !authTagB64 || !dataB64) {
|
||||
throw new Error('Invalid MFA secret ciphertext');
|
||||
export function decryptMfaSecret(stored: string): string {
|
||||
return KEY_ID_PATTERN.test(stored)
|
||||
? openWithRing(loadKeyRing('mfa'), stored).plaintext
|
||||
: openLegacyCiphertext('mfa', stored);
|
||||
}
|
||||
const decipher = createDecipheriv(
|
||||
ALGORITHM,
|
||||
getKey(),
|
||||
Buffer.from(ivB64, 'base64'),
|
||||
);
|
||||
decipher.setAuthTag(Buffer.from(authTagB64, 'base64'));
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(dataB64, 'base64')),
|
||||
decipher.final(),
|
||||
]).toString('utf8');
|
||||
|
||||
/** True for secrets the rekey job should reseal with the current key. */
|
||||
export function mfaSecretNeedsRekey(stored: string): boolean {
|
||||
return !isSealedWithCurrentKey(loadKeyRing('mfa'), stored);
|
||||
}
|
||||
@@ -123,6 +123,18 @@ export const PERMISSION_CATALOG = [
|
||||
],
|
||||
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(
|
||||
'Behandlungen',
|
||||
'treatments',
|
||||
@@ -361,6 +373,15 @@ export const PERMISSION_CATALOG = [
|
||||
[['manage', 'Anlegen, ansehen und widerrufen']],
|
||||
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
|
||||
// (ERPNext's nightly inventory mirror), separate from products:read etc.,
|
||||
// which return descriptions and full records.
|
||||
@@ -368,8 +389,34 @@ export const PERMISSION_CATALOG = [
|
||||
'ERP-Anbindung',
|
||||
'erp-inventory',
|
||||
[['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[];
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
|
||||
|
||||
@@ -37,6 +37,19 @@ export class PermissionResolverService {
|
||||
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
|
||||
* 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)) {
|
||||
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 =
|
||||
@@ -45,9 +45,7 @@ export class ReauthService {
|
||||
(await bcrypt.compare(proof.password, user.passwordHash));
|
||||
if (!passwordMatches) {
|
||||
await this.accountGuard.recordFailure(user.id);
|
||||
throw new UnauthorizedException(
|
||||
'Bitte zur Bestätigung Ihr aktuelles Passwort eingeben',
|
||||
);
|
||||
throw new UnauthorizedException('Das Passwort stimmt nicht.');
|
||||
}
|
||||
if (user.mfaEnabled) {
|
||||
const codeMatches =
|
||||
@@ -55,9 +53,7 @@ export class ReauthService {
|
||||
(await this.accountGuard.consumeTotp(user, proof.mfaCode));
|
||||
if (!codeMatches) {
|
||||
await this.accountGuard.recordFailure(user.id);
|
||||
throw new UnauthorizedException(
|
||||
'Bitte zur Bestätigung den aktuellen Code aus Ihrer Authenticator-App eingeben',
|
||||
);
|
||||
throw new UnauthorizedException('Der Code stimmt nicht.');
|
||||
}
|
||||
}
|
||||
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 { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { StockModule } from '../stock/stock.module';
|
||||
import { SuggestionsModule } from '../suggestions/suggestions.module';
|
||||
import { AutomationController } from './automation.controller';
|
||||
import { AutomationEventHandlers } from './automation.handlers';
|
||||
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({
|
||||
imports: [NotificationsModule],
|
||||
imports: [SuggestionsModule, StockModule],
|
||||
controllers: [AutomationController],
|
||||
providers: [AutomationService],
|
||||
providers: [AutomationService, AutomationEventHandlers],
|
||||
exports: [AutomationService],
|
||||
})
|
||||
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 2078 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user