Compare commits
236
Commits
ac45514356
..
main
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
# Shell scripts must keep LF line endings, also on Windows checkouts with
|
||||||
|
# core.autocrlf, or bash fails on the carriage returns.
|
||||||
|
*.sh text eol=lf
|
||||||
|
|
||||||
|
# The Whisper word list is generated (npm run export:voice-vocabulary) and
|
||||||
|
# copied into the image; keep LF so the file stays byte-identical everywhere.
|
||||||
|
ApiCore-Whisper/vocabulary.txt text eol=lf
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
name: Backend E2E
|
||||||
|
|
||||||
|
# Fuehrt `npm run test:e2e` (Jest, maxWorkers 4, siehe test/jest-e2e.json)
|
||||||
|
# gegen einen frischen Postgres-Service-Container aus. Das Backend-Image
|
||||||
|
# (backend-image.yml) fuehrt nur Lint, Typecheck und Unit-Tests aus, weil der
|
||||||
|
# Docker-Build keine Datenbank hat; diese Pipeline schliesst die Luecke.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "ApiCore-Backend/**"
|
||||||
|
- ".gitea/workflows/backend-e2e.yml"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Ein neuer Push auf denselben Ref ersetzt den laufenden Lauf.
|
||||||
|
concurrency:
|
||||||
|
group: backend-e2e-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
e2e:
|
||||||
|
runs-on: docker-builder
|
||||||
|
timeout-minutes: 30
|
||||||
|
# Gitea act_runner unterstuetzt `services:`. Annahme (beim ersten Lauf
|
||||||
|
# pruefen): der Job-Container teilt das Netz der Services und erreicht
|
||||||
|
# Postgres unter dem Service-Namen `postgres`. Laeuft der Runner
|
||||||
|
# stattdessen im Host-Modus, ist der Port auf localhost veroeffentlicht;
|
||||||
|
# der Schritt "Datenbank-Host ermitteln" probiert beide Wege.
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
# Gleiche Major-Version wie Produktion (deploy/k8s/base/postgres).
|
||||||
|
# Per Digest gepinnt (Index-Digest von 18.6-alpine, nachgeschlagen
|
||||||
|
# 2026-10-16); die k8s-Manifeste pinnen Postgres bisher nur per Tag.
|
||||||
|
image: postgres:18.6-alpine@sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873
|
||||||
|
env:
|
||||||
|
# Wegwerf-Datenbank im Service-Container, nur im Job-Netz erreichbar
|
||||||
|
# und nach dem Lauf verworfen: kein echtes Secret.
|
||||||
|
POSTGRES_USER: apicore
|
||||||
|
POSTGRES_PASSWORD: apicore-ci-only
|
||||||
|
POSTGRES_DB: apicore_test
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U apicore -d apicore_test"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 20
|
||||||
|
env:
|
||||||
|
NODE_ENV: test
|
||||||
|
# Das Wetter-e2e ruft die echte Open-Meteo-API; in CI ueberspringen.
|
||||||
|
SKIP_EXTERNAL: "1"
|
||||||
|
JWT_ACCESS_EXPIRES_IN: 15m
|
||||||
|
JWT_REFRESH_EXPIRES_IN: 7d
|
||||||
|
PORT: "3001"
|
||||||
|
FRONTEND_ORIGIN: http://localhost:3000
|
||||||
|
DOCUMENTS_STORAGE_PATH: ./test-uploads
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ApiCore-Backend
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
|
- name: Node einrichten
|
||||||
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Datenbank-Host ermitteln und Testumgebung erzeugen
|
||||||
|
# jest-e2e.json laedt .env.test per dotenv, das vorhandene Variablen
|
||||||
|
# nicht ueberschreibt: alles kommt aus der Job-Umgebung, .env.test
|
||||||
|
# existiert in CI nicht (gitignored). Die JWT-Secrets sind frisch
|
||||||
|
# erzeugte Wegwerfwerte (mind. 32 Zeichen, voneinander verschieden),
|
||||||
|
# nie echte Secrets.
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
host=""
|
||||||
|
for candidate in postgres localhost; do
|
||||||
|
if node -e "const s=require('net').connect(5432,process.argv[1]);s.on('connect',()=>process.exit(0));s.on('error',()=>process.exit(1));setTimeout(()=>process.exit(1),3000)" "$candidate"; then
|
||||||
|
host="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -z "$host" ]; then
|
||||||
|
echo "Postgres weder unter 'postgres' noch unter 'localhost' erreichbar" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Postgres-Host: $host"
|
||||||
|
access="$(openssl rand -hex 32)"
|
||||||
|
refresh="$(openssl rand -hex 32)"
|
||||||
|
echo "::add-mask::$access"
|
||||||
|
echo "::add-mask::$refresh"
|
||||||
|
{
|
||||||
|
echo "DATABASE_URL=postgresql://apicore:apicore-ci-only@${host}:5432/apicore_test?schema=public"
|
||||||
|
echo "JWT_ACCESS_SECRET=$access"
|
||||||
|
echo "JWT_REFRESH_SECRET=$refresh"
|
||||||
|
} >> "$GITEA_ENV"
|
||||||
|
|
||||||
|
- name: Abhaengigkeiten installieren
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Prisma-Client erzeugen
|
||||||
|
run: npx prisma generate
|
||||||
|
|
||||||
|
- name: Migrationen anwenden
|
||||||
|
# Die Rollen (z. B. BEEKEEPER) kommen aus den Migrationen, ein Seed
|
||||||
|
# ist fuer die e2e-Tests nicht noetig.
|
||||||
|
run: npx prisma migrate deploy
|
||||||
|
|
||||||
|
- name: E2E-Tests
|
||||||
|
# Auf dem geteilten Runner brauchten App-Start plus bcrypt-Logins in
|
||||||
|
# beforeAll mehr als die lokalen 20 s (Laeufe 826, 836); hier 60 s.
|
||||||
|
run: npm run test:e2e -- --ci --testTimeout=60000
|
||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
REGISTRY_PUSH: gitea:80
|
REGISTRY_PUSH: gitea:80
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
- name: Image-Namen in Kleinbuchstaben ermitteln
|
- name: Image-Namen in Kleinbuchstaben ermitteln
|
||||||
# OCI-Registries erlauben nur Kleinbuchstaben im Repository-Pfad;
|
# OCI-Registries erlauben nur Kleinbuchstaben im Repository-Pfad;
|
||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
run: echo "name=$(echo '${{ gitea.repository_owner }}/apicore-backend' | tr '[:upper:]' '[:lower:]')" >> "$GITEA_OUTPUT"
|
run: echo "name=$(echo '${{ gitea.repository_owner }}/apicore-backend' | tr '[:upper:]' '[:lower:]')" >> "$GITEA_OUTPUT"
|
||||||
|
|
||||||
- name: Docker Buildx einrichten
|
- name: Docker Buildx einrichten
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||||
with:
|
with:
|
||||||
# REGISTRY_PUSH ist Klartext-HTTP (Gitea-Service intern ohne TLS)
|
# REGISTRY_PUSH ist Klartext-HTTP (Gitea-Service intern ohne TLS)
|
||||||
# statt HTTPS — BuildKit lehnt das ohne diese Freigabe ab.
|
# statt HTTPS — BuildKit lehnt das ohne diese Freigabe ab.
|
||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
http = true
|
http = true
|
||||||
|
|
||||||
- name: Bei der Gitea-Container-Registry anmelden
|
- name: Bei der Gitea-Container-Registry anmelden
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY_PUSH }}
|
registry: ${{ env.REGISTRY_PUSH }}
|
||||||
username: ${{ gitea.actor }}
|
username: ${{ gitea.actor }}
|
||||||
@@ -50,16 +50,15 @@ jobs:
|
|||||||
|
|
||||||
- name: Metadaten (Tags/Labels) ermitteln
|
- name: Metadaten (Tags/Labels) ermitteln
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
|
||||||
type=sha,format=short
|
type=sha,format=short
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
- name: Image bauen und pushen
|
- name: Image bauen und pushen
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||||
with:
|
with:
|
||||||
context: ./ApiCore-Backend
|
context: ./ApiCore-Backend
|
||||||
push: true
|
push: true
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
REGISTRY_PUSH: gitea:80
|
REGISTRY_PUSH: gitea:80
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
- name: Image-Namen in Kleinbuchstaben ermitteln
|
- name: Image-Namen in Kleinbuchstaben ermitteln
|
||||||
# OCI-Registries erlauben nur Kleinbuchstaben im Repository-Pfad;
|
# OCI-Registries erlauben nur Kleinbuchstaben im Repository-Pfad;
|
||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
run: echo "name=$(echo '${{ gitea.repository_owner }}/apicore-frontend' | tr '[:upper:]' '[:lower:]')" >> "$GITEA_OUTPUT"
|
run: echo "name=$(echo '${{ gitea.repository_owner }}/apicore-frontend' | tr '[:upper:]' '[:lower:]')" >> "$GITEA_OUTPUT"
|
||||||
|
|
||||||
- name: Docker Buildx einrichten
|
- name: Docker Buildx einrichten
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||||
with:
|
with:
|
||||||
# REGISTRY_PUSH ist Klartext-HTTP (Gitea-Service intern ohne TLS)
|
# REGISTRY_PUSH ist Klartext-HTTP (Gitea-Service intern ohne TLS)
|
||||||
# statt HTTPS — BuildKit lehnt das ohne diese Freigabe ab.
|
# statt HTTPS — BuildKit lehnt das ohne diese Freigabe ab.
|
||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
http = true
|
http = true
|
||||||
|
|
||||||
- name: Bei der Gitea-Container-Registry anmelden
|
- name: Bei der Gitea-Container-Registry anmelden
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY_PUSH }}
|
registry: ${{ env.REGISTRY_PUSH }}
|
||||||
username: ${{ gitea.actor }}
|
username: ${{ gitea.actor }}
|
||||||
@@ -50,16 +50,15 @@ jobs:
|
|||||||
|
|
||||||
- name: Metadaten (Tags/Labels) ermitteln
|
- name: Metadaten (Tags/Labels) ermitteln
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
|
||||||
type=sha,format=short
|
type=sha,format=short
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
- name: Image bauen und pushen
|
- name: Image bauen und pushen
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||||
with:
|
with:
|
||||||
context: ./ApiCore-Frontend
|
context: ./ApiCore-Frontend
|
||||||
push: true
|
push: true
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
name: Manifests
|
||||||
|
|
||||||
|
# Prueft deploy/k8s ohne Cluster-Zugriff: Overlay und jeden Rollout-Schritt
|
||||||
|
# rendern, gegen die Kubernetes-Schemas validieren und die Regeln testen
|
||||||
|
# (NetworkPolicies, Pod-Haertung, Ressourcen, CronJobs, Digests, Live-Selektoren).
|
||||||
|
#
|
||||||
|
# Bewusst kein pull_request-Trigger: der Runner laeuft im Cluster, und ein
|
||||||
|
# Fork-PR braechte eigenen Code (auch eine geaenderte Fassung dieser Datei,
|
||||||
|
# also ohne jeden if-Waechter) darauf zur Ausfuehrung. push setzt
|
||||||
|
# Schreibrechte am Repo voraus; jeder Branch-Push wird geprueft, damit auch
|
||||||
|
# Aenderungen vor dem Merge nach main durch die CI gehen.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths: ['deploy/k8s/**', '.gitea/workflows/manifests.yml']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
runs-on: docker-builder
|
||||||
|
env:
|
||||||
|
# Werkzeuge mit festen Versionen; jede Datei wird gegen ihre SHA-256 von
|
||||||
|
# der offiziellen Release-Seite geprueft, bevor sie laeuft:
|
||||||
|
# kubectl: https://dl.k8s.io/release/<v>/bin/linux/amd64/kubectl.sha256
|
||||||
|
# kubeconform: CHECKSUMS im GitHub-Release yannh/kubeconform
|
||||||
|
# yq: checksums (Spalte SHA-256) im GitHub-Release mikefarah/yq
|
||||||
|
# Ein Update aendert Version und Pruefsumme zusammen.
|
||||||
|
KUBECTL_VERSION: v1.36.5
|
||||||
|
KUBECTL_SHA256: 33bc88a24c3b09cf55bfd59e6ca977881b61fc3f25ab65bc80c65b430248648e
|
||||||
|
KUBECONFORM_VERSION: v0.8.0
|
||||||
|
KUBECONFORM_SHA256: 9bc2bffbf71f261128533edaf912153948b7ff238f9a531ae6d34466ec287883
|
||||||
|
YQ_VERSION: v4.53.6
|
||||||
|
YQ_SHA256: c5f056448f973ae7d39b5401949648a78f2dc1947d6a8eb65be60d5c504b9385
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
|
- name: Node einrichten
|
||||||
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: kubectl, kubeconform und yq laden
|
||||||
|
run: |
|
||||||
|
mkdir -p "$RUNNER_TEMP/bin"
|
||||||
|
cd "$RUNNER_TEMP"
|
||||||
|
curl -fsSLo kubectl "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl"
|
||||||
|
echo "${KUBECTL_SHA256} kubectl" | sha256sum -c -
|
||||||
|
curl -fsSLo kubeconform.tar.gz "https://github.com/yannh/kubeconform/releases/download/${KUBECONFORM_VERSION}/kubeconform-linux-amd64.tar.gz"
|
||||||
|
echo "${KUBECONFORM_SHA256} kubeconform.tar.gz" | sha256sum -c -
|
||||||
|
tar -xzf kubeconform.tar.gz kubeconform
|
||||||
|
curl -fsSLo yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64"
|
||||||
|
echo "${YQ_SHA256} yq" | sha256sum -c -
|
||||||
|
install -m 0755 kubectl kubeconform yq "$RUNNER_TEMP/bin/"
|
||||||
|
|
||||||
|
- name: Overlay rendern
|
||||||
|
run: |
|
||||||
|
"$RUNNER_TEMP/bin/kubectl" kustomize deploy/k8s/overlays/encry > "$RUNNER_TEMP/rendered.yaml"
|
||||||
|
"$RUNNER_TEMP/bin/yq" ea -o=json '[.]' "$RUNNER_TEMP/rendered.yaml" > "$RUNNER_TEMP/rendered.json"
|
||||||
|
|
||||||
|
- name: Rollout-Schritte rendern
|
||||||
|
# Runbook Schritte 1, 2, 3 und 5: Overlay mit den bis dahin
|
||||||
|
# freigegebenen Komponenten (siehe render-stages.mjs).
|
||||||
|
run: |
|
||||||
|
export KUBECTL="$RUNNER_TEMP/bin/kubectl" YQ="$RUNNER_TEMP/bin/yq"
|
||||||
|
node deploy/k8s/tests/render-stages.mjs "$RUNNER_TEMP/stages"
|
||||||
|
|
||||||
|
- name: Gegen Kubernetes-Schemas pruefen
|
||||||
|
run: |
|
||||||
|
"$RUNNER_TEMP/bin/kubeconform" -strict -summary "$RUNNER_TEMP/rendered.yaml" "$RUNNER_TEMP"/stages/*.yaml
|
||||||
|
|
||||||
|
- name: Regeln testen
|
||||||
|
run: |
|
||||||
|
export RENDERED_MANIFESTS="$RUNNER_TEMP/rendered.json" RENDERED_STAGES="$RUNNER_TEMP/stages/stages.json"
|
||||||
|
node --test deploy/k8s/tests/manifest-policy.test.mjs
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
name: Whisper Image
|
||||||
|
|
||||||
|
# Nur bei Aenderungen am Abschreib-Image (Spec T7 11.1): das Image ist rund
|
||||||
|
# 1,2 GB gross (Modell + Python-Abhaengigkeiten) und aendert sich selten.
|
||||||
|
# Tag sha-<commit>; das Overlay setzt danach den Digest.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths: ['ApiCore-Whisper/**', '.gitea/workflows/whisper-image.yml']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: docker-builder
|
||||||
|
env:
|
||||||
|
# Nur fuer Doku/OCI-Labels, siehe REGISTRY_PUSH fuer den eigentlichen Push.
|
||||||
|
REGISTRY: git.encry.de
|
||||||
|
# Cluster-interner Service-Name statt git.encry.de: Registry-Blob-
|
||||||
|
# Uploads (PUT) vertragen sich nicht mit dem Cloudflare-Proxy vor
|
||||||
|
# git.encry.de ("413 Payload Too Large" unabhaengig von der Groesse).
|
||||||
|
# Der Runner-Pod erreicht den Gitea-Service direkt ueber Kubernetes-DNS.
|
||||||
|
# Push und Pull landen im selben Registry-Pfad (Owner/Repo:Tag), der
|
||||||
|
# Hostname ist nur die Verbindung, daher bleibt der Pull in den
|
||||||
|
# k8s-Manifesten unveraendert ueber git.encry.de.
|
||||||
|
REGISTRY_PUSH: gitea:80
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
|
- name: Image-Namen in Kleinbuchstaben ermitteln
|
||||||
|
# OCI-Registries erlauben nur Kleinbuchstaben im Repository-Pfad;
|
||||||
|
# der Gitea-Owner-Name behaelt Gross-/Kleinschreibung bei.
|
||||||
|
id: image
|
||||||
|
run: echo "name=$(echo '${{ gitea.repository_owner }}/apicore-whisper' | tr '[:upper:]' '[:lower:]')" >> "$GITEA_OUTPUT"
|
||||||
|
|
||||||
|
- name: Docker Buildx einrichten
|
||||||
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||||
|
with:
|
||||||
|
# REGISTRY_PUSH ist Klartext-HTTP (Gitea-Service intern ohne TLS)
|
||||||
|
# statt HTTPS — BuildKit lehnt das ohne diese Freigabe ab.
|
||||||
|
config-inline: |
|
||||||
|
[registry."gitea:80"]
|
||||||
|
http = true
|
||||||
|
|
||||||
|
- name: Bei der Gitea-Container-Registry anmelden
|
||||||
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY_PUSH }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
# GITEA_TOKEN hat kein Package-Write-Recht — separates Personal
|
||||||
|
# Access Token (Scope "package") als Secret REGISTRY_TOKEN anlegen.
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Metadaten (Tags/Labels) ermitteln
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
||||||
|
tags: |
|
||||||
|
type=sha,format=short
|
||||||
|
|
||||||
|
- name: Image bauen und pushen
|
||||||
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||||
|
with:
|
||||||
|
context: ./ApiCore-Whisper
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha,scope=apicore-whisper
|
||||||
|
cache-to: type=gha,scope=apicore-whisper,mode=max
|
||||||
|
|
||||||
|
- name: Schwachstellen-Scan (nur wenn trivy im Runner vorhanden ist)
|
||||||
|
# Hinweisend (exit-code 0): die Ausgabe gehoert ins Runbook, ein Fund
|
||||||
|
# blockiert den Push nicht, weil das Image dann schon in der Registry
|
||||||
|
# liegt. Der Build selbst ist die harte Sperre (Hashes, Modell-SHA,
|
||||||
|
# pytest in der Test-Stufe).
|
||||||
|
run: |
|
||||||
|
if command -v trivy >/dev/null 2>&1; then
|
||||||
|
first_tag="$(echo '${{ steps.meta.outputs.tags }}' | head -n1)"
|
||||||
|
trivy image --insecure --severity HIGH,CRITICAL --exit-code 0 "$first_tag"
|
||||||
|
else
|
||||||
|
echo "trivy ist im Runner nicht installiert, Scan uebersprungen"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Root-level artefacts only; each project has its own .gitignore.
|
||||||
|
|
||||||
|
# Tool caches created when tests or scripts run from the repo root.
|
||||||
|
/node_modules/
|
||||||
|
|
||||||
|
# Working notes of the SDD orchestration (ledgers, briefs, review reports)
|
||||||
|
# and design mockups/screenshots (~90 MB). Decisions that matter are in
|
||||||
|
# ARCHITECTURE.md, the specs/plans under docs/ and the Gitea issues.
|
||||||
|
/.superpowers/
|
||||||
+1046
-19
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,8 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
# A stale build info makes `nest build` emit only changed files into the
|
||||||
|
# freshly deleted dist (tsconfig.json: incremental).
|
||||||
|
*.tsbuildinfo
|
||||||
generated
|
generated
|
||||||
coverage
|
coverage
|
||||||
.devdb
|
.devdb
|
||||||
|
|||||||
@@ -2,16 +2,31 @@
|
|||||||
|
|
||||||
DATABASE_URL="postgresql://user:password@localhost:5432/apicore?schema=public"
|
DATABASE_URL="postgresql://user:password@localhost:5432/apicore?schema=public"
|
||||||
|
|
||||||
# Mindestens 32 Zeichen (server-seitig erzwungen, siehe main.ts) - signiert
|
# Mindestens 32 Zeichen (server-seitig erzwungen, siehe
|
||||||
# jedes Access-/MFA-/SSO-Token UND verschluesselt gespeicherte TOTP-Secrets.
|
# src/runtime/startup-checks.ts) - signiert jedes Access-/MFA-/SSO-Token.
|
||||||
|
# TOTP- und Webhook-Secrets aus der Zeit vor den eigenen Schluesseln (unten)
|
||||||
|
# sind noch damit verschluesselt: erst nach dem rekey-Job rotieren.
|
||||||
# Erzeugen z.B. mit: openssl rand -hex 32
|
# Erzeugen z.B. mit: openssl rand -hex 32
|
||||||
JWT_ACCESS_SECRET="change-me-to-a-random-value-at-least-32-characters-long"
|
JWT_ACCESS_SECRET="change-me-to-a-random-value-at-least-32-characters-long"
|
||||||
JWT_REFRESH_SECRET="change-me-refresh-secret"
|
JWT_REFRESH_SECRET="change-me-refresh-secret"
|
||||||
JWT_ACCESS_EXPIRES_IN="15m"
|
JWT_ACCESS_EXPIRES_IN="15m"
|
||||||
JWT_REFRESH_EXPIRES_IN="7d"
|
JWT_REFRESH_EXPIRES_IN="7d"
|
||||||
|
# Hard limit from login, however often the session is refreshed.
|
||||||
|
SESSION_MAX_AGE="7d"
|
||||||
|
# New passwords are checked against known breaches (Have I Been Pwned,
|
||||||
|
# k-anonymity: only 5 hash characters leave the server). "false" turns the
|
||||||
|
# check off for deployments without internet access.
|
||||||
|
# PASSWORD_BREACH_CHECK=true
|
||||||
|
|
||||||
PORT=3001
|
PORT=3001
|
||||||
FRONTEND_ORIGIN="http://localhost:3000"
|
FRONTEND_ORIGIN="http://localhost:3000"
|
||||||
|
# Reverse-proxy hops in front of the backend whose X-Forwarded-For is
|
||||||
|
# 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/base/backend/configmap.example.yaml); "1" would yield the
|
||||||
|
# Cloudflare edge IP that many clients share.
|
||||||
|
TRUST_PROXY=1
|
||||||
|
|
||||||
# Refresh-token cookie requires HTTPS by default. Set to "false" only for
|
# Refresh-token cookie requires HTTPS by default. Set to "false" only for
|
||||||
# local HTTP development - every real deployment must leave this unset (or
|
# local HTTP development - every real deployment must leave this unset (or
|
||||||
@@ -33,11 +48,46 @@ DOCUMENTS_STORAGE_PATH="./uploads"
|
|||||||
# ihre eigenen Backup-Dateien und die Lauf-Erkennung zwischen Replicas greift
|
# ihre eigenen Backup-Dateien und die Lauf-Erkennung zwischen Replicas greift
|
||||||
# nicht.
|
# nicht.
|
||||||
BACKUP_STORAGE_PATH="./backups"
|
BACKUP_STORAGE_PATH="./backups"
|
||||||
|
# Directory a backup path set in the admin UI must stay under (the env
|
||||||
|
# variable above is not restricted). Default "./backups".
|
||||||
|
# BACKUP_ROOT="./backups"
|
||||||
# Cron-Ausdruck (Standard: taeglich um 03:00 Uhr).
|
# Cron-Ausdruck (Standard: taeglich um 03:00 Uhr).
|
||||||
BACKUP_CRON_SCHEDULE="0 3 * * *"
|
BACKUP_CRON_SCHEDULE="0 3 * * *"
|
||||||
# Wie viele der neuesten Backup-Dateien behalten werden, aeltere werden nach
|
# Wie viele der neuesten Backup-Dateien behalten werden, aeltere werden nach
|
||||||
# jedem Lauf geloescht (Standard: 14).
|
# jedem Lauf geloescht (Standard: 14).
|
||||||
BACKUP_RETENTION_COUNT=14
|
BACKUP_RETENTION_COUNT=14
|
||||||
|
# Obergrenze fuer eine einzuspielende Backup-Datei in Bytes (Standard 50 MB,
|
||||||
|
# hoechstens 256 MB, weil der Upload komplett im RAM verarbeitet wird).
|
||||||
|
# Export und Zeitplan warnen, wenn eine Sicherung groesser wird.
|
||||||
|
# BACKUP_MAX_IMPORT_BYTES=52428800
|
||||||
|
|
||||||
|
# Erster Administrator (prisma/seed.ts): wird angelegt, wenn es noch keinen
|
||||||
|
# Benutzer mit dieser Adresse gibt. Ohne Passwort erzeugt der Seed ein
|
||||||
|
# zufaelliges und gibt es einmalig im Log aus.
|
||||||
|
# [email protected]
|
||||||
|
# SEED_ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
# Encryption keys, one per purpose (AES-256-GCM, key derived per purpose with
|
||||||
|
# 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
|
# Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert
|
||||||
# (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start).
|
# (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start).
|
||||||
@@ -63,3 +113,39 @@ OIDC_REDIRECT_URI="http://localhost:3001/api/v1/auth/sso/callback"
|
|||||||
# gesamte API-Schema ungeschuetzt (kein JwtAuthGuard) preisgeben wuerde.
|
# gesamte API-Schema ungeschuetzt (kein JwtAuthGuard) preisgeben wuerde.
|
||||||
# Nur fuer lokale Entwicklung/Debugging explizit aktivieren.
|
# Nur fuer lokale Entwicklung/Debugging explizit aktivieren.
|
||||||
ENABLE_SWAGGER=false
|
ENABLE_SWAGGER=false
|
||||||
|
|
||||||
|
# Laufrolle des Prozesses: api (nur HTTP), worker, job (CLI) oder all (API +
|
||||||
|
# Worker in einem Prozess, nur fuer lokale Entwicklung und Tests). Ohne Wert
|
||||||
|
# gilt der Standard des Einstiegspunkts (main: all, worker: worker, cli: job).
|
||||||
|
# In Produktion Pflicht und "all" ist dort verboten.
|
||||||
|
# APICORE_ROLE=api
|
||||||
|
# Port des Health-Endpunkts des Worker-Prozesses.
|
||||||
|
# WORKER_HEALTH_PORT=3002
|
||||||
|
|
||||||
|
# Wetterquelle fuer die Pflanzenentwicklung (Gradtagszahl) und die
|
||||||
|
# Wetteranzeige. Einzige Quelle ist die freie Open-Meteo-API (bewusst
|
||||||
|
# uebernommenes Lizenzrisiko, nicht-kommerzielle Bedingungen); der Adapter
|
||||||
|
# (src/weather/history) erlaubt einen Wechsel. Nur https:-URLs; ein
|
||||||
|
# unbekannter Quellenname oder eine http:-URL stoppt den Start. Der Schluessel
|
||||||
|
# ist optional, gehoert in ein Secret und steht nie in Logs oder Fehlermeldungen.
|
||||||
|
# WEATHER_HISTORY_SOURCE=open-meteo
|
||||||
|
# OPEN_METEO_FORECAST_URL=https://api.open-meteo.com/v1/forecast
|
||||||
|
# OPEN_METEO_ARCHIVE_URL=https://archive-api.open-meteo.com/v1/archive
|
||||||
|
# OPEN_METEO_API_KEY=
|
||||||
|
# Optional: Kontakt (E-Mail oder URL) fuer den User-Agent-Header der
|
||||||
|
# Wetteranfragen ("ApiCore/<Version> (+<Kontakt>)"); ohne Wert
|
||||||
|
# "ApiCore/<Version> (self-hosted beekeeping app)".
|
||||||
|
# WEATHER_USER_AGENT_CONTACT=
|
||||||
|
|
||||||
|
# Telegram-Erinnerungen (optional, T4c). TELEGRAM_BOT_USERNAME (ohne @, 5-32
|
||||||
|
# Zeichen: Buchstaben, Ziffern, Unterstrich) steht in API und Worker und
|
||||||
|
# erzeugt den Deep Link. TELEGRAM_BOT_TOKEN (BotFather) gehoert NUR dem Worker,
|
||||||
|
# im Cluster in das eigene Secret apicore-telegram; der API-Pod darf es nicht
|
||||||
|
# einbinden. Ein fehlerhaftes Token stoppt nichts: Telegram bleibt aus, der
|
||||||
|
# Worker loggt einen Fehler (nie den Wert). Bei BotFather Gruppen ausschalten
|
||||||
|
# (/setjoingroups) und keinen Webhook setzen.
|
||||||
|
# Je Umgebung ein eigener Bot. Im Cluster steht der Name in der ConfigMap
|
||||||
|
# (TELEGRAM_BOT_USERNAME, leer = Telegram in der App nicht eingerichtet); den
|
||||||
|
# Zustand zeigt Verwaltung > System (Telegram-Karte, nur system:manage).
|
||||||
|
# TELEGRAM_BOT_USERNAME=
|
||||||
|
# TELEGRAM_BOT_TOKEN=
|
||||||
@@ -18,3 +18,6 @@ prisma/seed.d.ts
|
|||||||
# Locally uploaded documents/images (Modul 29) and their test-run counterpart
|
# Locally uploaded documents/images (Modul 29) and their test-run counterpart
|
||||||
/uploads/
|
/uploads/
|
||||||
/test-uploads/
|
/test-uploads/
|
||||||
|
|
||||||
|
# T4 migration test harness: scratch copies of the migration folders
|
||||||
|
/.tmp-migrations/
|
||||||
+30
-10
@@ -1,23 +1,33 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
FROM node:22-alpine AS builder
|
# Base-Image per Digest gepinnt (node:22-alpine, Multi-Arch-Index vom
|
||||||
|
# 2026-09-23): ein Rebuild zieht nie ungeprueft ein neues Image. Ein Update
|
||||||
|
# ist ein bewusster Commit mit neuem Digest, ermittelt per
|
||||||
|
# docker buildx imagetools inspect node:22-alpine
|
||||||
|
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
|
# Qualitaets-Gate: ein Image entsteht nur, wenn Lint, Typecheck und
|
||||||
|
# Unit-Tests gruen sind. eslint laeuft hier ohne --fix (anders als
|
||||||
|
# `npm run lint`), damit ein Verstoss den Build abbricht statt still
|
||||||
|
# korrigiert zu werden. Die e2e-Tests brauchen eine Datenbank und laufen
|
||||||
|
# deshalb nicht im Image-Build.
|
||||||
|
RUN npx eslint "{src,test}/**/*.ts" && npm run typecheck && npm test -- --ci
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:22-alpine AS runtime
|
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS runtime
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY --from=builder /app/generated ./generated
|
COPY --from=builder /app/generated ./generated
|
||||||
# schema.prisma, migrations/ und prisma.config.ts: noetig fuer
|
# schema.prisma, migrations/ und prisma.config.ts: noetig fuer den
|
||||||
# `npm run migrate:deploy`, das der Container beim Start selbst ausfuehrt
|
# initContainer `migrate` (siehe Kommentar ueber CMD unten).
|
||||||
# (siehe CMD unten). seed.ts selbst wird NICHT zur Laufzeit ausgefuehrt -
|
# seed.ts selbst wird NICHT zur Laufzeit ausgefuehrt -
|
||||||
# `npm run build` kompiliert es vorab zu dist/seed.js (siehe
|
# `npm run build` kompiliert es vorab zu dist/seed.js (siehe
|
||||||
# tsconfig.seed.json), das schon in ./dist enthalten ist. Frueher lief
|
# tsconfig.seed.json), das schon in ./dist enthalten ist. Frueher lief
|
||||||
# `ts-node prisma/seed.ts` direkt im Container: der TypeScript-Compiler
|
# `ts-node prisma/seed.ts` direkt im Container: der TypeScript-Compiler
|
||||||
@@ -25,9 +35,19 @@ COPY --from=builder /app/generated ./generated
|
|||||||
# fuehrte zu sporadischen OOM-Crashes beim Start.
|
# fuehrte zu sporadischen OOM-Crashes beim Start.
|
||||||
COPY --from=builder /app/prisma ./prisma
|
COPY --from=builder /app/prisma ./prisma
|
||||||
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
|
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
|
||||||
EXPOSE 3001
|
# 3001: HTTP der api-Rolle. 3002: /health der worker-Rolle.
|
||||||
|
EXPOSE 3001 3002
|
||||||
USER node
|
USER node
|
||||||
# migrate deploy ist idempotent und nutzt einen DB-seitigen Advisory-Lock,
|
# Migration und Seed laufen nicht mehr hier, sondern als initContainer im
|
||||||
# seed ist idempotent per Unique-Constraint auf User.email (siehe seed.ts) -
|
# Pod (deploy/k8s/components/backend-roles): `migrate` in api und worker
|
||||||
# beides unproblematisch bei mehreren gleichzeitig startenden Pod-Replicas.
|
# (`node node_modules/prisma/build/index.js migrate deploy`, idempotent mit
|
||||||
CMD ["sh", "-c", "npm run migrate:deploy && npm run seed && node dist/main.js"]
|
# DB-seitigem Advisory-Lock), `seed` nur in der api (`node dist/seed.js`,
|
||||||
|
# idempotent per Unique-Constraint auf User.email). Scheitert die Migration,
|
||||||
|
# startet der neue Pod nicht und der alte laeuft weiter. Der Hauptprozess
|
||||||
|
# startet ohne Prisma-CLI; worker und Jobs ueberschreiben das Kommando
|
||||||
|
# (dist/worker.js, dist/cli.js <job>), die Rolle waehlt APICORE_ROLE.
|
||||||
|
# Dieses Image darf darum nur zusammen mit components/backend-roles live
|
||||||
|
# gehen (Runbook Schritt 3). Fehlt APICORE_ROLE, bricht der Start in
|
||||||
|
# Produktion absichtlich ab. APICORE_ROLE nicht von Hand in ein Deployment
|
||||||
|
# ohne die initContainer setzen: dann liefe keine Migration.
|
||||||
|
CMD ["node", "dist/main.js"]
|
||||||
+32
-86
@@ -1,98 +1,44 @@
|
|||||||
<p align="center">
|
# ApiCore-Backend
|
||||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
NestJS-API von ApiCore (TypeScript, Prisma, PostgreSQL). Alle fachlichen Funktionen liegen unter `/api/v1/...`. Überblick über das ganze Projekt: [`../README.md`](../README.md), Architektur: [`../ARCHITECTURE.md`](../ARCHITECTURE.md).
|
||||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
|
||||||
|
|
||||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
## Einrichten
|
||||||
<p align="center">
|
|
||||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
|
||||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
|
||||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
|
||||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
|
||||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
|
||||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
|
||||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
|
||||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
|
||||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
|
||||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
|
||||||
</p>
|
|
||||||
<!--[](https://opencollective.com/nest#backer)
|
|
||||||
[](https://opencollective.com/nest#sponsor)-->
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
|
||||||
|
|
||||||
## Project setup
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ npm install
|
npm install
|
||||||
|
cp .env.example .env # DATABASE_URL, JWT- und Verschlüsselungs-Schlüssel setzen
|
||||||
|
npx prisma migrate dev # Schema anwenden (nur lokal; gegen Produktion nur migrate deploy)
|
||||||
|
npm run start:dev # http://localhost:3001
|
||||||
```
|
```
|
||||||
|
|
||||||
## Compile and run the project
|
Alle Variablen mit Bedeutung stehen in [`.env.example`](.env.example). Swagger unter `/api/v1/docs` ist nur mit `ENABLE_SWAGGER=true` erreichbar.
|
||||||
|
|
||||||
|
## Laufrollen
|
||||||
|
|
||||||
|
Ein Build, drei Einstiegspunkte (`ARCHITECTURE.md` Abschnitt 10):
|
||||||
|
|
||||||
|
| Befehl | Rolle | Zweck |
|
||||||
|
|---|---|---|
|
||||||
|
| `npm run start:dev` / `node dist/main.js` | `api` (lokal ohne `APICORE_ROLE`: `all`) | HTTP-API; mit `all` laufen API und Worker in einem Prozess, nur für Entwicklung und Tests |
|
||||||
|
| `npm run worker` (`node dist/worker.js`) | `worker` | Zustellung der Outbox `DomainEvent`, Webhooks, Kalender-Sync, Wetter, Telegram; Health auf Port 3002 |
|
||||||
|
| `npm run job -- <name>` (`node dist/cli.js <name>`) | `job` | `cleanup`, `daily-evaluation`, `task-templates`, `app-backup`, `rekey` (auch `rekey --dry-run`); im Cluster als CronJobs |
|
||||||
|
|
||||||
|
In Produktion ist `APICORE_ROLE` Pflicht und `all` verboten. Migrationen laufen im Cluster als initContainer `migrate` (`prisma migrate deploy`), der Seed als initContainer `seed` (`node dist/seed.js`).
|
||||||
|
|
||||||
|
## Prüfen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# development
|
npm run lint # eslint mit --fix
|
||||||
$ npm run start
|
npm run typecheck # tsc --noEmit
|
||||||
|
npm test # Jest-Unit-Tests
|
||||||
# watch mode
|
npm run test:e2e # Jest-e2e gegen die Datenbank aus .env.test (vorher migrieren)
|
||||||
$ npm run start:dev
|
npm run build # dist/main.js, dist/worker.js, dist/cli.js, dist/seed.js
|
||||||
|
|
||||||
# production mode
|
|
||||||
$ npm run start:prod
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Run tests
|
Die e2e-Suites laufen parallel (`maxWorkers: 4` in `test/jest-e2e.json`) und scheitern dabei derzeit sporadisch; mit `npm run test:e2e -- --runInBand` sind sie grün ([Issue #32](https://git.encry.de/Nick.Rosenloecher/ApiCore/issues/32)). Der Image-Build (`Dockerfile`) führt Lint, Typecheck und Unit-Tests aus und bricht bei Fehlern ab; die e2e-Tests laufen dort nicht.
|
||||||
|
|
||||||
```bash
|
## Aufbau
|
||||||
# unit tests
|
|
||||||
$ npm run test
|
|
||||||
|
|
||||||
# e2e tests
|
- `src/<modul>/`: ein NestJS-Modul je Fachbereich (z. B. `colonies`, `inspections`, `treatments`, `honey-batches`, `compliance`, `hive`), dazu Querschnitt in `common`, `prisma`, `domain-events`, `worker`, `jobs`, `runtime`.
|
||||||
$ npm run test:e2e
|
- `prisma/schema.prisma` und `prisma/migrations/` sind die einzige Quelle des Schemas; `prisma db push` wird nirgends verwendet.
|
||||||
|
- `test/`: e2e-Specs und gemeinsame Fixtures (u. a. `test/fixtures/hive-ops/`, die auch das Frontend nutzt).
|
||||||
# test coverage
|
|
||||||
$ npm run test:cov
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
|
||||||
|
|
||||||
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ npm install -g @nestjs/mau
|
|
||||||
$ mau deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
Check out a few resources that may come in handy when working with NestJS:
|
|
||||||
|
|
||||||
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
|
||||||
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
|
||||||
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
|
||||||
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
|
||||||
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
|
||||||
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
|
||||||
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
|
||||||
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
|
||||||
|
|
||||||
## Stay in touch
|
|
||||||
|
|
||||||
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
|
||||||
- Website - [https://nestjs.com](https://nestjs.com/)
|
|
||||||
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
|
||||||
Generated
+93
-35
@@ -9,13 +9,13 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "0.131.0",
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^4.0.4",
|
"@nestjs/config": "^4.0.4",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
"@nestjs/jwt": "^11.0.2",
|
"@nestjs/jwt": "^11.0.2",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/schedule": "^12.0.1",
|
|
||||||
"@nestjs/swagger": "^11.4.7",
|
"@nestjs/swagger": "^11.4.7",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@prisma/adapter-pg": "^7.9.1",
|
"@prisma/adapter-pg": "^7.9.1",
|
||||||
@@ -24,12 +24,14 @@
|
|||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cron": "^4.4.0",
|
||||||
"helmet": "^8.3.0",
|
"helmet": "^8.3.0",
|
||||||
"openid-client": "^6.8.4",
|
"openid-client": "^6.8.4",
|
||||||
"otplib": "^13.5.0",
|
"otplib": "^13.5.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"pdfkit": "^0.19.1",
|
"pdfkit": "^0.19.1",
|
||||||
|
"pg": "^8.23.1",
|
||||||
"prisma": "^7.9.1",
|
"prisma": "^7.9.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/pdfkit": "^0.17.6",
|
"@types/pdfkit": "^0.17.6",
|
||||||
|
"@types/pg": "^8.23.1",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/supertest": "^7.0.0",
|
"@types/supertest": "^7.0.0",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
@@ -211,6 +214,27 @@
|
|||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@anthropic-ai/sdk": {
|
||||||
|
"version": "0.131.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.131.0.tgz",
|
||||||
|
"integrity": "sha512-9+PepoU7qVMmM70jSahdnfcMHAvAnzhMOLv2W9WjRLO+OG/MAiBETkwKZqv7KIJ1Cj5h8eZ019bHzZaq+Mbepw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"json-schema-to-ts": "^3.1.1",
|
||||||
|
"standardwebhooks": "^1.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"anthropic-ai-sdk": "bin/cli"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"zod": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
@@ -672,6 +696,15 @@
|
|||||||
"@babel/core": "^7.0.0-0"
|
"@babel/core": "^7.0.0-0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.29.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
|
||||||
|
"integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||||
@@ -2478,22 +2511,6 @@
|
|||||||
"@nestjs/core": "^11.0.0"
|
"@nestjs/core": "^11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nestjs/schedule": {
|
|
||||||
"version": "12.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-12.0.1.tgz",
|
|
||||||
"integrity": "sha512-v0z/li9L8oxpvuIDAGm5uOLaZ8KzsfZRzgWgppvs5vClneSinZP5IFl3C1F1DCeTCkNLiGN9g/oLUvzg8x++RQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"cron": "4.4.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.19.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@nestjs/common": "^11.0.0 || ^12.0.0",
|
|
||||||
"@nestjs/core": "^11.0.0 || ^12.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nestjs/schematics": {
|
"node_modules/@nestjs/schematics": {
|
||||||
"version": "11.1.0",
|
"version": "11.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz",
|
||||||
@@ -3227,6 +3244,12 @@
|
|||||||
"@sinonjs/commons": "^3.0.1"
|
"@sinonjs/commons": "^3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@stablelib/base64": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -3601,9 +3624,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/luxon": {
|
"node_modules/@types/luxon": {
|
||||||
"version": "3.7.5",
|
"version": "3.7.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.6.tgz",
|
||||||
"integrity": "sha512-jJ41Q4z6ZVO260MNDdHfW7+7a5iMiX8Mr6ZJHcmgrvhZha6dz5704o/lF2kKl6URjH6ivEL97w9xS/MgpJEphg==",
|
"integrity": "sha512-6KSjliQAXK8ZLgFQO4B7iE4Rpf/B3rItzCFuXezBY/XIAGxOdLd7vRNK9/StRPwiS/yJsVbB7HKpW46B8tcEuQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/methods": {
|
"node_modules/@types/methods": {
|
||||||
@@ -6939,6 +6962,12 @@
|
|||||||
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-sha256": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
|
||||||
@@ -8794,6 +8823,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/json-schema-to-ts": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.18.3",
|
||||||
|
"ts-algebra": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/json-schema-traverse": {
|
"node_modules/json-schema-traverse": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||||
@@ -10087,14 +10129,14 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pg": {
|
"node_modules/pg": {
|
||||||
"version": "8.23.0",
|
"version": "8.23.1",
|
||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
|
||||||
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
|
"integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.14.0",
|
"pg-connection-string": "^2.14.1",
|
||||||
"pg-pool": "^3.14.0",
|
"pg-pool": "^3.14.0",
|
||||||
"pg-protocol": "^1.16.0",
|
"pg-protocol": "^1.16.1",
|
||||||
"pg-types": "2.2.0",
|
"pg-types": "2.2.0",
|
||||||
"pgpass": "1.0.5"
|
"pgpass": "1.0.5"
|
||||||
},
|
},
|
||||||
@@ -10102,7 +10144,7 @@
|
|||||||
"node": ">= 16.0.0"
|
"node": ">= 16.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"pg-cloudflare": "^1.4.0"
|
"pg-cloudflare": "^1.4.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"pg-native": ">=3.0.1"
|
"pg-native": ">=3.0.1"
|
||||||
@@ -10114,16 +10156,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pg-cloudflare": {
|
"node_modules/pg-cloudflare": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
|
||||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
"integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/pg-connection-string": {
|
"node_modules/pg-connection-string": {
|
||||||
"version": "2.14.0",
|
"version": "2.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
|
||||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
"integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pg-int8": {
|
"node_modules/pg-int8": {
|
||||||
@@ -10145,9 +10187,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pg-protocol": {
|
"node_modules/pg-protocol": {
|
||||||
"version": "1.16.0",
|
"version": "1.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
|
||||||
"integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
|
"integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pg-types": {
|
"node_modules/pg-types": {
|
||||||
@@ -11263,6 +11305,16 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/standardwebhooks": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@stablelib/base64": "^1.0.0",
|
||||||
|
"fast-sha256": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
@@ -11799,6 +11851,12 @@
|
|||||||
"url": "https://github.com/sponsors/Borewit"
|
"url": "https://github.com/sponsors/Borewit"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ts-algebra": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "node dist/main",
|
"start:prod": "node dist/main",
|
||||||
|
"worker": "node dist/worker.js",
|
||||||
|
"job": "node dist/cli.js",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
@@ -20,16 +22,18 @@
|
|||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
"migrate:deploy": "prisma migrate deploy",
|
"migrate:deploy": "prisma migrate deploy",
|
||||||
"seed": "node dist/seed.js"
|
"seed": "node dist/seed.js",
|
||||||
|
"export:voice-vocabulary": "ts-node scripts/export-voice-vocabulary.ts",
|
||||||
|
"eval:voice": "ts-node scripts/eval-voice.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "0.131.0",
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^4.0.4",
|
"@nestjs/config": "^4.0.4",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
"@nestjs/jwt": "^11.0.2",
|
"@nestjs/jwt": "^11.0.2",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/schedule": "^12.0.1",
|
|
||||||
"@nestjs/swagger": "^11.4.7",
|
"@nestjs/swagger": "^11.4.7",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@prisma/adapter-pg": "^7.9.1",
|
"@prisma/adapter-pg": "^7.9.1",
|
||||||
@@ -38,12 +42,14 @@
|
|||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cron": "^4.4.0",
|
||||||
"helmet": "^8.3.0",
|
"helmet": "^8.3.0",
|
||||||
"openid-client": "^6.8.4",
|
"openid-client": "^6.8.4",
|
||||||
"otplib": "^13.5.0",
|
"otplib": "^13.5.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"pdfkit": "^0.19.1",
|
"pdfkit": "^0.19.1",
|
||||||
|
"pg": "^8.23.1",
|
||||||
"prisma": "^7.9.1",
|
"prisma": "^7.9.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
@@ -63,6 +69,7 @@
|
|||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/pdfkit": "^0.17.6",
|
"@types/pdfkit": "^0.17.6",
|
||||||
|
"@types/pg": "^8.23.1",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/supertest": "^7.0.0",
|
"@types/supertest": "^7.0.0",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
@@ -92,12 +99,13 @@
|
|||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose|@nestjs/schedule|cron)/)"
|
"node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose|cron)/)"
|
||||||
],
|
],
|
||||||
"collectCoverageFrom": [
|
"collectCoverageFrom": [
|
||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
],
|
],
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node"
|
"testEnvironment": "node",
|
||||||
|
"testTimeout": 20000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Treatment" ADD COLUMN "withdrawalPeriodDays" INTEGER;
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "IdempotencyStatus" AS ENUM ('PENDING', 'COMPLETED');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Task" ADD COLUMN "sourceAutomationTrigger" "AutomationTriggerType";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "IdempotencyRecord" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"scope" TEXT NOT NULL,
|
||||||
|
"status" "IdempotencyStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"statusCode" INTEGER,
|
||||||
|
"responseBody" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "IdempotencyRecord_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "IdempotencyRecord_createdAt_idx" ON "IdempotencyRecord"("createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "IdempotencyRecord_userId_key_key" ON "IdempotencyRecord"("userId", "key");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Task_sourceAutomationTrigger_relatedType_relatedId_status_idx" ON "Task"("sourceAutomationTrigger", "relatedType", "relatedId", "status");
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ApiKey" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"prefix" TEXT NOT NULL,
|
||||||
|
"keyHash" TEXT NOT NULL,
|
||||||
|
"scopes" TEXT[],
|
||||||
|
"createdByUserId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"lastUsedAt" TIMESTAMP(3),
|
||||||
|
"expiresAt" TIMESTAMP(3),
|
||||||
|
"revokedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ApiKey_keyHash_key" ON "ApiKey"("keyHash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ApiKey_revokedAt_idx" ON "ApiKey"("revokedAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "LabelTemplate" ADD COLUMN "cropMarks" BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
-- This migration adds more than one value to an enum.
|
||||||
|
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||||
|
-- in a single migration. This can be worked around by creating
|
||||||
|
-- multiple migrations, each migration adding only one value to
|
||||||
|
-- the enum.
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TYPE "AutomationTriggerType" ADD VALUE 'TREATMENT_FOLLOW_UP';
|
||||||
|
ALTER TYPE "AutomationTriggerType" ADD VALUE 'FEEDING_FOLLOW_UP';
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "SiteSettings" ADD COLUMN "holderAddress" TEXT,
|
||||||
|
ADD COLUMN "holderName" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Treatment" ADD COLUMN "apiaryId" TEXT,
|
||||||
|
ADD COLUMN "prescribingVet" TEXT,
|
||||||
|
ADD COLUMN "purchaseProof" TEXT,
|
||||||
|
ADD COLUMN "supplier" TEXT;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Treatment" ADD CONSTRAINT "Treatment_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "WinterCheckSeason" AS ENUM ('AUTUMN', 'SPRING');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "WinterOutcome" AS ENUM ('SURVIVED', 'WEAK', 'LOST');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ColonyLossCause" AS ENUM ('DEAD_OR_EMPTY', 'QUEEN_PROBLEM', 'NATURAL_DISASTER');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "WinterCheck" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"colonyId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"winterYear" INTEGER NOT NULL,
|
||||||
|
"season" "WinterCheckSeason" NOT NULL,
|
||||||
|
"date" TIMESTAMP(3) NOT NULL,
|
||||||
|
"occupiedSeams" INTEGER,
|
||||||
|
"feedStockKg" DOUBLE PRECISION,
|
||||||
|
"queenPresent" BOOLEAN,
|
||||||
|
"outcome" "WinterOutcome",
|
||||||
|
"lossCause" "ColonyLossCause",
|
||||||
|
"notes" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "WinterCheck_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WinterCheck_winterYear_idx" ON "WinterCheck"("winterYear");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "WinterCheck_colonyId_winterYear_season_key" ON "WinterCheck"("colonyId", "winterYear", "season");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "WinterCheck" ADD CONSTRAINT "WinterCheck_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "WinterCheck" ADD CONSTRAINT "WinterCheck_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Permissions: the boot-time catalog sync only grants new permissions to
|
||||||
|
-- system roles (ADMIN). Winter checks are everyday beekeeping records, so the
|
||||||
|
-- other roles get the same access they have to inspections.
|
||||||
|
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'winter-checks:read', 'winter-checks', 'read', 'Ansehen', 'Überwinterung', 850),
|
||||||
|
(gen_random_uuid()::text, 'winter-checks:create', 'winter-checks', 'create', 'Anlegen', 'Überwinterung', 851),
|
||||||
|
(gen_random_uuid()::text, 'winter-checks:update', 'winter-checks', 'update', 'Bearbeiten', 'Überwinterung', 852)
|
||||||
|
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 = 'winter-checks'
|
||||||
|
AND (
|
||||||
|
r.key IN ('ADMIN', 'BEEKEEPER', 'STAFF')
|
||||||
|
OR (r.key IN ('READONLY', 'API_CLIENT') AND p.action = 'read')
|
||||||
|
)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
-- Microsoft sign-in is bound to the IdP identity ("<iss>|<oid>"), set on the
|
||||||
|
-- first SSO login; the unverified email claim no longer picks the account.
|
||||||
|
ALTER TABLE "User" ADD COLUMN "ssoSubject" TEXT;
|
||||||
|
CREATE UNIQUE INDEX "User_ssoSubject_key" ON "User"("ssoSubject");
|
||||||
|
|
||||||
|
-- Emails are compared case-insensitively from now on. Store existing ones
|
||||||
|
-- lower-case, except where that would collide with another account - those
|
||||||
|
-- stay as they are and are still found by the case-insensitive lookup.
|
||||||
|
UPDATE "User" AS u
|
||||||
|
SET "email" = lower(u."email")
|
||||||
|
WHERE u."email" <> lower(u."email")
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "User" AS o
|
||||||
|
WHERE o."id" <> u."id" AND lower(o."email") = lower(u."email")
|
||||||
|
);
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
-- TOTP replay protection and per-account lockout after failed attempts.
|
||||||
|
ALTER TABLE "User" ADD COLUMN "mfaLastTimeStep" INTEGER;
|
||||||
|
ALTER TABLE "User" ADD COLUMN "failedAuthAttempts" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE "User" ADD COLUMN "authLockedUntil" TIMESTAMP(3);
|
||||||
|
ALTER TYPE "AuditAction" ADD VALUE 'ACCOUNT_LOCKED';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Absolute session lifetime: every rotated refresh token keeps the time
|
||||||
|
-- the session was first opened. Existing sessions count from now.
|
||||||
|
ALTER TABLE "RefreshToken" ADD COLUMN "sessionStartedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- API keys can be limited to source addresses (IP or CIDR).
|
||||||
|
ALTER TABLE "ApiKey" ADD COLUMN "allowedIps" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||||
|
ALTER TABLE "ApiKey" ADD COLUMN "lastUsedIp" TEXT;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- One-time helper tokens (SSO completion, MFA challenge) are recorded once
|
||||||
|
-- used; a second use of the same jti is refused.
|
||||||
|
CREATE TABLE "ConsumedToken" (
|
||||||
|
"jti" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "ConsumedToken_pkey" PRIMARY KEY ("jti")
|
||||||
|
);
|
||||||
|
CREATE INDEX "ConsumedToken_expiresAt_idx" ON "ConsumedToken"("expiresAt");
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
-- Bestandsabgleich mit dem Webshop (docs/superpowers/specs/2026-10-02-bestandsabgleich-design.md):
|
||||||
|
-- Bewegungsbuch je Charge, Herstellungschargen fuer Nicht-Honig-Artikel,
|
||||||
|
-- idempotente Buchungsanfragen des Shops. Rein additiv.
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ProductKind" AS ENUM ('BOTTLED_HONEY', 'MANUFACTURED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ShopCategory" AS ENUM ('HONIG', 'KERZEN', 'PROPOLIS', 'SONSTIGES');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "BatchMovementType" AS ENUM ('SHOP_SALE', 'SHOP_RETURN', 'WITHDRAWAL', 'CORRECTION');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "WithdrawalReason" AS ENUM ('HOFLADEN', 'EIGENBEDARF', 'BRUCH', 'PROBE', 'SONSTIGES');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ShopStockRequestKind" AS ENUM ('SALE', 'RETURN');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Product" ADD COLUMN "kind" "ProductKind" NOT NULL DEFAULT 'BOTTLED_HONEY',
|
||||||
|
ADD COLUMN "shopCategory" "ShopCategory" NOT NULL DEFAULT 'HONIG';
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ProductionBatch" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"productId" TEXT NOT NULL,
|
||||||
|
"lotNumber" TEXT NOT NULL,
|
||||||
|
"date" TIMESTAMP(3) NOT NULL,
|
||||||
|
"quantity" INTEGER NOT NULL,
|
||||||
|
"bestBeforeDate" TIMESTAMP(3),
|
||||||
|
"notes" TEXT,
|
||||||
|
"userId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ProductionBatch_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "BatchMovement" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"bottlingBatchId" TEXT,
|
||||||
|
"productionBatchId" TEXT,
|
||||||
|
"type" "BatchMovementType" NOT NULL,
|
||||||
|
"quantity" INTEGER NOT NULL,
|
||||||
|
"reason" "WithdrawalReason",
|
||||||
|
"notes" TEXT,
|
||||||
|
"shopRequestId" TEXT,
|
||||||
|
"userId" TEXT,
|
||||||
|
"apiKeyId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "BatchMovement_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ShopStockRequest" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"externalId" TEXT NOT NULL,
|
||||||
|
"kind" "ShopStockRequestKind" NOT NULL,
|
||||||
|
"bottlingBatchId" TEXT,
|
||||||
|
"productId" TEXT,
|
||||||
|
"quantity" INTEGER NOT NULL,
|
||||||
|
"reversesExternalId" TEXT,
|
||||||
|
"apiKeyId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ShopStockRequest_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ProductionBatch_lotNumber_key" ON "ProductionBatch"("lotNumber");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProductionBatch_productId_idx" ON "ProductionBatch"("productId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "BatchMovement_bottlingBatchId_idx" ON "BatchMovement"("bottlingBatchId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "BatchMovement_productionBatchId_idx" ON "BatchMovement"("productionBatchId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "BatchMovement_shopRequestId_idx" ON "BatchMovement"("shopRequestId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ShopStockRequest_externalId_key" ON "ShopStockRequest"("externalId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ShopStockRequest_reversesExternalId_idx" ON "ShopStockRequest"("reversesExternalId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ProductionBatch" ADD CONSTRAINT "ProductionBatch_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ProductionBatch" ADD CONSTRAINT "ProductionBatch_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_bottlingBatchId_fkey" FOREIGN KEY ("bottlingBatchId") REFERENCES "BottlingBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_productionBatchId_fkey" FOREIGN KEY ("productionBatchId") REFERENCES "ProductionBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_shopRequestId_fkey" FOREIGN KEY ("shopRequestId") REFERENCES "ShopStockRequest"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Genau eine Charge je Bewegung, genau ein Ziel je Shop-Anfrage.
|
||||||
|
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_exactly_one_batch"
|
||||||
|
CHECK (("bottlingBatchId" IS NULL) <> ("productionBatchId" IS NULL));
|
||||||
|
ALTER TABLE "ShopStockRequest" ADD CONSTRAINT "ShopStockRequest_exactly_one_target"
|
||||||
|
CHECK (("bottlingBatchId" IS NULL) <> ("productId" IS NULL));
|
||||||
|
ALTER TABLE "ShopStockRequest" ADD CONSTRAINT "ShopStockRequest_quantity_positive"
|
||||||
|
CHECK ("quantity" > 0);
|
||||||
|
ALTER TABLE "ProductionBatch" ADD CONSTRAINT "ProductionBatch_quantity_positive"
|
||||||
|
CHECK ("quantity" > 0);
|
||||||
|
|
||||||
|
-- Permissions: the boot-time catalog sync only grants new permissions to
|
||||||
|
-- ADMIN; the other built-in roles get them here, matching their existing
|
||||||
|
-- bottling rights.
|
||||||
|
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'bottling:withdraw', 'bottling', 'withdraw', 'Entnehmen und Inventur', 'Abfüllungen', 1604),
|
||||||
|
(gen_random_uuid()::text, 'production-batch:read', 'production-batch', 'read', 'Ansehen', 'Herstellungschargen', 1650),
|
||||||
|
(gen_random_uuid()::text, 'production-batch:create', 'production-batch', 'create', 'Anlegen', 'Herstellungschargen', 1651),
|
||||||
|
(gen_random_uuid()::text, 'production-batch:update', 'production-batch', 'update', 'Bearbeiten', 'Herstellungschargen', 1652),
|
||||||
|
(gen_random_uuid()::text, 'production-batch:delete', 'production-batch', 'delete', 'Löschen', 'Herstellungschargen', 1653),
|
||||||
|
(gen_random_uuid()::text, 'production-batch:withdraw', 'production-batch', 'withdraw', 'Entnehmen und Inventur', 'Herstellungschargen', 1654),
|
||||||
|
(gen_random_uuid()::text, 'shop-sync:write', 'shop-sync', 'write', 'Verkäufe und Rückbuchungen des Shops buchen', 'Shop-Synchronisation', 3301)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
-- Wer Abfuellungen bearbeiten darf, darf kuenftig auch entnehmen und
|
||||||
|
-- Herstellungschargen pflegen; wer Abfuellungen sehen darf, sieht auch
|
||||||
|
-- Herstellungschargen.
|
||||||
|
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||||
|
SELECT DISTINCT rp."roleId", p.id, now()
|
||||||
|
FROM "RolePermission" rp
|
||||||
|
JOIN "Permission" existing ON existing.id = rp."permissionId" AND existing.key = 'bottling:update'
|
||||||
|
JOIN "Permission" p ON p.key IN (
|
||||||
|
'bottling:withdraw', 'production-batch:create', 'production-batch:update',
|
||||||
|
'production-batch:delete', 'production-batch:withdraw'
|
||||||
|
)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||||
|
SELECT DISTINCT rp."roleId", p.id, now()
|
||||||
|
FROM "RolePermission" rp
|
||||||
|
JOIN "Permission" existing ON existing.id = rp."permissionId" AND existing.key = 'bottling:read'
|
||||||
|
JOIN "Permission" p ON p.key = 'production-batch:read'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Only ADMIN may hand out shop-sync:write to an API key (a key can only get
|
||||||
|
-- permissions its creator holds). Inserted here, the row is no longer "new"
|
||||||
|
-- for the boot-time catalog sync, so the grant has to happen here too.
|
||||||
|
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||||
|
SELECT r.id, p.id, now()
|
||||||
|
FROM "Role" r, "Permission" p
|
||||||
|
WHERE r.key = 'ADMIN' AND p.key = 'shop-sync:write'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
-- Rohstoffe (Wachs, Propolis) mit eigenen Chargen und Bestand; Herstellungs-
|
||||||
|
-- chargen verbrauchen sie (ProductionInput + Bewegung CONSUMPTION). Damit
|
||||||
|
-- reicht die Rueckverfolgung vom Rohstoff ueber die Charge bis zur Kundin.
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "RawMaterialKind" AS ENUM ('BEESWAX', 'PROPOLIS');
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "BatchMovementType" ADD VALUE 'CONSUMPTION';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "BatchMovement" ADD COLUMN "rawMaterialLotId" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RawMaterialLot" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"kind" "RawMaterialKind" NOT NULL,
|
||||||
|
"lotNumber" TEXT NOT NULL,
|
||||||
|
"date" TIMESTAMP(3) NOT NULL,
|
||||||
|
"quantity" INTEGER NOT NULL,
|
||||||
|
"apiaryId" TEXT,
|
||||||
|
"harvestId" TEXT,
|
||||||
|
"notes" TEXT,
|
||||||
|
"userId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "RawMaterialLot_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ProductionInput" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"productionBatchId" TEXT NOT NULL,
|
||||||
|
"rawMaterialLotId" TEXT NOT NULL,
|
||||||
|
"quantity" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ProductionInput_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "RawMaterialLot_lotNumber_key" ON "RawMaterialLot"("lotNumber");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RawMaterialLot_kind_idx" ON "RawMaterialLot"("kind");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProductionInput_productionBatchId_idx" ON "ProductionInput"("productionBatchId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProductionInput_rawMaterialLotId_idx" ON "ProductionInput"("rawMaterialLotId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "BatchMovement_rawMaterialLotId_idx" ON "BatchMovement"("rawMaterialLotId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RawMaterialLot" ADD CONSTRAINT "RawMaterialLot_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RawMaterialLot" ADD CONSTRAINT "RawMaterialLot_harvestId_fkey" FOREIGN KEY ("harvestId") REFERENCES "Harvest"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RawMaterialLot" ADD CONSTRAINT "RawMaterialLot_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ProductionInput" ADD CONSTRAINT "ProductionInput_productionBatchId_fkey" FOREIGN KEY ("productionBatchId") REFERENCES "ProductionBatch"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ProductionInput" ADD CONSTRAINT "ProductionInput_rawMaterialLotId_fkey" FOREIGN KEY ("rawMaterialLotId") REFERENCES "RawMaterialLot"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_rawMaterialLotId_fkey" FOREIGN KEY ("rawMaterialLotId") REFERENCES "RawMaterialLot"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
-- Eine Bewegung gehoert jetzt zu genau einer von drei Chargenarten.
|
||||||
|
ALTER TABLE "BatchMovement" DROP CONSTRAINT "BatchMovement_exactly_one_batch";
|
||||||
|
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_exactly_one_batch"
|
||||||
|
CHECK (num_nonnulls("bottlingBatchId", "productionBatchId", "rawMaterialLotId") = 1);
|
||||||
|
ALTER TABLE "RawMaterialLot" ADD CONSTRAINT "RawMaterialLot_quantity_positive" CHECK ("quantity" > 0);
|
||||||
|
ALTER TABLE "ProductionInput" ADD CONSTRAINT "ProductionInput_quantity_positive" CHECK ("quantity" > 0);
|
||||||
|
|
||||||
|
-- Permissions (catalog sync grants only brand-new rows to ADMIN): roles that
|
||||||
|
-- may maintain production batches may maintain raw materials, readers read.
|
||||||
|
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'raw-material:read', 'raw-material', 'read', 'Ansehen', 'Rohstoffe', 1630),
|
||||||
|
(gen_random_uuid()::text, 'raw-material:create', 'raw-material', 'create', 'Anlegen', 'Rohstoffe', 1631),
|
||||||
|
(gen_random_uuid()::text, 'raw-material:update', 'raw-material', 'update', 'Bearbeiten', 'Rohstoffe', 1632),
|
||||||
|
(gen_random_uuid()::text, 'raw-material:delete', 'raw-material', 'delete', 'Löschen', 'Rohstoffe', 1633),
|
||||||
|
(gen_random_uuid()::text, 'raw-material:withdraw', 'raw-material', 'withdraw', 'Entnehmen und Inventur', 'Rohstoffe', 1634)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||||
|
SELECT DISTINCT rp."roleId", p.id, now()
|
||||||
|
FROM "RolePermission" rp
|
||||||
|
JOIN "Permission" existing ON existing.id = rp."permissionId" AND existing.key = 'production-batch:create'
|
||||||
|
JOIN "Permission" p ON p.key IN ('raw-material:create', 'raw-material:update', 'raw-material:delete', 'raw-material:withdraw')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||||
|
SELECT DISTINCT rp."roleId", p.id, now()
|
||||||
|
FROM "RolePermission" rp
|
||||||
|
JOIN "Permission" existing ON existing.id = rp."permissionId" AND existing.key = 'production-batch:read'
|
||||||
|
JOIN "Permission" p ON p.key = 'raw-material:read'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "AutomationTriggerType" ADD VALUE 'RAW_MATERIAL_LOT_NUMBER_FORMAT';
|
||||||
|
ALTER TYPE "AutomationTriggerType" ADD VALUE 'PRODUCTION_BATCH_NUMBER_FORMAT';
|
||||||
|
ALTER TYPE "AutomationTriggerType" ADD VALUE 'RAW_MATERIAL_LOW';
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AutomationSequence" (
|
||||||
|
"triggerType" "AutomationTriggerType" NOT NULL,
|
||||||
|
"scope" TEXT NOT NULL,
|
||||||
|
"value" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "AutomationSequence_pkey" PRIMARY KEY ("triggerType","scope")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AutomationSequence" ADD CONSTRAINT "AutomationSequence_triggerType_fkey" FOREIGN KEY ("triggerType") REFERENCES "AutomationRule"("triggerType") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Outbox claims: one worker at a time writes an entry to M365, and a
|
||||||
|
-- revision counter lets it notice an enqueue that arrived during the sync.
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "CalendarSyncOutboxEntry" ADD COLUMN "claimedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "revision" INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -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
+219
-29
@@ -16,34 +16,209 @@ function generateRandomPassword(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only printed the moment the account is actually created (first deploy on
|
// Only printed the moment the account is actually created (first deploy on
|
||||||
// an empty database), never on later restarts: by then the real admin may
|
// an empty user table), never on later restarts: by then the real admin may
|
||||||
// have changed the password via /profile, so repeating it would just be
|
// have changed the password via /profile, so repeating it would just be
|
||||||
// stale and would leak credentials into logs indefinitely for no reason.
|
// stale and would leak credentials into logs indefinitely for no reason.
|
||||||
function printLoginBanner(email: string, password: string): void {
|
// An operator-supplied password is never printed: the operator already has
|
||||||
|
// it, and the log would only be one more place it leaks to.
|
||||||
|
function printLoginBanner(
|
||||||
|
email: string,
|
||||||
|
password: string | null,
|
||||||
|
log: (line: string) => void,
|
||||||
|
): void {
|
||||||
const url = process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000';
|
const url = process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000';
|
||||||
const line = '='.repeat(60);
|
const line = '='.repeat(60);
|
||||||
console.log(line);
|
log(line);
|
||||||
console.log('Seed: Admin-Zugang eingerichtet.');
|
log('Seed: Admin-Zugang eingerichtet.');
|
||||||
console.log(` URL: ${url}`);
|
log(` URL: ${url}`);
|
||||||
console.log(` Benutzer: ${email}`);
|
log(` Benutzer: ${email}`);
|
||||||
console.log(` Passwort: ${password}`);
|
log(
|
||||||
console.log(line);
|
password === null
|
||||||
|
? ' Passwort: wie in SEED_ADMIN_PASSWORD gesetzt'
|
||||||
|
: ` Passwort: ${password}`,
|
||||||
|
);
|
||||||
|
log(line);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
// Duplicated from src/task-templates/recurrence.ts rather than imported:
|
||||||
const prisma = new PrismaClient({
|
// tsconfig.seed.json scopes this build to rootDir "./prisma" only (its own
|
||||||
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
|
// isolated compile, separate from the main Nest build), so a cross-rootDir
|
||||||
});
|
// import would fail to compile. Only the YEARLY case is needed here.
|
||||||
|
function nextYearlyOccurrenceUtc(
|
||||||
|
month: number,
|
||||||
|
dayOfMonth: number,
|
||||||
|
from: Date,
|
||||||
|
): Date {
|
||||||
|
const year = from.getUTCFullYear();
|
||||||
|
const clampedDay = (y: number): number =>
|
||||||
|
Math.min(dayOfMonth, new Date(Date.UTC(y, month, 0)).getUTCDate());
|
||||||
|
const today = new Date(
|
||||||
|
Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate()),
|
||||||
|
);
|
||||||
|
const candidate = new Date(Date.UTC(year, month - 1, clampedDay(year)));
|
||||||
|
if (candidate.getTime() >= today.getTime()) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
return new Date(Date.UTC(year + 1, month - 1, clampedDay(year + 1)));
|
||||||
|
}
|
||||||
|
|
||||||
const email = process.env.SEED_ADMIN_EMAIL ?? DEFAULT_ADMIN_EMAIL;
|
interface DefaultTaskTemplate {
|
||||||
const password = process.env.SEED_ADMIN_PASSWORD ?? generateRandomPassword();
|
title: string;
|
||||||
const existing = await prisma.user.findUnique({ where: { email } });
|
description: string;
|
||||||
if (existing) {
|
priority: 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT';
|
||||||
console.log(`Seed: admin user ${email} already exists, skipping.`);
|
month: number;
|
||||||
await prisma.$disconnect();
|
dayOfMonth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The standard German-beekeeping annual cycle, one flagship task per month -
|
||||||
|
// gives a new deployment a ready-made starter calendar instead of a
|
||||||
|
// completely empty task-template list. Admins can edit/deactivate any of
|
||||||
|
// these afterward; this only ever runs once (see the count() guard below).
|
||||||
|
const DEFAULT_TASK_TEMPLATES: DefaultTaskTemplate[] = [
|
||||||
|
{
|
||||||
|
title: 'Wintersitz kontrollieren',
|
||||||
|
description:
|
||||||
|
'Ruhige Kontrolle von außen: Flugloch frei, Gewicht per Anheben schätzen, auf Mäuse-/Spechtschäden achten. Beute möglichst nicht öffnen.',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
month: 1,
|
||||||
|
dayOfMonth: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Gemülldiagnose',
|
||||||
|
description:
|
||||||
|
'Windel auswerten: natürlicher Varroa-Totenfall, Bienensitz und Lage der Wintertraube anhand des Gemülls einschätzen.',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
month: 2,
|
||||||
|
dayOfMonth: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Frühjahrsdurchsicht',
|
||||||
|
description:
|
||||||
|
'Erste Innendurchsicht bei ca. 15°C und ruhigem Wetter: Brutnest, Futtervorrat, Volksstärke und Weiselrichtigkeit prüfen.',
|
||||||
|
priority: 'HIGH',
|
||||||
|
month: 3,
|
||||||
|
dayOfMonth: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Volk erweitern',
|
||||||
|
description:
|
||||||
|
'Zargen/Rähmchen nach Volksstärke erweitern, Baurahmen einhängen, Platz für die Frühtracht schaffen.',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
month: 4,
|
||||||
|
dayOfMonth: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Schwarmkontrolle beginnen',
|
||||||
|
description:
|
||||||
|
'Wöchentlich auf Weiselzellen kontrollieren, bis zum Ende der Schwarmzeit fortführen. Maßnahmen bei Bedarf: Ableger bilden, Zellen brechen.',
|
||||||
|
priority: 'HIGH',
|
||||||
|
month: 5,
|
||||||
|
dayOfMonth: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Honigernte vorbereiten',
|
||||||
|
description:
|
||||||
|
'Reifegrad der Frühtracht prüfen (Spritzprobe), Schleuderplan und Gerätereinigung vorbereiten.',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
month: 6,
|
||||||
|
dayOfMonth: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Varroa-Kontrolle nach der Ernte',
|
||||||
|
description:
|
||||||
|
'Milbenbefall messen (z. B. Puderzuckermethode oder natürlicher Totenfall) und Behandlung planen, sobald die Honigräume abgeräumt sind.',
|
||||||
|
priority: 'HIGH',
|
||||||
|
month: 7,
|
||||||
|
dayOfMonth: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Einfütterung beginnen',
|
||||||
|
description:
|
||||||
|
'Wintervorrat auffüttern (Zielgewicht je nach Beutensystem), Futteraufnahme nach ein bis zwei Wochen kontrollieren.',
|
||||||
|
priority: 'HIGH',
|
||||||
|
month: 8,
|
||||||
|
dayOfMonth: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Varroa-Restentmilbung / Winterfestigkeit prüfen',
|
||||||
|
description:
|
||||||
|
'Zweite Varroabehandlung durchführen, Volksstärke und Weiselrichtigkeit für den Winter beurteilen.',
|
||||||
|
priority: 'HIGH',
|
||||||
|
month: 9,
|
||||||
|
dayOfMonth: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Fütterung abschließen, Mäusegitter anbringen',
|
||||||
|
description:
|
||||||
|
'Letzte Futtergabe, Fluglochkeile bzw. Mäusegitter montieren, Bienenstand winterfest machen.',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
month: 10,
|
||||||
|
dayOfMonth: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Winterbehandlung (Oxalsäure)',
|
||||||
|
description:
|
||||||
|
'Brutfreiheit abwarten und Träufel- oder Sprühbehandlung gegen Varroa durchführen.',
|
||||||
|
priority: 'HIGH',
|
||||||
|
month: 11,
|
||||||
|
dayOfMonth: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Jahresrückblick und Gerätewartung',
|
||||||
|
description:
|
||||||
|
'Saison auswerten, Zargen und Rähmchen reinigen/reparieren, Material für die kommende Saison planen.',
|
||||||
|
priority: 'LOW',
|
||||||
|
month: 12,
|
||||||
|
dayOfMonth: 15,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function seedDefaultTaskTemplates(prisma: PrismaClient): Promise<void> {
|
||||||
|
const existingCount = await prisma.taskTemplate.count();
|
||||||
|
if (existingCount > 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const now = new Date();
|
||||||
|
await prisma.taskTemplate.createMany({
|
||||||
|
data: DEFAULT_TASK_TEMPLATES.map((template) => ({
|
||||||
|
title: template.title,
|
||||||
|
description: template.description,
|
||||||
|
priority: template.priority,
|
||||||
|
recurrenceType: 'YEARLY',
|
||||||
|
recurrenceMonth: template.month,
|
||||||
|
recurrenceDayOfMonth: template.dayOfMonth,
|
||||||
|
nextDueDate: nextYearlyOccurrenceUtc(
|
||||||
|
template.month,
|
||||||
|
template.dayOfMonth,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`Seed: ${DEFAULT_TASK_TEMPLATES.length} Standard-Aufgabenvorlagen angelegt.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminSeedClient = Pick<PrismaClient, 'user' | 'role'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
|
||||||
const adminRole = await prisma.role.findUniqueOrThrow({
|
const adminRole = await prisma.role.findUniqueOrThrow({
|
||||||
where: { key: 'ADMIN' },
|
where: { key: 'ADMIN' },
|
||||||
@@ -57,27 +232,42 @@ async function main(): Promise<void> {
|
|||||||
roleId: adminRole.id,
|
roleId: adminRole.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
printLoginBanner(email, password);
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// P2002 = unique constraint violation on email: another replica's
|
// P2002 = unique constraint violation on email: another replica's
|
||||||
// startup won this race between the existence check above and this
|
// startup won this race between the count above and this create()
|
||||||
// create() call. The user now exists either way, so this is a no-op,
|
// call. The user now exists either way, so this is a no-op, not a
|
||||||
// not a failure - container startup must not crash on it.
|
// failure - container startup must not crash on it.
|
||||||
if (
|
if (
|
||||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
error.code === 'P2002'
|
error.code === 'P2002'
|
||||||
) {
|
) {
|
||||||
console.log(
|
log(`Seed: admin user ${email} was created concurrently, skipping.`);
|
||||||
`Seed: admin user ${email} was created concurrently, skipping.`,
|
return 'skipped';
|
||||||
);
|
}
|
||||||
} else {
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
printLoginBanner(
|
||||||
|
email,
|
||||||
|
suppliedPassword === undefined ? password : null,
|
||||||
|
log,
|
||||||
|
);
|
||||||
|
return 'created';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const prisma = new PrismaClient({
|
||||||
|
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await seedDefaultTaskTemplates(prisma);
|
||||||
|
await seedAdmin(prisma, process.env, (line) => console.log(line));
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error: unknown) => {
|
// Only when run as `node dist/seed.js`, not when a test imports seedAdmin.
|
||||||
|
if (require.main === module) {
|
||||||
|
main().catch((error: unknown) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* Evaluates voice extraction against the gold set (test/fixtures/voice/gold).
|
||||||
|
*
|
||||||
|
* npm run eval:voice offline, replays the expected answers, no network, free
|
||||||
|
* npm run eval:voice -- --live --yes real Claude calls, costs money
|
||||||
|
* npm run eval:voice -- --live --yes --model=claude-haiku-5-5
|
||||||
|
*
|
||||||
|
* Live mode needs ANTHROPIC_API_KEY in the environment AND both --live and
|
||||||
|
* --yes; it is never the default and never runs in CI (no workflow calls
|
||||||
|
* this script). This is an operator tool, not a request path: it lives
|
||||||
|
* outside src/, so the network guard (src/common/no-network-outside-loops)
|
||||||
|
* does not scan it, and it talks to the adapter only through the
|
||||||
|
* StructuredModel port.
|
||||||
|
*
|
||||||
|
* Exit code: 0 pass, 1 below the thresholds (precision < 98 % or recall <
|
||||||
|
* 85 %, see EVAL_THRESHOLDS), 2 usage or setup error.
|
||||||
|
*/
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { anthropicApiKey } from '../src/voice/anthropic-config';
|
||||||
|
import { formatReport } from '../src/voice/eval/eval-report';
|
||||||
|
import { loadGoldCases } from '../src/voice/eval/gold-case';
|
||||||
|
import { estimateCostUsd } from '../src/voice/eval/voice-prices';
|
||||||
|
import {
|
||||||
|
offlineModelFor,
|
||||||
|
runEval,
|
||||||
|
summarize,
|
||||||
|
} from '../src/voice/eval/voice-eval';
|
||||||
|
import { AnthropicStructuredModel } from '../src/voice/extraction/anthropic-structured-model';
|
||||||
|
import { FAKE_USAGE } from '../src/voice/extraction/fake-structured-model';
|
||||||
|
import { DEFAULT_VOICE_MODEL, VOICE_MODELS } from '../src/voice/voice-config';
|
||||||
|
|
||||||
|
const GOLD_DIR = join(__dirname, '..', 'test', 'fixtures', 'voice', 'gold');
|
||||||
|
const EXIT_PASS = 0;
|
||||||
|
const EXIT_BELOW_THRESHOLD = 1;
|
||||||
|
const EXIT_USAGE = 2;
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
live: boolean;
|
||||||
|
yes: boolean;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(args: readonly string[]): Options | string {
|
||||||
|
let live = false;
|
||||||
|
let yes = false;
|
||||||
|
let model: string = DEFAULT_VOICE_MODEL;
|
||||||
|
for (const arg of args) {
|
||||||
|
if (arg === '--live') {
|
||||||
|
live = true;
|
||||||
|
} else if (arg === '--yes') {
|
||||||
|
yes = true;
|
||||||
|
} else if (arg.startsWith('--model=')) {
|
||||||
|
model = arg.slice('--model='.length);
|
||||||
|
} else {
|
||||||
|
return `Unknown argument: ${arg}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!(VOICE_MODELS as readonly string[]).includes(model)) {
|
||||||
|
return `--model must be one of ${VOICE_MODELS.join(', ')}`;
|
||||||
|
}
|
||||||
|
return { live, yes, model };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message: string): never {
|
||||||
|
console.error(message);
|
||||||
|
process.exit(EXIT_USAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<number> {
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
if (typeof options === 'string') {
|
||||||
|
return fail(options);
|
||||||
|
}
|
||||||
|
const cases = loadGoldCases(GOLD_DIR);
|
||||||
|
if (!options.live) {
|
||||||
|
const results = await runEval(
|
||||||
|
cases,
|
||||||
|
offlineModelFor(options.model),
|
||||||
|
options.model,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
formatReport(results, summarize(results), {
|
||||||
|
mode: 'offline',
|
||||||
|
model: options.model,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return summarizeExit(results);
|
||||||
|
}
|
||||||
|
const key = anthropicApiKey(process.env);
|
||||||
|
if (key === null) {
|
||||||
|
return fail('--live needs a valid ANTHROPIC_API_KEY in the environment.');
|
||||||
|
}
|
||||||
|
const estimate = estimateCostUsd(options.model, {
|
||||||
|
input: FAKE_USAGE.input * cases.length,
|
||||||
|
output: FAKE_USAGE.output * cases.length,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
});
|
||||||
|
console.warn(
|
||||||
|
`WARNING: ${cases.length} real Claude calls on ${options.model}, roughly ${
|
||||||
|
estimate === null ? 'an unknown amount' : `$${estimate.toFixed(2)}`
|
||||||
|
} of API cost.`,
|
||||||
|
);
|
||||||
|
if (!options.yes) {
|
||||||
|
return fail('Add --yes to confirm and run the live evaluation.');
|
||||||
|
}
|
||||||
|
const live = new AnthropicStructuredModel(key);
|
||||||
|
const results = await runEval(cases, () => live, options.model);
|
||||||
|
console.log(
|
||||||
|
formatReport(results, summarize(results), {
|
||||||
|
mode: 'live',
|
||||||
|
model: options.model,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return summarizeExit(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeExit(results: Parameters<typeof summarize>[0]): number {
|
||||||
|
return summarize(results).failures.length === 0
|
||||||
|
? EXIT_PASS
|
||||||
|
: EXIT_BELOW_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
main().then(
|
||||||
|
(code) => process.exit(code),
|
||||||
|
(error: unknown) => {
|
||||||
|
console.error(error instanceof Error ? error.message : 'eval failed');
|
||||||
|
process.exit(EXIT_USAGE);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Writes the Whisper initial prompt to ApiCore-Whisper/vocabulary.txt, so the
|
||||||
|
* Python image uses the same word list as the backend (one source:
|
||||||
|
* src/voice/core/voice-vocabulary.ts). Run `npm run export:voice-vocabulary`
|
||||||
|
* after changing the list; voice-vocabulary-export.spec.ts fails until you do.
|
||||||
|
*/
|
||||||
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { whisperInitialPrompt } from '../src/voice/core/voice-vocabulary';
|
||||||
|
|
||||||
|
const target = join(__dirname, '..', '..', 'ApiCore-Whisper', 'vocabulary.txt');
|
||||||
|
|
||||||
|
mkdirSync(dirname(target), { recursive: true });
|
||||||
|
writeFileSync(target, `${whisperInitialPrompt()}\n`, 'utf8');
|
||||||
|
console.log(`Wrote ${target}`);
|
||||||
@@ -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 {}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { PermissionKey } from '../auth/permissions/permission-catalog';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permissions an API key may carry. Deliberately a short allowlist, not the
|
||||||
|
* whole catalog: a key is a long-lived secret stored in another system, so it
|
||||||
|
* only ever gets what a machine integration needs. shop-sync:write lets the
|
||||||
|
* shop book its own sales and returns - nothing else - and every booking is
|
||||||
|
* idempotent and kept in the ledger, so a leaked key cannot erase history.
|
||||||
|
* erp-inventory:read lets ERPNext read the inventory mirror (articles,
|
||||||
|
* batches with remaining stock, stock movements, harvest totals) - read
|
||||||
|
* only, no people, no notes. Removing an entry here also takes it away from
|
||||||
|
* existing keys (see ApiKeysService.authenticate).
|
||||||
|
*/
|
||||||
|
export const API_KEY_SCOPES = [
|
||||||
|
'shop-sync:read',
|
||||||
|
'shop-sync:write',
|
||||||
|
'erp-inventory:read',
|
||||||
|
] as const satisfies readonly PermissionKey[];
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||||
|
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
||||||
|
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
|
||||||
|
import {
|
||||||
|
ApiKeySummary,
|
||||||
|
ApiKeysService,
|
||||||
|
CreatedApiKey,
|
||||||
|
} from './api-keys.service';
|
||||||
|
|
||||||
|
@ApiTags('api-keys')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('api-keys')
|
||||||
|
export class ApiKeysController {
|
||||||
|
constructor(private readonly apiKeys: ApiKeysService) {}
|
||||||
|
|
||||||
|
@RequirePermission('api-keys:manage')
|
||||||
|
@Get()
|
||||||
|
findAll(): Promise<ApiKeySummary[]> {
|
||||||
|
return this.apiKeys.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('api-keys:manage')
|
||||||
|
@Post()
|
||||||
|
create(
|
||||||
|
@Body() dto: CreateApiKeyDto,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
): Promise<CreatedApiKey> {
|
||||||
|
return this.apiKeys.create(dto, actor.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('api-keys:manage')
|
||||||
|
@Patch(':id')
|
||||||
|
updateAllowedIps(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateApiKeyDto,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
): Promise<ApiKeySummary> {
|
||||||
|
return this.apiKeys.updateAllowedIps(id, dto, actor.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('api-keys:manage')
|
||||||
|
@Post(':id/revoke')
|
||||||
|
revoke(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
): Promise<ApiKeySummary> {
|
||||||
|
return this.apiKeys.revoke(id, actor.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +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, FailedAttemptLimiter],
|
||||||
|
exports: [ApiKeysService],
|
||||||
|
})
|
||||||
|
export class ApiKeysModule {}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
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,
|
||||||
|
FailedAttemptLimiter,
|
||||||
|
} from './failed-attempt-limiter';
|
||||||
|
|
||||||
|
// Synthetischer Schlüssel im echten Format - kein echter Zugang.
|
||||||
|
const TOKEN = `ack_abcd1234_${'A'.repeat(43)}`;
|
||||||
|
const OTHER_TOKEN = `ack_zzzz9999_${'B'.repeat(43)}`;
|
||||||
|
|
||||||
|
function storedKey(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'key-1',
|
||||||
|
name: 'ERPNext',
|
||||||
|
prefix: 'ack_abcd1234',
|
||||||
|
keyHash: createHash('sha256').update(TOKEN).digest('hex'),
|
||||||
|
scopes: ['erp-inventory:read'],
|
||||||
|
createdByUserId: 'user-1',
|
||||||
|
createdAt: new Date('2026-10-01T00:00:00.000Z'),
|
||||||
|
lastUsedAt: null,
|
||||||
|
lastUsedIp: null,
|
||||||
|
allowedIps: [],
|
||||||
|
expiresAt: null,
|
||||||
|
revokedAt: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
let warn: jest.SpyInstance;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma = {
|
||||||
|
apiKey: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue(null),
|
||||||
|
update: jest.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
service = new ApiKeysService(
|
||||||
|
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(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
function loggedText(): string {
|
||||||
|
return JSON.stringify(warn.mock.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('sucht den Schlüssel nur über seinen Hash', async () => {
|
||||||
|
prisma.apiKey.findUnique.mockResolvedValueOnce(storedKey());
|
||||||
|
|
||||||
|
const principal = await service.authenticate(TOKEN, '203.0.113.7');
|
||||||
|
|
||||||
|
expect(principal).toEqual({ id: 'key-1', scopes: ['erp-inventory:read'] });
|
||||||
|
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { keyHash: createHash('sha256').update(TOKEN).digest('hex') },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt einen fehlerhaft aufgebauten Schlüssel ohne Datenbankzugriff ab', async () => {
|
||||||
|
await expect(service.authenticate('ack_kurz', '203.0.113.7')).resolves.toBe(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(prisma.apiKey.findUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt einen unbekannten Schlüssel ab', async () => {
|
||||||
|
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt einen widerrufenen (gesperrten) Schlüssel ab', async () => {
|
||||||
|
prisma.apiKey.findUnique.mockResolvedValueOnce(
|
||||||
|
storedKey({ revokedAt: new Date('2026-10-02T00:00:00.000Z') }),
|
||||||
|
);
|
||||||
|
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(loggedText()).toContain('revoked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt einen abgelaufenen Schlüssel ab', async () => {
|
||||||
|
prisma.apiKey.findUnique.mockResolvedValueOnce(
|
||||||
|
storedKey({ expiresAt: new Date('2020-01-01T00:00:00.000Z') }),
|
||||||
|
);
|
||||||
|
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(loggedText()).toContain('expired');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schreibt nie den Schlüssel selbst ins Log, nur das Präfix', async () => {
|
||||||
|
prisma.apiKey.findUnique.mockResolvedValueOnce(
|
||||||
|
storedKey({ revokedAt: new Date() }),
|
||||||
|
);
|
||||||
|
await service.authenticate(TOKEN, '203.0.113.7');
|
||||||
|
await service.authenticate(OTHER_TOKEN, '203.0.113.7');
|
||||||
|
|
||||||
|
const text = loggedText();
|
||||||
|
expect(text).not.toContain(TOKEN);
|
||||||
|
expect(text).not.toContain(OTHER_TOKEN);
|
||||||
|
expect(text).not.toContain('A'.repeat(43));
|
||||||
|
expect(text).not.toContain('B'.repeat(43));
|
||||||
|
expect(text).toContain('ack_abcd1234');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gibt nur Rechte weiter, die für Schlüssel freigegeben sind', async () => {
|
||||||
|
prisma.apiKey.findUnique.mockResolvedValueOnce(
|
||||||
|
storedKey({ scopes: ['erp-inventory:read', 'users:create'] }),
|
||||||
|
);
|
||||||
|
const principal = await service.authenticate(TOKEN, '203.0.113.7');
|
||||||
|
expect(principal?.scopes).toEqual(['erp-inventory:read']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist einen richtigen Schlüssel von einer nicht freigegebenen Adresse mit 403 ab', async () => {
|
||||||
|
prisma.apiKey.findUnique.mockResolvedValueOnce(
|
||||||
|
storedKey({ allowedIps: ['198.51.100.0/24'] }),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
service.authenticate(TOKEN, '203.0.113.7'),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function failFrom(ip: string, times: number): Promise<void> {
|
||||||
|
for (let i = 0; i < times; i++) {
|
||||||
|
// Unbekannter, aber richtig aufgebauter Schlüssel.
|
||||||
|
await expect(service.authenticate(OTHER_TOKEN, ip)).resolves.toBe(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it(`sperrt unbekannte Schlüssel einer Adresse nach ${API_KEY_FAILURE_LIMIT.maxFailures} Fehlversuchen mit 429`, async () => {
|
||||||
|
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
|
||||||
|
|
||||||
|
const blocked = service.authenticate(OTHER_TOKEN, '203.0.113.7');
|
||||||
|
await expect(blocked).rejects.toBeInstanceOf(HttpException);
|
||||||
|
await expect(
|
||||||
|
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
|
||||||
|
).rejects.toMatchObject({ status: 429 });
|
||||||
|
expect(loggedText()).toContain('203.0.113.7');
|
||||||
|
|
||||||
|
// Widerrufene Schlüssel sind ebenfalls ungültig und bleiben gesperrt.
|
||||||
|
prisma.apiKey.findUnique.mockResolvedValueOnce(
|
||||||
|
storedKey({ revokedAt: new Date('2026-10-02T00:00:00.000Z') }),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
service.authenticate(TOKEN, '203.0.113.7'),
|
||||||
|
).rejects.toMatchObject({ status: 429 });
|
||||||
|
|
||||||
|
// Andere Adressen sind nicht betroffen.
|
||||||
|
await expect(
|
||||||
|
service.authenticate(OTHER_TOKEN, '198.51.100.1'),
|
||||||
|
).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(
|
||||||
|
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
|
||||||
|
).rejects.toMatchObject({ status: 429 });
|
||||||
|
prisma.apiKey.findUnique.mockResolvedValueOnce(storedKey());
|
||||||
|
|
||||||
|
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toEqual({
|
||||||
|
id: 'key-1',
|
||||||
|
scopes: ['erp-inventory:read'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zählt fehlerhaft aufgebaute Schlüssel nicht als Fehlversuch', async () => {
|
||||||
|
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures * 2; i++) {
|
||||||
|
await expect(service.authenticate('ack_x', '203.0.113.7')).resolves.toBe(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Weder gesperrt noch eine Datenbankabfrage.
|
||||||
|
expect(prisma.apiKey.findUnique).not.toHaveBeenCalled();
|
||||||
|
await expect(
|
||||||
|
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
|
||||||
|
).resolves.toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zählt IPv6-Adressen aus demselben /64 gemeinsam (wie der Throttler)', async () => {
|
||||||
|
// Wechselnde Adressen im eigenen /64 umgehen die Sperre nicht.
|
||||||
|
for (let i = 1; i <= API_KEY_FAILURE_LIMIT.maxFailures; i++) {
|
||||||
|
await service.authenticate(
|
||||||
|
OTHER_TOKEN,
|
||||||
|
`2001:db8:1:2::${i.toString(16)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await expect(
|
||||||
|
service.authenticate(OTHER_TOKEN, '2001:db8:1:2::ffff'),
|
||||||
|
).rejects.toMatchObject({ status: 429 });
|
||||||
|
// Ein anderes /64 ist frei.
|
||||||
|
await expect(
|
||||||
|
service.authenticate(OTHER_TOKEN, '2001:db8:1:3::1'),
|
||||||
|
).resolves.toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
expect(extractApiKeyToken('Bearer eyJhbGciOi.jwt')).toBeNull();
|
||||||
|
expect(extractApiKeyToken(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { createHash, randomBytes, randomInt } from 'node:crypto';
|
||||||
|
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,
|
||||||
|
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 {
|
||||||
|
API_KEY_FAILURE_LIMIT,
|
||||||
|
FailedAttemptLimiter,
|
||||||
|
} from './failed-attempt-limiter';
|
||||||
|
import { rateLimitSubject } from '../common/throttler/rate-limit-subject';
|
||||||
|
|
||||||
|
function normalizeAllowlist(entries: readonly string[] | undefined): string[] {
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
|
(entries ?? [])
|
||||||
|
.map(normalizeAllowlistEntry)
|
||||||
|
.filter((entry): entry is string => entry !== null),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ack_<8 lowercase chars>_<32 random bytes, base64url>
|
||||||
|
const TOKEN_PATTERN = /^ack_[a-z0-9]{8}_[A-Za-z0-9_-]{43}$/;
|
||||||
|
const PREFIX_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
// lastUsedAt is informational; writing it on every request would turn each
|
||||||
|
// read into a write for no benefit.
|
||||||
|
const LAST_USED_RESOLUTION_MS = 5 * 60 * 1000;
|
||||||
|
// "ack_" plus the 8-character prefix: identifies a key in logs without
|
||||||
|
// revealing its secret part.
|
||||||
|
const LOGGABLE_PREFIX_LENGTH = 12;
|
||||||
|
|
||||||
|
type RejectReason = 'unknown' | 'revoked' | 'expired';
|
||||||
|
|
||||||
|
/** What a request authenticated by an API key may do - it is not a user. */
|
||||||
|
export interface ApiKeyPrincipal {
|
||||||
|
id: string;
|
||||||
|
scopes: ApiKeyScope[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An API key as shown in the admin UI - never the hash or the key itself. */
|
||||||
|
export type ApiKeySummary = Omit<ApiKey, 'keyHash'>;
|
||||||
|
|
||||||
|
export interface CreatedApiKey {
|
||||||
|
apiKey: ApiKeySummary;
|
||||||
|
/** The only time the plain key exists outside the caller's system. */
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashToken(token: string): string {
|
||||||
|
return createHash('sha256').update(token).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomPrefix(): string {
|
||||||
|
let prefix = '';
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
prefix += PREFIX_ALPHABET[randomInt(PREFIX_ALPHABET.length)];
|
||||||
|
}
|
||||||
|
return `ack_${prefix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSummary(apiKey: ApiKey): ApiKeySummary {
|
||||||
|
const { keyHash, ...summary } = apiKey;
|
||||||
|
void keyHash;
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApiKeysService {
|
||||||
|
private readonly logger = new Logger(ApiKeysService.name);
|
||||||
|
|
||||||
|
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[]> {
|
||||||
|
const keys = await this.prisma.apiKey.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
return keys.map(toSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
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);
|
||||||
|
const missing = dto.scopes.filter((scope) => !callerPermissions.has(scope));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Diese Berechtigung haben Sie selbst nicht: ${missing.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const prefix = randomPrefix();
|
||||||
|
// 32 random bytes: a plain hash of it is safe to store without a slow
|
||||||
|
// KDF - there is nothing guessable to brute-force.
|
||||||
|
const token = `${prefix}_${randomBytes(32).toString('base64url')}`;
|
||||||
|
const expiresAt =
|
||||||
|
dto.expiresInDays !== undefined
|
||||||
|
? new Date(Date.now() + dto.expiresInDays * 24 * 60 * 60 * 1000)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const apiKey = await this.prisma.apiKey.create({
|
||||||
|
data: {
|
||||||
|
name: dto.name.trim(),
|
||||||
|
prefix,
|
||||||
|
keyHash: hashToken(token),
|
||||||
|
scopes: [...new Set(dto.scopes)],
|
||||||
|
allowedIps: normalizeAllowlist(dto.allowedIps),
|
||||||
|
expiresAt,
|
||||||
|
createdByUserId: actingUserId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const summary = toSummary(apiKey);
|
||||||
|
await this.auditLog.record({
|
||||||
|
entityType: 'ApiKey',
|
||||||
|
entityId: apiKey.id,
|
||||||
|
action: 'CREATE',
|
||||||
|
userId: actingUserId,
|
||||||
|
afterData: summary,
|
||||||
|
});
|
||||||
|
return { apiKey: summary, token };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Changes where a key may be used from, without issuing a new one. */
|
||||||
|
async updateAllowedIps(
|
||||||
|
id: string,
|
||||||
|
dto: UpdateApiKeyDto,
|
||||||
|
actingUserId: string,
|
||||||
|
): Promise<ApiKeySummary> {
|
||||||
|
const before = await this.prisma.apiKey.findUnique({ where: { id } });
|
||||||
|
if (!before) {
|
||||||
|
throw new NotFoundException('API-Schlüssel nicht gefunden');
|
||||||
|
}
|
||||||
|
const updated = await this.prisma.apiKey.update({
|
||||||
|
where: { id },
|
||||||
|
data: { allowedIps: normalizeAllowlist(dto.allowedIps) },
|
||||||
|
});
|
||||||
|
await this.auditLog.record({
|
||||||
|
entityType: 'ApiKey',
|
||||||
|
entityId: id,
|
||||||
|
action: 'UPDATE',
|
||||||
|
userId: actingUserId,
|
||||||
|
beforeData: toSummary(before),
|
||||||
|
afterData: toSummary(updated),
|
||||||
|
});
|
||||||
|
return toSummary(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async revoke(id: string, actingUserId: string): Promise<ApiKeySummary> {
|
||||||
|
const before = await this.prisma.apiKey.findUnique({ where: { id } });
|
||||||
|
if (!before) {
|
||||||
|
throw new NotFoundException('API-Schlüssel nicht gefunden');
|
||||||
|
}
|
||||||
|
if (before.revokedAt) {
|
||||||
|
return toSummary(before);
|
||||||
|
}
|
||||||
|
const revoked = await this.prisma.apiKey.update({
|
||||||
|
where: { id },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
await this.auditLog.record({
|
||||||
|
entityType: 'ApiKey',
|
||||||
|
entityId: id,
|
||||||
|
action: 'DEACTIVATE',
|
||||||
|
userId: actingUserId,
|
||||||
|
beforeData: toSummary(before),
|
||||||
|
afterData: toSummary(revoked),
|
||||||
|
});
|
||||||
|
return toSummary(revoked);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a presented key to what it may do, or null for anything that
|
||||||
|
* must not pass: malformed, unknown, revoked or expired.
|
||||||
|
*
|
||||||
|
* The key is found by the SHA-256 of the presented token through a unique
|
||||||
|
* index - never by comparing secrets byte by byte, so response timing says
|
||||||
|
* nothing about the stored key. Order matters: the lookup comes first, and
|
||||||
|
* only an invalid key counts against (or is stopped by) the failure limit
|
||||||
|
* of the client network. A malformed token is not a guess at a key and is
|
||||||
|
* refused without lookup and without counting.
|
||||||
|
*/
|
||||||
|
async authenticate(
|
||||||
|
token: string,
|
||||||
|
clientIp?: string,
|
||||||
|
): Promise<ApiKeyPrincipal | null> {
|
||||||
|
if (!TOKEN_PATTERN.test(token)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const apiKey = await this.prisma.apiKey.findUnique({
|
||||||
|
where: { keyHash: hashToken(token) },
|
||||||
|
});
|
||||||
|
const now = new Date();
|
||||||
|
const reason = rejectReason(apiKey, now);
|
||||||
|
if (reason === null && apiKey) {
|
||||||
|
return this.accept(apiKey, clientIp, now);
|
||||||
|
}
|
||||||
|
await this.recordRejection(token, reason ?? 'unknown', clientIp);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Counts an invalid key; 429 once the client network is over the limit. */
|
||||||
|
private async recordRejection(
|
||||||
|
token: string,
|
||||||
|
reason: RejectReason,
|
||||||
|
clientIp: string | undefined,
|
||||||
|
): 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';
|
||||||
|
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(
|
||||||
|
{
|
||||||
|
statusCode: HttpStatus.TOO_MANY_REQUESTS,
|
||||||
|
error: 'Too Many Requests',
|
||||||
|
message:
|
||||||
|
'Zu viele Fehlversuche mit API-Schlüsseln - bitte später erneut versuchen',
|
||||||
|
},
|
||||||
|
HttpStatus.TOO_MANY_REQUESTS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Only the public prefix is ever logged, never the key itself.
|
||||||
|
this.logger.warn(
|
||||||
|
`API key rejected (${reason}): ${token.slice(0, LOGGABLE_PREFIX_LENGTH)} from ${clientIp ?? 'unknown'}`,
|
||||||
|
);
|
||||||
|
if (verdict.limitReached) {
|
||||||
|
this.logger.warn(
|
||||||
|
`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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async accept(
|
||||||
|
apiKey: ApiKey,
|
||||||
|
clientIp: string | undefined,
|
||||||
|
now: Date,
|
||||||
|
): Promise<ApiKeyPrincipal> {
|
||||||
|
if (!isIpAllowed(clientIp, apiKey.allowedIps)) {
|
||||||
|
// The key itself is right: say so, so the admin finds the cause - a
|
||||||
|
// thief learns nothing new, they already hold the key.
|
||||||
|
this.logger.warn(
|
||||||
|
`API key ${apiKey.prefix} used from ${clientIp ?? 'unknown'}, not in its allowlist`,
|
||||||
|
);
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'API-Schlüssel ist für diese IP-Adresse nicht freigegeben',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!apiKey.lastUsedAt ||
|
||||||
|
now.getTime() - apiKey.lastUsedAt.getTime() > LAST_USED_RESOLUTION_MS ||
|
||||||
|
apiKey.lastUsedIp !== (clientIp ?? null)
|
||||||
|
) {
|
||||||
|
await this.prisma.apiKey.update({
|
||||||
|
where: { id: apiKey.id },
|
||||||
|
data: { lastUsedAt: now, lastUsedIp: clientIp ?? null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Intersect with today's allowlist: narrowing it later also narrows
|
||||||
|
// keys issued before.
|
||||||
|
return { id: apiKey.id, scopes: apiKey.scopes.filter(isApiKeyScope) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectReason(apiKey: ApiKey | null, now: Date): RejectReason | null {
|
||||||
|
if (!apiKey) return 'unknown';
|
||||||
|
if (apiKey.revokedAt) return 'revoked';
|
||||||
|
if (apiKey.expiresAt && apiKey.expiresAt <= now) return 'expired';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The key from an `Authorization: Bearer ack_…` header, or null. */
|
||||||
|
export function extractApiKeyToken(
|
||||||
|
authorization: string | undefined,
|
||||||
|
): string | null {
|
||||||
|
if (!authorization?.startsWith('Bearer ack_')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return authorization.slice('Bearer '.length).trim();
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import {
|
||||||
|
ValidationArguments,
|
||||||
|
ValidatorConstraint,
|
||||||
|
ValidatorConstraintInterface,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { normalizeAllowlistEntry } from '../ip-allowlist';
|
||||||
|
|
||||||
|
@ValidatorConstraint({ name: 'allowedIp' })
|
||||||
|
export class AllowedIpConstraint implements ValidatorConstraintInterface {
|
||||||
|
validate(value: unknown): boolean {
|
||||||
|
return typeof value === 'string' && normalizeAllowlistEntry(value) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultMessage(args: ValidationArguments): string {
|
||||||
|
return `${String(args.value)} ist keine IP-Adresse und kein Netz (z. B. 203.0.113.7 oder 203.0.113.0/24)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
ArrayMinSize,
|
||||||
|
ArrayUnique,
|
||||||
|
IsArray,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
Validate,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { API_KEY_SCOPES } from '../api-key-scopes';
|
||||||
|
import { MAX_ALLOWED_IPS } from '../ip-allowlist';
|
||||||
|
import { AllowedIpConstraint } from './allowed-ips.validator';
|
||||||
|
|
||||||
|
export class CreateApiKeyDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Wofür der Schlüssel ist, z. B. "Wabenhain-Shop"',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(80)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: API_KEY_SCOPES, isArray: true })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ArrayUnique()
|
||||||
|
@IsIn(API_KEY_SCOPES, { each: true })
|
||||||
|
scopes!: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Gültigkeit in Tagen; ohne Angabe gilt der Schlüssel bis zum Widerruf',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(730)
|
||||||
|
expiresInDays?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Erlaubte Absender (IP oder CIDR), z. B. die Adresse des Wabenhain-Servers',
|
||||||
|
type: [String],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(MAX_ALLOWED_IPS)
|
||||||
|
@IsString({ each: true })
|
||||||
|
@Validate(AllowedIpConstraint, { each: true })
|
||||||
|
allowedIps?: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { ArrayMaxSize, IsArray, IsString, Validate } from 'class-validator';
|
||||||
|
import { MAX_ALLOWED_IPS } from '../ip-allowlist';
|
||||||
|
import { AllowedIpConstraint } from './allowed-ips.validator';
|
||||||
|
|
||||||
|
export class UpdateApiKeyDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
'Erlaubte Absender (IP oder CIDR); leere Liste = von überall nutzbar',
|
||||||
|
type: [String],
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(MAX_ALLOWED_IPS)
|
||||||
|
@IsString({ each: true })
|
||||||
|
@Validate(AllowedIpConstraint, { each: true })
|
||||||
|
allowedIps!: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
|
||||||
|
import {
|
||||||
|
API_KEY_FAILURE_LIMIT,
|
||||||
|
API_KEY_FAILURE_THROTTLER,
|
||||||
|
FailedAttemptLimiter,
|
||||||
|
} from './failed-attempt-limiter';
|
||||||
|
|
||||||
|
describe('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(() => {
|
||||||
|
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 nicht unterhalb der Grenze', async () => {
|
||||||
|
increment.mockResolvedValueOnce(
|
||||||
|
record(API_KEY_FAILURE_LIMIT.maxFailures - 1, false),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
|
||||||
|
blocked: false,
|
||||||
|
limitReached: 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('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 Datenbankfehler weiter, statt still durchzulassen', async () => {
|
||||||
|
increment.mockRejectedValueOnce(new Error('db down'));
|
||||||
|
|
||||||
|
await expect(limiter.recordFailure('203.0.113.7')).rejects.toThrow(
|
||||||
|
'db down',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
constructor(private readonly storage: PrismaThrottlerStorage) {}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
|
||||||
|
|
||||||
|
describe('ip-allowlist', () => {
|
||||||
|
it('normalizes addresses and networks', () => {
|
||||||
|
expect(normalizeAllowlistEntry(' 203.0.113.7 ')).toBe('203.0.113.7');
|
||||||
|
expect(normalizeAllowlistEntry('203.0.113.0/24')).toBe('203.0.113.0/24');
|
||||||
|
expect(normalizeAllowlistEntry('2001:db8::/32')).toBe('2001:db8::/32');
|
||||||
|
expect(normalizeAllowlistEntry('::ffff:203.0.113.7')).toBe('203.0.113.7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects anything else', () => {
|
||||||
|
for (const entry of [
|
||||||
|
'',
|
||||||
|
'example.com',
|
||||||
|
'203.0.113.0/33',
|
||||||
|
'1.2.3.4/x',
|
||||||
|
'1.2.3.4/8/1',
|
||||||
|
]) {
|
||||||
|
expect(normalizeAllowlistEntry(entry)).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows every address when the list is empty', () => {
|
||||||
|
expect(isIpAllowed('198.51.100.1', [])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows only listed addresses and networks', () => {
|
||||||
|
const list = ['203.0.113.7', '198.51.100.0/24'];
|
||||||
|
expect(isIpAllowed('203.0.113.7', list)).toBe(true);
|
||||||
|
expect(isIpAllowed('::ffff:198.51.100.42', list)).toBe(true);
|
||||||
|
expect(isIpAllowed('203.0.113.8', list)).toBe(false);
|
||||||
|
expect(isIpAllowed(undefined, list)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import * as net from 'node:net';
|
||||||
|
|
||||||
|
/** Max entries per key - an allowlist is a short list of servers. */
|
||||||
|
export const MAX_ALLOWED_IPS = 20;
|
||||||
|
|
||||||
|
function unmapIpv4(ip: string): string {
|
||||||
|
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(ip);
|
||||||
|
return mapped ? mapped[1] : ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "203.0.113.7", "203.0.113.0/24", "2001:db8::/32" in canonical form, or
|
||||||
|
* null if the entry is neither an address nor a network.
|
||||||
|
*/
|
||||||
|
export function normalizeAllowlistEntry(entry: string): string | null {
|
||||||
|
const trimmed = entry.trim();
|
||||||
|
const [address, prefix, ...rest] = trimmed.split('/');
|
||||||
|
if (rest.length > 0 || !address) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const ip = unmapIpv4(address);
|
||||||
|
const version = net.isIP(ip);
|
||||||
|
if (version === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (prefix === undefined) {
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
if (!/^\d{1,3}$/.test(prefix)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const bits = Number(prefix);
|
||||||
|
if (bits > (version === 4 ? 32 : 128)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `${ip}/${bits}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An empty list allows every address. */
|
||||||
|
export function isIpAllowed(
|
||||||
|
clientIp: string | undefined,
|
||||||
|
allowedIps: readonly string[],
|
||||||
|
): boolean {
|
||||||
|
if (allowedIps.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!clientIp) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const ip = unmapIpv4(clientIp);
|
||||||
|
const version = net.isIP(ip);
|
||||||
|
if (version === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const list = new net.BlockList();
|
||||||
|
for (const entry of allowedIps) {
|
||||||
|
const [address, prefix] = entry.split('/');
|
||||||
|
const family = net.isIP(address) === 6 ? 'ipv6' : 'ipv4';
|
||||||
|
if (prefix === undefined) {
|
||||||
|
list.addAddress(address, family);
|
||||||
|
} else {
|
||||||
|
list.addSubnet(address, Number(prefix), family);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list.check(ip, version === 6 ? 'ipv6' : 'ipv4');
|
||||||
|
}
|
||||||
@@ -15,12 +15,16 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
|||||||
import { CreateApiaryDto } from './dto/create-apiary.dto';
|
import { CreateApiaryDto } from './dto/create-apiary.dto';
|
||||||
import { UpdateApiaryDto } from './dto/update-apiary.dto';
|
import { UpdateApiaryDto } from './dto/update-apiary.dto';
|
||||||
import { ApiariesService } from './apiaries.service';
|
import { ApiariesService } from './apiaries.service';
|
||||||
|
import { ApiaryFieldService } from './apiary-field.service';
|
||||||
|
|
||||||
@ApiTags('apiaries')
|
@ApiTags('apiaries')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('apiaries')
|
@Controller('apiaries')
|
||||||
export class ApiariesController {
|
export class ApiariesController {
|
||||||
constructor(private readonly apiariesService: ApiariesService) {}
|
constructor(
|
||||||
|
private readonly apiariesService: ApiariesService,
|
||||||
|
private readonly apiaryFieldService: ApiaryFieldService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@RequirePermission('apiaries:read')
|
@RequirePermission('apiaries:read')
|
||||||
@Get()
|
@Get()
|
||||||
@@ -31,6 +35,24 @@ export class ApiariesController {
|
|||||||
return this.apiariesService.findAll({ status, location });
|
return this.apiariesService.findAll({ status, location });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequirePermission('colonies:read')
|
||||||
|
@Get(':id/colony-overview')
|
||||||
|
colonyOverview(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.apiaryFieldService.colonyOverview(id, actor.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('colonies:read')
|
||||||
|
@Get(':id/offline-pack')
|
||||||
|
offlinePack(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.apiaryFieldService.offlinePack(id, actor.id);
|
||||||
|
}
|
||||||
|
|
||||||
@RequirePermission('apiaries:read')
|
@RequirePermission('apiaries:read')
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AutopilotModule } from '../autopilot/autopilot.module';
|
||||||
|
import { DomainEventsModule } from '../domain-events/domain-events.module';
|
||||||
|
import { HiveModule } from '../hive/hive.module';
|
||||||
import { ApiariesController } from './apiaries.controller';
|
import { ApiariesController } from './apiaries.controller';
|
||||||
import { ApiariesService } from './apiaries.service';
|
import { ApiariesService } from './apiaries.service';
|
||||||
|
import { ApiaryFieldService } from './apiary-field.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [AutopilotModule, DomainEventsModule, HiveModule],
|
||||||
controllers: [ApiariesController],
|
controllers: [ApiariesController],
|
||||||
providers: [ApiariesService],
|
providers: [ApiariesService, ApiaryFieldService],
|
||||||
exports: [ApiariesService],
|
exports: [ApiariesService],
|
||||||
})
|
})
|
||||||
export class ApiariesModule {}
|
export class ApiariesModule {}
|
||||||
@@ -2,42 +2,78 @@ import { ConflictException, NotFoundException } from '@nestjs/common';
|
|||||||
import { ApiariesService } from './apiaries.service';
|
import { ApiariesService } from './apiaries.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||||
|
import { expectOnlyIdPayloads } from '../domain-events/herd-payload.spec-helper';
|
||||||
|
|
||||||
describe('ApiariesService', () => {
|
describe('ApiariesService', () => {
|
||||||
let service: ApiariesService;
|
let service: ApiariesService;
|
||||||
let prisma: {
|
let order: string[];
|
||||||
apiary: {
|
let tx: {
|
||||||
findMany: jest.Mock;
|
$queryRaw: jest.Mock;
|
||||||
findUnique: jest.Mock;
|
apiary: { findUnique: jest.Mock; create: jest.Mock; update: jest.Mock };
|
||||||
create: jest.Mock;
|
colony: { count: jest.Mock };
|
||||||
update: jest.Mock;
|
|
||||||
};
|
};
|
||||||
|
let prisma: {
|
||||||
|
apiary: { findMany: jest.Mock; findUnique: jest.Mock };
|
||||||
|
$transaction: jest.Mock;
|
||||||
};
|
};
|
||||||
let auditLog: { record: jest.Mock };
|
let auditLog: { record: jest.Mock };
|
||||||
|
let events: { record: jest.Mock };
|
||||||
|
|
||||||
const existingApiary = {
|
const existingApiary = {
|
||||||
id: 'apiary-1',
|
id: 'apiary-1',
|
||||||
name: 'Heimstand',
|
name: 'Heimstand',
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
deactivatedAt: null,
|
deactivatedAt: null,
|
||||||
|
address: 'Weg 1',
|
||||||
|
location: 'Garten',
|
||||||
|
latitude: 48.1,
|
||||||
|
longitude: 11.5,
|
||||||
|
notes: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const changes = (): string[] =>
|
||||||
|
(
|
||||||
|
events.record.mock.calls as [unknown, { payload: { change: string } }][]
|
||||||
|
).map(([, event]) => event.payload.change);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
prisma = {
|
order = [];
|
||||||
|
tx = {
|
||||||
|
$queryRaw: jest.fn().mockImplementation(() => {
|
||||||
|
order.push('lock');
|
||||||
|
return Promise.resolve([{ id: 'apiary-1' }]);
|
||||||
|
}),
|
||||||
apiary: {
|
apiary: {
|
||||||
findMany: jest.fn(),
|
findUnique: jest.fn().mockImplementation(() => {
|
||||||
findUnique: jest.fn(),
|
order.push('read');
|
||||||
|
return Promise.resolve(existingApiary);
|
||||||
|
}),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
|
colony: { count: jest.fn().mockResolvedValue(0) },
|
||||||
|
};
|
||||||
|
prisma = {
|
||||||
|
apiary: { findMany: jest.fn(), findUnique: jest.fn() },
|
||||||
|
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
|
||||||
|
callback(tx),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
events = { record: jest.fn().mockResolvedValue(undefined) };
|
||||||
service = new ApiariesService(
|
service = new ApiariesService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
auditLog as unknown as AuditLogService,
|
auditLog as unknown as AuditLogService,
|
||||||
|
events as unknown as DomainEventsService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Whatever a test recorded, the herd payloads carry only ids.
|
||||||
|
afterEach(() => {
|
||||||
|
expectOnlyIdPayloads(events.record.mock.calls);
|
||||||
|
});
|
||||||
|
|
||||||
it('findById throws NotFoundException when missing', async () => {
|
it('findById throws NotFoundException when missing', async () => {
|
||||||
prisma.apiary.findUnique.mockResolvedValue(null);
|
prisma.apiary.findUnique.mockResolvedValue(null);
|
||||||
await expect(service.findById('missing')).rejects.toBeInstanceOf(
|
await expect(service.findById('missing')).rejects.toBeInstanceOf(
|
||||||
@@ -45,65 +81,152 @@ describe('ApiariesService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('create persists the apiary and records an audit entry', async () => {
|
it('create writes record, audit and ApiaryChanged CREATED in one transaction', async () => {
|
||||||
prisma.apiary.create.mockResolvedValue(existingApiary);
|
tx.apiary.create.mockResolvedValue(existingApiary);
|
||||||
|
|
||||||
const result = await service.create({ name: 'Heimstand' }, 'user-1');
|
const result = await service.create({ name: 'Heimstand' }, 'user-1');
|
||||||
|
|
||||||
expect(result).toEqual(existingApiary);
|
expect(result).toEqual(existingApiary);
|
||||||
|
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||||
expect(auditLog.record).toHaveBeenCalledWith(
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
entityType: 'Apiary',
|
entityType: 'Apiary',
|
||||||
action: 'CREATE',
|
action: 'CREATE',
|
||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
}),
|
}),
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
expect(events.record).toHaveBeenCalledWith(tx, {
|
||||||
|
type: 'ApiaryChanged',
|
||||||
|
aggregateType: 'Apiary',
|
||||||
|
aggregateId: 'apiary-1',
|
||||||
|
actorUserId: 'user-1',
|
||||||
|
payload: { change: 'CREATED' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a failing audit write rolls back the apiary change', async () => {
|
||||||
|
tx.apiary.create.mockResolvedValue(existingApiary);
|
||||||
|
auditLog.record.mockRejectedValue(new Error('audit down'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.create({ name: 'Heimstand' }, 'user-1'),
|
||||||
|
).rejects.toThrow('audit down');
|
||||||
|
// Record, audit and event share one transaction callback: its rejection
|
||||||
|
// is what rolls the record back.
|
||||||
|
expect(events.record).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update reads before after locking the Apiary row', async () => {
|
||||||
|
tx.apiary.update.mockResolvedValue(existingApiary);
|
||||||
|
|
||||||
|
await service.update('apiary-1', { notes: 'neu' }, 'user-1');
|
||||||
|
|
||||||
|
expect(order).toEqual(['lock', 'read']);
|
||||||
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: 'UPDATE',
|
||||||
|
beforeData: existingApiary,
|
||||||
|
}),
|
||||||
|
tx,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deactivate sets status to INACTIVE and records an audit entry', async () => {
|
it('update of a missing apiary answers 404', async () => {
|
||||||
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
tx.apiary.findUnique.mockResolvedValue(null);
|
||||||
prisma.apiary.update.mockResolvedValue({
|
await expect(
|
||||||
|
service.update('missing', { notes: 'x' }, 'user-1'),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
expect(tx.apiary.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ address: 'Neuer Weg 2' }],
|
||||||
|
[{ location: 'Wiese' }],
|
||||||
|
[{ latitude: 50 }],
|
||||||
|
[{ longitude: 12 }],
|
||||||
|
])('update records LOCATION_CHANGED for %j', async (dto) => {
|
||||||
|
tx.apiary.update.mockResolvedValue({ ...existingApiary, ...dto });
|
||||||
|
|
||||||
|
await service.update('apiary-1', dto, 'user-1');
|
||||||
|
|
||||||
|
expect(changes()).toEqual(['LOCATION_CHANGED']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update records LOCATION_CHANGED only when address, location, latitude or longitude changed', async () => {
|
||||||
|
tx.apiary.update.mockResolvedValue({ ...existingApiary, notes: 'neu' });
|
||||||
|
await service.update(
|
||||||
|
'apiary-1',
|
||||||
|
{ name: 'Anders', notes: 'neu' },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
// The same value sent again is no change either.
|
||||||
|
tx.apiary.update.mockResolvedValue(existingApiary);
|
||||||
|
await service.update('apiary-1', { address: 'Weg 1' }, 'user-1');
|
||||||
|
|
||||||
|
expect(events.record).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deactivate counts living colonies inside the transaction and records DEACTIVATED', async () => {
|
||||||
|
tx.apiary.update.mockResolvedValue({
|
||||||
...existingApiary,
|
...existingApiary,
|
||||||
status: 'INACTIVE',
|
status: 'INACTIVE',
|
||||||
deactivatedAt: new Date(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.deactivate('apiary-1', 'user-1');
|
const result = await service.deactivate('apiary-1', 'user-1');
|
||||||
|
|
||||||
expect(result.status).toBe('INACTIVE');
|
expect(result.status).toBe('INACTIVE');
|
||||||
|
expect(order).toEqual(['lock', 'read']);
|
||||||
|
expect(tx.colony.count).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
apiaryId: 'apiary-1',
|
||||||
|
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
|
||||||
|
},
|
||||||
|
});
|
||||||
expect(auditLog.record).toHaveBeenCalledWith(
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ entityType: 'Apiary', action: 'DEACTIVATE' }),
|
expect.objectContaining({ entityType: 'Apiary', action: 'DEACTIVATE' }),
|
||||||
|
tx,
|
||||||
);
|
);
|
||||||
|
expect(changes()).toEqual(['DEACTIVATED']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deactivate throws ConflictException when active colonies remain', async () => {
|
||||||
|
tx.colony.count.mockResolvedValue(2);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.deactivate('apiary-1', 'user-1'),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
expect(tx.apiary.update).not.toHaveBeenCalled();
|
||||||
|
expect(events.record).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reactivate throws ConflictException when already active', async () => {
|
it('reactivate throws ConflictException when already active', async () => {
|
||||||
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
|
||||||
await expect(
|
await expect(
|
||||||
service.reactivate('apiary-1', 'user-1'),
|
service.reactivate('apiary-1', 'user-1'),
|
||||||
).rejects.toBeInstanceOf(ConflictException);
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
expect(events.record).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reactivate sets status to ACTIVE and clears deactivatedAt', async () => {
|
it('reactivate records REACTIVATED and clears deactivatedAt', async () => {
|
||||||
prisma.apiary.findUnique.mockResolvedValue({
|
tx.apiary.findUnique.mockResolvedValue({
|
||||||
...existingApiary,
|
...existingApiary,
|
||||||
status: 'INACTIVE',
|
status: 'INACTIVE',
|
||||||
deactivatedAt: new Date(),
|
deactivatedAt: new Date(),
|
||||||
});
|
});
|
||||||
prisma.apiary.update.mockResolvedValue({
|
tx.apiary.update.mockResolvedValue(existingApiary);
|
||||||
...existingApiary,
|
|
||||||
status: 'ACTIVE',
|
|
||||||
deactivatedAt: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.reactivate('apiary-1', 'user-1');
|
const result = await service.reactivate('apiary-1', 'user-1');
|
||||||
|
|
||||||
expect(result.status).toBe('ACTIVE');
|
expect(result.status).toBe('ACTIVE');
|
||||||
expect(prisma.apiary.update).toHaveBeenCalledWith({
|
expect(tx.apiary.update).toHaveBeenCalledWith({
|
||||||
where: { id: 'apiary-1' },
|
where: { id: 'apiary-1' },
|
||||||
data: { status: 'ACTIVE', deactivatedAt: null },
|
data: { status: 'ACTIVE', deactivatedAt: null },
|
||||||
});
|
});
|
||||||
expect(auditLog.record).toHaveBeenCalledWith(
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ entityType: 'Apiary', action: 'UPDATE' }),
|
expect.objectContaining({ entityType: 'Apiary', action: 'UPDATE' }),
|
||||||
|
tx,
|
||||||
);
|
);
|
||||||
|
expect(changes()).toEqual(['REACTIVATED']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -5,7 +5,11 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { Apiary, ApiaryStatus } from '../../generated/prisma';
|
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, Prisma } from '../../generated/prisma';
|
||||||
import { CreateApiaryDto } from './dto/create-apiary.dto';
|
import { CreateApiaryDto } from './dto/create-apiary.dto';
|
||||||
import { UpdateApiaryDto } from './dto/update-apiary.dto';
|
import { UpdateApiaryDto } from './dto/update-apiary.dto';
|
||||||
|
|
||||||
@@ -14,11 +18,22 @@ export interface ApiaryFilter {
|
|||||||
location?: string;
|
location?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ApiaryChange = DomainEventPayloads['ApiaryChanged']['change'];
|
||||||
|
|
||||||
|
// The fields a change of which counts as a new location of the apiary.
|
||||||
|
const LOCATION_FIELDS = [
|
||||||
|
'address',
|
||||||
|
'location',
|
||||||
|
'latitude',
|
||||||
|
'longitude',
|
||||||
|
] as const;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApiariesService {
|
export class ApiariesService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
|
private readonly events: DomainEventsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(filter: ApiaryFilter = {}): Promise<Apiary[]> {
|
async findAll(filter: ApiaryFilter = {}): Promise<Apiary[]> {
|
||||||
@@ -42,15 +57,21 @@ export class ApiariesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateApiaryDto, actingUserId: string): Promise<Apiary> {
|
async create(dto: CreateApiaryDto, actingUserId: string): Promise<Apiary> {
|
||||||
const apiary = await this.prisma.apiary.create({ data: { ...dto } });
|
return this.prisma.$transaction(async (tx) => {
|
||||||
await this.auditLog.record({
|
const apiary = await tx.apiary.create({ data: { ...dto } });
|
||||||
|
await this.auditLog.record(
|
||||||
|
{
|
||||||
entityType: 'Apiary',
|
entityType: 'Apiary',
|
||||||
entityId: apiary.id,
|
entityId: apiary.id,
|
||||||
action: 'CREATE',
|
action: 'CREATE',
|
||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
afterData: apiary,
|
afterData: apiary,
|
||||||
});
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
await this.recordChange(tx, apiary.id, 'CREATED', actingUserId);
|
||||||
return apiary;
|
return apiary;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(
|
async update(
|
||||||
@@ -58,56 +79,120 @@ export class ApiariesService {
|
|||||||
dto: UpdateApiaryDto,
|
dto: UpdateApiaryDto,
|
||||||
actingUserId: string,
|
actingUserId: string,
|
||||||
): Promise<Apiary> {
|
): Promise<Apiary> {
|
||||||
const before = await this.findById(id);
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const apiary = await this.prisma.apiary.update({
|
const before = await this.lockApiary(tx, id);
|
||||||
|
const apiary = await tx.apiary.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { ...dto },
|
data: { ...dto },
|
||||||
});
|
});
|
||||||
await this.auditLog.record({
|
await this.auditLog.record(
|
||||||
|
{
|
||||||
entityType: 'Apiary',
|
entityType: 'Apiary',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
action: 'UPDATE',
|
action: 'UPDATE',
|
||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
beforeData: before,
|
beforeData: before,
|
||||||
afterData: apiary,
|
afterData: apiary,
|
||||||
});
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
if (LOCATION_FIELDS.some((field) => before[field] !== apiary[field])) {
|
||||||
|
await this.recordChange(tx, id, 'LOCATION_CHANGED', actingUserId);
|
||||||
|
}
|
||||||
return apiary;
|
return apiary;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async deactivate(id: string, actingUserId: string): Promise<Apiary> {
|
async deactivate(id: string, actingUserId: string): Promise<Apiary> {
|
||||||
const before = await this.findById(id);
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const apiary = await this.prisma.apiary.update({
|
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. 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) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Der Bienenstand hat noch ${activeColonyCount} aktive(s) Volk/Völker - zuerst verlegen oder auflösen`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const apiary = await tx.apiary.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: 'INACTIVE', deactivatedAt: new Date() },
|
data: { status: 'INACTIVE', deactivatedAt: new Date() },
|
||||||
});
|
});
|
||||||
await this.auditLog.record({
|
await this.auditLog.record(
|
||||||
|
{
|
||||||
entityType: 'Apiary',
|
entityType: 'Apiary',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
action: 'DEACTIVATE',
|
action: 'DEACTIVATE',
|
||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
beforeData: before,
|
beforeData: before,
|
||||||
afterData: apiary,
|
afterData: apiary,
|
||||||
});
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
await this.recordChange(tx, id, 'DEACTIVATED', actingUserId);
|
||||||
return apiary;
|
return apiary;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async reactivate(id: string, actingUserId: string): Promise<Apiary> {
|
async reactivate(id: string, actingUserId: string): Promise<Apiary> {
|
||||||
const before = await this.findById(id);
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const before = await this.lockApiary(tx, id);
|
||||||
if (before.status === 'ACTIVE') {
|
if (before.status === 'ACTIVE') {
|
||||||
throw new ConflictException('Apiary is already active');
|
throw new ConflictException('Apiary is already active');
|
||||||
}
|
}
|
||||||
const apiary = await this.prisma.apiary.update({
|
const apiary = await tx.apiary.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: 'ACTIVE', deactivatedAt: null },
|
data: { status: 'ACTIVE', deactivatedAt: null },
|
||||||
});
|
});
|
||||||
await this.auditLog.record({
|
await this.auditLog.record(
|
||||||
|
{
|
||||||
entityType: 'Apiary',
|
entityType: 'Apiary',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
action: 'UPDATE',
|
action: 'UPDATE',
|
||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
beforeData: before,
|
beforeData: before,
|
||||||
afterData: apiary,
|
afterData: apiary,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
await this.recordChange(tx, id, 'REACTIVATED', actingUserId);
|
||||||
|
return apiary;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locks the apiary row and reads it afterwards: the result - not an earlier
|
||||||
|
// read - is "before" for the comparison, the audit and the event.
|
||||||
|
private async lockApiary(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
id: string,
|
||||||
|
): Promise<Apiary> {
|
||||||
|
await lockRowForUpdate(tx, 'Apiary', id);
|
||||||
|
const apiary = await tx.apiary.findUnique({ where: { id } });
|
||||||
|
if (!apiary) {
|
||||||
|
throw new NotFoundException('Apiary not found');
|
||||||
|
}
|
||||||
return apiary;
|
return apiary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private recordChange(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
apiaryId: string,
|
||||||
|
change: ApiaryChange,
|
||||||
|
actorUserId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.events.record(tx, {
|
||||||
|
type: 'ApiaryChanged',
|
||||||
|
aggregateType: 'Apiary',
|
||||||
|
aggregateId: apiaryId,
|
||||||
|
actorUserId,
|
||||||
|
payload: { change },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
|
import { ApiaryFieldService } from './apiary-field.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
|
import type { ColonyNextSteps } from '../autopilot/next-step-ranking';
|
||||||
|
import { NextStepsService } from '../autopilot/next-steps.service';
|
||||||
|
import { HiveService } from '../hive/hive.service';
|
||||||
|
import { HiveCatalogService } from '../hive/hive-catalog.service';
|
||||||
|
|
||||||
|
describe('ApiaryFieldService', () => {
|
||||||
|
let service: ApiaryFieldService;
|
||||||
|
let prisma: {
|
||||||
|
apiary: { findUnique: jest.Mock };
|
||||||
|
colony: { findMany: jest.Mock };
|
||||||
|
inspection: { groupBy: jest.Mock; findMany: jest.Mock };
|
||||||
|
$queryRaw: jest.Mock;
|
||||||
|
queen: { findMany: jest.Mock };
|
||||||
|
task: { findMany: jest.Mock };
|
||||||
|
};
|
||||||
|
let permissionResolver: { getPermissions: jest.Mock };
|
||||||
|
let nextSteps: { forColonies: jest.Mock; warningCounts: jest.Mock };
|
||||||
|
let hives: { getMany: jest.Mock };
|
||||||
|
let hiveCatalogs: { listSystems: jest.Mock };
|
||||||
|
|
||||||
|
const stepsOf = (
|
||||||
|
colonyId: string,
|
||||||
|
overrides: Partial<ColonyNextSteps> = {},
|
||||||
|
): ColonyNextSteps => ({
|
||||||
|
colonyId,
|
||||||
|
segment: 'SCHWARMZEIT',
|
||||||
|
phase: null,
|
||||||
|
phaseSource: null,
|
||||||
|
inspectionDueOn: null,
|
||||||
|
withdrawalUntil: null,
|
||||||
|
steps: [],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const colonyRow = (id: string, code: string, queenId: string | null) => ({
|
||||||
|
id,
|
||||||
|
code,
|
||||||
|
apiaryId: 'apiary-1',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
|
hiveType: null,
|
||||||
|
strength: 5,
|
||||||
|
occupiedFrames: 12,
|
||||||
|
broodStatus: null,
|
||||||
|
feedStatus: null,
|
||||||
|
healthStatus: null,
|
||||||
|
currentQueenId: queenId,
|
||||||
|
notes: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const grant = (...keys: string[]) =>
|
||||||
|
permissionResolver.getPermissions.mockResolvedValue(new Set(keys));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma = {
|
||||||
|
apiary: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue({
|
||||||
|
id: 'apiary-1',
|
||||||
|
name: 'Heimstand',
|
||||||
|
location: 'Garten',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
colony: {
|
||||||
|
findMany: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
|
colonyRow('c10', 'V-10', 'q1'),
|
||||||
|
colonyRow('c2', 'V-2', null),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
inspection: {
|
||||||
|
groupBy: jest.fn().mockResolvedValue([]),
|
||||||
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||||
|
queen: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
task: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
};
|
||||||
|
permissionResolver = { getPermissions: jest.fn() };
|
||||||
|
nextSteps = {
|
||||||
|
forColonies: jest.fn().mockResolvedValue(
|
||||||
|
new Map([
|
||||||
|
['c10', stepsOf('c10')],
|
||||||
|
['c2', stepsOf('c2')],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
warningCounts: jest.fn().mockResolvedValue(new Map()),
|
||||||
|
};
|
||||||
|
hives = { getMany: jest.fn().mockResolvedValue({}) };
|
||||||
|
hiveCatalogs = { listSystems: jest.fn().mockResolvedValue([]) };
|
||||||
|
service = new ApiaryFieldService(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
permissionResolver as unknown as PermissionResolverService,
|
||||||
|
nextSteps as unknown as NextStepsService,
|
||||||
|
hives as unknown as HiveService,
|
||||||
|
hiveCatalogs as unknown as HiveCatalogService,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundException for an unknown apiary', async () => {
|
||||||
|
grant('colonies:read');
|
||||||
|
prisma.apiary.findUnique.mockResolvedValue(null);
|
||||||
|
await expect(service.colonyOverview('x', 'u1')).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts living colonies by code with numeric collation', async () => {
|
||||||
|
grant('colonies:read');
|
||||||
|
const overview = await service.colonyOverview('apiary-1', 'u1');
|
||||||
|
expect(overview.rows.map((row) => row.code)).toEqual(['V-2', 'V-10']);
|
||||||
|
expect(prisma.colony.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({ apiaryId: 'apiary-1' }) as unknown,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves out inspections, queens and tasks without their read permission', async () => {
|
||||||
|
grant('colonies:read');
|
||||||
|
const pack = await service.offlinePack('apiary-1', 'u1');
|
||||||
|
expect(pack.inspections).toBeNull();
|
||||||
|
expect(pack.queens).toBeNull();
|
||||||
|
expect(pack.tasks).toBeNull();
|
||||||
|
expect(pack.overview.inspectionsVisible).toBe(false);
|
||||||
|
expect(pack.overview.rows.every((r) => r.lastInspectionDate === null)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(prisma.inspection.groupBy).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.inspection.findMany).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.queen.findMany).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.task.findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the hives of the apiary colonies and the hive catalog', async () => {
|
||||||
|
grant('colonies:read');
|
||||||
|
const view = { colonyId: 'c2', systemId: 's1', version: 3 };
|
||||||
|
hives.getMany.mockResolvedValue({ c2: view });
|
||||||
|
const systems = [{ id: 's1', name: 'Zander', active: true, partTypes: [] }];
|
||||||
|
hiveCatalogs.listSystems.mockResolvedValue(systems);
|
||||||
|
|
||||||
|
const pack = await service.offlinePack('apiary-1', 'u1');
|
||||||
|
|
||||||
|
expect(hives.getMany).toHaveBeenCalledWith(['c2', 'c10']);
|
||||||
|
expect(pack.hives).toEqual({ c2: view });
|
||||||
|
expect(pack.hiveCatalog).toEqual(systems);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limits the hive catalog to active systems and those the hives use', async () => {
|
||||||
|
grant('colonies:read');
|
||||||
|
hives.getMany.mockResolvedValue({ c2: { colonyId: 'c2', systemId: 's2' } });
|
||||||
|
hiveCatalogs.listSystems.mockResolvedValue([
|
||||||
|
{ id: 's1', active: true },
|
||||||
|
{ id: 's2', active: false },
|
||||||
|
{ id: 's3', active: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pack = await service.offlinePack('apiary-1', 'u1');
|
||||||
|
|
||||||
|
expect(pack.hiveCatalog?.map((system) => system.id)).toEqual(['s1', 's2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps colonies without a hive setup out of the hives', async () => {
|
||||||
|
grant('colonies:read');
|
||||||
|
const pack = await service.offlinePack('apiary-1', 'u1');
|
||||||
|
expect(pack.hives).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills the permitted sections and the last inspection date', async () => {
|
||||||
|
grant('colonies:read', 'inspections:read', 'queens:read', 'tasks:read');
|
||||||
|
const date = new Date('2026-05-01T10:00:00.000Z');
|
||||||
|
prisma.inspection.groupBy.mockResolvedValue([
|
||||||
|
{ colonyId: 'c2', _max: { date } },
|
||||||
|
]);
|
||||||
|
prisma.queen.findMany.mockResolvedValue([{ id: 'q1', code: 'K-1' }]);
|
||||||
|
prisma.task.findMany.mockResolvedValue([{ id: 't1', title: 'Futter' }]);
|
||||||
|
|
||||||
|
const pack = await service.offlinePack('apiary-1', 'u1');
|
||||||
|
|
||||||
|
expect(pack.overview.inspectionsVisible).toBe(true);
|
||||||
|
expect(pack.overview.rows[0].lastInspectionDate).toBe(date.toISOString());
|
||||||
|
expect(pack.overview.rows[1].lastInspectionDate).toBeNull();
|
||||||
|
expect(pack.queens).toEqual([{ id: 'q1', code: 'K-1' }]);
|
||||||
|
expect(prisma.queen.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: { id: { in: ['q1'] } } }),
|
||||||
|
);
|
||||||
|
expect(pack.tasks).toEqual([{ id: 't1', title: 'Futter' }]);
|
||||||
|
expect(prisma.task.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: {
|
||||||
|
status: { in: ['OPEN', 'IN_PROGRESS'] },
|
||||||
|
OR: [
|
||||||
|
{ relatedType: 'APIARY', relatedId: 'apiary-1' },
|
||||||
|
{ relatedType: 'COLONY', relatedId: { in: ['c2', 'c10'] } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps at most three inspections per colony, newest first', async () => {
|
||||||
|
grant('colonies:read', 'inspections:read');
|
||||||
|
prisma.inspection.findMany.mockResolvedValue([{ id: 'i1' }]);
|
||||||
|
|
||||||
|
const pack = await service.offlinePack('apiary-1', 'u1');
|
||||||
|
|
||||||
|
expect(prisma.inspection.findMany).toHaveBeenCalledTimes(2);
|
||||||
|
expect(prisma.inspection.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { colonyId: 'c2' },
|
||||||
|
take: 3,
|
||||||
|
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }],
|
||||||
|
omit: { userId: true },
|
||||||
|
});
|
||||||
|
expect(pack.inspections).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never returns coordinates, address, apiary notes or inspection userId', async () => {
|
||||||
|
grant('colonies:read', 'inspections:read');
|
||||||
|
const pack = await service.offlinePack('apiary-1', 'u1');
|
||||||
|
|
||||||
|
expect(Object.keys(pack.apiary).sort()).toEqual([
|
||||||
|
'id',
|
||||||
|
'location',
|
||||||
|
'name',
|
||||||
|
'status',
|
||||||
|
]);
|
||||||
|
const apiarySelect = (
|
||||||
|
prisma.apiary.findUnique.mock.calls[0] as [{ select: object }]
|
||||||
|
)[0].select;
|
||||||
|
expect(apiarySelect).not.toHaveProperty('latitude');
|
||||||
|
expect(apiarySelect).not.toHaveProperty('longitude');
|
||||||
|
expect(apiarySelect).not.toHaveProperty('address');
|
||||||
|
expect(apiarySelect).not.toHaveProperty('notes');
|
||||||
|
const inspectionArgs = (
|
||||||
|
prisma.inspection.findMany.mock.calls[0] as [{ omit: object }]
|
||||||
|
)[0];
|
||||||
|
expect(inspectionArgs.omit).toEqual({ userId: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes the warning counts from the open suggestions the caller may see', async () => {
|
||||||
|
grant('colonies:read', 'tasks:read');
|
||||||
|
nextSteps.warningCounts.mockResolvedValue(
|
||||||
|
new Map([['c2', { warnings: 2, critical: 1 }]]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const overview = await service.colonyOverview('apiary-1', 'u1');
|
||||||
|
|
||||||
|
expect(nextSteps.warningCounts).toHaveBeenCalledWith(
|
||||||
|
['c2', 'c10'],
|
||||||
|
'u1',
|
||||||
|
new Set(['colonies:read', 'tasks:read']),
|
||||||
|
);
|
||||||
|
expect(overview.rows[0]).toMatchObject({
|
||||||
|
code: 'V-2',
|
||||||
|
openWarnings: 2,
|
||||||
|
openCritical: 1,
|
||||||
|
});
|
||||||
|
expect(overview.rows[1]).toMatchObject({
|
||||||
|
openWarnings: 0,
|
||||||
|
openCritical: 0,
|
||||||
|
});
|
||||||
|
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries inspectionDueOn, segment and the title of the first next step', async () => {
|
||||||
|
grant('colonies:read', 'tasks:read');
|
||||||
|
nextSteps.forColonies.mockResolvedValue(
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
'c2',
|
||||||
|
stepsOf('c2', {
|
||||||
|
segment: 'SOMMER',
|
||||||
|
inspectionDueOn: '2026-05-10',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
key: 'suggestion:s1',
|
||||||
|
kind: 'SUGGESTION',
|
||||||
|
intent: 'INSPECT',
|
||||||
|
title: 'Durchsicht mit Schwarmkontrolle',
|
||||||
|
reasons: [],
|
||||||
|
suggestionIds: ['s1'],
|
||||||
|
dueOn: '2026-05-10',
|
||||||
|
urgency: 'OVERDUE',
|
||||||
|
action: { type: 'ACCEPT', suggestionId: 's1' },
|
||||||
|
dismissible: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
['c10', stepsOf('c10', { segment: 'WINTERRUHE' })],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const overview = await service.colonyOverview('apiary-1', 'u1');
|
||||||
|
|
||||||
|
expect(overview.rows[0]).toMatchObject({
|
||||||
|
inspectionDueOn: '2026-05-10',
|
||||||
|
segment: 'SOMMER',
|
||||||
|
nextStepTitle: 'Durchsicht mit Schwarmkontrolle',
|
||||||
|
});
|
||||||
|
expect(overview.rows[1]).toMatchObject({
|
||||||
|
inspectionDueOn: null,
|
||||||
|
segment: 'WINTERRUHE',
|
||||||
|
nextStepTitle: null,
|
||||||
|
});
|
||||||
|
expect(nextSteps.forColonies).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the segment null when the colony has no season state', async () => {
|
||||||
|
grant('colonies:read');
|
||||||
|
nextSteps.forColonies.mockResolvedValue(new Map());
|
||||||
|
|
||||||
|
const overview = await service.colonyOverview('apiary-1', 'u1');
|
||||||
|
|
||||||
|
expect(overview.rows.every((row) => row.segment === null)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds the next steps per colony to the offline pack', async () => {
|
||||||
|
grant('colonies:read');
|
||||||
|
|
||||||
|
const pack = await service.offlinePack('apiary-1', 'u1');
|
||||||
|
|
||||||
|
expect(pack.version).toBe(1);
|
||||||
|
expect(pack.nextSteps).toEqual({ c2: [], c10: [] });
|
||||||
|
expect(nextSteps.forColonies).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
|
import type { ColonyNextSteps } from '../autopilot/next-step-ranking';
|
||||||
|
import { NextStepsService } from '../autopilot/next-steps.service';
|
||||||
|
import { HiveCatalogService } from '../hive/hive-catalog.service';
|
||||||
|
import { HiveService } from '../hive/hive.service';
|
||||||
|
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import type {
|
||||||
|
ColonyOverview,
|
||||||
|
ColonyOverviewRow,
|
||||||
|
OfflinePackV1,
|
||||||
|
} from './apiary-field.types';
|
||||||
|
|
||||||
|
const INSPECTIONS_PER_COLONY = 3;
|
||||||
|
|
||||||
|
/** Active systems plus the ones the pack's hives use (a hidden system still builds an old hive). */
|
||||||
|
function activeOrUsed<S extends { id: string; active: boolean }>(
|
||||||
|
systems: S[],
|
||||||
|
hives: Record<string, { systemId: string }>,
|
||||||
|
): S[] {
|
||||||
|
const used = new Set(Object.values(hives).map((hive) => hive.systemId));
|
||||||
|
return systems.filter((system) => system.active || used.has(system.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
type PackColony = OfflinePackV1['colonies'][number];
|
||||||
|
|
||||||
|
interface FieldData {
|
||||||
|
apiary: OfflinePackV1['apiary'];
|
||||||
|
colonies: PackColony[];
|
||||||
|
permissions: ReadonlySet<string>;
|
||||||
|
overview: ColonyOverview;
|
||||||
|
nextSteps: ReadonlyMap<string, ColonyNextSteps>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read models for work at the apiary: the colony traffic-light overview and
|
||||||
|
* the offline pack the PWA caches before the beekeeper loses reception.
|
||||||
|
* Sections beyond the colonies are only included for callers who may read them.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ApiaryFieldService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissionResolver: PermissionResolverService,
|
||||||
|
private readonly nextSteps: NextStepsService,
|
||||||
|
private readonly hives: HiveService,
|
||||||
|
private readonly hiveCatalogs: HiveCatalogService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async colonyOverview(
|
||||||
|
apiaryId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<ColonyOverview> {
|
||||||
|
return (await this.load(apiaryId, userId)).overview;
|
||||||
|
}
|
||||||
|
|
||||||
|
async offlinePack(apiaryId: string, userId: string): Promise<OfflinePackV1> {
|
||||||
|
const { apiary, colonies, permissions, overview, nextSteps } =
|
||||||
|
await this.load(apiaryId, userId);
|
||||||
|
const [queens, inspections, tasks, hives, hiveCatalog] = await Promise.all([
|
||||||
|
permissions.has('queens:read') ? this.loadQueens(colonies) : null,
|
||||||
|
permissions.has('inspections:read')
|
||||||
|
? this.loadInspections(colonies)
|
||||||
|
: null,
|
||||||
|
permissions.has('tasks:read') ? this.loadTasks(apiaryId, colonies) : null,
|
||||||
|
this.hives.getMany(colonies.map((colony) => colony.id)),
|
||||||
|
this.hiveCatalogs.listSystems(),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
apiary,
|
||||||
|
overview,
|
||||||
|
colonies,
|
||||||
|
queens,
|
||||||
|
inspections,
|
||||||
|
tasks,
|
||||||
|
hives,
|
||||||
|
hiveCatalog: activeOrUsed(hiveCatalog, hives),
|
||||||
|
nextSteps: Object.fromEntries(
|
||||||
|
colonies.map((colony) => [
|
||||||
|
colony.id,
|
||||||
|
nextSteps.get(colony.id)?.steps ?? [],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(apiaryId: string, userId: string): Promise<FieldData> {
|
||||||
|
// Explicit select: coordinates, address and notes of an apiary are
|
||||||
|
// theft-sensitive and must never reach the field payloads.
|
||||||
|
const apiary = await this.prisma.apiary.findUnique({
|
||||||
|
where: { id: apiaryId },
|
||||||
|
select: { id: true, name: true, location: true, status: true },
|
||||||
|
});
|
||||||
|
if (!apiary) {
|
||||||
|
throw new NotFoundException('Apiary not found');
|
||||||
|
}
|
||||||
|
const permissions = await this.permissionResolver.getPermissions(userId);
|
||||||
|
const found = await this.prisma.colony.findMany({
|
||||||
|
where: { ...LIVING_COLONY_WHERE, apiaryId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
apiaryId: true,
|
||||||
|
status: true,
|
||||||
|
colonyType: true,
|
||||||
|
hiveType: true,
|
||||||
|
strength: true,
|
||||||
|
occupiedFrames: true,
|
||||||
|
broodStatus: true,
|
||||||
|
feedStatus: true,
|
||||||
|
healthStatus: true,
|
||||||
|
currentQueenId: true,
|
||||||
|
notes: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const colonies = [...found].sort((a, b) =>
|
||||||
|
a.code.localeCompare(b.code, 'de', { numeric: true }),
|
||||||
|
);
|
||||||
|
const inspectionsVisible = permissions.has('inspections:read');
|
||||||
|
const colonyIds = colonies.map((colony) => colony.id);
|
||||||
|
const [lastInspections, warnings, nextSteps] = await Promise.all([
|
||||||
|
inspectionsVisible ? this.lastInspectionDates(colonies) : null,
|
||||||
|
this.nextSteps.warningCounts(colonyIds, userId, permissions),
|
||||||
|
this.nextSteps.forColonies(colonyIds, userId, undefined, permissions),
|
||||||
|
]);
|
||||||
|
const rows = colonies.map((colony): ColonyOverviewRow => ({
|
||||||
|
id: colony.id,
|
||||||
|
code: colony.code,
|
||||||
|
status: colony.status,
|
||||||
|
strength: colony.strength,
|
||||||
|
occupiedFrames: colony.occupiedFrames,
|
||||||
|
broodStatus: colony.broodStatus,
|
||||||
|
feedStatus: colony.feedStatus,
|
||||||
|
healthStatus: colony.healthStatus,
|
||||||
|
lastInspectionDate: lastInspections?.get(colony.id) ?? null,
|
||||||
|
inspectionDueOn: nextSteps.get(colony.id)?.inspectionDueOn ?? null,
|
||||||
|
segment: nextSteps.get(colony.id)?.segment ?? null,
|
||||||
|
nextStepTitle: nextSteps.get(colony.id)?.steps.at(0)?.title ?? null,
|
||||||
|
openWarnings: warnings.get(colony.id)?.warnings ?? 0,
|
||||||
|
openCritical: warnings.get(colony.id)?.critical ?? 0,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
apiary,
|
||||||
|
colonies,
|
||||||
|
permissions,
|
||||||
|
overview: { apiaryId, inspectionsVisible, rows },
|
||||||
|
nextSteps,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lastInspectionDates(
|
||||||
|
colonies: PackColony[],
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
const groups = await this.prisma.inspection.groupBy({
|
||||||
|
by: ['colonyId'],
|
||||||
|
where: { colonyId: { in: colonies.map((colony) => colony.id) } },
|
||||||
|
_max: { date: true },
|
||||||
|
});
|
||||||
|
const dates = new Map<string, string>();
|
||||||
|
for (const group of groups) {
|
||||||
|
if (group._max.date) {
|
||||||
|
dates.set(group.colonyId, group._max.date.toISOString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadQueens(colonies: PackColony[]): Promise<OfflinePackV1['queens']> {
|
||||||
|
const queenIds = colonies.flatMap((colony) =>
|
||||||
|
colony.currentQueenId ? [colony.currentQueenId] : [],
|
||||||
|
);
|
||||||
|
return this.prisma.queen.findMany({
|
||||||
|
where: { id: { in: queenIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
hatchYear: true,
|
||||||
|
breed: true,
|
||||||
|
markingColor: true,
|
||||||
|
markingNumber: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadInspections(
|
||||||
|
colonies: PackColony[],
|
||||||
|
): Promise<OfflinePackV1['inspections']> {
|
||||||
|
const perColony = await Promise.all(
|
||||||
|
colonies.map((colony) =>
|
||||||
|
this.prisma.inspection.findMany({
|
||||||
|
where: { colonyId: colony.id },
|
||||||
|
take: INSPECTIONS_PER_COLONY,
|
||||||
|
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }],
|
||||||
|
omit: { userId: true },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return perColony.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadTasks(
|
||||||
|
apiaryId: string,
|
||||||
|
colonies: PackColony[],
|
||||||
|
): Promise<OfflinePackV1['tasks']> {
|
||||||
|
return this.prisma.task.findMany({
|
||||||
|
where: {
|
||||||
|
status: { in: ['OPEN', 'IN_PROGRESS'] },
|
||||||
|
OR: [
|
||||||
|
{ relatedType: 'APIARY', relatedId: apiaryId },
|
||||||
|
{
|
||||||
|
relatedType: 'COLONY',
|
||||||
|
relatedId: { in: colonies.map((colony) => colony.id) },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
dueDate: true,
|
||||||
|
priority: true,
|
||||||
|
status: true,
|
||||||
|
relatedType: true,
|
||||||
|
relatedId: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ dueDate: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import type {
|
||||||
|
ApiaryStatus,
|
||||||
|
Colony,
|
||||||
|
ColonyCondition,
|
||||||
|
ColonyStatus,
|
||||||
|
Inspection,
|
||||||
|
Queen,
|
||||||
|
Task,
|
||||||
|
} from '../../generated/prisma';
|
||||||
|
import type { HiveSystemDto } from '../hive/hive-catalog.service';
|
||||||
|
import type { HiveSetupView } from '../hive/hive.service';
|
||||||
|
import type { NextStep } from '../autopilot/next-step-ranking';
|
||||||
|
import type { SeasonSegmentKey } from '../season/season-types';
|
||||||
|
|
||||||
|
/** One living colony on the apiary page, reduced to what the traffic light needs. */
|
||||||
|
export interface ColonyOverviewRow {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
status: ColonyStatus;
|
||||||
|
strength: number | null;
|
||||||
|
occupiedFrames: number | null;
|
||||||
|
broodStatus: ColonyCondition | null;
|
||||||
|
feedStatus: ColonyCondition | null;
|
||||||
|
healthStatus: ColonyCondition | null;
|
||||||
|
/** ISO timestamp; always null when the caller may not read inspections. */
|
||||||
|
lastInspectionDate: string | null;
|
||||||
|
/**
|
||||||
|
* The day the next inspection is due (YYYY-MM-DD); null in the winter
|
||||||
|
* rest, when the interval rule is off and without `inspections:read`.
|
||||||
|
*/
|
||||||
|
inspectionDueOn: string | null;
|
||||||
|
/** The season segment of the apiary today; null when it is unknown. */
|
||||||
|
segment: SeasonSegmentKey | null;
|
||||||
|
/** Title of the first next step; null without one or without `tasks:read`. */
|
||||||
|
nextStepTitle: string | null;
|
||||||
|
/** Open suggestions of priority HIGH the caller may see. */
|
||||||
|
openWarnings: number;
|
||||||
|
/** Open suggestions of priority URGENT the caller may see. */
|
||||||
|
openCritical: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColonyOverview {
|
||||||
|
apiaryId: string;
|
||||||
|
inspectionsVisible: boolean;
|
||||||
|
rows: ColonyOverviewRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the offline apiary mode needs. It deliberately carries no
|
||||||
|
* coordinates, address or apiary notes (apiary locations are theft-sensitive)
|
||||||
|
* and no inspection userId. Sections the caller has no read permission for
|
||||||
|
* are null.
|
||||||
|
*/
|
||||||
|
export interface OfflinePackV1 {
|
||||||
|
version: 1;
|
||||||
|
generatedAt: string;
|
||||||
|
apiary: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
location: string | null;
|
||||||
|
status: ApiaryStatus;
|
||||||
|
};
|
||||||
|
overview: ColonyOverview;
|
||||||
|
colonies: Pick<
|
||||||
|
Colony,
|
||||||
|
| 'id'
|
||||||
|
| 'code'
|
||||||
|
| 'apiaryId'
|
||||||
|
| 'status'
|
||||||
|
| 'colonyType'
|
||||||
|
| 'hiveType'
|
||||||
|
| 'strength'
|
||||||
|
| 'occupiedFrames'
|
||||||
|
| 'broodStatus'
|
||||||
|
| 'feedStatus'
|
||||||
|
| 'healthStatus'
|
||||||
|
| 'currentQueenId'
|
||||||
|
| 'notes'
|
||||||
|
>[];
|
||||||
|
queens:
|
||||||
|
| Pick<
|
||||||
|
Queen,
|
||||||
|
| 'id'
|
||||||
|
| 'code'
|
||||||
|
| 'hatchYear'
|
||||||
|
| 'breed'
|
||||||
|
| 'markingColor'
|
||||||
|
| 'markingNumber'
|
||||||
|
| 'status'
|
||||||
|
>[]
|
||||||
|
| null;
|
||||||
|
inspections: Omit<Inspection, 'userId'>[] | null;
|
||||||
|
tasks:
|
||||||
|
| Pick<
|
||||||
|
Task,
|
||||||
|
| 'id'
|
||||||
|
| 'title'
|
||||||
|
| 'description'
|
||||||
|
| 'dueDate'
|
||||||
|
| 'priority'
|
||||||
|
| 'status'
|
||||||
|
| 'relatedType'
|
||||||
|
| 'relatedId'
|
||||||
|
>[]
|
||||||
|
| null;
|
||||||
|
/**
|
||||||
|
* The next steps per colony id (what `GET /colonies/:id/next-steps`
|
||||||
|
* returns as `steps`). Absent in packs cached before T4.
|
||||||
|
*/
|
||||||
|
nextSteps?: Record<string, NextStep[]>;
|
||||||
|
/**
|
||||||
|
* The hive setup per colony id, only for colonies of this apiary that have
|
||||||
|
* one. Absent in packs cached before the hive card.
|
||||||
|
*/
|
||||||
|
hives?: Record<string, HiveSetupView>;
|
||||||
|
/** The hive systems with their part types. Absent in older packs. */
|
||||||
|
hiveCatalog?: HiveSystemDto[];
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger';
|
import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsLatitude, IsLongitude, IsOptional, IsString } from 'class-validator';
|
import {
|
||||||
|
IsLatitude,
|
||||||
|
IsLongitude,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class CreateApiaryDto {
|
export class CreateApiaryDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -35,4 +41,13 @@ export class CreateApiaryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Ort für die öffentliche Herkunftsseite, z. B. "Nordhessen". Nie die genaue Adresse.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
publicLocationLabel?: string;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { PartialType } from '@nestjs/swagger';
|
import { PartialType } from '@nestjs/swagger';
|
||||||
import { CreateApiaryDto } from './create-apiary.dto';
|
import { CreateApiaryDto } from './create-apiary.dto';
|
||||||
|
|
||||||
export class UpdateApiaryDto extends PartialType(CreateApiaryDto) {}
|
export class UpdateApiaryDto extends PartialType(CreateApiaryDto, {
|
||||||
|
skipNullProperties: false,
|
||||||
|
}) {}
|
||||||
@@ -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 },
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
import {
|
||||||
|
configureApp,
|
||||||
|
describeTrustProxy,
|
||||||
|
trustProxySetting,
|
||||||
|
} from './app.config';
|
||||||
|
|
||||||
|
describe('trustProxySetting', () => {
|
||||||
|
const original = process.env.TRUST_PROXY;
|
||||||
|
afterEach(() => {
|
||||||
|
if (original === undefined) delete process.env.TRUST_PROXY;
|
||||||
|
else process.env.TRUST_PROXY = original;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to one hop when unset or blank', () => {
|
||||||
|
delete process.env.TRUST_PROXY;
|
||||||
|
expect(trustProxySetting()).toBe(1);
|
||||||
|
process.env.TRUST_PROXY = ' ';
|
||||||
|
expect(trustProxySetting()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a number as hop count and anything else as a list', () => {
|
||||||
|
process.env.TRUST_PROXY = '2';
|
||||||
|
expect(trustProxySetting()).toBe(2);
|
||||||
|
process.env.TRUST_PROXY = 'loopback, 10.42.0.0/16';
|
||||||
|
expect(trustProxySetting()).toBe('loopback, 10.42.0.0/16');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('describeTrustProxy', () => {
|
||||||
|
it('reports the hop count mode and the default fallback', () => {
|
||||||
|
expect(describeTrustProxy(1, false)).toBe(
|
||||||
|
'TRUST_PROXY not set, using default of 1 trusted hop',
|
||||||
|
);
|
||||||
|
expect(describeTrustProxy(2, true)).toBe('TRUST_PROXY: 2 trusted hop(s)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the list mode with entry count but not the entries', () => {
|
||||||
|
const text = describeTrustProxy(
|
||||||
|
'loopback, 10.42.0.0/16, 2400:cb00::/32',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(text).toBe('TRUST_PROXY: list of 3 trusted proxy range(s)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('configureApp trust proxy logging', () => {
|
||||||
|
it('logs the active mode once at startup', () => {
|
||||||
|
const log = jest.spyOn(Logger.prototype, 'log').mockImplementation();
|
||||||
|
const app = {
|
||||||
|
set: jest.fn(),
|
||||||
|
useBodyParser: jest.fn(),
|
||||||
|
use: jest.fn(),
|
||||||
|
useGlobalFilters: jest.fn(),
|
||||||
|
enableCors: jest.fn(),
|
||||||
|
setGlobalPrefix: jest.fn(),
|
||||||
|
enableVersioning: jest.fn(),
|
||||||
|
useGlobalPipes: jest.fn(),
|
||||||
|
};
|
||||||
|
configureApp(app as unknown as NestExpressApplication);
|
||||||
|
expect(
|
||||||
|
log.mock.calls.filter(([m]) => String(m).startsWith('TRUST_PROXY')),
|
||||||
|
).toHaveLength(1);
|
||||||
|
log.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('configureApp trust proxy warning', () => {
|
||||||
|
const originalProxy = process.env.TRUST_PROXY;
|
||||||
|
const originalEnv = process.env.NODE_ENV;
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalProxy === undefined) delete process.env.TRUST_PROXY;
|
||||||
|
else process.env.TRUST_PROXY = originalProxy;
|
||||||
|
if (originalEnv === undefined) delete process.env.NODE_ENV;
|
||||||
|
else process.env.NODE_ENV = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
function configure(): jest.SpyInstance {
|
||||||
|
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
|
||||||
|
jest.spyOn(Logger.prototype, 'log').mockImplementation();
|
||||||
|
const app = {
|
||||||
|
set: jest.fn(),
|
||||||
|
useBodyParser: jest.fn(),
|
||||||
|
use: jest.fn(),
|
||||||
|
useGlobalFilters: jest.fn(),
|
||||||
|
enableCors: jest.fn(),
|
||||||
|
setGlobalPrefix: jest.fn(),
|
||||||
|
enableVersioning: jest.fn(),
|
||||||
|
useGlobalPipes: jest.fn(),
|
||||||
|
};
|
||||||
|
configureApp(app as unknown as NestExpressApplication);
|
||||||
|
return warn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trustProxyWarnings(warn: jest.SpyInstance): unknown[][] {
|
||||||
|
const calls = warn.mock.calls as unknown[][];
|
||||||
|
return calls.filter(([m]) => String(m).includes('TRUST_PROXY'));
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
it('warnt in Produktion, wenn TRUST_PROXY fehlt', () => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
delete process.env.TRUST_PROXY;
|
||||||
|
expect(trustProxyWarnings(configure())).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warnt nicht, wenn TRUST_PROXY gesetzt ist', () => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
process.env.TRUST_PROXY = 'loopback, 10.42.0.0/16';
|
||||||
|
expect(trustProxyWarnings(configure())).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warnt nicht in der Entwicklung', () => {
|
||||||
|
process.env.NODE_ENV = 'development';
|
||||||
|
delete process.env.TRUST_PROXY;
|
||||||
|
expect(trustProxyWarnings(configure())).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,21 +1,96 @@
|
|||||||
import { RequestMethod, ValidationPipe, VersioningType } from '@nestjs/common';
|
import {
|
||||||
|
Logger,
|
||||||
|
RequestMethod,
|
||||||
|
ValidationPipe,
|
||||||
|
VersioningType,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
|
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
|
||||||
import { requestLoggerMiddleware } from './common/middleware/request-logger.middleware';
|
import { requestLoggerMiddleware } from './common/middleware/request-logger.middleware';
|
||||||
|
import { noStoreMiddleware } from './common/middleware/no-store.middleware';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which proxies to trust for X-Forwarded-For. It decides the client IP behind
|
||||||
|
* every rate limit and API-key IP allowlist: trust too little and all users
|
||||||
|
* share the proxy's IP, trust too much and a client can forge its own.
|
||||||
|
*
|
||||||
|
* 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/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 {
|
||||||
|
const raw = process.env.TRUST_PROXY?.trim();
|
||||||
|
if (!raw) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return /^\d+$/.test(raw) ? Number(raw) : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Startup log line for the active mode; never includes the addresses. */
|
||||||
|
export function describeTrustProxy(
|
||||||
|
setting: number | string,
|
||||||
|
isExplicit: boolean,
|
||||||
|
): string {
|
||||||
|
if (typeof setting === 'number') {
|
||||||
|
return isExplicit
|
||||||
|
? `TRUST_PROXY: ${setting} trusted hop(s)`
|
||||||
|
: `TRUST_PROXY not set, using default of ${setting} trusted hop`;
|
||||||
|
}
|
||||||
|
const entries = setting.split(',').filter((e) => e.trim()).length;
|
||||||
|
return `TRUST_PROXY: list of ${entries} trusted proxy range(s)`;
|
||||||
|
}
|
||||||
|
|
||||||
export function configureApp(app: NestExpressApplication): void {
|
export function configureApp(app: NestExpressApplication): void {
|
||||||
// Trust the single reverse-proxy hop (K8s ingress) so req.ip reflects the
|
// Trust the configured proxies (Traefik, Cloudflare) so req.ip reflects the
|
||||||
// real client IP from X-Forwarded-For — required for per-client throttling
|
// real client IP from X-Forwarded-For — required for per-client throttling
|
||||||
// to work correctly rather than rate-limiting all traffic together.
|
// to work correctly rather than rate-limiting all traffic together.
|
||||||
app.set('trust proxy', 1);
|
const trustProxy = trustProxySetting();
|
||||||
|
app.set('trust proxy', trustProxy);
|
||||||
|
const bootstrapLogger = new Logger('Bootstrap');
|
||||||
|
const trustProxyExplicit = !!process.env.TRUST_PROXY?.trim();
|
||||||
|
bootstrapLogger.log(describeTrustProxy(trustProxy, trustProxyExplicit));
|
||||||
|
if (!trustProxyExplicit && process.env.NODE_ENV === 'production') {
|
||||||
|
// Behind Cloudflare and Traefik one hop is wrong: req.ip becomes the
|
||||||
|
// 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/base/backend/configmap.example.yaml',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// No ETags: they let caches and proxies revalidate (and keep) personal
|
||||||
|
// responses that are meant to be no-store.
|
||||||
|
app.set('etag', false);
|
||||||
// Default 100kb JSON body limit is too small for the avatar upload, which
|
// Default 100kb JSON body limit is too small for the avatar upload, which
|
||||||
// sends the image as a base64 data: URL in the JSON body (not multipart -
|
// sends the image as a base64 data: URL in the JSON body (not multipart -
|
||||||
// see profile/dto/upload-avatar.dto.ts for why) - 5mb comfortably covers
|
// see profile/dto/upload-avatar.dto.ts for why) - 5mb comfortably covers
|
||||||
// the 2MB decoded size cap plus base64 overhead.
|
// the 2MB decoded size cap plus base64 overhead.
|
||||||
app.useBodyParser('json', { limit: '5mb' });
|
app.useBodyParser('json', { limit: '5mb' });
|
||||||
app.use(helmet());
|
app.use(
|
||||||
|
helmet({
|
||||||
|
// This server only answers JSON and files. A strict policy means even
|
||||||
|
// a response that is somehow rendered as a page can load nothing and
|
||||||
|
// cannot be framed. The Swagger UI (opt-in) needs scripts, so it keeps
|
||||||
|
// helmet's default page policy.
|
||||||
|
contentSecurityPolicy:
|
||||||
|
process.env.ENABLE_SWAGGER === 'true'
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
useDefaults: false,
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'none'"],
|
||||||
|
frameAncestors: ["'none'"],
|
||||||
|
baseUri: ["'none'"],
|
||||||
|
formAction: ["'none'"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
crossOriginResourcePolicy: { policy: 'same-site' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
app.use(noStoreMiddleware);
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use(requestLoggerMiddleware);
|
app.use(requestLoggerMiddleware);
|
||||||
app.useGlobalFilters(new AllExceptionsFilter());
|
app.useGlobalFilters(new AllExceptionsFilter());
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { APP_GUARD } from '@nestjs/core';
|
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerModule } from '@nestjs/throttler';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ClientIpThrottlerGuard } from './common/throttler/client-ip-throttler.guard';
|
||||||
|
import { PrismaThrottlerStorage } from './common/throttler/prisma-throttler.storage';
|
||||||
|
import { ThrottlerStorageModule } from './common/throttler/throttler-storage.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { AuditModule } from './audit/audit.module';
|
import { AuditModule } from './audit/audit.module';
|
||||||
|
import { DomainEventsModule } from './domain-events/domain-events.module';
|
||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { RolesModule } from './roles/roles.module';
|
import { RolesModule } from './roles/roles.module';
|
||||||
@@ -25,8 +28,12 @@ import { DashboardModule } from './dashboard/dashboard.module';
|
|||||||
import { SearchModule } from './search/search.module';
|
import { SearchModule } from './search/search.module';
|
||||||
import { ExportModule } from './export/export.module';
|
import { ExportModule } from './export/export.module';
|
||||||
import { QrModule } from './qr/qr.module';
|
import { QrModule } from './qr/qr.module';
|
||||||
|
import { PhenologyModule } from './phenology/phenology.module';
|
||||||
|
import { RemindersModule } from './reminders/reminders.module';
|
||||||
import { ProductsModule } from './products/products.module';
|
import { ProductsModule } from './products/products.module';
|
||||||
import { BottlingModule } from './bottling/bottling.module';
|
import { BottlingModule } from './bottling/bottling.module';
|
||||||
|
import { ProductionBatchesModule } from './production-batches/production-batches.module';
|
||||||
|
import { RawMaterialsModule } from './raw-materials/raw-materials.module';
|
||||||
import { EquipmentModule } from './equipment/equipment.module';
|
import { EquipmentModule } from './equipment/equipment.module';
|
||||||
import { CalendarModule } from './calendar/calendar.module';
|
import { CalendarModule } from './calendar/calendar.module';
|
||||||
import { CalendarSyncModule } from './calendar-sync/calendar-sync.module';
|
import { CalendarSyncModule } from './calendar-sync/calendar-sync.module';
|
||||||
@@ -40,22 +47,44 @@ import { ImportModule } from './import/import.module';
|
|||||||
import { PublicModule } from './public/public.module';
|
import { PublicModule } from './public/public.module';
|
||||||
import { TaskTemplatesModule } from './task-templates/task-templates.module';
|
import { TaskTemplatesModule } from './task-templates/task-templates.module';
|
||||||
import { SwarmControlModule } from './swarm-control/swarm-control.module';
|
import { SwarmControlModule } from './swarm-control/swarm-control.module';
|
||||||
|
import { WinterChecksModule } from './winter-checks/winter-checks.module';
|
||||||
import { WeatherModule } from './weather/weather.module';
|
import { WeatherModule } from './weather/weather.module';
|
||||||
import { ProfileModule } from './profile/profile.module';
|
import { ProfileModule } from './profile/profile.module';
|
||||||
import { SiteSettingsModule } from './site-settings/site-settings.module';
|
import { SiteSettingsModule } from './site-settings/site-settings.module';
|
||||||
import { BackupModule } from './backup/backup.module';
|
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 { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||||
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
||||||
import { PermissionsModule } from './auth/permissions/permissions.module';
|
import { PermissionsModule } from './auth/permissions/permissions.module';
|
||||||
|
import { IdempotencyInterceptor } from './common/idempotency/idempotency.interceptor';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
ScheduleModule.forRoot(),
|
ThrottlerModule.forRootAsync({
|
||||||
ThrottlerModule.forRoot([{ name: 'default', ttl: 60000, limit: 100 }]),
|
imports: [ThrottlerStorageModule],
|
||||||
|
inject: [PrismaThrottlerStorage],
|
||||||
|
useFactory: (storage: PrismaThrottlerStorage) => ({
|
||||||
|
throttlers: [{ name: 'default', ttl: 60000, limit: 100 }],
|
||||||
|
storage,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PermissionsModule,
|
PermissionsModule,
|
||||||
AuditModule,
|
AuditModule,
|
||||||
|
DomainEventsModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
@@ -78,6 +107,8 @@ import { PermissionsModule } from './auth/permissions/permissions.module';
|
|||||||
QrModule,
|
QrModule,
|
||||||
ProductsModule,
|
ProductsModule,
|
||||||
BottlingModule,
|
BottlingModule,
|
||||||
|
ProductionBatchesModule,
|
||||||
|
RawMaterialsModule,
|
||||||
EquipmentModule,
|
EquipmentModule,
|
||||||
CalendarModule,
|
CalendarModule,
|
||||||
CalendarSyncModule,
|
CalendarSyncModule,
|
||||||
@@ -86,21 +117,40 @@ import { PermissionsModule } from './auth/permissions/permissions.module';
|
|||||||
WebhooksModule,
|
WebhooksModule,
|
||||||
DocumentsModule,
|
DocumentsModule,
|
||||||
AutomationModule,
|
AutomationModule,
|
||||||
|
SuggestionsModule,
|
||||||
|
CleanupModule,
|
||||||
|
AutopilotModule,
|
||||||
|
PhenologyModule,
|
||||||
|
WithdrawalModule,
|
||||||
|
RemindersModule,
|
||||||
LabelTemplatesModule,
|
LabelTemplatesModule,
|
||||||
ImportModule,
|
ImportModule,
|
||||||
PublicModule,
|
PublicModule,
|
||||||
TaskTemplatesModule,
|
TaskTemplatesModule,
|
||||||
SwarmControlModule,
|
SwarmControlModule,
|
||||||
|
WinterChecksModule,
|
||||||
WeatherModule,
|
WeatherModule,
|
||||||
ProfileModule,
|
ProfileModule,
|
||||||
SiteSettingsModule,
|
SiteSettingsModule,
|
||||||
BackupModule,
|
BackupModule,
|
||||||
|
ShopSyncModule,
|
||||||
|
ErpExportModule,
|
||||||
|
ApiKeysModule,
|
||||||
|
SystemModule,
|
||||||
|
VeterinaryMedicinesModule,
|
||||||
|
HiveModule,
|
||||||
|
ComplianceModule,
|
||||||
|
ReadinessModule,
|
||||||
|
VoiceModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [
|
providers: [
|
||||||
|
// Throttle first: a request with a wrong token or API key must count
|
||||||
|
// against the limit too, before any database lookup is spent on it.
|
||||||
|
{ provide: APP_GUARD, useClass: ClientIpThrottlerGuard },
|
||||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||||
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
||||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
{ provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
@@ -43,6 +43,18 @@ describe('AuditLogService', () => {
|
|||||||
expect(createArg.data.userId).toBe('user-1');
|
expect(createArg.data.userId).toBe('user-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('writes through the transaction client when one is given', async () => {
|
||||||
|
const tx = { auditLog: { create: jest.fn() } };
|
||||||
|
|
||||||
|
await service.record(
|
||||||
|
{ entityType: 'Inspection', entityId: 'i1', action: 'CREATE' },
|
||||||
|
tx as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tx.auditLog.create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('findAll filters by entity type and id', async () => {
|
it('findAll filters by entity type and id', async () => {
|
||||||
await service.findAll({ entityType: 'Colony', entityId: 'colony-1' });
|
await service.findAll({ entityType: 'Colony', entityId: 'colony-1' });
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { DbClient } from '../prisma/db-client';
|
||||||
import { AuditAction, AuditLog, Prisma } from '../../generated/prisma';
|
import { AuditAction, AuditLog, Prisma } from '../../generated/prisma';
|
||||||
|
|
||||||
interface RecordAuditLogInput {
|
export interface RecordAuditLogInput {
|
||||||
entityType: string;
|
entityType: string;
|
||||||
entityId: string;
|
entityId: string;
|
||||||
action: AuditAction;
|
action: AuditAction;
|
||||||
@@ -20,8 +21,17 @@ export interface AuditLogFilter {
|
|||||||
export class AuditLogService {
|
export class AuditLogService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async record(input: RecordAuditLogInput): Promise<void> {
|
/**
|
||||||
await this.prisma.auditLog.create({
|
* With `tx` the entry commits or rolls back together with the change it
|
||||||
|
* describes - no audited change without its entry, and no entry for a
|
||||||
|
* change that never happened.
|
||||||
|
*/
|
||||||
|
async record(
|
||||||
|
input: RecordAuditLogInput,
|
||||||
|
tx?: Prisma.TransactionClient,
|
||||||
|
): Promise<void> {
|
||||||
|
const db: DbClient = tx ?? this.prisma;
|
||||||
|
await db.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
entityType: input.entityType,
|
entityType: input.entityType,
|
||||||
entityId: input.entityId,
|
entityId: input.entityId,
|
||||||
|
|||||||
Loaded 100 of 2368 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user