Compare commits
84
Commits
dd1eecdfa0
...
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
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Image-Namen in Kleinbuchstaben ermitteln
|
||||
# OCI-Registries erlauben nur Kleinbuchstaben im Repository-Pfad;
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
run: echo "name=$(echo '${{ gitea.repository_owner }}/apicore-backend' | tr '[:upper:]' '[:lower:]')" >> "$GITEA_OUTPUT"
|
||||
|
||||
- name: Docker Buildx einrichten
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
with:
|
||||
# REGISTRY_PUSH ist Klartext-HTTP (Gitea-Service intern ohne TLS)
|
||||
# statt HTTPS — BuildKit lehnt das ohne diese Freigabe ab.
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
http = true
|
||||
|
||||
- name: Bei der Gitea-Container-Registry anmelden
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY_PUSH }}
|
||||
username: ${{ gitea.actor }}
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
|
||||
- name: Metadaten (Tags/Labels) ermitteln
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
||||
tags: |
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Image bauen und pushen
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: ./ApiCore-Backend
|
||||
push: true
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
REGISTRY_PUSH: gitea:80
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Image-Namen in Kleinbuchstaben ermitteln
|
||||
# OCI-Registries erlauben nur Kleinbuchstaben im Repository-Pfad;
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
run: echo "name=$(echo '${{ gitea.repository_owner }}/apicore-frontend' | tr '[:upper:]' '[:lower:]')" >> "$GITEA_OUTPUT"
|
||||
|
||||
- name: Docker Buildx einrichten
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
with:
|
||||
# REGISTRY_PUSH ist Klartext-HTTP (Gitea-Service intern ohne TLS)
|
||||
# statt HTTPS — BuildKit lehnt das ohne diese Freigabe ab.
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
http = true
|
||||
|
||||
- name: Bei der Gitea-Container-Registry anmelden
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY_PUSH }}
|
||||
username: ${{ gitea.actor }}
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
|
||||
- name: Metadaten (Tags/Labels) ermitteln
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
||||
tags: |
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Image bauen und pushen
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: ./ApiCore-Frontend
|
||||
push: true
|
||||
|
||||
@@ -32,10 +32,10 @@ jobs:
|
||||
YQ_SHA256: c5f056448f973ae7d39b5401949648a78f2dc1947d6a8eb65be60d5c504b9385
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Node einrichten
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Whisper Image
|
||||
|
||||
# Nur bei Aenderungen am Abschreib-Image (Spec T7 11.1): das Image ist rund
|
||||
# 1,2 GB gross (Modell + Python-Abhaengigkeiten) und aendert sich selten.
|
||||
# Tag sha-<commit>; das Overlay setzt danach den Digest.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['ApiCore-Whisper/**', '.gitea/workflows/whisper-image.yml']
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: docker-builder
|
||||
env:
|
||||
# Nur fuer Doku/OCI-Labels, siehe REGISTRY_PUSH fuer den eigentlichen Push.
|
||||
REGISTRY: git.encry.de
|
||||
# Cluster-interner Service-Name statt git.encry.de: Registry-Blob-
|
||||
# Uploads (PUT) vertragen sich nicht mit dem Cloudflare-Proxy vor
|
||||
# git.encry.de ("413 Payload Too Large" unabhaengig von der Groesse).
|
||||
# Der Runner-Pod erreicht den Gitea-Service direkt ueber Kubernetes-DNS.
|
||||
# Push und Pull landen im selben Registry-Pfad (Owner/Repo:Tag), der
|
||||
# Hostname ist nur die Verbindung, daher bleibt der Pull in den
|
||||
# k8s-Manifesten unveraendert ueber git.encry.de.
|
||||
REGISTRY_PUSH: gitea:80
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Image-Namen in Kleinbuchstaben ermitteln
|
||||
# OCI-Registries erlauben nur Kleinbuchstaben im Repository-Pfad;
|
||||
# der Gitea-Owner-Name behaelt Gross-/Kleinschreibung bei.
|
||||
id: image
|
||||
run: echo "name=$(echo '${{ gitea.repository_owner }}/apicore-whisper' | tr '[:upper:]' '[:lower:]')" >> "$GITEA_OUTPUT"
|
||||
|
||||
- name: Docker Buildx einrichten
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
with:
|
||||
# REGISTRY_PUSH ist Klartext-HTTP (Gitea-Service intern ohne TLS)
|
||||
# statt HTTPS — BuildKit lehnt das ohne diese Freigabe ab.
|
||||
config-inline: |
|
||||
[registry."gitea:80"]
|
||||
http = true
|
||||
|
||||
- name: Bei der Gitea-Container-Registry anmelden
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY_PUSH }}
|
||||
username: ${{ gitea.actor }}
|
||||
# GITEA_TOKEN hat kein Package-Write-Recht — separates Personal
|
||||
# Access Token (Scope "package") als Secret REGISTRY_TOKEN anlegen.
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Metadaten (Tags/Labels) ermitteln
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
||||
tags: |
|
||||
type=sha,format=short
|
||||
|
||||
- name: Image bauen und pushen
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: ./ApiCore-Whisper
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=apicore-whisper
|
||||
cache-to: type=gha,scope=apicore-whisper,mode=max
|
||||
|
||||
- name: Schwachstellen-Scan (nur wenn trivy im Runner vorhanden ist)
|
||||
# Hinweisend (exit-code 0): die Ausgabe gehoert ins Runbook, ein Fund
|
||||
# blockiert den Push nicht, weil das Image dann schon in der Registry
|
||||
# liegt. Der Build selbst ist die harte Sperre (Hashes, Modell-SHA,
|
||||
# pytest in der Test-Stufe).
|
||||
run: |
|
||||
if command -v trivy >/dev/null 2>&1; then
|
||||
first_tag="$(echo '${{ steps.meta.outputs.tags }}' | head -n1)"
|
||||
trivy image --insecure --severity HIGH,CRITICAL --exit-code 0 "$first_tag"
|
||||
else
|
||||
echo "trivy ist im Runner nicht installiert, Scan uebersprungen"
|
||||
fi
|
||||
@@ -0,0 +1,9 @@
|
||||
# Root-level artefacts only; each project has its own .gitignore.
|
||||
|
||||
# Tool caches created when tests or scripts run from the repo root.
|
||||
/node_modules/
|
||||
|
||||
# Working notes of the SDD orchestration (ledgers, briefs, review reports)
|
||||
# and design mockups/screenshots (~90 MB). Decisions that matter are in
|
||||
# ARCHITECTURE.md, the specs/plans under docs/ and the Gitea issues.
|
||||
/.superpowers/
|
||||
+222
-10
@@ -76,7 +76,9 @@ Zentrale Schnellaktion "+ Neu" (Abschnitt 22): permanent sichtbarer Floating-But
|
||||
|
||||
**Offline-Seiten:** Ohne Netz zeigen `/offline` (Start: geladene Stände), `/offline/apiary?id=…` und `/offline/colony?id=…` die Daten aus den Offline-Paketen (IndexedDB). QR- und NFC-Deep-Links (`/apiaries/<id>`, `/colonies/<id>`) weicht der Service Worker ohne Netz auf diese Seiten aus; der Weg zurück („Mit Netz öffnen“) übernimmt `from` nur bei gleicher Herkunft (same-origin). Formulare lesen Völker und Königinnen aus dem Paket, wenn die API nicht antwortet (`withPackFallback`, `networkMode: "always"`).
|
||||
|
||||
**API-Client und Service Worker.** `apiFetch` und `apiFetchFormData` nehmen die Option `refreshOn401: false`: Ein 401 wird dann weder erneuert noch wiederholt, die deutsche Meldung des Servers kommt unverändert an. Alle Nachweis-Aufrufe (Step-up, Abschnitt 11.13) setzen sie (Annahme 56). Der Service Worker legt `/api/v1/profile/telegram`, `/api/v1/profile/notification-settings` und `/api/v1/system` nie im Cache ab (`NEVER_CACHED_API_PATHS` in `public/sw-routes.js`; eine Änderung hebt `SW_ROUTES_VERSION` in `public/sw.js`, aktuell der Hash `0681ff4b095d`; ein Spec prüft, dass die Version zum Inhalt von `sw-routes.js` passt, jede Änderung an der Datei braucht also eine neue Version). Seit T6 kommen dazu: `/api/v1/compliance` (Prüfbereitschaft, Anzeigen-Einstellungen), `authority-notices`, `apiary-restrictions`, `apiaries/:id/restrictions`, `veterinary-medicines`, `veterinary-medicine-lots`, die Etikett-Prüfung und der Druck (`label-templates/check`, `label-templates/:id/check…`, `label-templates/:id/print…`; sie tragen die Anschrift des Lebensmittelunternehmers), das Einzeldokument `documents/:id` (die Art steht nicht im Pfad) und die Dokumentlisten der drei eingeschränkten Arten `MEDICINE_LOT`, `AUTHORITY_NOTICE` und `APIARY_RESTRICTION` (die Art steht in der Query `/documents?entityType=…`, daher prüft `isCacheableApiUrl` die URL samt Query; Listen der Durchsicht-Fotos und `documents/:id/download` bleiben offline verfügbar, das Präfix `/api/v1/documents` wird nicht als Ganzes gesperrt). **Der globale Header `Cache-Control: no-store` des Backends ist bewusst kein Signal für den Worker:** Jede API-Antwort trägt ihn (Abschnitt 9, `noStoreMiddleware`), ein Header-Check würde das gesamte Offline-Verhalten abschalten. `isKeepableApiAnswer` prüft nur Erfolg und JSON; ob etwas behalten wird, entscheidet allein der Pfad (Annahme 87).
|
||||
**Beute offline (Abschnitt 13.1):** Das Paket (`offline-pack`) trägt optional `hives` (Aufbau je Volk) und `hiveCatalog` (aktive Systeme plus die von den Aufbauten genutzten); ein älteres Paket ohne diese Felder sagt „unbekannt“, nicht „kein Aufbau“ (`hiveFromPacks`, `lib/hive/pack.ts`). Änderungen ohne Netz landen im IndexedDB-Speicher `pending-hive-changes` (DB-Version 5, `queueHiveChange`). `drainTimeline` (`lib/offline/sync.ts`) sendet diese Einträge und die Durchsichten, die eine `hiveChange` tragen, gemeinsam nach `queuedAt`, nacheinander. Netzfehler und 5xx stoppen den Lauf; jede andere Ablehnung (`HIVE_CONFLICT`, 403, 400) wird am Eintrag gespeichert, der Eintrag bleibt in der Warteschlange und hält die späteren Einträge desselben Volks zurück, andere Völker laufen weiter. **Überholschutz:** Solange für ein Volk eine Beute-Änderung wartet (auch eine abgelehnte), reiht `useHiveMutation` jede neue Kartenänderung hinter sie ein, statt online zu senden (`hasQueuedHiveChange`). Die Karte zeigt die wartenden Operationen auf dem geladenen Stand; eine abgelehnte Änderung zeigt `HivePendingNote` als Hinweis mit Weg in die Warteschlange (Annahme 102).
|
||||
|
||||
**API-Client und Service Worker.** `apiFetch` und `apiFetchFormData` nehmen die Option `refreshOn401: false`: Ein 401 wird dann weder erneuert noch wiederholt, die deutsche Meldung des Servers kommt unverändert an. Alle Nachweis-Aufrufe (Step-up, Abschnitt 11.13) setzen sie (Annahme 56). Der Service Worker legt `/api/v1/profile/telegram`, `/api/v1/profile/notification-settings` und `/api/v1/system` nie im Cache ab (`NEVER_CACHED_API_PATHS` in `public/sw-routes.js`; eine Änderung hebt `SW_ROUTES_VERSION` in `public/sw.js`, aktuell der Hash `215afb9f84e9`; ein Spec prüft, dass die Version zum Inhalt von `sw-routes.js` passt, jede Änderung an der Datei braucht also eine neue Version). Seit T6 kommen dazu: `/api/v1/compliance` (Prüfbereitschaft, Anzeigen-Einstellungen), `authority-notices`, `apiary-restrictions`, `apiaries/:id/restrictions`, `veterinary-medicines`, `veterinary-medicine-lots`, die Etikett-Prüfung und der Druck (`label-templates/check`, `label-templates/:id/check…`, `label-templates/:id/print…`; sie tragen die Anschrift des Lebensmittelunternehmers), das Einzeldokument `documents/:id` (die Art steht nicht im Pfad) und die Dokumentlisten der drei eingeschränkten Arten `MEDICINE_LOT`, `AUTHORITY_NOTICE` und `APIARY_RESTRICTION` (die Art steht in der Query `/documents?entityType=…`, daher prüft `isCacheableApiUrl` die URL samt Query; Listen der Durchsicht-Fotos und `documents/:id/download` bleiben offline verfügbar, das Präfix `/api/v1/documents` wird nicht als Ganzes gesperrt). **Der globale Header `Cache-Control: no-store` des Backends ist bewusst kein Signal für den Worker:** Jede API-Antwort trägt ihn (Abschnitt 9, `noStoreMiddleware`), ein Header-Check würde das gesamte Offline-Verhalten abschalten. `isKeepableApiAnswer` prüft nur Erfolg und JSON; ob etwas behalten wird, entscheidet allein der Pfad (Annahme 87). Ebenfalls nie gespeichert wird `/api/v1/colonies/:id/hive` (`/\/api\/v1\/colonies\/[^/]+\/hive\/?$/i`): das Paket antwortet dort, eine alte Kopie würde einen neueren Stand verdecken. `…/hive/changes` und der Katalog (`/hive-systems`) bleiben cachebar.
|
||||
|
||||
**Stand-Seite und Standrunde:** `apiaries/[id]` zeigt die lebenden Völker mit Ampel und „Runde starten“; nach dem Speichern führt „Weiter mit …“ zum nächsten Volk. Der Rundenzustand liegt je Benutzer lokal und wird beim Abmelden gelöscht.
|
||||
|
||||
@@ -139,7 +141,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
||||
19. **Backups versiegelt**: AES-256-GCM über das gzip-Bundle, Schlüssel aus dem Schlüsselbund `BACKUP_ENCRYPTION_KEY` (+ `BACKUP_ENCRYPTION_KEY_PREVIOUS`, in Produktion Pflicht, siehe Annahme 22); Dateien von vor dem Schlüsselbund (Schlüssel per HKDF aus `JWT_ACCESS_SECRET`) bleiben lesbar, solange `JWT_ACCESS_SECRET` unverändert ist. Nur versiegelte Dateien stellen vollständig wieder her (und überschreiben). Eine unversiegelte (alte v2-)Datei darf nur die 27 Stamm- und Feldtabellen der Erlaubt-Liste `LEGACY_UNSEALED_TABLES` ergänzen – fehlende Zeilen anlegen, bestehende nie ändern. Alles andere (Benutzer, Rollen, Webhooks, Audit-Log, Kalender-Sync, Backup- und Website-Einstellungen, Nummernkreise, Bestands- und Chargenbewegungen, Shop-Anfragen, Rohstoffe/Herstellung, Dokumente, Benachrichtigungen samt Einstellungen, Vorschläge, Pflanzenentwicklungs-Meldungen und jede künftige Tabelle) kommt nur aus versiegelten Dateien; die Oberfläche listet übersprungene Tabellen auf; Import verlangt Passwort (+ TOTP). MFA- und Webhook-Secrets liegen mit eigenen Schlüsseln (`MFA_ENCRYPTION_KEY`, `WEBHOOK_ENCRYPTION_KEY`) verschlüsselt in der DB und damit auch im Backup – für eine Wiederherstellung in einem anderen Deployment dort dieselben Schlüssel setzen (oder als `*_PREVIOUS`), sonst müssen Webhook-Secrets und MFA neu eingerichtet werden.
|
||||
20. **Bearbeiten-Regel für `null`** (Grundsatz „alles bearbeitbar“): In jeder Bearbeiten-Anfrage bleibt ein weggelassenes Feld unverändert, `null` leert ein Feld, das leer sein darf, und `null` auf einem Pflichtfeld wird mit 400 abgelehnt (statt still ignoriert oder als 500). Umgesetzt über `PartialType(..., { skipNullProperties: false })` in den Update-DTOs, `@IsOptionalNotNull()` (`src/common/validation/is-optional-not-null.ts`) für weglassbare, aber nicht leerbare Felder und `optionalDate()` (`src/common/patch-values.ts`) für leerbare Datumsfelder; `update-dto-null-handling.spec.ts` hält den Vertrag je DTO fest. Das Frontend sendet geleerte optionale Felder über `emptyToNull` (`ApiCore-Frontend/src/lib/forms/empty-to-null.ts`) als `null`. Für Regel-Configs gilt dasselbe Muster: `null` löscht einen optionalen Schlüssel (z. B. `dueInDays`, `windowDays`, `minGrams`, einzelne GTS-Schwellen), ein Pflichtschlüssel (z. B. `queenMaxAgeYears`, `notify`, die Gruppe `swarmSeason`) lehnt `null` mit 400 ab (Abschnitt 11.2). Neu leerbar sind `Colony.occupiedFrames`, `VarroaMeasurement.sampleBees` und `Inspection.feedStatus`.
|
||||
21. **Lebende Völker**: „Lebend“ heißt jeder Status außer `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` – also auch `WEAK`, `CONSPICUOUS`, `QUEENLESS`. Backend-Abfragen filtern über `LIVING_COLONY_WHERE` (`colonies/colony-status.ts`), das Frontend über `isLivingColony` (`lib/colonies/status.ts`); `status = ACTIVE` steht nur dort, wo wirklich genau dieser Status gemeint ist.
|
||||
22. **Schlüsselbund**: Jeder Zweck hat einen eigenen Schlüssel, nie ein JWT-Secret: `MFA_ENCRYPTION_KEY` (TOTP-Secrets), `WEBHOOK_ENCRYPTION_KEY` (Webhook-Signaturschlüssel), `BACKUP_ENCRYPTION_KEY` (Backup-Dateien). In Produktion Pflicht: je ≥ 32 Zeichen, paarweise verschieden und verschieden von `JWT_ACCESS_SECRET`/`JWT_REFRESH_SECRET` – sonst startet kein Prozess (`src/runtime/startup-checks.ts`). Außerhalb der Produktion fällt ein fehlender Schlüssel je Zweck auf eine Ableitung aus `JWT_ACCESS_SECRET` zurück. Format `k<8 hex>:<iv>.<tag>.<data>` (Base64-Teile, AES-256-GCM, 128-Bit-Tag Pflicht); AES-Schlüssel = HKDF-SHA256(Secret, Salt `apicore-key-ring`, Info `apicore-<zweck>-v1`), Schlüssel-ID = `k` + die ersten 8 Hex-Zeichen von sha256(Schlüssel). Backups behalten Dateiformat v3 und ihre bisherige Ableitung (`keyId` 12 Hex), folgen aber denselben Ring-Regeln. Code: `src/common/crypto/key-ring.ts`. In Produktion lehnt der Start außerdem Platzhalterwerte ab (`change-me…`, ohne Groß-/Kleinschreibung) – für beide JWT-Secrets, die drei Schlüssel und ihre `*_PREVIOUS`, ein gesetztes `OIDC_CLIENT_SECRET` und das Passwort in `DATABASE_URL`; auch `*_PREVIOUS` muss von den JWT-Secrets verschieden sein. Außerhalb der Produktion meldet jeder Prozess einmal je Variable, dass ein aus `JWT_ACCESS_SECRET` abgeleiteter Entwicklungsschlüssel einspringt.
|
||||
22. **Schlüsselbund**: Jeder Zweck hat einen eigenen Schlüssel, nie ein JWT-Secret: `MFA_ENCRYPTION_KEY` (TOTP-Secrets), `WEBHOOK_ENCRYPTION_KEY` (Webhook-Signaturschlüssel), `BACKUP_ENCRYPTION_KEY` (Backup-Dateien), `VOICE_ENCRYPTION_KEY` (Sprachnotizen: Transkript, Vorschlag, Aufnahme; seit T7, AAD `capture:<id>:<art>`). In Produktion Pflicht: je ≥ 32 Zeichen, paarweise verschieden und verschieden von `JWT_ACCESS_SECRET`/`JWT_REFRESH_SECRET` – sonst startet kein Prozess (`src/runtime/startup-checks.ts`). Außerhalb der Produktion fällt ein fehlender Schlüssel je Zweck auf eine Ableitung aus `JWT_ACCESS_SECRET` zurück. Format `k<8 hex>:<iv>.<tag>.<data>` (Base64-Teile, AES-256-GCM, 128-Bit-Tag Pflicht); AES-Schlüssel = HKDF-SHA256(Secret, Salt `apicore-key-ring`, Info `apicore-<zweck>-v1`), Schlüssel-ID = `k` + die ersten 8 Hex-Zeichen von sha256(Schlüssel). Backups behalten Dateiformat v3 und ihre bisherige Ableitung (`keyId` 12 Hex), folgen aber denselben Ring-Regeln. Code: `src/common/crypto/key-ring.ts`. In Produktion lehnt der Start außerdem Platzhalterwerte ab (`change-me…`, ohne Groß-/Kleinschreibung) – für beide JWT-Secrets, die vier Schlüssel und ihre `*_PREVIOUS`, ein gesetztes `OIDC_CLIENT_SECRET` und das Passwort in `DATABASE_URL`; auch `*_PREVIOUS` muss von den JWT-Secrets verschieden sein. Außerhalb der Produktion meldet jeder Prozess einmal je Variable, dass ein aus `JWT_ACCESS_SECRET` abgeleiteter Entwicklungsschlüssel einspringt. Der Job `rekey` versiegelt beim Schlüsseltausch nur offene Sprachnotizen neu (bestätigte, verworfene und abgelaufene haben keinen Inhalt mehr). **Rollout:** Ein Image mit T7 startet in Produktion in keiner Rolle ohne `VOICE_ENCRYPTION_KEY` in `apicore-backend-secret`; vorher `deploy/k8s/scripts/ensure-secrets.sh` ausführen und den Schlüssel wie die anderen sichern.
|
||||
23. **Öffentliche Herkunftsseite ohne Standort**: Sie nennt nur `Apiary.publicLocationLabel` (je Stand, z. B. „Nordhessen“) und `SiteSettings.originCountry`. Name, Ort, Adresse und Koordinaten eines Stands werden nie öffentlich ausgeliefert (Diebstahlschutz). Ohne Label erscheint nur das Land. Eine Label-Änderung kann am Edge bis zu 1 h verzögert sichtbar werden.
|
||||
**Rotation über `*_PREVIOUS`:** alten Wert nach `<NAME>_PREVIOUS` (öffnet weiter, versiegelt nie), neuen `<NAME>` setzen, api und worker neu starten (sonst versiegeln sie weiter mit dem alten Schlüssel), Job `rekey` laufen lassen; danach muss `rekey --dry-run` bei `rekeyed`, `plaintextSealed`, `changedMeanwhile` und `failed` 0 melden, erst dann `*_PREVIOUS` entfernen. Backup-Dateien werden nicht umgeschlüsselt: `BACKUP_ENCRYPTION_KEY_PREVIOUS` bleibt gesetzt, solange ältere Dateien einspielbar sein sollen.
|
||||
**Altchiffrate sind nur lesbar** (nie geschrieben), **und nur, solange `JWT_ACCESS_SECRET` unverändert ist**: MFA-Secrets ohne Präfix und Webhook-Secrets mit `enc:v1:` (beide AES-Schlüssel sha256(`JWT_ACCESS_SECRET`)), Webhook-Secrets im Klartext, Backup-Dateien mit JWT-abgeleiteter `keyId`. Liest ein Prozess ein solches MFA- oder Webhook-Secret, warnt er einmal je Zweck „legacy ciphertext read – run the rekey job“ (ohne Werte). Reihenfolge darum: Schlüssel setzen → `rekey` → neues Backup ziehen → erst dann die JWT-Secrets rotieren (alle Sitzungen enden einmal; JWT-versiegelte Backup-Dateien sind danach nicht mehr einspielbar).
|
||||
@@ -159,7 +161,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
||||
36. **Saison aus Pflanzenentwicklung**: Die Saison (Abschnitte `WINTERRUHE`, `FRUEHJAHR`, `SCHWARMZEIT`, `SOMMER`, `SPAETSOMMER`, `HERBST`) leitet sich aus der Pflanzenentwicklung ab (Abschnitt 11.3). Der Kalender-Rückfall (`PHENOLOGY.calendarFallback`, Standard `SALWEIDE` 20.03., `KIRSCHE` 15.04., `APFEL` 28.04., `HOLUNDER` 30.05., `LINDE` 20.06., `TRACHTENDE` 01.08., `HERBST` 15.09., `WINTER` 15.11.). Die Tage sind eine Annahme für Schwaben und per Regel anpassbar. Ohne gespeicherte Zeile für das laufende Jahr gilt der reine Kalenderzustand (Verhalten von T4a); dort steht vor `SALWEIDE` noch `WINTER` des Vorjahres. Seit T4b kommen gemeldete Phasen und die Grünlandtemperatursumme (GTS, Wetter) dazu; sie wirken über `SeasonService.statesFor`, die Aufrufer bleiben gleich. Der Zustand eines Stands steht in `ApiaryPhenology (apiaryId, year)`; der Job-Schritt `phenology` und jede Meldung oder Rücknahme berechnen ihn neu (`PhenologyStateService.recomputeIn`, unter Zeilensperre auf `Apiary`). Die Phase ist das Maximum aus eigener Meldung (Meldungen für alle Stände zählen für jeden Stand), GTS-Schätzung und Kalender. Sie läuft im Jahr nur vorwärts, außer nach einer Korrektur oder Rücknahme einer Meldung (`allowBackward`, gesetzt nur vom Handler bei `PhenologyCorrected`; eine neue Meldung schiebt die Phase nie zurück). GTS = Summe der positiven Tagesmittel ab 1. Januar bis gestern, Januar × 0,5, Februar × 0,75, ab März × 1 (nach DWD). Annahme: 200 °C gelten als `SALWEIDE` (Standardschwelle `PHENOLOGY.gtsThresholds`). Weitere Schwellen werden nie erfunden, sondern als Median der GTS am ersten Meldetag der letzten bis zu 3 Vorjahre kalibriert (Standard `KIRSCHE` und `APFEL`); ein einzelnes Vorjahr genügt und ist dann der Median. Ohne Vorjahresmeldung gilt für diese Phasen der Kalender. Fehlen mehr als `maxGapShare` (10 %) der Tage, steht „Wetterdaten lückenhaft“; ist der letzte Wettertag älter als 3 Tage, „Wetterdaten veraltet“; ohne Koordinaten „Keine Koordinaten“. In allen drei Fällen wird die GTS nicht benutzt und der Kalender entscheidet. Vor der ersten Phase des Jahres ist die gespeicherte Phase leer (Quelle Kalender), der Abschnitt `WINTERRUHE`. Ist die gespeicherte Zeile älter als 2 Tage (Job ausgefallen), gilt wieder der Kalenderzustand. `springSince` ist nicht gespeichert, sondern genähert: im Abschnitt `FRUEHJAHR` der Abschnittsbeginn, später im Jahr das Kalenderdatum von `SALWEIDE` (höchstens der Abschnittsbeginn). Weicht der GTS-Tag von `SALWEIDE` stark vom Kalender ab, verschiebt sich die Frist „Erste Durchsicht im Jahr“ um diese Differenz. Die Rückfall-Daten für Schwaben und die 200-°C-Schwelle sind Annahmen und noch nicht an regionalen Daten geprüft. Die Wetterquelle beschreibt Abschnitt 11.11. Meldungen und Rücknahmen stoßen die Neuberechnung über Ereignisse an, Saisonwechsel erscheinen nur in der Morgenübersicht (Annahme 47).
|
||||
37. **Alter der Königin**: Eine Königin gilt als gealtert, wenn aktuelles Jahr minus Schlupfjahr ≥ `queenMaxAgeYears` (Regel `SEASON_AUTOPILOT`, Standard 2). Die frühere getrennte Schwelle (3 im Dashboard, 2 im Ereignis) ist aufgehoben; `isQueenAged` (`src/season/queen-age.ts`) ist die einzige Stelle, ein Wächter-Test (`queen-age-threshold.spec.ts`) hält das fest. Das Dashboard-Fenster „alte Königinnen“ beginnt daher bei 2 Jahren und folgt der Regel. Der Wert 2 ist eine Annahme (Imkerpraxis).
|
||||
38. **Autopilot: Annahmen zu Ablauf und Alter**: Ein Vorschlag läuft `max(Fälligkeit, heute) + suggestionTtlDays` (Standard 30 Tage) ab, nicht schlicht Fälligkeit + 30: Sonst würde eine schon über 30 Tage überfällige Bedingung beim Anlegen abgelaufen, ihr Schlüssel freigegeben und sie täglich neu vorgeschlagen, ohne je sichtbar zu sein. Das Auffrischen behält `expiresAt`; besteht die Bedingung weiter, läuft der Vorschlag nach etwa 30 Tagen ab und kommt mit frischer Frist wieder. A3 beobachtet nur Behandlungen, die vor höchstens 60 Tagen endeten (`RECHECK_MAX_AGE_DAYS`); ältere Historie ohne spätere Messung erzeugt keinen Vorschlag. Beim Abgleich werden entfallene Vorschläge vor neuen Kandidaten aufgelöst (gleicher Endzustand, aber einen Abgleich früher, weil `proposeIn` Kandidaten bei offenem Vorschlag derselben Absicht verwirft).
|
||||
39. **Benachrichtigungen je Nutzer**: Jede Meldung gehört genau einem Empfänger (`Notification.userId` Pflicht). Rundmeldungen gibt es nicht mehr: `NotificationsService.recordForPermission` und `recordForUsers` schreiben beim Anlegen je einen Eintrag für jeden aktiven Nutzer mit aktiver Rolle, der alle nötigen Rechte hält (Fan-out mit Rechteprüfung beim Schreiben, bewusste Abweichung von der Rückgrat-Spec §4). Lesen und Gelesen-Markieren ist so pro Person, und wer das Recht nicht hat, sieht den Inhalt nie. Dasselbe Recht gilt beim Lesen: `requiredPermissions` (Vereinigung aus Empfänger- und Inhaltsrechten) muss der Nutzer zum Lesezeitpunkt vollständig halten, sonst ist die Zeile für ihn unsichtbar (`GET /notifications`, `POST /notifications/:id/read` → 404, `read-all` markiert nur sichtbare). `dedupeKey = "<Präfix>:<userId>"` verhindert Doppelungen (`createManyAndReturn` mit `skipDuplicates`); `link` ist ein relativer App-Pfad (`assertAppLink`). Die Migration hat vorhandene Rundmeldungen für alle aktiven Nutzer kopiert (gleiche Sichtbarkeit wie zuvor; ohne aktiven Nutzer entfallen sie). `Notification.userId` und `NotificationPreference.userId` hängen mit `ON DELETE CASCADE` am Benutzer (Benutzer werden produktiv nur deaktiviert, `RESTRICT` schützte nichts und blockierte Aufräumen). Wächter: `no-broadcast-notifications.spec.ts`.
|
||||
39. **Benachrichtigungen je Nutzer**: Jede Meldung gehört genau einem Empfänger (`Notification.userId` Pflicht). Rundmeldungen gibt es nicht mehr: `NotificationsService.recordForPermission` und `recordForUsers` schreiben beim Anlegen je einen Eintrag für jeden aktiven Nutzer mit aktiver Rolle, der alle nötigen Rechte hält (Fan-out mit Rechteprüfung beim Schreiben, bewusste Abweichung von der Rückgrat-Spec §4). Lesen und Gelesen-Markieren ist so pro Person, und wer das Recht nicht hat, sieht den Inhalt nie. Dasselbe Recht gilt beim Lesen: `requiredPermissions` (Vereinigung aus Empfänger- und Inhaltsrechten) muss der Nutzer zum Lesezeitpunkt vollständig halten, sonst ist die Zeile für ihn unsichtbar (`GET /notifications`, `POST /notifications/:id/read` → 404, `read-all` markiert nur sichtbare). `dedupeKey = "<Präfix>:<userId>"` verhindert Doppelungen (`createManyAndReturn` mit `skipDuplicates`); `link` ist ein relativer App-Pfad (`assertAppLink`). Die Migration hat vorhandene Rundmeldungen für alle aktiven Nutzer kopiert (gleiche Sichtbarkeit wie zuvor; ohne aktiven Nutzer entfallen sie). `Notification.userId` und `NotificationPreference.userId` hängen mit `ON DELETE CASCADE` am Benutzer (Benutzer werden produktiv nur deaktiviert, `RESTRICT` schützte nichts und blockierte Aufräumen). Wächter: `no-broadcast-notifications.spec.ts`. `NotificationsService` liest die Empfänger und schreibt die Meldungen in einer Transaktion und sperrt die Nutzerzeilen dabei mit `FOR KEY SHARE`; ein gleichzeitiges Löschen eines Empfängers wartet, statt einen Fremdschlüsselfehler (P2003) im Handler auszulösen.
|
||||
40. **Erinnerungen in der App und Morgenübersicht**: Siehe Abschnitt 11.9. In der App steuern die Morgenzeit (05:00–12:00), die Ruhezeit und die Umstellung Sommer-/Winterzeit nichts; die Übersicht entsteht immer um 04:30 mit dem Stand dieser Stunde. Sie wirken auf die Telegram-Zustellung (Annahme 49). Die Saison-Zeile der Übersicht nennt die Abschnittswechsel seit dem Zeitfenster der letzten Übersicht, nicht als Einzelmeldung (Annahme 47).
|
||||
41. **Berliner Kalendertage**: Tagesrechnung steckt in `src/common/dates.ts` (`berlinToday`, `berlinDayOf`, `dayToDate`, `addDays`, `daysBetween`; unmögliche Tage wie 30.02. werfen). Fachliche Tage sind Berliner Kalendertage, gespeichert als Mitternacht UTC dieses Tages: Varroa-Ampel, Behandlungs- und Wartezeitfenster, Erntetag, Fälligkeiten der Vorschläge, „heute“ im Job und im Autopilot. Für Werte um Mitternacht UTC ändert das nichts; ein Behandlungszeitpunkt am Abend (UTC) kann so auf den nächsten Berliner Tag fallen.
|
||||
42. **Wetterquelle Open-Meteo (Entscheidung des Inhabers)**: Die Pflanzenentwicklung nutzt die freie Open-Meteo-API (Forecast-Endpunkt für die letzten 90 Tage, Archiv für ältere Lücken). Die nicht-kommerziellen Bedingungen und das damit verbundene Lizenzrisiko hat der Inhaber bewusst übernommen. Es wird kein API-Schlüssel verwendet (`OPEN_METEO_API_KEY` bleibt leer). Die Quelle liegt hinter der Schnittstelle `WeatherHistorySource` (`src/weather/history`); ein Wechsel geht über `WEATHER_HISTORY_SOURCE` und einen neuen Adapter. Koordinaten werden auf 0,01° gerundet. URL und Schlüssel erscheinen nie in Logs oder Fehlermeldungen. Details: Abschnitt 11.11.
|
||||
@@ -211,6 +213,34 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
||||
88. **„1.000“ wird als mehrdeutig abgelehnt**: Die Mengenfelder der Tierarzneimittel-Oberfläche lesen das Komma als Dezimalzeichen und lehnen einen einzelnen Punkt mit genau drei Nachkommastellen („1.000“, „12.345“) sowie jeden Text mit mehreren Trennzeichen ab, weil er Tausender oder Dezimalstelle meinen könnte. Kosten bei falscher Annahme: Wer „1.000“ als eins meint, muss „1“ oder „1,0“ schreiben. Abschnitt 12.11.
|
||||
89. **Eine neue, ungespeicherte Etikettvorlage prüft nichts, bis sie einen Wert hat**: Der Check-Endpunkt verlangt die Mindest-x-Höhe; das Frontend kennt keinen eigenen Vorgabewert (kein Rechtswert im Code). Eine neue Vorlage startet mit leerem Feld, sendet beim Anlegen keinen Wert (der Server setzt seinen Standard) und zeigt das Panel erst nach Eingabe oder nach dem ersten Speichern. Kosten bei falscher Annahme: Im Designer fehlt bei einer ganz neuen Vorlage die Prüfung, bis der Wert gesetzt ist. Abschnitt 12.11.
|
||||
90. **Das Prüfbericht-PDF druckt Zählungen, keine Namen**: Es nennt je Punkt nur die Zahl der betroffenen Objekte, nie Stände, Völker, Mittel, Packungen oder Aktenzeichen, damit der Bericht ohne Rechteprüfung der Namen weitergegeben werden kann. Kosten bei falscher Annahme: Wer die Objekte braucht, öffnet die Seite `/compliance` mit den Rechten. Abschnitt 12.10.
|
||||
91. **Eine Sperre, die heute endet, gilt bis Tagesende; ein Aufheben am selben Tag gibt es nicht**: `endsOn` ist inklusive (aktiv bei `startsOn` ≤ Berliner Tag ≤ `endsOn`), das Backend lehnt `endsOn` vor `startsOn` ab. Die Oberfläche schlägt „gestern“ als Endtag vor; wer heute oder später wählt, bekommt „Die Sperre gilt noch bis einschließlich …“. Kosten bei falscher Annahme: Wer eine Sperre heute am Vormittag aufheben will, hat sie bis Mitternacht Berliner Zeit weiter am Stand. Abschnitt 12.11.
|
||||
92. **Beim Bearbeiten einer Behandlung wählt die Oberfläche nie selbst eine Packung vor**: Nur das Anlegen schlägt die Packung mit dem frühesten Verfall vor; im Bearbeiten-Dialog steht nach einem Mittelwechsel „Ohne Packung“, bis der Nutzer wählt. Kosten bei falscher Annahme: Wer eine Abbuchung erwartet, muss die Packung ausdrücklich wählen. Abschnitt 12.11.
|
||||
93. **Die Glocke zeigt für `COMPLIANCE` das Kurz-Etikett „Behörden“**: Die Badges der Glocke sind Einwort-Etiketten („Aufgabe“, „Vorschlag“); „Behörden & Prüfung“ würde die Zeile umbrechen und steht darum nur in `lib/notifications/labels.ts` und der Telegram-Kategorienauswahl. Kosten bei falscher Annahme: Zwei Bezeichnungen für dieselbe Kategorie. Abschnitt 12.11.
|
||||
94. **Die Dosis ist auf drei Nachkommastellen begrenzt**: Das Eingabefeld lehnt mehr als drei Nachkommastellen mit „Höchstens 3 Nachkommastellen.“ ab (Packungsmengen rechnen auf Tausendstel, `roundToThousandths`). Kosten bei falscher Annahme: Eine feinere Dosis lässt sich nicht erfassen. Abschnitt 12.11.
|
||||
95. **Die Liebig-Zander-Zarge misst außen 525 × 430 mm**: Der Wert stammt aus dem Innenmaß des Edelstahldeckels und steht im Katalog (Migration `20261017100100_hive_catalog_zander_liebig`, `test/fixtures/hive-ops/catalog.json`). Kosten bei falscher Annahme: Die 3D-Maße stimmen nicht; der Inhaber misst nach, der Katalogwert wird mit `hive-systems:manage` geändert. Abschnitt 13.
|
||||
96. **Die Styropor-Einlage misst 465 × 365 × 20 mm**: Der Inhaber misst nach. Kosten bei falscher Annahme: Die Dämmung sitzt im Modell zu groß oder zu klein; Katalogwert ändern, kein Code. Abschnitt 13.
|
||||
97. **Der Nassenheider Professional liegt in einer Leerzarge auf den Rähmchen der Zarge darunter**: Im Modell ist er ein Einsatz der Leerzarge (`EVAPORATOR` in einer `BOX` mit 0 Plätzen). Kosten bei falscher Annahme: Die Lage im Aufbau weicht vom Stock ab; der Inhaber bestätigt mit Foto, die Regel steht in `hive-validate.ts`. Abschnitt 13.
|
||||
98. **Rähmchen-Plätze zählen von links, mit Blick auf das Flugloch**: Platz 1 ist links, Platz `frameSlots` rechts. Kosten bei falscher Annahme: Alle gespeicherten Platznummern sind gespiegelt; das lässt sich nur mit einer Datenmigration über alle Aufbauten beheben. Abschnitt 13.
|
||||
99. **Den Aufbau ändert nur, wer `colonies:update` hat; den Katalog pflegt nur `hive-systems:manage` (ADMIN)**: STAFF hat `colonies:update` nicht und kann die Beute daher nicht ändern, auch nicht in der Durchsicht (403 `HIVE_FORBIDDEN`). `hive-systems:manage` kommt nur über die Katalog-Synchronisierung zur Rolle ADMIN, ohne Rechte-Migration. Kosten bei falscher Annahme: Mitarbeiter müssen den Aufbau über einen Administrator ändern lassen; der Inhaber vergibt `colonies:update` in der Rollenverwaltung, das ist kein Code. Abschnitt 13.
|
||||
100. **Die Bausperre ist eine Einlage ohne Zustand; „Winter“ ist kein eigener Modus**: Die Bausperre (`FLOOR_INSERT`) hat kein Zustandsfeld, Einlegen und Entfernen sind die Operationen `addInsert` und `removePart`. Kosten bei falscher Annahme: Wer einen Winter-Modus erwartet, muss Einlagen einzeln setzen; ein Modus wäre eine spätere Erweiterung des Katalogs. Abschnitt 13.
|
||||
101. **Die Futtertasche misst 477 × 70 × 220 mm und fasst 2 l**: Der Hersteller (Holtermann) nennt Anschlussmaße, kein Volumen; Maße und `max: 2` im Zustandsfeld `syrupLiters` sind Annahmen der Startdaten (Migration `20261018100100_hive_catalog_frame_feeder`) und im Katalog editierbar (Einstellungen > Beutensysteme). Kosten bei falscher Annahme: Die Tasche sitzt im 3D-Modell zu groß oder zu klein und die Füllstandsgrenze stimmt nicht; der Inhaber misst nach, der Katalogwert wird mit `hive-systems:manage` geändert, kein Code. Abschnitt 13.
|
||||
102. **Eine wartende Beute-Änderung wird nie überholt, auch eine abgelehnte nicht**: Ohne Netz oder mit offener Warteschlange reiht die Karte jede weitere Änderung desselben Volks hinten ein, statt sie online zu senden; ein abgelehnter Eintrag hält die späteren desselben Volks zurück, bis der Nutzer ihn in der Warteschlange klärt (Abschnitt 2). Kosten bei falscher Annahme: Nach einer abgelehnten Änderung sind neue Änderungen am Volk vorerst blockiert, bis der Nutzer entscheidet; die Alternative (überholen) könnte einen Aufbau erzeugen, den die Reihenfolge nie hatte. Abschnitt 13.1.
|
||||
103. **Die Historienzeilen haben keine Folgeknöpfe**: Eine Zeile der Beute-Historie zeigt die Änderung und den Stand an diesem Tag, bietet aber keine Vorschläge wie „Futter nachlegen“ an, weil ein Eintrag den Zustand davor nicht trägt; die Vorschläge erscheinen nur als Hinweis (Toast) direkt nach dem Speichern. Kosten bei falscher Annahme: Ein weggewischter Hinweis ist aus der Historie nicht wiederzufinden; Abhilfe wäre, `stateBefore` im Protokoll zu speichern (Migration). Abschnitt 13.1.
|
||||
104. **Der Editor hält den Stand fest, auf dem der Entwurf begann**: Beim ersten Schritt merkt sich `useHiveDraft` den geladenen Aufbau (`snapshot`) und zeigt die Vorschau darauf; ändert sich der Aufbau auf dem Server inzwischen, erscheint ein Hinweis, und beim Speichern wendet der Server die Operationen auf den aktuellen Stand an. Passen sie nicht mehr, öffnet sich das Konflikt-Sheet, es wird nichts gespeichert. Kosten bei falscher Annahme: Die Vorschau im Editor weicht nach einer gleichzeitigen Änderung vom gespeicherten Ergebnis ab; der Konflikt-Weg fängt Unverträgliches ab. Abschnitt 13.1.
|
||||
105. **Der Zoom der 3D-Szene (0,6 bis 2,5) kommt mit dem Ziehen und Ablegen**: Die Szene hat Drehen und Fokus auf ein Teil, aber noch keinen frei wählbaren Zoom; der Bereich ist der Aufgabe für Ziehen und Ablegen zugeordnet (Task 18). Kosten bei falscher Annahme: Auf kleinen Bildschirmen fehlt zunächst ein Zoom; der Browser-Zoom und die Listenansicht (Gliederung) bleiben als Weg. Abschnitt 13.1.
|
||||
106. **Restrisiko im Abschreib-Pod wird getragen**: Der Container `whisper` (CronJob `voice-transcribe`, Komponente `deploy/k8s/components/voice`) dekodiert nicht vertrauenswürdige Audiodateien mit libav/PyAV und CTranslate2. Er teilt das Pod-Netz; die NetworkPolicy `apicore-jobs` erlaubt nur Postgres (5432) und DNS. Ein kompromittierter Decoder könnte über DNS-Anfragen langsam Daten nach außen tragen und Postgres erreichen, hat aber keine Zugangsdaten (kein `envFrom`, kein `secretKeyRef`, kein Service-Account-Token, eigenes `/tmp`, damit er `ingest` mit den Secrets nichts unterschieben kann). Er sieht Ton und Abschriften seines Laufs (höchstens 5 Aufnahmen, 600 s) und könnte deren Ergebnisse verfälschen; `/work/in` ist für ihn nur lesbar, und `ingest` übernimmt nur Ergebnisse zu Aufnahmen des eigenen Manifests mit passendem `claimToken`, prüft jedes Ergebnis streng, und nichts geht ohne Klick in ein Formular. Akzeptiert nach Spec 9.2: ein Betrieb mit eigenen Nutzern, keine Geheimnisse im Decoder, kein Internet, Schaden auf einen Lauf begrenzt. Das CronJob-Objekt trägt bewusst `app: apicore-voice-job` (nur das Pod-Template `app: apicore-job`), damit der gemeinsame Job-Patch keinen weiteren Container einsetzt; `av` ist wegen faster-whisper 1.2.1 auf 18.1.0 festgelegt. Kosten bei falscher Annahme: Abschriften eines Laufs könnten bei einer Lücke im Decoder abfließen oder vertauscht werden. Stärkere Optionen später: eigener Pod für den Decoder mit deny-all-NetworkPolicy (auch ohne DNS) und Übergabe-Volume, oder ein Subprozess je Aufnahme mit eigener Sandbox.
|
||||
107. **Speichern der Durchsicht bestätigt eine Sprachnotiz in jedem offenen Zustand**: Auch eine Notiz, die noch abgeschrieben oder ausgewertet wird, wird mit dem Speichern `CONFIRMED` und ihr Inhalt in derselben Transaktion gelöscht; ein laufender Job verliert seinen Anspruch. Grund: Wer speichert, hat entschieden, und Inhalte sollen nie liegen bleiben. Kosten bei falscher Annahme: Eine noch laufende Abschrift oder Auswertung geht verloren, wenn der Nutzer vorher speichert; die Notiz lässt sich nicht mehr nutzen und muss neu aufgenommen werden. Abschnitt 14.2.
|
||||
108. **Eine Dezimalzahl bleibt beim Zitatabgleich ein Wort**: `4,5` und `4.5` gelten als dieselbe Zahl und werden nicht an Komma oder Punkt zerlegt, sonst ließe sich ein Zitat aus Zahlenresten zusammensetzen, das nie gesagt wurde. Kosten bei falscher Annahme: Ein Zitat mit anderer Schreibweise der Dezimalzahl trifft die Abschrift nicht, ein gültiger Vorschlag wird verworfen (der Nutzer trägt von Hand ein). Abschnitt 14.4.
|
||||
109. **Wiederholen wird über Grenzen gebremst, nicht über eine Zahl je Notiz**: Jedes Wiederholen reiht die Notiz neu ein und kann einen bezahlten Aufruf auslösen. Gebremst wird es durch die Sperre je Nutzer, die Grenze von 10 offenen Notizen und höchstens 5 Anfragen je Minute und Netz; eine Obergrenze je Notiz gibt es nicht (sie bräuchte eine Schemaänderung). Kosten bei falscher Annahme: Ein Nutzer mit einer dauerhaft kaputten Notiz kann sie wiederholt neu starten und Tagesbudget verbrauchen; Abhilfe wäre ein Zähler für manuelle Wiederholungen mit Migration. Abschnitt 14.1.
|
||||
110. **Das Tagesbudget zählt bezahlte Aufrufe im Protokoll, nicht Notizen**: `VoiceExtractionCall` hält jeden Aufruf mit Ausgang; `AUTH` und `ABORTED` zählen nicht, abgelehnte, abgeschnittene, ungültige und wiederholte Aufrufe schon, weil sie Geld kosten. Die Tabelle ist nicht im Backup und wird nach 90 Tagen gelöscht. Kosten bei falscher Annahme: Bei vielen Fehlversuchen ist das Budget früher erschöpft, als die Zahl der Notizen vermuten lässt, und Notizen warten bis zum nächsten Tag; eine weitere Tabelle ist zu pflegen. Abschnitt 14.4.
|
||||
111. **Das Tagesbudget wechselt um 00:00 UTC**: Die Grenze ist der UTC-Tag der Datenbankuhr (wie die 30 neuen Notizen je Tag), also 01:00 oder 02:00 Uhr in Berlin. Kosten bei falscher Annahme: Wer in der Nacht arbeitet, sieht das Limit nicht zum Berliner Tageswechsel zurückgesetzt; für einen Betrieb mit Tagesarbeit ohne Folgen, sonst ein Wechsel der Grenze auf den Berliner Tag. Abschnitt 14.4.
|
||||
112. **Eine von selbst beendete Aufnahme wird sofort gespeichert**: Erreicht die Aufnahme 180 s, geht sie ohne Rückfrage in die Geräte-Warteschlange; ein Verlassen der Seite verwirft sie nicht. Kosten bei falscher Annahme: Eine Aufnahme, die der Nutzer nicht wollte, liegt in der Warteschlange und wird hochgeladen, bis er sie verwirft; sie kostet Speicher und Abschreibzeit, aber keine Auswertung, solange er sie nicht öffnet. Abschnitt 14.6.
|
||||
113. **Der Schalter wirkt auch im Server**: Bei `VOICE_RECORDING_ENABLED=false` lehnen Upload und Text-Weg mit 403 `VOICE_RECORDING_DISABLED` ab, nicht nur die Oberfläche. Lesen, Wiederholen und Verwerfen bleiben. Kosten bei falscher Annahme: Notizen, die offline vor dem Ausschalten aufgenommen wurden, werden beim Sync abgelehnt und bleiben mit Fehler in der Warteschlange (der Nutzer verwirft sie); der Rückweg ist damit sauber, aber nicht verlustfrei. Abschnitt 14.1.
|
||||
114. **Kosten werden für die letzten 7 Tage gezeigt, zum Preis des eingestellten Modells**: Die Zähler der Systemseite kennen nur ein 7-Tage-Fenster, und die Preistabelle liegt in zwei Dateien (`lib/voice/prices.ts`, `src/voice/eval/voice-prices.ts`, ein Paritätstest). Kosten bei falscher Annahme: Der Inhaber liest eine Wochen-, keine Monatssumme; die echte Abrechnung steht in der Anthropic-Konsole. Abschnitt 14.5.
|
||||
115. **Eine abgelehnte Anfrage darf serverseitig auf einem anderen Modell laufen**: Mit `VOICE_SERVER_FALLBACK=default` (Voreinstellung) setzt der Adapter für Sonnet und Opus den Ausweichweg von Anthropic; gebucht wird das tatsächlich antwortende Modell. Kosten bei falscher Annahme: Eine Notiz wird auf einem anderen, womöglich teureren Modell beantwortet als eingestellt, und das Tagesbudget zählt Aufrufe, nicht Euro; abschaltbar mit `off`, dann endet die Notiz bei einer Ablehnung `FAILED/REFUSED`. Abschnitt 14.4.
|
||||
116. **Der Text einer Sprachnotiz darf zu Anthropic (Drittland)**: Der Inhaber hat den API-Schlüssel angelegt (2026-10-16); das gilt als Freigabe von Spec-Frage 3. Modell `claude-sonnet-5-5` mit `effort: low` (Frage 1); die Cloud-Erkennung des Browsers bleibt ohne Einwilligung aus (Frage 2). Eine Region ist nicht gesetzt (`inference_geo` bleibt aus), es gilt die Voreinstellung des Anthropic-Arbeitsbereichs. Kosten bei falscher Annahme: Fehlt die Freigabe, bleibt der Worker ohne das Secret `apicore-anthropic`, die Notizen bleiben `TRANSCRIBED` und sind als Text nutzbar; Datenschutzerklärung und Verzeichnis müssen die Übermittlung vor dem Einschalten nennen. Abschnitt 14.7.
|
||||
117. **Die Grenzen der Vorschläge folgen dem Formular, nicht der Spec**: Volksstärke bis 40 und fünf Futterstufen, wie `CreateInspectionDto` und das Formular; für `broodFrames` ohne Obergrenze im Formular gilt im Sprachweg 40. Kosten bei falscher Annahme: Die Auswertung schlägt Werte vor, die der Autor der Spec nicht erwartet hat, oder verwirft zulässige (sie landen als Zahl in `dropped`); die Grenzen stehen an einer Stelle (`core/inspection-field-specs.ts`). Abschnitt 14.4.
|
||||
118. **Meldungen zu Sprachnotizen gehören in die Kategorie „Vorschläge“**: „Sprachnotiz ist fertig“ und „konnte nicht ausgewertet werden“ haben die Kategorie `SUGGESTION`, weil sie zu Vorschlägen führen. Kosten bei falscher Annahme: Wer die Kategorie „Vorschläge“ in Telegram oder der App abschaltet, bekommt auch diese Meldungen nicht mehr; die Notiz bleibt unter `/voice` sichtbar. Abschnitt 14.5.
|
||||
|
||||
## 7. Kubernetes
|
||||
|
||||
@@ -223,7 +253,8 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
||||
- `pod-hardening`: Frontend mit `readOnlyRootFilesystem`, `emptyDir` auf `/tmp` und `/app/.next/cache`, ohne Service-Account-Token, Ressourcen laut Spec §7.
|
||||
- `backend-roles`: `APICORE_ROLE=api` samt initContainern `migrate` und `seed` im Deployment `apicore-backend`, Deployment `apicore-worker`, CronJobs `apicore-job-<name>`. Das neue Backend-Image (CMD nur `node dist/main.js`) geht nur zusammen mit dieser Komponente live. Ohne `APICORE_ROLE` bricht es den Start in Produktion ab, und ohne die initContainer liefe keine Migration.
|
||||
- `api-hpa`: HPA `apicore-backend` (`autoscaling/v2`, 1–2 Pods bei 70 % CPU); entfernt `replicas` aus dem Deployment.
|
||||
- `overlays/encry/`: Namespace `apicore`, ConfigMaps, Ingresses (Traefik, Middleware `apicore-cloudflare-only@kubernetescrd`, cert-manager `letsencrypt-prod` mit HTTP-01), Image-Digests. Hosts, ConfigMap-Werte und Digests sind bis zum Rollout Platzhalter (`example.com`, `sha256:000…`).
|
||||
- `overlays/encry/`: Namespace `apicore`, ConfigMaps, Ingresses (Traefik, Middleware `apicore-cloudflare-only@kubernetescrd`, cert-manager `letsencrypt-prod` mit HTTP-01), Image-Digests. Die ConfigMap-Werte sind seit 2026-10-15 die Live-Werte aus dem Cluster (Runbook Schritt 1, Commit `6e3e9ae`). Hosts und TLS in `ingress-*.yaml` und die Image-Digests sind bis zum Rollout noch Platzhalter (`example.com`, `sha256:000…`).
|
||||
- `scripts/ensure-secrets.sh`: ergänzt fehlende Schlüssel in `apicore-backend-secret` und `apicore-telegram`, ohne vorhandene Werte zu ändern, und gibt nur Schlüsselnamen aus (`--dry-run` zeigt nur an). Die Secrets selbst legt der Inhaber an.
|
||||
- `tests/`: `manifest-policy.test.mjs` prüft das gerenderte Overlay (NetworkPolicies nach Kubernetes-Semantik, Pod-Härtung, Ressourcen, CronJobs, Migration, Digests, Live-Selektoren) und jeden Rollout-Schritt (DNS, Ingress, api → Postgres und alle DB-Clients bleiben erreichbar). `render-stages.mjs` rendert die Schritte 1, 2, 3 und 5 aus dem Overlay. Die CI (`.gitea/workflows/manifests.yml`, nur `push` und `workflow_dispatch`, kein `pull_request`, weil der Runner im Cluster läuft) rendert mit `kubectl kustomize`, validiert mit `kubeconform -strict` und führt den Test aus; kubectl, kubeconform und yq mit festen Versionen und SHA-256.
|
||||
- **Telegram-Secret.** `apicore-telegram` (Schlüssel `TELEGRAM_BOT_TOKEN`) bindet nur `apicore-worker` ein: als einzelne `secretKeyRef`-Variable mit `optional: true` am Haupt-Container, ohne `envFrom`, ohne Volume, nicht im initContainer. Die api bekommt das Secret nie. Fehlt es, startet der Worker normal, nur die Telegram-Schleifen laufen nicht. `TELEGRAM_BOT_USERNAME` steht in der ConfigMap `apicore-backend-config` (api und Worker lesen sie per `envFrom`; leerer Platzhalter, bis der Bot angelegt ist); das Token steht nie in einer ConfigMap. `manifest-policy.test.mjs` prüft für den Zielstand und jede Rollout-Stufe mit Worker: genau ein Worker-Pod, genau eine Token-Variable ohne Klartextwert, kein anderer Pod mit `TELEGRAM_BOT_TOKEN`. Einrichtung bei BotFather: je Umgebung ein eigener Bot, Gruppen aus (`/setjoingroups` → Disable), kein Webhook (der Worker ruft `getUpdates`). Der Worker läuft mit `replicas: 1`, weil der Versand das annimmt (Annahme 46); Claims und Empfangs-Lease bleiben auch mit mehr Pods korrekt.
|
||||
|
||||
@@ -269,7 +300,7 @@ initContainer haben die Ressourcen der CronJob-Pods. Alle App-Pods laufen ohne S
|
||||
|
||||
**Images:** Das Base-Image `node:22-alpine` ist in beiden Dockerfiles per Digest gepinnt; ein Update ist ein bewusster Commit mit neuem Digest. Die CI taggt nur noch `sha-<commit>` und Versionen, kein `latest`. Das Overlay setzt die Digests der gebauten Images. Bis Schritt 3 bleibt beim Backend der Digest des laufenden Images.
|
||||
|
||||
**Rollout:** nur nach dem Runbook im Plan `docs/superpowers/plans/2026-10-13-plattform-rueckgrat.md`, jeder Schritt mit Freigabe des Inhabers: 0 Digests, 1 Overlay auf den Ist-Stand (Diff leer), 2 NetworkPolicies, 3 Rollen und Pod-Härtung, 4 Schlüssel trennen, 5 HPA.
|
||||
**Rollout:** nur nach dem Runbook im Plan `docs/superpowers/plans/2026-10-13-plattform-rueckgrat.md`, jeder Schritt mit Freigabe des Inhabers: 0 Digests, 1 Overlay auf den Ist-Stand (Diff leer), 2 NetworkPolicies, 3 Rollen und Pod-Härtung, 4 Schlüssel trennen, 5 HPA. Stand 2026-10-16: Im Cluster wurde noch kein Schritt angewendet; vorbereitet sind nur die ConfigMap-Werte aus Schritt 1. Live laufen das Backend-Image `sha-f5b1964` und ein Frontend-Image vom 2026-10-12, im Namespace gelten nur die drei Policies aus `rke2-cluster`. Fortschritt: Gitea-Issue „Rollout in den Cluster“ (#28).
|
||||
|
||||
## 8. Backup & Restore
|
||||
|
||||
@@ -283,7 +314,7 @@ Der Seed (`node dist/seed.js`) läuft als zweiter initContainer `seed` nur im `a
|
||||
|
||||
Scheitert die Migration, scheitert der initContainer und der neue Pod startet nicht. Wegen `maxSurge: 1` und `maxUnavailable: 0` läuft der alte Pod weiter. Die Hauptprozesse starten ohne Prisma-CLI. Die `startupProbe` der api (bis 5 Minuten) bleibt für das alte Image, das die Migration noch im `CMD` ausführt.
|
||||
|
||||
**Erster Admin-Zugang:** `prisma/seed.ts` legt beim allerersten Start (leere `User`-Tabelle) einen Administrator an (`SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD`, Default `[email protected]`/`ChangeMe123!`) und gibt URL (`FRONTEND_ORIGIN`), Benutzername und Passwort einmalig im Container-Log aus. Existiert der User schon (auch deaktiviert), passiert nichts – kein erneutes Log, kein erneutes Anlegen, damit spätere Passwortänderungen nicht durch veraltete Logs verwirrend überschrieben wirken und Zugangsdaten nicht bei jedem Neustart in die Logs wandern. Ersetzen: neuen Admin über `/settings/users` anlegen, dann den alten deaktivieren (Löschen gibt es nicht, siehe "Historie statt Löschen") – die Lockout-Sperre in `UsersService.assertNotLastSystemRoleUser` verhindert ohnehin, den letzten aktiven Admin zu deaktivieren.
|
||||
**Erster Admin-Zugang:** `prisma/seed.ts` (`seedAdmin`) legt nur an, solange die `User`-Tabelle leer ist, also beim allerersten Start. Adresse aus `SEED_ADMIN_EMAIL` (Standard `[email protected]`), Passwort aus `SEED_ADMIN_PASSWORD`; fehlt es, erzeugt der Seed ein zufälliges. Ins Container-Log kommen einmalig URL (`FRONTEND_ORIGIN`), Benutzername und nur das erzeugte Passwort; ein vom Betrieb gesetztes Passwort erscheint nie im Log. Sobald irgendein Benutzer existiert, passiert nichts – auch nicht, wenn der Administrator seine Adresse geändert hat (früher entstand dann bei jedem Start ein weiterer ADMIN, #45). Ersetzen: neuen Admin über `/settings/users` anlegen, dann den alten deaktivieren (Löschen gibt es nicht, siehe "Historie statt Löschen") – die Lockout-Sperre in `UsersService.assertNotLastSystemRoleUser` verhindert ohnehin, den letzten aktiven Admin zu deaktivieren.
|
||||
|
||||
**Datensicherung:** Seit der Entscheidung, PostgreSQL selbst im Cluster zu betreiben (siehe CLAUDE.md), liegt die Backup-Verantwortung bei diesem Projekt, nicht mehr bei einem externen Cluster-/DBA-Betrieb. Umgesetzt als anwendungsseitiges `BackupModule` (`ApiCore-Backend/src/backup/`) statt der ursprünglich hier vorgesehenen reinen `pg_dump`/PITR-Strategie:
|
||||
|
||||
@@ -308,6 +339,7 @@ Grundlage: OWASP API Security Top 10 (2023) und OWASP ASVS Level 2. Umsetzung un
|
||||
| Ausgehende Aufrufe | Webhooks: nie aus der Anfrage heraus gesendet, sondern über die Zustelltabelle `WebhookDelivery` (Warteschlange und Zustellprotokoll), die der Worker abarbeitet; Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, keine Weiterleitungen, 5 s Leerlauf-Timeout und 10 s Frist je Versuch (danach wird die Verbindung abgebrochen); nur eine `2xx`-Antwort gilt als zugestellt, sonst Wiederholung nach der Outbox-Regel (30 s bis 6 h, nach 8 Versuchen `FAILED`); stabile Delivery-ID (`X-ApiCore-Delivery` = `WebhookDelivery.id`, mit gleichem Body über alle Wiederholungen); Signatur über Zeitstempel+ID+Body; Antwortkörper wird nie gelesen, `lastError` nur über `loggableError()` |
|
||||
| Wetter und Telegram | Wetter (Open-Meteo) nur aus der Worker-Schleife `phenology-weather` und der Wetter-Anzeige, über `src/weather/`: nur https-URLs aus der Konfiguration, keine Weiterleitungen (`redirect: 'error'`), 8 s Frist, Antwort höchstens 1 000 000 Byte, Fehlertexte fest (kein URL, keine Ursache, kein Schlüssel). Telegram (`api.telegram.org`) nur aus Worker-Schleifen über `TelegramClient` (`src/telegram/`): Klartext ohne `parse_mode`, Linkvorschau aus, Text höchstens 600 Zeichen ohne Steuer- und Richtungszeichen, keine Weiterleitungen, Antwort höchstens 256 kB; der Token steht nie in Logs oder Fehlern (`TelegramApiError` trägt Methode, Status, bereinigte Beschreibung, `retry_after` 1–3600 s, keine URL). Wächter `src/common/no-network-outside-loops.spec.ts`: Wetter-Hosts nur in `src/weather/`, `api.telegram.org` nur in `src/telegram/`, `fetch(` und Netzwerkpakete nur in den erlaubten Dateien (Graph-Client, Passwort-Prüfung, Webhook-Zustellung, Worker-Health, Wetter, Telegram), nie in Handlern oder Jobs; `handler-contract.spec.ts` verbietet den Import von `telegram-client` in Handlern |
|
||||
| Transport/Antworten | Alle API-Antworten tragen `Cache-Control: no-store` und `Pragma: no-cache`; einzige Ausnahme ist die erfolgreiche (200) öffentliche Herkunftsantwort `GET /public/batches/:lot` mit `Cache-Control: public, max-age=300, s-maxage=3600` (ohne Pragma, am Cloudflare-Edge cachebar), Fehlerantworten bleiben `no-store`; `Cache-Control: no-store` (außer öffentliche Chargen-Seite), CSP `default-src 'none'`, `frame-ancestors 'none'`, keine ETags; Produktion startet nur mit https-`FRONTEND_ORIGIN` und sicheren Cookies |
|
||||
| Frontend-CSP | `src/proxy.ts` setzt für die Oberfläche `script-src 'self' 'unsafe-inline'` und `style-src 'self' 'unsafe-inline'` (dazu u. a. `media-src 'self' blob:` für die eigene Aufnahme). `'unsafe-inline'` ist ein getragenes Risiko: Next.js liefert eigene Inline-Bootstrap-Skripte, und sobald `script-src` einen Hash oder Nonce enthält, ignorieren Browser `'unsafe-inline'` und blockieren diese; ein Nonce würde jede Seite dynamisch rendern. Das Darstellungs-Skript im `<head>` (T5 A2) ist eine Konstante ohne Nutzerdaten. Wird die CSP verschärft, Hash für `appearanceScript()` ergänzen. |
|
||||
| Rate-Limits | Throttler vor der Authentifizierung, Zähler in der UNLOGGED-Tabelle `ThrottlerHit` und damit über alle Pods geteilt (eine Upsert-Anweisung, ausschließlich DB-Uhr `now()`; 100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über `TRUST_PROXY` (IPv6 je /64); bei DB-Fehler schlägt die Anfrage fehl (fail-closed) – nur die Liveness `GET /health` ist ausgenommen, damit ein DB-Ausfall nicht alle Pods neu startet; abgelaufene Zeilen räumt der Job `cleanup` auf |
|
||||
| Daten | Backups verschlüsselt und signiert, Dateien 0600; CSV-Export ohne Formel-Injection; Logs ohne Query-Strings |
|
||||
|
||||
@@ -469,6 +501,8 @@ Das Ergebnis zählt jede Kategorie (`idempotencyRecords`, `consumedTokens`, `thr
|
||||
|
||||
**`rekey`** (nur von Hand, CronJobs `apicore-job-rekey` und `apicore-job-rekey-dry-run` mit `suspend: true`): siehe Annahme 22.
|
||||
|
||||
**`voice-transcribe`** (CronJob alle 2 Minuten, Phasen `--phase=fetch` und `--phase=ingest` um den Whisper-Container): Abschnitt 14.3.
|
||||
|
||||
**Tests.** `test/jobs.e2e-spec.ts` ruft `runCli([...])` mit dem echten `JobsModule` auf, wie `node dist/cli.js`. `test/daily-evaluation.e2e-spec.ts` ruft `TimeEventsStep.run(Tag)` mit Tagen in den Jahren 2001 und 2002 auf: Der Schritt wertet die ganze geteilte Test-DB aus, so bleiben die Ereignisse paralleler e2e-Dateien unberührt (und es entstehen keine `JobRun`-Zeilen, die `jobs.e2e-spec.ts` zählt). `test/rekey.e2e-spec.ts` setzt nur `*_PREVIOUS`, weil der Job jede Zeile der geteilten Test-DB besucht und die anderen e2e-Prozesse mit denselben aktuellen Schlüsseln weiterlesen müssen.
|
||||
|
||||
### System-Seite
|
||||
@@ -736,7 +770,7 @@ Logs nennen nur Zustellungs-IDs, nie Token, Chat-ID oder Text.
|
||||
|
||||
Prüfbereitschaft für Tierarzneimittel, Etiketten und Behörden. Rechtswerte (Fristen, Paragraphen, Ziffernhöhen, x-Höhe, Landesrecht Bayern) stehen nie im Code, sondern sind Einstellungen mit dem Vermerk „Annahme – vom Inhaber zu prüfen“; sie sind vor dem Rollout gegen gesetze-im-internet.de und EUR-Lex abzugleichen. Belege, Rechnungsnummern, Anschriften, Koordinaten und Aktenzeichen sehen nur Inhaber von `compliance:read` bzw. `veterinary-medicines:read`; sie erscheinen nie in öffentlichen Endpunkten, im Shop-Feed, in Webhooks oder in Ereignis-Payloads. Entscheidungen des Inhabers vom 2026-10-15: eigene Meldungs-Kategorie `COMPLIANCE`, Stand vor Volk beim Sperren, kein Step-up für die Ausnahme vom Sperrbezirk.
|
||||
|
||||
**Stand:** Umgesetzt sind Task 1 (Schema, Rechte, Sperren, Backup), 2 (Tierarzneimittel), 3 (Behandlung aus Packung), 4 (Dokumente), 5 (Etikett-Pflichtangaben), 6 (Druck-Sperre und Probedruck), 7 (Ereignisse), 8 (Sperrbezirk und Sperr-Umbau der Völker) und 9 (Behördenpaket I), 10 (Anzeigen-PDFs), 11 (Hinweise und Tagesauswertung) und 12 (Prüfbereitschaft I), 13 (Prüfbereitschaft II), 14 (Oberfläche Prüfbereitschaft), 15 (Oberfläche Tierarzneimittel) und 17 (Oberfläche Etikett). Task 1 bis 15 und 17 sind erledigt; Task 16, 18 und 19 (Behandlungsmaske, Anzeigen- und Sperrbezirk-Oberfläche, Abschluss) folgen und ergänzen diesen Abschnitt.
|
||||
**Stand:** Umgesetzt sind Task 1 (Schema, Rechte, Sperren, Backup), 2 (Tierarzneimittel), 3 (Behandlung aus Packung), 4 (Dokumente), 5 (Etikett-Pflichtangaben), 6 (Druck-Sperre und Probedruck), 7 (Ereignisse), 8 (Sperrbezirk und Sperr-Umbau der Völker) und 9 (Behördenpaket I), 10 (Anzeigen-PDFs), 11 (Hinweise und Tagesauswertung) und 12 (Prüfbereitschaft I), 13 (Prüfbereitschaft II), 14 (Oberfläche Prüfbereitschaft), 15 (Oberfläche Tierarzneimittel) 17 (Oberfläche Etikett), 16 (Behandlungsmaske), 18 (Anzeigen und Einstellungen) und 19 (Oberfläche Sperrbezirk). Task 1 bis 19 sind erledigt (T6a Backend: Task 1 bis 13; T6b Frontend: Task 14 bis 19). Offen sind die Browserprüfung aller T6-Seiten, die Rechtswerte-Prüfung durch den Inhaber und das Ausrollen (TASKS.md).
|
||||
|
||||
### 12.1 Schema und Rechte
|
||||
|
||||
@@ -830,7 +864,7 @@ Vier neue Typen im Katalog (`domain-event-types.ts`, 38 Typen), Aggregattyp `Com
|
||||
- **Sperrmuster und 409 „neu laden“.** Die Pfade lesen die Stand-Ids ungesperrt, sperren die Stände `FOR SHARE` aufsteigend, dann die Völker (beim Vereinigen Überlebendes und Aufgelöstes in einer Anweisung), vergleichen unter der Sperre den Stand des Volkes mit dem gelesenen (sonst 409 „Der Stand wurde gerade geändert. Bitte neu laden.“, kein automatischer Neuversuch; ein verschwundenes Volk ist 404), lesen `assertApiaryActive` über `tx` und prüfen erst dann die Sperre. `ApiaryRestrictionsService` (`POST`, `PATCH`) sperrt die Stände `FOR UPDATE`: Ein laufendes Umsetzen und eine neu beginnende Sperre reihen sich hintereinander ein, eine Sperrenänderung wartet auf laufende Umsetzungen. `updateIn` nimmt selbst keinen Standlock; ein Patch mit Standwechsel ohne den Wächter aus `update()` wirft (Programmierfehler). Die Durchsicht-Übernahme (ohne `apiaryId`) sperrt nur das Volk.
|
||||
- **Endpunkte.** `POST /apiary-restrictions` (`compliance:manage`, `@Idempotent`, 1 bis 50 verschiedene Stände, je Stand eine Zeile und ein Audit, eine Transaktion, unbekannter Stand 404 mit Rollback, `endsOn >= startsOn` sonst 400), `PATCH /apiary-restrictions/:id` (Stand `FOR UPDATE`, dann Sperre, `before` danach, `endsOn: null` öffnet wieder), `GET /apiary-restrictions?active=true|false`, `GET /apiaries/:id/restrictions` (`apiaries:read`).
|
||||
- **Einzelheiten nur mit `compliance:read`:** Behörde, Aktenzeichen, Notiz und Ersteller liefern beide GET-Pfade nur dann; mit nur `apiaries:read` besteht eine Zeile aus `id`, `apiaryId`, `disease`, `startsOn`, `endsOn`. `active=true` liefert für alle die schlanke `ActiveRestrictionInfo` (ohne diese Felder). Öffentliche Seiten und der Shop-Feed nennen weder Seuche noch Sperre (e2e mit echter öffentlicher Charge).
|
||||
- Das Frontend (Task 18/19) bietet den Ausnahmedialog nur mit `compliance:manage` an und sendet `restrictionOverrideReason`.
|
||||
- Das Frontend (Task 19, Abschnitt 12.11) bietet den Ausnahmedialog nur mit `compliance:manage` an und sendet `restrictionOverrideReason`.
|
||||
|
||||
### 12.8 Behördenpaket I: Einstellungen, Bestandsbild, Anzeigen
|
||||
|
||||
@@ -908,11 +942,11 @@ Task 12 liefert den Rahmen der Prüfbereitschaft mit den ersten sieben Regeln, T
|
||||
|
||||
### 12.11 Oberfläche (Frontend)
|
||||
|
||||
Die Oberfläche des Compliance-Blocks entsteht in mehreren Tasks; dieser Stand umfasst Prüfbereitschaft (Task 14), Tierarzneimittel (Task 15) und Etikett (Task 17). Behandlungsmaske (Task 16) sowie Anzeigen, Sperrbezirke und Einstellungen (Task 18, 19) folgen. Alle Aktionen sind online-only: Auslöser sind offline deaktiviert und nennen den sichtbaren Grund „Nur mit Verbindung“ (`aria-describedby`). Der Service-Worker-Ausschluss steht in Abschnitt 2.
|
||||
Die Oberfläche des Compliance-Blocks entstand in Task 14 bis 19: Prüfbereitschaft (14), Tierarzneimittel (15), Behandlungsmaske (16), Etikett (17), Anzeigen und Einstellungen (18) und Sperrbezirk (19). Alle Aktionen sind online-only: Auslöser sind offline deaktiviert und nennen den sichtbaren Grund „Nur mit Verbindung“ (`aria-describedby`). Der Service-Worker-Ausschluss steht in Abschnitt 2.
|
||||
|
||||
**Prüfbereitschaft (Task 14).** Code in `lib/compliance/` (`types`, `api`, `fix-links`, `errors`, `labels`, `use-readiness`; Query-Schlüssel `['compliance', …]`) und `app/(app)/compliance/`.
|
||||
- **Seite `/compliance`** zeigt `GET /compliance/readiness`. Oben der Status: „Prüfbereit“ (grün) oder „n offen“ (rot), jeweils mit Symbol und Wort. **Jeder offene Punkt ist rot** (Token `destructive`) und trägt Symbol, das Wort „Offen“, die **Schwere als Wort „Pflicht“ oder „Prüfen“** und den Titel; dazu ein Satz Detail und klein die Norm. Betroffene Objekte stehen eingeklappt mit einem „Beheben“-Link je Objekt, „und n weitere“ für den Rest; fehlt das Recht auf die Namen, steht nur „n betroffen“. Erledigte Punkte stehen eingeklappt unter „Erledigt (n)“, Quittierte ruhig mit Nutzer, Datum und Notiz. Die Zuordnung Schwere → Wort und offen → rot liegt allein in `labels.ts` und hat einen eigenen Test. Hauptaktion ist „Nächsten Punkt lösen“ (erster offener Pflicht-Punkt); ist dessen Ziel noch nicht gebaut, heißt sie „Nächsten Punkt ansehen“ und springt auf die Karte (`#item-<Schlüssel>-<Subjekt>`). Bei grünem Status steht dort „Bestandsbuch {Jahr} drucken“. Hinter „Mehr“ liegen der Bericht (PDF über `downloadReadinessReport`, als Blob mit Bearer-Header, Dateiname `pruefbericht.pdf`), das Bestandsbuch, Anzeigen, Sperrbezirke und Einstellungen. Downloads sind offline aus.
|
||||
- **Beheben-Links aus einer Stelle:** `fixLinkFor(itemKey, subject)` in `fix-links.ts` bildet Subjekte auf Seiten ab (Typen ohne Beachtung der Groß-/Kleinschreibung). Die Ziele `/compliance/notices(/[id])`, `/compliance/restrictions` und `/compliance/settings` gibt es erst mit Task 18/19; bis dahin blendet die Menge `ROUTE_AVAILABLE` solche Links aus (`available: false`), und die jeweilige Task trägt ihre Route dort ein. Eine Behandlung verlinkt auf `/colonies/{parentId}`, Stammdaten auf `/settings/automation?tab=social-media` (Reiter „Stammdaten & Web“). `herd-change` führt zur Anzeige (`/compliance/notices`), nicht zur Sperrenliste; `restriction-active` ohne Subjekt zur Sperrenliste.
|
||||
- **Beheben-Links aus einer Stelle:** `fixLinkFor(itemKey, subject)` in `fix-links.ts` bildet Subjekte auf Seiten ab (Typen ohne Beachtung der Groß-/Kleinschreibung). Die Ziele `/compliance/notices(/[id])`, `/compliance/restrictions` und `/compliance/settings` schaltet `ROUTE_AVAILABLE` seit Task 18 und 19 ein (vorher blendete die Menge solche Links aus, `available: false`). Eine Behandlung verlinkt auf `/colonies/{parentId}`, Stammdaten auf `/settings/automation?tab=social-media` (Reiter „Stammdaten & Web“). `herd-change` führt zur Anzeige (`/compliance/notices`), nicht zur Sperrenliste; `restriction-active` ohne Subjekt zur Sperrenliste.
|
||||
- **Quittieren und Zurücknehmen** (`acknowledge-dialog.tsx`): nur bei `acknowledgeable` und offenem Punkt bzw. quittiertem Punkt, nur mit `compliance:manage`. Notiz 10 bis 2000 Zeichen, vor dem Senden geprüft; Zurücknehmen über `ConfirmDialog`. Fehler laufen über `readinessConflictText`, der die drei Codes `READINESS_FINGERPRINT_STALE`, `READINESS_ITEM_NOT_OPEN` und `READINESS_ACK_REVOKED` mit je eigenem deutschem Text auswertet (der Servertext ist nur Rückfall); bei jedem 409 laden alle `compliance`-Queries neu. Nach Erfolg und Neuladen geht der Fokus auf die Kartenüberschrift (`tabIndex=-1`), eine `role="status"`-Region sagt „Punkt quittiert“ bzw. „Quittung zurückgenommen“. `lib/compliance/errors.ts` liest außerdem `DRAFT_EXISTS` und `RESTRICTED_ZONE`, alles andere läuft über `saveErrorText`; es gibt keine zweite Fehlerlogik.
|
||||
- **Dashboard-Zeile** (`components/today/compliance-line.tsx`): „n Punkte offen in der Prüfbereitschaft“, nur mit `compliance:read` und `openCount > 0`, aus `GET /compliance/readiness/summary` (60 s gecacht, Annahme 86). Schlägt das Laden fehl (kein Netzfehler; offline bleibt es still), steht eine **Fehlerzeile** „Prüfbereitschaft nicht geladen.“ mit „Erneut versuchen“.
|
||||
- **Navigation:** `nav-config.ts` hat „Prüfbereitschaft“ (Symbol `ShieldCheck`, Gruppe „Betrieb“, Recht `compliance:read`).
|
||||
@@ -933,3 +967,181 @@ Die Oberfläche des Compliance-Blocks entsteht in mehreren Tasks; dieser Stand u
|
||||
- **Druckkarte der Abfüllung** (`bottling/[id]/print-labels-card.tsx`): Vor dem Anzeigen läuft `GET /label-templates/:id/check/:bottlingBatchId` (`gcTime: 0`, keine Persistenz, `cache: "no-store"`). Bei Lücken zeigt sie die **rote Lückenliste** (Region, Symbol und „Fehlt:“) mit Links zu Stammdaten, Produkt und Designer (der Produktlink entfällt ohne Produkt an der Abfüllung); „Etiketten als PDF“ ist dann `aria-disabled` (fokussierbar, Grund per `aria-describedby`). Der **Probedruck** („Probedruck (MUSTER)“, `proof=true`) bleibt immer möglich; sein Dateiname enthält „muster“, der des echten Drucks nicht. Antwortet der Druck mit 409 `LABEL_PARTICULARS_MISSING`, zeigt die Karte die Liste `gaps` des Servers und **setzt den Fokus darauf**. 409 `COMPLIANCE_SETTINGS_INVALID` und 403 erscheinen als deutsche Meldung (`labelCheckErrorText`). Ein zweiter Klick während der Wartezeit löst keinen zweiten Aufruf aus. Ohne `label-templates:read` steht nur ein Hinweis, ohne `:print` gibt es keine Druckknöpfe, ohne Netz „Nur mit Verbindung“. Prüf- und Druckaufrufe laufen mit `cache: "no-store"` (`apiFetchBlob` reicht `init` durch).
|
||||
- **Stammdaten.** Im Reiter „Stammdaten & Web“ (Verwaltung > Automatisierung) stehen `foodBusinessName` (200) und `foodBusinessAddress` (300); **leere oder nur aus Leerzeichen bestehende Felder werden als `null` gesendet** (`value.trim() || null`), damit sie serverseitig löschen. „Vom Bienenhalter übernehmen“ füllt nur die Felder, ohne zu speichern. Der Text nennt, dass sie weder im Shop noch auf der Herkunftsseite erscheinen.
|
||||
- **Produkt `legalName`.** Das Feld „Bezeichnung auf dem Etikett (z. B. Blütenhonig)“ steht im Typ `Product`, im Neu-Formular, im Bearbeiten-Dialog und auf der Detailseite; Platzhalter-Schlüssel und deutsche Labels liegen in `lib/label-templates/placeholder-keys.ts` (Wächter-Spec gegen `test/fixtures/placeholder-keys.json`).
|
||||
|
||||
**Behandlung mit Mittel, Packung und Dosis (Task 16).** Formular „Neue Behandlung“, Dialog „Behandlung bearbeiten“ und Sammelerfassung.
|
||||
- **Mittel.** „Aus dem Katalog wählen“ bietet die aktiven Mittel (nur mit `veterinary-medicines:read`; ohne das Recht gibt es nur „Anderes Mittel“, ohne Fehler); „Anderes Mittel“ ist die letzte Option mit dem Freitextfeld wie bisher. Ein Katalogmittel füllt Wirkstoff, Wartezeit, Methode und Dosis als sichtbaren, bearbeitbaren Vorschlag mit dem Vermerk „aus Katalog“. Er gewinnt gegen die Vorbelegung aus der letzten Behandlung, überschreibt aber nie, was der Nutzer getippt hat; der Server füllt nichts nach. Lade- und Fehlerzustand des Katalogs zeigt `PrefillStatus`, Speichern bleibt möglich.
|
||||
- **Packung.** Beim Anlegen wird die Packung mit dem frühesten Verfall vorgewählt (mit Hinweis); „Ohne Packung“ bucht nichts und wird nicht wieder überschrieben. **Beim Bearbeiten wählt die Oberfläche nie selbst eine Packung vor** (nach einem Mittelwechsel steht „Ohne Packung“, der Nutzer wählt; Annahme 92). Ist eine Packung gesetzt, stammen Chargennummer, Lieferant, Beleg und Tierarzt aus der Packung (Zeile „aus Packung: …“); die Nutzlast lässt diese Felder weg (der Server lehnt Text daneben mit 400 ab), `product` entfällt mit Katalogmittel. Über „Speichern“ steht die Buchungszeile („Bucht 30 ml je Volk ab (× 6 = 180 ml).“, in der Sammelerfassung mal Anzahl der Völker). Abgelaufene oder zu kleine Packungen warnen, blockieren nie. Der Temperaturhinweis („Heute 9–17 °C. Laut Katalog 12–25 °C.“) kommt aus `GET /apiaries/:id/weather` (`apiaries:read`) und entfällt still, wenn das Wetter fehlt.
|
||||
- **Dosis.** Dezimalfeld, kein `NumberStepper` (der kennt nur ganze Zahlen): Komma als Dezimalzeichen, mindestens 0,001, **höchstens 3 Nachkommastellen** („Höchstens 3 Nachkommastellen.“, Annahme 94), „1.000“ wird als mehrdeutig abgelehnt (Annahme 88).
|
||||
- **Mittelwechsel beim Bearbeiten.** Der Dialog nutzt dieselbe Katalog-Vorbelegung wie das Anlegen; was die Behandlung schon enthält, gehört dem Nutzer, nur leere Felder füllt der Katalog. Eine vorhandene Wartezeit wird beim Wechsel nie still ersetzt: Weicht sie vom Katalogwert des neuen Mittels ab (oder kennt der Katalog keinen), steht am Feld „Wartezeit stammt vom vorherigen Mittel – bitte prüfen.“, dazu der Knopf „aus Katalog übernehmen“ (nur mit Katalogwert) als ausdrückliche Übernahme. Eine Eingabe im Feld entfernt die Warnung.
|
||||
- **Patch.** `medicinePatch` sendet die Mittel-Gruppe (`product`, `medicineId`, `medicineLotId`, `doseQuantity`, vier Packungstexte) nur, wenn sich in ihr etwas geändert hat; eine reine Notiz-Änderung bleibt so immer möglich.
|
||||
- **Fehler und Deep-Link.** Anlegen und Dialog lesen Fehler über **einen** Leser, `saveErrorText` (kennt auch Feldfehler-Listen des Servers). `?editTreatment=<id>` an der Volksseite öffnet den Dialog dieser Behandlung; findet die geladene Liste die ID nicht, sagt eine Live-Region „Behandlung nicht gefunden.“.
|
||||
- **Entwurf wiederherstellen** (`treatments/new`): Ist das Mittel des Entwurfs nicht mehr im Katalog, nicht aktiv oder fehlt das Leserecht, fallen `medicineId` und `medicineLotId` weg, `product` bleibt als Freitext, und eine Statuszeile sagt „Das Mittel aus dem Entwurf ist im Katalog nicht mehr aktiv. Bitte wähle es neu.“ Ist die Katalogliste noch nicht geladen, wird nichts verworfen.
|
||||
|
||||
**Anzeigen und Einstellungen (Task 18).** Seiten unter `app/(app)/compliance/`, Fehlertexte über `complianceErrorText` (`lib/compliance/errors.ts`).
|
||||
- **Liste `/compliance/notices`:** Anzeigen ans Veterinäramt (Art, Jahr, Status mit Symbol und Wort, Völkerzahl, Stichtag), Filter nach Art und **Jahresfilter** (vier Ziffern, sonst Hinweis am Feld; Server-Query `year`). Ab 200 Einträgen steht der Hinweis „Es werden die neuesten 200 angezeigt.“ (Obergrenze der Liste).
|
||||
- **Anlegen:** „Anzeige anlegen“ (nur `compliance:manage`) öffnet einen Dialog (Erstanzeige, Bestandsänderung, TSK-Zählung mit Jahr; „Bereits angezeigt“ mit Datum legt direkt eine gesendete an). Der Aufruf trägt einen `Idempotency-Key`. Gibt es schon einen Entwurf, antwortet der Server 409 `DRAFT_EXISTS` mit `existingId`; der Dialog verlinkt auf den vorhandenen Entwurf.
|
||||
- **Detail `/compliance/notices/[id]`** mit drei sichtbaren Schritten (1. PDF öffnen, 2. ans Amt schicken, 3. hier bestätigen; je Symbol und Wort Jetzt/Danach/Erledigt). Der **PDF-Download** (authentifiziert, kein Token im Link) meldet in der `aria-live`-Region „PDF heruntergeladen. Schick es jetzt ans Amt.“ und setzt den Fokus auf die neue Hauptaktion. **„Als gesendet markieren“ ist im Entwurf immer erreichbar**: als zweite Aktion (Umrissknopf neben „Neu zählen“), nach dem PDF als Hauptaktion; so geht es auch nach einem Neuladen ohne erneutes PDF. Das Sendedatum und „Gesendet am“ nutzen den **Berliner Kalendertag** (`berlinCalendarDay`); ein geleertes Datum zeigt „Das Datum kann nicht entfernt werden.“ „Neu zählen“ und „Anzeige stornieren“ gibt es nur im Entwurf, „Wieder öffnen“ nur bei gesendeter Anzeige; Kopie und Eingangsbestätigung sind Dokumente der Art `AUTHORITY_NOTICE`. Mit nur `compliance:read` fehlen die Aktionsknöpfe. `complianceErrorText` übersetzt 403 („Dazu fehlt dir das Recht.“) und 5xx („Der Server hat einen Fehler gemeldet. Versuch es gleich noch einmal.“) und fügt eine 400-Antwort mit `message: string[]` zusammen; Nests Englisch erscheint nie.
|
||||
- **Einstellungen `/compliance/settings`:** Veterinäramt, Erinnerungs-Schalter (Völkerzahl, Stand), TSK-Stichtag und Meldefrist, Mitgliedsnummer. Jeder Rechts- und Fristwert trägt am Feld „Annahme – bitte prüfen“. Stichtag und Meldefrist (leer erlaubt) prüft der Client vor dem Senden auf `MM-TT` (`isValidMonthDay`, Tage je Monat im Nichtschaltjahr, „02-29“ ungültig; Hinweis „Bitte einen Tag im Format MM-TT eingeben, zum Beispiel 03-31. Den 29. Februar gibt es hier nicht.“, `aria-describedby`, kein Request). Unter „Erweitert“ stehen die gezählten Völkerarten und der **Editor der Ziffernregeln** (1 bis 10 Regeln; „Regel hinzufügen“ fügt vor der letzten ein, „Entfernen, Regel n“; Höhe 0,5 bis 20 mm, Obergrenzen > 0 und aufsteigend, die letzte Regel hat keine Grenze und geht als `null` hinaus). Die Schranken sind die Grenzen des Backends (`assertDigitRules`, Konstanten in `compliance-settings.service.ts`), keine Rechtswerte; Fehler stehen an der Zeile (`role="alert"`) und als Satz „Bitte die Ziffernhöhen unter „Erweitert“ prüfen.“, weil der Abschnitt eingeklappt sein kann. Ohne `compliance:manage` sind die Felder nur lesbar.
|
||||
- **Formular-Schutz:** Ein Hintergrund-Refetch setzt eine Eingabe nie zurück. Ohne Eingabe werden neue Serverdaten still übernommen; mit ungespeicherter Eingabe bleibt sie stehen, und „Daten wurden geändert – neu laden?“ mit „Neu laden“ erscheint. Nach eigenem Speichern wird der Zustand direkt übernommen.
|
||||
- **Meldungskategorie `COMPLIANCE`** („Behörden & Prüfung“) steht in `lib/notifications/labels.ts` und damit in der Kategorienauswahl der Telegram-Karte. Die Glocke zeigt dafür das Kurz-Etikett „Behörden“ (Annahme 93).
|
||||
|
||||
**Sperrbezirk (Task 19).** Aktive Sperren lädt jede Seite einmal (`useActiveRestrictions`, Schlüssel `['compliance','restrictions','active']`, `staleTime` 30 s, Neuladen beim Fokus; Umsetzen- und Vereinigen-Dialog laden beim Öffnen neu). Der Server entscheidet endgültig, der Browser blendet nur früh aus.
|
||||
- **Seite `/compliance/restrictions`** (Recht `compliance:read`, Pflege mit `compliance:manage`; ohne Leserecht `NoComplianceAccess` „Dazu fehlt dir das Recht.“ und keine Abfragen) mit drei Abschnitten **Aktiv / Geplant / Beendet** (Symbol und Wort; `restrictionState` rechnet nach dem Berliner Kalendertag, eine Sperre mit Beginn in der Zukunft steht unter „Geplant“, nie unter „Beendet“). Die Live-Region sagt nur Laden und Fehler. Die Verfügung hängt als Dokument (`APIARY_RESTRICTION`, `DocumentUploadCard`, schreibend nur mit `compliance:manage`; ohne Leserecht entfällt die Zeile „Verfügung“).
|
||||
- **Anlegen:** „Sperrbezirk anlegen“ legt je gewähltem Stand (1 bis 50) einen Eintrag an (Seuche: Amerikanische Faulbrut, Kleiner Beutenkäfer oder Andere mit Freitext; Beginn, optional Ende, Behörde, Aktenzeichen, Notizen) mit `Idempotency-Key`.
|
||||
- **Beenden:** Der Dialog setzt nur `endsOn` (der Eintrag wird nie gelöscht). **Vorgabe ist „gestern“** (nicht vor dem Beginn); `endsOn` gilt **einschließlich**. Ist der gewählte Endtag heute oder später, zeigen Hinweis und Toast „Die Sperre gilt noch bis einschließlich TT.MM.JJJJ.“. **Ein Aufheben am selben Tag ist nicht möglich** (Annahme 91): Ist die Sperre heute aufgehoben, trägt man gestern ein, außer sie begann heute, dann gilt sie bis einschließlich heute.
|
||||
- **Rotes Band** am Stand, solange eine Sperre aktiv ist, mit dem Link „Sperrbezirke ansehen“ (Link nur mit `compliance:read`).
|
||||
- **Wächter** bei Umsetzen (Quell- und Zielstand), Vereinigen über verschiedene Stände, Standkorrektur am Volk und neuem Volk: rote Meldung („Stand {Name} liegt im Sperrbezirk ({Krankheit}, seit {Datum}). … ist gesperrt.“; ohne Servertext der Rückfall „Der Stand liegt im Sperrbezirk.“) mit Symbol und Wort in einer persistenten `aria-live`-Region. Der Hauptknopf ist `aria-disabled` mit `aria-describedby` auf den Grund (bleibt fokussierbar, Klick und Submit sind gesperrt; `ConfirmDialog` hat dafür die additive Eigenschaft `triggerAriaDisabled`). Ableger (`split`) bleiben frei. Der Umsetzen-Dialog trägt den festen Hinweis zu BienSeuchV § 5 (Gesundheitsbescheinigung, im Zweifel beim Veterinäramt nachfragen) und keine Rechtswerte.
|
||||
- **Ausnahme.** Nur mit `compliance:manage` erscheint der aufklappbare Abschnitt „Ausnahme mit Begründung“ (10 bis 500 Zeichen; Leerzeichen allein genügen nicht); die Begründung gilt erst nach der Rückfrage im `ConfirmDialog`, ohne Passwort-Abfrage (Inhaber-Entscheidung 2026-10-15). `restrictionOverrideReason` wird nur gesendet, wenn die Ausnahme genutzt wird; ändern sich die Sperren, verfällt sie. Ohne das Recht wäre die Antwort immer 403, darum wird das Feld nicht angeboten.
|
||||
- **Ernte:** nur die Warnung „Im Sperrbezirk gelten Auflagen auch für Honig und Waben. Verfügung prüfen.“; sie speichert immer.
|
||||
- **409.** `RESTRICTED_ZONE` und „Der Stand wurde gerade geändert. Bitte neu laden.“ erscheinen mit dem Servertext über dem Knopf, der Fokus springt auf die Meldung, die aktiven Sperren laden neu, und ein Knopf „Neu laden“ (Umsetzen, Vereinigen, Standkorrektur) lädt Volk und Stände neu und löscht die Meldung. Kein automatischer Neuversuch.
|
||||
- **Service Worker.** Die Sperrbezirk-Pfade sind seit Task 14 nie zwischengespeichert; offline fehlt die frühe Sperre, der Server entscheidet.
|
||||
|
||||
## 13. Beute (3D-Aufbau)
|
||||
|
||||
Der Aufbau einer Beute (Boden, Zargen, Rähmchen, Einlagen, Dach) je Volk. Spec: `docs/superpowers/specs/2026-10-15-beute-3d-design.md`. L1 ist das Backend (dieser Abschnitt), L2–L5 die Oberfläche (Abschnitt 13.1); Ziehen und Ablegen in 3D (Task 18) folgt.
|
||||
|
||||
**Modul.** `src/hive` (`HiveModule`): `hive.service.ts` (Aufbau je Volk), `hive-catalog.service.ts` (Katalog), `hive-ops.ts` (`applyHiveOps`), `hive-validate.ts` (Validator), `hive-facts.ts` (abgeleitete Fakten), `hive-state-schema.ts` (Zustandsfelder der Teile). Hive-Fakten schreiben nie in `Colony` oder `Inspection`; `Colony.occupiedFrames` bleibt die Volksstärke.
|
||||
|
||||
**Speicherform.**
|
||||
- `HiveSystem` (Maße, `template`) und `HivePartType` (Art, Maße, `frameSlots`, `stateSchema`, `active`) bilden den Katalog. Die Startdaten (Zander nach Liebig, 10 Rähmchen, 14 Teile) kommen per Migration `20261017100100_hive_catalog_zander_liebig` mit festen Ids; `test/fixtures/hive-ops/catalog.json` ist dieselbe Quelle für das Frontend, ein e2e vergleicht beide. Die Futtertasche (`FRAME_FEEDER`, Migration `20261018100100_hive_catalog_frame_feeder`) ist ein Einsatz einer Zarge mit `slot` (erster belegter Rähmchenplatz) und der Katalogspalte `slotWidth` (Zahl belegter Plätze); die von ihr bedeckten Rähmchen sind `NONE`, Überschneidungen melden `RULE_FEEDER_SLOT` (Annahme 101).
|
||||
- `HiveSetup` hält den aktuellen Aufbau eines Volks als JSON (Stand und `version`), `HiveChange` das Protokoll: je Änderung `occurredOn` (Berliner Tag), `source`, die Operationen und `stateAfter`. Der Stand an einem Tag kommt aus dem letzten `stateAfter` bis zu diesem Tag. Nachträge sind möglich, aber nie vor dem `INIT`-Tag und nie in der Zukunft.
|
||||
- Katalogteile werden nie gelöscht, nur `active: false`. Bestehende Aufbauten mit einer inaktiven Art bleiben gültig.
|
||||
|
||||
**Operationen.** `addPart`, `addInsert`, `removePart`, `movePart`, `setState`, `setFrame`, `setFrames`, `setLabel`; höchstens 100 pro Anfrage, höchstens 30 Teile im Stapel, Beschriftungen und Rähmchen-Namen höchstens 40 Zeichen. Die Regeln und die Gründe (`reason`-Codes wie `RULE_FLOOR_BOTTOM`, `STATE_OUT_OF_RANGE`, `UNKNOWN_FIELD`, `LIMIT_PARTS`) stehen im Kopf von `hive-validate.ts`; die Prüfreihenfolge entscheidet, welcher Grund zuerst gemeldet wird, und das Frontend muss sie übernehmen: Wurzelform (`schemaVersion` 1, unbekannte Schlüssel), `LIMIT_PARTS`, dann je Stapelteil Id, unbekannte Felder, Art, Beschriftung, Zustand, Rähmchen und Einsätze (`RULE_INSERT_HOST`, `RULE_FEEDER_SLOT`), danach Boden (`RULE_FLOOR_BOTTOM`), zuletzt Dach (`RULE_ROOF_TOP`); Textlängen zählen UTF-16-Einheiten. `applyHiveOps` und `validateHiveState` sind reine Funktionen ohne Prisma; `applyHiveOps` prüft den Zustand nach jeder Operation, ein Fehler trägt Index und Grund. Scheitert eine Operation am aktuellen Stand, wird die ganze Änderung abgelehnt (409 `HIVE_CONFLICT`).
|
||||
|
||||
**Endpunkte** (`/colonies/:id/hive`): `GET` (Stand und Fakten), `POST` (Aufbau anlegen), `POST changes` (Änderung mit `Idempotency-Key`: dieselbe Wiederholung liefert dieselbe Antwort ohne zweite Änderung), `GET changes` (Historie mit Cursor), `GET at` (Stand an einem Tag). Katalog: `GET /hive-systems`, `GET /hive-systems/:id/template` (Vorlage mit frischen Ids je Aufruf), `POST` und `PATCH /hive-systems`, `POST /hive-systems/:id/part-types`, `PATCH /hive-part-types/:id`.
|
||||
|
||||
**Sperrreihenfolge.** `Colony` FOR UPDATE, dann `HiveSetup` FOR UPDATE, dann `HivePartType` (`LOCKABLE_TABLES` in `src/prisma/row-lock.ts`: `HiveSetup` direkt nach `Colony`). Die Version steigt je Änderung um 1; zwei gleichzeitige Änderungen laufen nacheinander, die zweite sieht den Stand der ersten.
|
||||
|
||||
**Rechte.** Lesen `colonies:read`, Aufbau anlegen und ändern `colonies:update`, Katalog ändern `hive-systems:manage` (Gruppe „Beutensysteme“). `hive-systems:manage` kommt nur über die Katalog-Synchronisierung (`PermissionCatalogSyncService`) zur Rolle ADMIN, ohne Rechte-Migration. STAFF hat `colonies:update` nicht und kann die Beute nicht ändern (Annahme 99).
|
||||
|
||||
**Ereignis.** `HiveChanged` (Outbox, in derselben Transaktion wie die Änderung; Nutzlast `colonyId`, `version`, `source`, `inspectionId`). Ein nicht lebendes Volk ist schreibgeschützt: 409 wie `assertActive`.
|
||||
|
||||
**Durchsicht.** `POST /inspections` nimmt optional `hiveChange` (`ops`). Durchsicht und Beute-Änderung werden in einer Transaktion gespeichert und gemeinsam abgelehnt. Ohne `colonies:update` antwortet der Server 403 `HIVE_FORBIDDEN` und schreibt nichts. Der Tag der Änderung ist der Tag der Durchsicht (nicht in der Zukunft, nicht vor dem `INIT`-Tag); die Änderung hat `source` `INSPECTION` und verweist auf die Durchsicht.
|
||||
|
||||
**Backup.** Export und Import enthalten `HiveSystem`, `HivePartType`, `HiveSetup` und `HiveChange`. Katalogzeilen werden nach Id eingespielt (auch in eine frische Installation mit Startdaten). Je Volk gewinnt die Sicherung: Aufbau und Protokoll eines wiederhergestellten Volks ersetzen die vorhandenen. Nicht versiegelte Altdateien ohne diese Tabellen werden gemeldet und übersprungen (Abschnitt 8).
|
||||
|
||||
**Fehlercodes.**
|
||||
|
||||
| Code | Status | Bedeutung |
|
||||
|---|---|---|
|
||||
| `HIVE_CONFLICT` | 409 | Operation passt nicht zum aktuellen Stand; mit `opIndex`, `reason`, `current` (Version und Stand) |
|
||||
| `HIVE_EXISTS` | 409 | Das Volk hat schon einen Aufbau |
|
||||
| `HIVE_NOT_FOUND` | 404 | Das Volk hat noch keinen Aufbau |
|
||||
| `HIVE_FORBIDDEN` | 403 | Durchsicht mit `hiveChange` ohne `colonies:update` |
|
||||
| `TEMPLATE_NOT_SET` | 409 | Das System hat noch keine Vorlage |
|
||||
| `SCHEMA_NOT_ADDITIVE` | 400 | Zustandsfelder dürfen nur angehängt werden |
|
||||
| `TEMPLATE_INVALID` | 400 | Die Vorlage besteht den Validator nicht |
|
||||
|
||||
Formfehler (Form der Anfrage, zukünftiger Tag, Tag vor `INIT`, mehr als 100 Operationen) sind 400. Ein neues System beginnt mit leerer Vorlage; sie wird per `PATCH` gesetzt, sobald die Teile existieren.
|
||||
|
||||
### 13.1 Frontend (Karte, Editor, Durchsicht, 3D)
|
||||
|
||||
- **Port von Regeln und Fixtures.** `src/lib/hive` spiegelt das Backend: `ops.ts` (`applyHiveOps`), `validate.ts`, `facts.ts`, `types.ts`. Ob beide Seiten gleich rechnen, sichern gemeinsame Fixtures: `ApiCore-Backend/test/fixtures/hive-ops/*.json` liest das Backend-Spec und das Frontend-Spec (`lib/hive/test-utils.ts`, `FIXTURE_DIR`); dazu `catalog.json` für den Katalog. Wer eine Regel ändert, ändert Backend, Frontend und Fixtures zusammen; die Prüfreihenfolge aus Abschnitt 13 gilt auch hier, `reason-text.ts` übersetzt die Gründe in Sätze.
|
||||
- **Karte.** `components/hive/hive-card.tsx` auf der Volksseite (`mode` `view` oder `inspection`). Die Bühne (`HiveVisualSlot`) zeigt immer das SVG (`HiveSvg`, aus `lib/hive/layout.ts`), die 3D-Szene liegt darüber. Daneben stehen die Gliederung (`HiveOutline`, Baum aller Teile, per Tastatur bedienbar), Fakten und Fokusleiste. Teile und Rähmchen öffnen Sheets (`part-sheet`, `frame-sheet`); jede Änderung geht über `useHiveMutation` als `POST changes` mit eigenem `Idempotency-Key` je Versuch, einen Versand nach dem anderen, optimistisch auf dem Cache. Das Sheet zeigt bei `HIVE_CONFLICT` den aktuellen Stand aus der Antwort (`HiveConflictSheet`). Die Historie (`HiveHistory`) zeigt Änderungen und den Stand eines Tages (`GET at`).
|
||||
- **Editor** `/colonies/[id]/hive` („Umbauen“): Mehrere Schritte werden lokal als Entwurf gesammelt (`useHiveDraft`: Schritte, Rückgängig und Wiederholen, `snapshot` des Ausgangsstands, Annahme 104) und mit einer Änderung gespeichert. Bis dahin berührt der Entwurf den Server nicht; Verlassen der Seite mit offenen Schritten fragt nach. Der Editor lädt den Aufbau neu, sobald das Fenster den Fokus bekommt, und alle 30 s, solange ein Entwurf besteht (`REFRESH_INTERVAL_MS`). Umbauen geht nur mit Netz.
|
||||
- **Durchsicht.** `inspections/new` hat den Abschnitt „Beute“ (`hive-section.tsx`): die Karte im Modus `inspection` sammelt Operationen als Entwurf, sie gehen als `hiveChange` mit der Durchsicht in `POST /inspections` und in der Warteschlange mit dem Eintrag (Abschnitt 2). Die Sheets stoppen das `submit` an ihrem Formular (`stopPropagation`; sie liegen in einem Portal, doch React reicht Ereignisse im Komponentenbaum weiter), sonst würde „Speichern“ im Sheet die Durchsicht speichern; neue Formulare in Sheets müssen dasselbe tun. Zusätzlich stoppt der Abschnitt selbst `submit` an seinem Wrapper (`hive-section.tsx`).
|
||||
- **3D-Szene.** `components/hive/hive-scene` (three, react-three-fiber, drei, postprocessing) wird nur über `next/dynamic` mit `ssr: false` geladen, sobald die Karte sichtbar wird (`IntersectionObserver`); andere Seiten laden nichts davon. Budget für den Chunk: höchstens 320 kB gzip (Spec 9.7); gemessen zuletzt etwa 296 kB. Die Szene misst die Bildrate (`quality.ts`): unter 45 fps fällt sie auf die Stufe `low` (kein Spiegelboden, kein MSAA, keine Tiefenunschärfe, keine Partikel), die Tiefenunschärfe läuft erst ab 55 fps. Bewegung folgt der Stufe `full`, `subtle` oder `off` (`useEffectiveMotionLevel`: Flüge, Überblendung 400, 150 oder 0 ms). **Rückfall auf das SVG** (`shouldUse3D`, `lib/hive/webgl.ts`): kein WebGL, erzwungene Farben, oder Bewegung `off` bei höchstens 2 Prozessorkernen; ein verlorener WebGL-Kontext baut die Szene einmal neu auf, der zweite lässt es beim SVG. Das SVG ist darunter immer gerendert und bleibt bedienbar, bis das erste 3D-Bild steht; im Druck entfällt die 3D-Ebene.
|
||||
|
||||
## 14. Sprache (T7 „Freihand“)
|
||||
|
||||
Spracheingabe für die Durchsicht am Stand, in drei Stufen, die einzeln abschaltbar sind. Spec: `docs/superpowers/specs/2026-10-14-t7-sprache-design.md`, Plan: `docs/superpowers/plans/2026-10-16-t7-sprache.md`. Eine Sprachnotiz füllt nie etwas selbst aus: Vorschläge landen erst nach einem Tipp im Formular (Abschnitt 14.6). Inhalte (Ton, Abschrift, Vorschlag) sind personenbezogen und leben nur verschlüsselt und nur kurz; Protokolle, Audit-Einträge und Ereignisse tragen nie Inhalt, nur Ids, Zähler und feste Codes.
|
||||
|
||||
**Stufen.**
|
||||
1. **Diktat im Browser.** Die Spracherkennung des Browsers (`components/voice/use-dictation.ts`) schreibt in Textfelder der Durchsicht. Die Cloud-Erkennung mancher Browser ist ohne Einwilligung aus (`lib/voice/consent.ts`, nur `localStorage`). Kein Server beteiligt.
|
||||
2. **Sprachnotiz mit Abschrift im Cluster.** Das Gerät nimmt bis zu 180 s auf, lädt die Aufnahme hoch, und der CronJob `apicore-job-voice-transcribe` schreibt sie mit Whisper ab (Abschnitt 14.3). Das Ergebnis ist ein Text (Status `TRANSCRIBED`), der im Formular nutzbar ist, auch ohne Stufe 3.
|
||||
3. **Auswertung mit Claude.** Die Worker-Schleife `voice-extractions` ruft Claude, prüft die Antwort gegen die Abschrift und legt Vorschläge ab (Status `READY`, Abschnitt 14.4). Ohne Schlüssel bleibt die Notiz bei `TRANSCRIBED`.
|
||||
|
||||
**Datenmodell** (Migration `20261019100000_t7_voice_captures`).
|
||||
- `VoiceCapture`: Besitzer, Volk, Berliner Tag der Durchsicht (`inspectionDate`), `source` (`AUDIO` oder `TEXT`), `status`, `attempts`, `availableAt`, `claimedAt`, `claimToken`, `lastErrorCode`, Spalten `transcriptEnc` und `suggestionEnc` (Chiffrat), Modell und Tokens, `contentExpiresAt` (Anlage + 7 Tage), `contentDeletedAt`, `inspectionId` (ohne Fremdschlüssel; ein CHECK bindet `CONFIRMED` an eine gesetzte `inspectionId`). `VoiceCaptureAudio` hält den Ton getrennt (`dataEnc`, höchstens 2 MB Klartext), damit Löschen ein `DELETE` ist und Listen den Ton nie laden.
|
||||
- `VoiceExtractionCall`: Protokoll bezahlter Modellaufrufe (Modell, Token, `outcome`, optional `captureId`), ohne Inhalt und ohne Fremdschlüssel. Das Tagesbudget und die Kostenkarte lesen es, nicht die Notizen.
|
||||
- Status: `QUEUED` → `TRANSCRIBING` → `TRANSCRIBED` → `EXTRACTING` → `READY` → `CONFIRMED`; dazu `DISCARDED`, `FAILED` (Code in `lastErrorCode`) und `EXPIRED`. Der Text-Weg beginnt bei `TRANSCRIBED`. Feste Fehlercodes: `src/voice/voice-error-codes.ts`; deutsche Texte: `voice-error-texts.ts`.
|
||||
- `VoiceCapture`, `VoiceCaptureAudio` und `VoiceExtractionCall` stehen im Backup auf der Ausschlussliste (flüchtig, personenbezogen, nach einer Wiederherstellung nicht zustellbar). In der Sperrreihenfolge ist `VoiceCapture` die letzte Tabelle (`row-lock.ts`).
|
||||
|
||||
**Verschlüsselung.** Alles Inhaltliche liegt mit dem Schlüssel `VOICE_ENCRYPTION_KEY` (Zweck `voice`, Annahme 22) versiegelt. Die GCM-AAD bindet jeden Wert an seine Zeile und Art: `capture:<id>:transcript`, `capture:<id>:suggestion`, `capture:<id>:audio` (`voiceAad` in `core/voice-crypto.ts`). Ein in eine andere Notiz oder Art kopiertes Chiffrat öffnet sich nicht. Text hat das Format der anderen Zwecke; Ton ist `[Version][Schlüssel-Id][IV][Tag][Daten]`. Der Job `rekey` versiegelt nur offene Notizen neu.
|
||||
|
||||
**Aufbewahrung und Löschen.**
|
||||
- Ton: gelöscht, sobald die Abschrift gespeichert ist, spätestens nach 24 h; eine Notiz, die dann noch auf ihre Abschrift wartet, endet `FAILED` mit `AUDIO_EXPIRED`.
|
||||
- Inhalte (Abschrift, Vorschlag): spätestens 7 Tage (`contentExpiresAt`), dann `EXPIRED`. Inhalte deaktivierter Nutzer gehen mit derselben Regel.
|
||||
- Zeilen und Protokoll: 90 Tage (`deleteOldVoiceRows`, `deleteOldVoiceCalls`); leere Läufe des Abschreib-Jobs räumt `deleteIdleVoiceRuns` nach einem Tag aus `JobRun`.
|
||||
- Die reinen SQL-Regeln (`expireVoiceContent`, Datenbankuhr, kein Schlüssel nötig) laufen in jedem Lauf der Phase `fetch`, also alle 2 Minuten, und zusätzlich im täglichen `cleanup`, das jede Kategorie zählt (`voice.audioExpired`, `voice.contentExpired`, `voice.inactiveOwnersWiped`, `voice.rowsDeleted`, `voice.callsDeleted`).
|
||||
- Sofort gelöscht: bei Übernahme (Durchsicht gespeichert, Abschnitt 14.2), bei „Verwerfen“, bei „Alle löschen“ (`POST /voice-captures/discard-all`) und, auf dem Gerät, beim Abmelden (Abschnitt 14.6).
|
||||
|
||||
### 14.1 Rechte, Routen und Grenzen
|
||||
|
||||
**Rechte.** Alle Routen außer den Zählern brauchen `voice-captures:use` (Gruppe „Sprache“; die Migration vergibt es an ADMIN, BEEKEEPER und STAFF, nicht an READONLY und API_CLIENT) und zusätzlich `inspections:create` (`InspectionCreateGuard`): wer keine Durchsicht anlegen darf, bekommt keine Sprachnotizen. `GET /voice-captures/stats` verlangt `system:manage`. Jede Abfrage filtert auf den Besitzer; eine fremde Id ist 404. Antworten tragen `Cache-Control: no-store`.
|
||||
|
||||
**Routen** (`src/voice/captures/voice-captures.controller.ts`; statische Pfade vor `:id`).
|
||||
- `POST /voice-captures`: Aufnahme hochladen (multipart `audio`, `colonyId`, `inspectionDate`, `durationSec` 1 bis 240), `@Idempotent()`, Antwort `{ id, status: 'QUEUED' }`. Multer hält die Datei im RAM (`fileSize` 2 MB, ein Teil); der Typ kommt aus den ersten Bytes (WebM, Ogg, MP4), nie aus dem `Content-Type` (415 `UNSUPPORTED_AUDIO`, 413 `TOO_LARGE`).
|
||||
- `POST /voice-captures/text`: Text zur Auswertung (`colonyId`, `inspectionDate`, `text`, nach NFKC und ohne Steuerzeichen 20 bis 4000 Zeichen), `@Idempotent()`, Status `TRANSCRIBED`.
|
||||
- `GET /voice-captures?status=` (eigene Notizen ohne Inhalt, höchstens 200), `GET /voice-captures/:id` (bei `READY`, `TRANSCRIBED` und `FAILED` mit Abschrift und Vorschlägen; ändert nichts), `GET /voice-captures/config`, `GET /voice-captures/stats`.
|
||||
- `POST /voice-captures/:id/retry`: nur bei `FAILED` mit vorhandenem Text oder frischem Ton (unter 24 h) und `contentExpiresAt` in der Zukunft; zurück auf `TRANSCRIBED` oder `QUEUED`, `attempts = 0`; sonst 409 `VOICE_CAPTURE_GONE`. `POST /voice-captures/:id/discard` (204, idempotent) und `POST /voice-captures/discard-all`.
|
||||
- `POST /inspections` nimmt `voiceCaptureId`; `VoiceConfirmationService.confirmIn` läuft als Letztes in der Anlege-Transaktion (Annahme 107). Eine fremde, fehlende oder abgeschlossene Notiz ist 409 `VOICE_CAPTURE_GONE`, eine Notiz eines anderen Volks 409 `VOICE_CAPTURE_COLONY_MISMATCH`; eine Wiederholung mit demselben `Idempotency-Key` ändert nichts.
|
||||
|
||||
**Grenzen.** Je Nutzer höchstens 10 offene Notizen (`QUEUED` bis `READY`), 30 neue je UTC-Tag und 10 neue je Minute, unter einer Advisory-Sperre je Nutzer (sonst 429 `TOO_MANY_OPEN`, Text „Zu viele offene Sprachnotizen. Erst auswerten oder verwerfen.“); ein Wiederholen zählt wie eine neue Notiz. Dazu Throttle je Netz: 10/min für Upload, 5/min für Wiederholen (Annahme 109). Das Volk muss leben (400), der Tag der Durchsicht höchstens 7 Tage zurück und nicht in der Zukunft.
|
||||
|
||||
**Schalter.** `VOICE_RECORDING_ENABLED` (ConfigMap `apicore-backend-config`, Voreinstellung `false`) steuert die Aufnahme. Aus: Upload und Text-Weg antworten 403 `VOICE_RECORDING_DISABLED`, die Oberfläche blendet die Einstiege aus; Liste, Lesen, Wiederholen und Verwerfen bleiben (Annahme 113). `GET /voice-captures/config` meldet `recordingEnabled`, `extractionEnabled` (der API-Pod kennt den Schlüssel nicht und schließt „aus“ daraus, dass der Worker in der letzten Stunde eine Notiz mit `EXTRACT_DISABLED` geparkt hat), das Modell und die Fristen (24 h, 7 Tage, 90 Tage). Weitere Einstellungen: `VOICE_MODEL` (`claude-sonnet-5-5`, `claude-haiku-5-5`, `claude-opus-5-5`; die Modell-Ids stehen nur in `voice-config.ts`), `VOICE_DAILY_EXTRACTIONS` (60, erlaubt 1 bis 10000), `WHISPER_MODEL` (`small` oder `medium`), `VOICE_SERVER_FALLBACK` (`default` oder `off`). Ungültige Werte verhindern den Start.
|
||||
|
||||
**Ansprüche, Wiederholungen, Verfallsfenster.** Beide Phasen teilen sich die Hilfen in `claims/voice-claims.ts` und die Regeln in `claims/voice-phases.ts`. Ein Lauf beansprucht eine Notiz atomar (`claimToken`); Abschluss, Fehlversuch und Verschieben prüfen den Token (Compare-and-Set), sodass ein Prozess, der den Anspruch verloren hat, nichts mehr schreibt. Ein Abschluss der Phase `transcribe` setzt `attempts` auf 0, damit `extract` seine eigenen 3 Versuche hat.
|
||||
|
||||
| Phase | Von → in Arbeit → fertig | Versuche | Wartezeit nach Fehlversuch | Anspruch veraltet nach | Endcode |
|
||||
|---|---|---|---|---|---|
|
||||
| `transcribe` | `QUEUED` → `TRANSCRIBING` → `TRANSCRIBED` | 3 | 5 min, 15 min | 30 min | `TRANSCRIBE_FAILED` |
|
||||
| `extract` | `TRANSCRIBED` → `EXTRACTING` → `READY` | 3 | 30 s, 2 min | 5 min | `EXTRACT_FAILED` |
|
||||
|
||||
Ein veralteter Anspruch geht beim nächsten Lauf zurück und zählt einen Versuch; ist der letzte verbraucht, endet die Notiz `FAILED` und `VoiceCaptureFailed` wird geschrieben.
|
||||
|
||||
### 14.2 Übernahme
|
||||
|
||||
Das Speichern der Durchsicht bestätigt die Notiz in jedem offenen Zustand: Status `CONFIRMED`, `inspectionId` gesetzt, Ton, Abschrift und Vorschlag gelöscht, alles in der Transaktion der Durchsicht. Ein laufender Job verliert seinen Anspruch und kann nicht mehr abschließen. Scheitert die Bestätigung, scheitert das ganze Speichern.
|
||||
|
||||
### 14.3 Abschreiben im Cluster
|
||||
|
||||
**Job.** `voice-transcribe` (`src/jobs/voice-transcribe.job.ts`, Argumente `--phase=fetch` und `--phase=ingest`) ist die Node-Hälfte um den Whisper-Container; Komponente `deploy/k8s/components/voice`, CronJob `apicore-job-voice-transcribe`: `*/2 * * * *`, `Europe/Berlin`, `concurrencyPolicy: Forbid`, `backoffLimit: 0`, `activeDeadlineSeconds: 1500`. Drei Container laufen nacheinander mit derselben uid 1000 und fsGroup 1000:
|
||||
1. `fetch` (initContainer, Backend-Image, Rolle `job`): wendet die Aufbewahrungsregeln an, gibt veraltete Ansprüche frei, beansprucht bis zu 5 Notizen mit zusammen höchstens 600 s Ton, entschlüsselt in den Speicher und legt Ton und Manifest nach `/work/in`.
|
||||
2. `whisper` (initContainer, Image `apicore-whisper` aus `ApiCore-Whisper/`, faster-whisper auf CPU, `int8`, Deutsch, `beam_size` 1, VAD-Filter, 2 Threads): liest `/work/in` (nur lesbar), schreibt je Notiz ein Ergebnis nach `/work/out`. Kein `envFrom`, kein `secretKeyRef`, kein Service-Account-Token, kein Datenbankzugang; zusätzlich sperrt das Skript `socket.socket`. Das Modell steckt mit Prüfsummen im Image; `av` ist auf 18.1.0 festgelegt (Annahme 106).
|
||||
3. `ingest` (Hauptcontainer, Backend-Image): übernimmt nur Ergebnisse zu Notizen des eigenen Manifests mit passendem `claimToken`, prüft sie streng, versiegelt den Text, löscht den Ton, setzt `TRANSCRIBED` und weckt den Worker (`pg_notify('voice_extraction')`), je Notiz in einer eigenen Transaktion. Zu kurzer Text (unter 5 Zeichen) ist `NO_SPEECH`, mehr als 240 s `TOO_LONG`; beide enden die Notiz sofort `FAILED`. Ein nicht dekodierbarer Ton zählt als Fehlversuch (`TRANSCRIBE_FAILED`).
|
||||
|
||||
**Härtung.** `/work` ist ein `emptyDir` mit `medium: Memory` (32Mi): entschlüsselter Ton und Abschriften berühren nie eine Platte; die Verzeichnisse sind 0700, die Dateien 0600. `fetch` und `ingest` teilen ein `/tmp` (64Mi), `whisper` hat ein eigenes im RAM (16Mi), damit ein kompromittierter Decoder `ingest` (das alle Secrets lädt) keinen Code unterschieben kann. Das Pod-Template trägt `app: apicore-job` und `db-client: "true"`; die NetworkPolicy `apicore-jobs` erlaubt nur Postgres (5432) und DNS. Das CronJob-Objekt selbst trägt `app: apicore-voice-job`, damit der gemeinsame Job-Patch keinen weiteren Container `job` einsetzt. Whisper: Request und Limit je 1280Mi Speicher (Messung vor der Freigabe, Abschnitt 14.7). Das Restrisiko eines gemeinsamen Pod-Netzes steht in Annahme 106.
|
||||
|
||||
### 14.4 Auswertung mit Claude
|
||||
|
||||
**Schleife.** `VoiceExtractionService` (`src/voice/extraction/`) läuft als Worker-Schleife `voice-extractions` (15 s Sicherheitsabfrage, sofort bei `voice_extraction`, ein Lauf zur Zeit, höchstens 20 Notizen je Lauf). Der Anspruch, das Ergebnis und das Ereignis einer Notiz committen zusammen.
|
||||
|
||||
**Aufruf.** Der Adapter `AnthropicStructuredModel` ist die einzige Stelle, die das SDK einbindet (`@anthropic-ai/sdk` 0.131.0, exakt festgelegt; eine Netz-Wächterprüfung sichert das). Der Endpunkt ist fest `https://api.anthropic.com`; `ANTHROPIC_BASE_URL` und `ANTHROPIC_CUSTOM_HEADERS` aus der Umgebung werden mit Warnung ignoriert. Je Aufruf `max_tokens` 2000, Timeout 60 s, 2 SDK-Wiederholungen, insgesamt höchstens 240 s, `effort: low`. Der Schlüssel `ANTHROPIC_API_KEY` kommt nur im Worker an (Secret `apicore-anthropic`, optional, einzelne Variable per `patch-worker-voice.yaml`); API- und Job-Pods warnen beim Start, wenn sie ihn trotzdem sehen.
|
||||
|
||||
**Prompt als Daten.** Der Systemprompt ist fester Text ohne Datum, Volk, Nutzer oder Modell-Id (`voice-prompt.ts`, `VOICE_PROMPT_VERSION`): Feldliste mit Wertebereichen aus dem Formular, die vier Skalen mit Ankerwörtern, die Futtercodes, neun Regeln (nur Ausdrückliches, Verneinung ist ein Wert, wörtliches Zitat, keine Namen, kein Wetter, Korrektur gewinnt) und die Fachwortliste. Die Abschrift steht in `<transkript>…</transkript>` und gilt als Datenmaterial, nie als Anweisung; `<` und `>` im Text werden zu ‹ und ›, damit sie den Rahmen weder schließen noch öffnen.
|
||||
|
||||
**Schema und Prüfung.** Strukturierte Ausgabe `{ findings: [{ field, value, quote, corrected }], unclear: [] }` (`voice-schema.ts`, ohne Grenzen wie `maxItems`, die das Schema nicht zulässt). Der Server prüft jede Antwort (`validate-suggestions.ts`, rein und deterministisch): höchstens 20 Einträge und 5 Unklarheiten (je 300 Zeichen); Feld und Wert gegen die Regeln des Formulars (`core/inspection-field-specs.ts`: Bereiche, Skalen 1 bis 5, Futtercodes, gekürzte Texte); das Zitat (höchstens 120 Zeichen) muss wörtlich in der Abschrift stehen, wobei eine Dezimalzahl ein Wort bleibt (Annahme 108); je Feld bleibt ein Wert (eine Korrektur schlägt eine Nichtkorrektur, dann das spätere Zitat), der stärkste andere Wert wird als `previous` mitgegeben. Verworfene Einträge zählen nur als Zahl (`dropped`, `truncated`). Das Ergebnis `{ suggestions, unclear, dropped, truncated }` liegt versiegelt in `suggestionEnc`.
|
||||
|
||||
**Budget und Ausgänge.** Das Tagesbudget zählt bezahlte Aufrufe aus `VoiceExtractionCall` auf dem UTC-Tag der Datenbankuhr, alle Ausgänge außer `AUTH` und `ABORTED` (Annahmen 110 und 111). Ist es erreicht, wartet die Notiz bis zum Tagesende (`DAILY_LIMIT`, kein Fehlversuch). Ein Aufruf wird vor dem Abschluss der Notiz gebucht, damit ein verlorener Anspruch ihn nicht kostenlos macht.
|
||||
- Kein brauchbarer Schlüssel oder Ablehnung des Schlüssels (401, 403): die Notiz wartet eine Stunde mit `EXTRACT_DISABLED` (kein Fehlversuch); je Periode entsteht ein Ereignis für `system:manage`.
|
||||
- Modell lehnt ab (`refusal`): sofort `FAILED` mit `REFUSED`. Abgeschnitten, ungültige Antwort oder `BAD_REQUEST`: Fehlversuch mit `EXTRACT_INVALID`. Netz, Zeitüberschreitung, 408, 409, 429, 5xx: Fehlversuch mit `EXTRACT_RETRY`, nach dem dritten `EXTRACT_FAILED`.
|
||||
- Abbruch beim Herunterfahren: nicht gezählt, die Notiz wird sofort wieder frei (`ABORTED`).
|
||||
- Serverseitiger Ausweichweg: für Sonnet und Opus setzt der Adapter bei `VOICE_SERVER_FALLBACK=default` die Beta-Kennung `server-side-fallback-2026-07-01` und `fallbacks: "default"`; eine abgelehnte Anfrage läuft dann bei Anthropic auf dem empfohlenen Modell neu. Gebucht wird das tatsächlich antwortende Modell (Annahme 115). Haiku hat keinen Ausweichweg.
|
||||
|
||||
**Messung.** `npm run eval:voice` (Gold-Menge `test/fixtures/voice/gold/g01` bis `g30`, Schwellen: Genauigkeit ≥ 98 %, Trefferquote ≥ 85 %) läuft ohne Zusatz offline gegen eine Attrappe; `-- --live --yes` ruft Claude auf und kostet etwa 0,21 $ für 30 Fälle (Sonnet). Es läuft nie in der CI.
|
||||
|
||||
### 14.5 Ereignisse und Meldungen
|
||||
|
||||
`VoiceCaptureReady` und `VoiceCaptureFailed` (Outbox, in derselben Transaktion wie der Zustandswechsel; Nutzlast Ids und Code). Handler (`voice.handlers.ts`, über `VoiceHandlersModule` im Worker): `voice.notify-ready` legt dem Besitzer eine Meldung `HINWEIS` der Kategorie `SUGGESTION` an („Sprachnotiz für {Volk} ist fertig“, Link `/inspections/new?colonyId=…&voice=<id>`, einmal je Notiz), `voice.notify-failed` eine `WARNUNG` mit dem deutschen Hinweistext des Codes und Link `/voice`, nur wenn die Notiz `FAILED` ist. `EXTRACT_DISABLED` erzeugt zusätzlich einen Hinweis ohne Nutzer-, Volk- und Notizbezug an `system:manage`. Beide Meldungen verlangen `voice-captures:use` beim Empfänger. Ein Fehlschlag des Jobs meldet wie bei allen Jobs an `system:manage`; die Systemkarte zeigt Zähler je Status und Fehlercode der letzten 7 Tage sowie Tokens je Modell und die daraus geschätzten Kosten (`lib/voice/prices.ts`, dieselbe Preistabelle wie `src/voice/eval/voice-prices.ts`; Annahme 114).
|
||||
|
||||
### 14.6 Frontend
|
||||
|
||||
- **Aufnahme.** `components/voice/voice-recorder.tsx` mit `lib/voice/recorder.ts`: höchstens 180 s (`MAX_RECORDING_SEC`, Warnung ab 150 s), Bildschirm bleibt wach; nach einer Mikrofonverweigerung hilft `microphone-help.tsx`. Der Server nimmt bis 240 s an, damit eine Messabweichung die Notiz nicht verwirft. Ein Hinweis zur Aufnahme erscheint einmal je Gerät (`recording-notice-dialog.tsx`). Endet die Aufnahme von selbst, geht sie sofort in die Geräte-Warteschlange (Annahme 112).
|
||||
- **Geräte-Warteschlange.** IndexedDB, Version 6, Store `pending-voice` (Index `ownerUserId`, `lib/voice/pending-voice.ts`, Typen in `lib/offline/db.ts`). Der Upload hängt am Synclauf (`uploadPendingVoice` nach Datensätzen und Fotos): ältester zuerst, mit einem festen `uploadIdempotencyKey` je Notiz, damit ein verlorener Antwortweg nichts doppelt anlegt. 2xx löscht den lokalen Ton sofort; eine endgültige Ablehnung (4xx) bleibt mit Fehler an der Notiz und wartet auf den Nutzer; Netzfehler, 5xx, 408 und 429 beenden den Lauf, `TOO_MANY_OPEN` sperrt den Eintrag, bis Platz ist. Notizen eines anderen Kontos werden gezählt, nie gesendet. Beim Abmelden löscht `clearQueueOf` die Sprachnotizen des Kontos; die Abmeldefrage nennt vorher die Zahl der noch nicht gesendeten Notizen.
|
||||
- **Seiten.** `/voice` (Liste: Notizen vom Server und vom Gerät, Verwerfen, Wiederholen), `/voice/new` (aufnehmen, Volk und Tag wählen), Einstiege per `VoiceEntryLink` in Schnell-Anlegen, Navigation, Volksseite und Durchsicht; sie erscheinen nur, wenn der Schalter `on` oder `unknown` (offline) ist (`useVoiceRecordingSwitch`: `loading`, `on`, `off`, `unknown`). `/settings/voice` („Sprache & Datenschutz“): Info- und Datenschutztext, Einwilligung zur Browser-Cloud-Erkennung (aus), „Alle löschen“ für Server und Gerät.
|
||||
- **Durchsicht.** `inspections/new?voice=<id>` zeigt oben die Notiz (`VoiceReviewBox`, `useVoiceReview`: fragt alle 5 s, höchstens 10 min) und je Feld einen Vorschlag mit Zitat (`VoiceFieldRow`, „Übernehmen“, „Alle übernehmen“, Ignorieren). Nichts wird ohne Tipp übernommen; die Abschrift lässt sich einsehen und in die Zwischenablage kopieren, auch wenn die Auswertung aus ist (`TRANSCRIBED`). Beim Speichern geht `voiceCaptureId` mit.
|
||||
- **Weg bei Konflikt.** 409 `VOICE_CAPTURE_GONE` beim Speichern: `createInspection` versucht es genau einmal ohne `voiceCaptureId` und mit demselben `Idempotency-Key` erneut (der Interceptor gibt den Schlüssel bei Handlerfehler frei und prüft den Rumpf nicht; ein neuer Schlüssel könnte doppelt anlegen). 409 `VOICE_CAPTURE_COLONY_MISMATCH` wird nie wiederholt; in der Warteschlange erscheint „Ohne Sprachnotiz senden“ (`retryInspectionWithoutVoice`), und der Nutzer entscheidet.
|
||||
- **Textweg.** „Ganze Durchsicht auswerten“ (`EvaluateDictationButton`, unter den Textfeldern) sendet den noch in den vier Freitextfeldern stehenden diktierten Text (20 bis 4000 Zeichen, so normalisiert wie auf dem Server) an `POST /voice-captures/text` mit einem `Idempotency-Key` je Inhalt. Daneben steht „Der Text geht zur Auswertung an Anthropic.“. Offline ist der Knopf gesperrt (nichts wird eingereiht); mit `?voice=` fehlt er; die Antwort kommt als Sprachnotiz oben im Formular, und nach 2 min Warten ohne Ergebnis verweist ein Hinweis auf die Meldung.
|
||||
- **Service-Worker.** `public/sw-routes.js` cacht `/api/v1/voice-captures` nie (Abschrift und Vorschläge sind Inhalt) und hält nur die Hüllen `/voice` und `/voice/new` vor.
|
||||
|
||||
### 14.7 Rollout
|
||||
|
||||
Reihenfolge, jeder Schritt mit Freigabe:
|
||||
1. **Schlüssel vor dem Image.** `deploy/k8s/scripts/ensure-secrets.sh` legt `VOICE_ENCRYPTION_KEY` in `apicore-backend-secret` an (48 Zufallsbytes, Base64URL); er muss existieren und gesichert sein, bevor das Backend-Image mit T7 startet, denn in Produktion startet sonst keine Rolle (Annahme 22). Dann Image mit Migration. Die Auswertung bleibt ohne Anthropic-Schlüssel aus (`EXTRACT_DISABLED`).
|
||||
2. **Whisper-Image.** `.gitea/workflows/whisper-image.yml` baut es; der Digest ersetzt den Platzhalter im Overlay `deploy/k8s/overlays/encry/kustomization.yaml` (Eintrag `apicore-whisper`).
|
||||
3. **Komponente `voice`** (im Overlay eingebunden, erst nach Schritt 1 und 2 anwenden): CronJob und Worker-Patch. Erster Lauf von Hand (`kubectl create job --from=cronjob/apicore-job-voice-transcribe`); das Log darf keinen Inhalt enthalten.
|
||||
4. **Anthropic.** Der Inhaber legt das Secret `apicore-anthropic` (Schlüssel `ANTHROPIC_API_KEY`) an und verschiebt den Schlüssel aus `apicore-backend-secret`, wo er über `envFrom` auch API- und Job-Pods erreichte; er setzt in der Anthropic-Konsole ein Ausgabenlimit (Vorschlag 10 $ im Monat). Mit 60 Aufrufen am Tag (`VOICE_DAILY_EXTRACTIONS`) bleibt der Verbrauch dahinter.
|
||||
5. **Messung (Task 24)** auf dem Zielknoten: Speicherspitze des Whisper-Containers gegen 1280Mi (inklusive der Speicher-Volumes), Zeit und CPU-Drosselung, Trefferquote der Fachwörter (Ziel ≥ 90 %, sonst `medium` oder längere Fachwortliste), einmal `npm run eval:voice -- --live --yes`.
|
||||
6. **Freigabe (Task 25).** `VOICE_RECORDING_ENABLED` auf `"true"` und Frontend ausrollen. Rückweg: Schalter auf `"false"` und die Komponente `voice` abschalten; offene Notizen laufen über die Fristen ab, es gehen nur flüchtige Daten verloren.
|
||||
@@ -56,6 +56,16 @@ BACKUP_CRON_SCHEDULE="0 3 * * *"
|
||||
# Wie viele der neuesten Backup-Dateien behalten werden, aeltere werden nach
|
||||
# jedem Lauf geloescht (Standard: 14).
|
||||
BACKUP_RETENTION_COUNT=14
|
||||
# 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
|
||||
@@ -72,9 +82,12 @@ BACKUP_RETENTION_COUNT=14
|
||||
# MFA_ENCRYPTION_KEY=""
|
||||
# WEBHOOK_ENCRYPTION_KEY=""
|
||||
# BACKUP_ENCRYPTION_KEY=""
|
||||
# Seals voice notes (audio, transcripts, suggestions); required in production.
|
||||
# VOICE_ENCRYPTION_KEY=""
|
||||
# MFA_ENCRYPTION_KEY_PREVIOUS=""
|
||||
# WEBHOOK_ENCRYPTION_KEY_PREVIOUS=""
|
||||
# BACKUP_ENCRYPTION_KEY_PREVIOUS=""
|
||||
# VOICE_ENCRYPTION_KEY_PREVIOUS=""
|
||||
|
||||
# Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert
|
||||
# (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start).
|
||||
|
||||
+32
-86
@@ -1,98 +1,44 @@
|
||||
<p align="center">
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
# ApiCore-Backend
|
||||
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||
NestJS-API von ApiCore (TypeScript, Prisma, PostgreSQL). Alle fachlichen Funktionen liegen unter `/api/v1/...`. Überblick über das ganze Projekt: [`../README.md`](../README.md), Architektur: [`../ARCHITECTURE.md`](../ARCHITECTURE.md).
|
||||
|
||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
||||
</p>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](https://opencollective.com/nest#sponsor)-->
|
||||
|
||||
## Description
|
||||
|
||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||
|
||||
## Project setup
|
||||
## Einrichten
|
||||
|
||||
```bash
|
||||
$ npm install
|
||||
npm install
|
||||
cp .env.example .env # DATABASE_URL, JWT- und Verschlüsselungs-Schlüssel setzen
|
||||
npx prisma migrate dev # Schema anwenden (nur lokal; gegen Produktion nur migrate deploy)
|
||||
npm run start:dev # http://localhost:3001
|
||||
```
|
||||
|
||||
## Compile and run the project
|
||||
Alle Variablen mit Bedeutung stehen in [`.env.example`](.env.example). Swagger unter `/api/v1/docs` ist nur mit `ENABLE_SWAGGER=true` erreichbar.
|
||||
|
||||
## Laufrollen
|
||||
|
||||
Ein Build, drei Einstiegspunkte (`ARCHITECTURE.md` Abschnitt 10):
|
||||
|
||||
| Befehl | Rolle | Zweck |
|
||||
|---|---|---|
|
||||
| `npm run start:dev` / `node dist/main.js` | `api` (lokal ohne `APICORE_ROLE`: `all`) | HTTP-API; mit `all` laufen API und Worker in einem Prozess, nur für Entwicklung und Tests |
|
||||
| `npm run worker` (`node dist/worker.js`) | `worker` | Zustellung der Outbox `DomainEvent`, Webhooks, Kalender-Sync, Wetter, Telegram; Health auf Port 3002 |
|
||||
| `npm run job -- <name>` (`node dist/cli.js <name>`) | `job` | `cleanup`, `daily-evaluation`, `task-templates`, `app-backup`, `rekey` (auch `rekey --dry-run`); im Cluster als CronJobs |
|
||||
|
||||
In Produktion ist `APICORE_ROLE` Pflicht und `all` verboten. Migrationen laufen im Cluster als initContainer `migrate` (`prisma migrate deploy`), der Seed als initContainer `seed` (`node dist/seed.js`).
|
||||
|
||||
## Prüfen
|
||||
|
||||
```bash
|
||||
# development
|
||||
$ npm run start
|
||||
|
||||
# watch mode
|
||||
$ npm run start:dev
|
||||
|
||||
# production mode
|
||||
$ npm run start:prod
|
||||
npm run lint # eslint mit --fix
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm test # Jest-Unit-Tests
|
||||
npm run test:e2e # Jest-e2e gegen die Datenbank aus .env.test (vorher migrieren)
|
||||
npm run build # dist/main.js, dist/worker.js, dist/cli.js, dist/seed.js
|
||||
```
|
||||
|
||||
## Run tests
|
||||
Die e2e-Suites laufen parallel (`maxWorkers: 4` in `test/jest-e2e.json`) und scheitern dabei derzeit sporadisch; mit `npm run test:e2e -- --runInBand` sind sie grün ([Issue #32](https://git.encry.de/Nick.Rosenloecher/ApiCore/issues/32)). Der Image-Build (`Dockerfile`) führt Lint, Typecheck und Unit-Tests aus und bricht bei Fehlern ab; die e2e-Tests laufen dort nicht.
|
||||
|
||||
```bash
|
||||
# unit tests
|
||||
$ npm run test
|
||||
## Aufbau
|
||||
|
||||
# e2e tests
|
||||
$ npm run test:e2e
|
||||
|
||||
# test coverage
|
||||
$ npm run test:cov
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
||||
|
||||
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
||||
|
||||
```bash
|
||||
$ npm install -g @nestjs/mau
|
||||
$ mau deploy
|
||||
```
|
||||
|
||||
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
||||
|
||||
## Resources
|
||||
|
||||
Check out a few resources that may come in handy when working with NestJS:
|
||||
|
||||
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
||||
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
||||
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
||||
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
||||
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
||||
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
||||
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
||||
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
||||
|
||||
## Support
|
||||
|
||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||
|
||||
## Stay in touch
|
||||
|
||||
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
||||
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||
|
||||
## License
|
||||
|
||||
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
||||
- `src/<modul>/`: ein NestJS-Modul je Fachbereich (z. B. `colonies`, `inspections`, `treatments`, `honey-batches`, `compliance`, `hive`), dazu Querschnitt in `common`, `prisma`, `domain-events`, `worker`, `jobs`, `runtime`.
|
||||
- `prisma/schema.prisma` und `prisma/migrations/` sind die einzige Quelle des Schemas; `prisma db push` wird nirgends verwendet.
|
||||
- `test/`: e2e-Specs und gemeinsame Fixtures (u. a. `test/fixtures/hive-ops/`, die auch das Frontend nutzt).
|
||||
Generated
+72
@@ -9,6 +9,7 @@
|
||||
"version": "0.0.1",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.131.0",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
@@ -213,6 +214,27 @@
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.131.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.131.0.tgz",
|
||||
"integrity": "sha512-9+PepoU7qVMmM70jSahdnfcMHAvAnzhMOLv2W9WjRLO+OG/MAiBETkwKZqv7KIJ1Cj5h8eZ019bHzZaq+Mbepw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
"standardwebhooks": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"anthropic-ai-sdk": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
@@ -674,6 +696,15 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
|
||||
"integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||
@@ -3213,6 +3244,12 @@
|
||||
"@sinonjs/commons": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@stablelib/base64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
@@ -6925,6 +6962,12 @@
|
||||
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-sha256": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
|
||||
@@ -8780,6 +8823,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-to-ts": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"ts-algebra": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
@@ -11249,6 +11305,16 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/standardwebhooks": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz",
|
||||
"integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@stablelib/base64": "^1.0.0",
|
||||
"fast-sha256": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -11785,6 +11851,12 @@
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-algebra": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||
|
||||
@@ -22,9 +22,12 @@
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"migrate:deploy": "prisma migrate deploy",
|
||||
"seed": "node dist/seed.js"
|
||||
"seed": "node dist/seed.js",
|
||||
"export:voice-vocabulary": "ts-node scripts/export-voice-vocabulary.ts",
|
||||
"eval:voice": "ts-node scripts/eval-voice.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.131.0",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
@@ -102,6 +105,7 @@
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
"testEnvironment": "node",
|
||||
"testTimeout": 20000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
-- Beute in 3D: hive systems, part types, per-colony setup and its change
|
||||
-- history (spec 2026-10-15, section 5.1).
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "HivePartKind" AS ENUM ('FLOOR', 'BOX', 'DIVIDER_BOARD', 'QUEEN_EXCLUDER', 'FEEDER', 'COVER_SHEET', 'PROPOLIS_SCREEN', 'INNER_LID', 'INSULATION', 'ROOF', 'FLOOR_INSERT', 'EVAPORATOR');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "HiveBoxDepth" AS ENUM ('FULL', 'SHALLOW');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "HiveChangeSource" AS ENUM ('INIT', 'CARD', 'EDITOR', 'INSPECTION', 'AI_SUGGESTION');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "HiveSystem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"frameWidthMm" INTEGER NOT NULL,
|
||||
"frameHeightMm" INTEGER NOT NULL,
|
||||
"footprintLMm" INTEGER NOT NULL,
|
||||
"footprintWMm" INTEGER NOT NULL,
|
||||
"template" JSONB NOT NULL,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "HiveSystem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "HivePartType" (
|
||||
"id" TEXT NOT NULL,
|
||||
"systemId" TEXT NOT NULL,
|
||||
"kind" "HivePartKind" NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"articleRef" TEXT,
|
||||
"lengthMm" INTEGER NOT NULL,
|
||||
"widthMm" INTEGER NOT NULL,
|
||||
"heightMm" INTEGER NOT NULL,
|
||||
"boxDepth" "HiveBoxDepth",
|
||||
"frameSlots" INTEGER,
|
||||
"stateSchema" JSONB NOT NULL,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "HivePartType_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "HiveSetup" (
|
||||
"colonyId" TEXT NOT NULL,
|
||||
"systemId" TEXT NOT NULL,
|
||||
"version" INTEGER NOT NULL DEFAULT 1,
|
||||
"state" JSONB NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "HiveSetup_pkey" PRIMARY KEY ("colonyId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "HiveChange" (
|
||||
"id" TEXT NOT NULL,
|
||||
"colonyId" TEXT NOT NULL,
|
||||
"version" INTEGER NOT NULL,
|
||||
"occurredOn" TIMESTAMP(3) NOT NULL,
|
||||
"source" "HiveChangeSource" NOT NULL,
|
||||
"inspectionId" TEXT,
|
||||
"userId" TEXT NOT NULL,
|
||||
"ops" JSONB NOT NULL,
|
||||
"stateAfter" JSONB NOT NULL,
|
||||
"note" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "HiveChange_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "HiveSystem_name_key" ON "HiveSystem"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "HivePartType_systemId_name_key" ON "HivePartType"("systemId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "HiveChange_inspectionId_key" ON "HiveChange"("inspectionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "HiveChange_colonyId_occurredOn_idx" ON "HiveChange"("colonyId", "occurredOn");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "HiveChange_colonyId_version_key" ON "HiveChange"("colonyId", "version");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "HivePartType" ADD CONSTRAINT "HivePartType_systemId_fkey" FOREIGN KEY ("systemId") REFERENCES "HiveSystem"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "HiveSetup" ADD CONSTRAINT "HiveSetup_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "HiveSetup" ADD CONSTRAINT "HiveSetup_systemId_fkey" FOREIGN KEY ("systemId") REFERENCES "HiveSystem"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "HiveChange" ADD CONSTRAINT "HiveChange_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "HiveSetup"("colonyId") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "HiveChange" ADD CONSTRAINT "HiveChange_inspectionId_fkey" FOREIGN KEY ("inspectionId") REFERENCES "Inspection"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "HiveChange" ADD CONSTRAINT "HiveChange_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
-- Beute in 3D: start catalog for the Liebig-Zander equipment (spec 2026-10-15,
|
||||
-- sections 4.3 and 4.4) under fixed ids, so the system template has stable
|
||||
-- references and a restore into a fresh install creates no duplicate names.
|
||||
-- Dimensions are catalog data and editable in the settings.
|
||||
|
||||
-- Hive system
|
||||
INSERT INTO "HiveSystem" ("id", "name", "frameWidthMm", "frameHeightMm", "footprintLMm", "footprintWMm", "template", "active", "updatedAt")
|
||||
VALUES (
|
||||
'hive-system-zander-liebig-10',
|
||||
'Zander nach Dr. Liebig, 10 Rähmchen',
|
||||
477,
|
||||
220,
|
||||
525,
|
||||
430,
|
||||
'{"schemaVersion":1,"stack":[{"partTypeId":"hive-part-zl10-floor","state":{}},{"partTypeId":"hive-part-zl10-box-10","state":{},"frames":[{"slot":1,"type":"FOUNDATION"},{"slot":2,"type":"FOUNDATION"},{"slot":3,"type":"FOUNDATION"},{"slot":4,"type":"FOUNDATION"},{"slot":5,"type":"FOUNDATION"},{"slot":6,"type":"FOUNDATION"},{"slot":7,"type":"FOUNDATION"},{"slot":8,"type":"FOUNDATION"},{"slot":9,"type":"FOUNDATION"},{"slot":10,"type":"FOUNDATION"}]},{"partTypeId":"hive-part-zl10-cover-abdeckfix","state":{}},{"partTypeId":"hive-part-zl10-inner-lid","state":{}},{"partTypeId":"hive-part-zl10-insulation","state":{}},{"partTypeId":"hive-part-zl10-roof","state":{}}]}'::jsonb,
|
||||
true,
|
||||
CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Part types
|
||||
INSERT INTO "HivePartType" ("id", "systemId", "kind", "name", "lengthMm", "widthMm", "heightMm", "boxDepth", "frameSlots", "stateSchema", "active", "updatedAt")
|
||||
VALUES
|
||||
('hive-part-zl10-floor', 'hive-system-zander-liebig-10', 'FLOOR', 'Liebig Wanderboden', 525, 430, 120, NULL, NULL, '[{"key":"entrance","label":"Flugloch","kind":"choice","options":["OPEN","NARROW","CLOSED"],"optionLabels":["offen","eng","zu"]},{"key":"varroaTray","label":"Varroa-Schublade","kind":"choice","options":["IN","OUT"],"optionLabels":["drin","draußen"]}]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||
('hive-part-zl10-floor-insert', 'hive-system-zander-liebig-10', 'FLOOR_INSERT', 'Liebig Bausperre', 475, 375, 5, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||
('hive-part-zl10-box-10', 'hive-system-zander-liebig-10', 'BOX', 'Liebig Zander Ganzzarge, 10 Rähmchen', 525, 430, 230, 'FULL', 10, '[]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||
('hive-part-zl10-box-empty', 'hive-system-zander-liebig-10', 'BOX', 'Leerzarge (Liebig Zander Ganzzarge ohne Rähmchen)', 525, 430, 230, 'FULL', 0, '[]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||
('hive-part-zl10-divider-board', 'hive-system-zander-liebig-10', 'DIVIDER_BOARD', 'Liebig Zwischenboden', 525, 430, 50, NULL, NULL, '[{"key":"bungHoles","label":"Spundlöcher","kind":"choice","options":["OPEN","CLOSED","BEE_ESCAPE"],"optionLabels":["offen","zu","Bienenflucht"]}]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||
('hive-part-zl10-queen-excluder', 'hive-system-zander-liebig-10', 'QUEEN_EXCLUDER', 'Niro-Absperrgitter Zander Liebig', 478, 376, 2, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||
('hive-part-zl10-feeder', 'hive-system-zander-liebig-10', 'FEEDER', 'Liebig Zweikammer-Futterzarge', 520, 420, 70, NULL, NULL, '[{"key":"syrupLiters","label":"Flüssigfutter","kind":"level","unit":"l","max":3,"step":0.5,"feedType":"SUGAR_WATER"},{"key":"fondantKg","label":"Futterteig","kind":"level","unit":"kg","max":2.5,"step":0.5,"feedType":"FEED_PASTE"}]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||
('hive-part-zl10-cover-abdeckfix', 'hive-system-zander-liebig-10', 'COVER_SHEET', 'Abdeck-Fix (Folie, klar, 0,4 mm)', 478, 378, 1, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||
('hive-part-zl10-cover-lotus', 'hive-system-zander-liebig-10', 'COVER_SHEET', 'Imgut Lotus-Abdeckung (0,5 mm)', 478, 378, 1, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||
('hive-part-zl10-propolis-screen', 'hive-system-zander-liebig-10', 'PROPOLIS_SCREEN', 'Imgut Propolisgitter', 478, 378, 3, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||
('hive-part-zl10-inner-lid', 'hive-system-zander-liebig-10', 'INNER_LID', 'Liebig Holzdeckel', 525, 430, 35, NULL, NULL, '[{"key":"feedHole","label":"Futterloch","kind":"choice","options":["OPEN","CLOSED"],"optionLabels":["offen","zu"]}]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||
('hive-part-zl10-insulation', 'hive-system-zander-liebig-10', 'INSULATION', 'Styropor-Einlage (selbst gebaut)', 465, 365, 20, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||
('hive-part-zl10-roof', 'hive-system-zander-liebig-10', 'ROOF', 'Edelstahldeckel konisch Liebig', 525, 430, 60, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||
('hive-part-zl10-evaporator', 'hive-system-zander-liebig-10', 'EVAPORATOR', 'Nassenheider Verdunster Professional', 200, 140, 120, NULL, NULL, '[{"key":"acidMl","label":"Ameisensäure","kind":"level","unit":"ml","max":290,"step":10}]'::jsonb, true, CURRENT_TIMESTAMP);
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Beute in 3D: Futtertasche (FRAME_FEEDER, spec 2026-10-15 sections 4.1, 4.3,
|
||||
-- 5.1). A frame feeder hangs in a box in place of frames and covers
|
||||
-- `slotWidth` frame slots. The enum value cannot be used in the migration that
|
||||
-- adds it, so the catalog row comes in 20261018100100.
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "HivePartKind" ADD VALUE 'FRAME_FEEDER';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "HivePartType" ADD COLUMN "slotWidth" INTEGER;
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
-- Beute in 3D: start catalog row for the Futtertasche (owner wish 2026-10-16,
|
||||
-- spec sections 4.3 and 4.1). Fixed id like the other start data; dimensions and
|
||||
-- the 2 l capacity are assumptions the owner confirms (catalog data, editable).
|
||||
|
||||
INSERT INTO "HivePartType" ("id", "systemId", "kind", "name", "lengthMm", "widthMm", "heightMm", "boxDepth", "frameSlots", "slotWidth", "stateSchema", "active", "updatedAt")
|
||||
VALUES (
|
||||
'hive-part-zl10-frame-feeder',
|
||||
'hive-system-zander-liebig-10',
|
||||
'FRAME_FEEDER',
|
||||
'Holz-Futtertasche Zander, doppelte Wabenbreite',
|
||||
477,
|
||||
70,
|
||||
220,
|
||||
NULL,
|
||||
NULL,
|
||||
2,
|
||||
'[{"key":"syrupLiters","label":"Flüssigfutter","kind":"level","unit":"l","max":2,"step":0.25,"feedType":"SUGAR_WATER"}]'::jsonb,
|
||||
true,
|
||||
CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -0,0 +1,96 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "VoiceCaptureSource" AS ENUM ('AUDIO', 'TEXT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "VoiceCaptureStatus" AS ENUM ('QUEUED', 'TRANSCRIBING', 'TRANSCRIBED', 'EXTRACTING', 'READY', 'CONFIRMED', 'DISCARDED', 'FAILED', 'EXPIRED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "VoiceCapture" (
|
||||
"id" TEXT NOT NULL,
|
||||
"ownerUserId" TEXT NOT NULL,
|
||||
"colonyId" TEXT NOT NULL,
|
||||
"inspectionDate" DATE NOT NULL,
|
||||
"source" "VoiceCaptureSource" NOT NULL,
|
||||
"status" "VoiceCaptureStatus" NOT NULL DEFAULT 'QUEUED',
|
||||
"audioMime" TEXT,
|
||||
"audioBytes" INTEGER,
|
||||
"durationSec" INTEGER,
|
||||
"transcriptEnc" TEXT,
|
||||
"suggestionEnc" TEXT,
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"claimedAt" TIMESTAMP(3),
|
||||
"claimToken" TEXT,
|
||||
"lastErrorCode" TEXT,
|
||||
"model" TEXT,
|
||||
"inputTokens" INTEGER,
|
||||
"outputTokens" INTEGER,
|
||||
"inspectionId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"readyAt" TIMESTAMP(3),
|
||||
"contentExpiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"contentDeletedAt" TIMESTAMP(3),
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "VoiceCapture_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "VoiceCaptureAudio" (
|
||||
"captureId" TEXT NOT NULL,
|
||||
"dataEnc" BYTEA NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "VoiceCaptureAudio_pkey" PRIMARY KEY ("captureId")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VoiceCapture_status_availableAt_idx" ON "VoiceCapture"("status", "availableAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VoiceCapture_ownerUserId_status_idx" ON "VoiceCapture"("ownerUserId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VoiceCapture_contentExpiresAt_idx" ON "VoiceCapture"("contentExpiresAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VoiceCapture" ADD CONSTRAINT "VoiceCapture_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VoiceCapture" ADD CONSTRAINT "VoiceCapture_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VoiceCaptureAudio" ADD CONSTRAINT "VoiceCaptureAudio_captureId_fkey" FOREIGN KEY ("captureId") REFERENCES "VoiceCapture"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
|
||||
-- Constraints Prisma cannot express.
|
||||
ALTER TABLE "VoiceCapture"
|
||||
ADD CONSTRAINT "VoiceCapture_audioBytes_range"
|
||||
CHECK ("audioBytes" IS NULL OR "audioBytes" BETWEEN 1 AND 2097152);
|
||||
|
||||
ALTER TABLE "VoiceCapture"
|
||||
ADD CONSTRAINT "VoiceCapture_durationSec_range"
|
||||
CHECK ("durationSec" IS NULL OR "durationSec" BETWEEN 1 AND 240);
|
||||
|
||||
ALTER TABLE "VoiceCapture"
|
||||
ADD CONSTRAINT "VoiceCapture_confirmed_matches_inspection"
|
||||
CHECK (("status" = 'CONFIRMED') = ("inspectionId" IS NOT NULL));
|
||||
|
||||
ALTER TABLE "VoiceCapture"
|
||||
ADD CONSTRAINT "VoiceCapture_audioMime_only_for_audio"
|
||||
CHECK ("source" = 'AUDIO' OR "audioMime" IS NULL);
|
||||
|
||||
-- Permission: the boot-time catalog sync only grants new permissions to
|
||||
-- system roles (ADMIN), so the grants are made here (same as T6). READONLY
|
||||
-- and API_CLIENT get nothing: voice notes are for people who inspect.
|
||||
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
|
||||
VALUES
|
||||
(gen_random_uuid()::text, 'voice-captures:use', 'voice-captures', 'use', 'Benutzen', 'Sprache', 4000)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||
SELECT r.id, p.id, now()
|
||||
FROM "Role" r, "Permission" p
|
||||
WHERE p.key = 'voice-captures:use'
|
||||
AND r.key IN ('ADMIN', 'BEEKEEPER', 'STAFF')
|
||||
ON CONFLICT DO NOTHING;
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "VoiceExtractionOutcome" AS ENUM ('OK', 'REFUSED', 'TRUNCATED', 'INVALID', 'RETRY', 'BAD_REQUEST', 'AUTH', 'ABORTED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "VoiceExtractionCall" (
|
||||
"id" TEXT NOT NULL,
|
||||
"at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"model" TEXT NOT NULL,
|
||||
"inputTokens" INTEGER NOT NULL DEFAULT 0,
|
||||
"outputTokens" INTEGER NOT NULL DEFAULT 0,
|
||||
"outcome" "VoiceExtractionOutcome" NOT NULL,
|
||||
"captureId" TEXT,
|
||||
|
||||
CONSTRAINT "VoiceExtractionCall_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VoiceExtractionCall_at_idx" ON "VoiceExtractionCall"("at");
|
||||
@@ -104,6 +104,8 @@ model User {
|
||||
refreshTokens RefreshToken[]
|
||||
|
||||
inspections Inspection[]
|
||||
voiceCaptures VoiceCapture[]
|
||||
hiveChanges HiveChange[]
|
||||
varroaMeasurements VarroaMeasurement[]
|
||||
swarmControlChecks SwarmControlCheck[]
|
||||
winterChecks WinterCheck[]
|
||||
@@ -331,6 +333,8 @@ model Colony {
|
||||
harvests HarvestColony[]
|
||||
queenEvaluations QueenEvaluation[]
|
||||
suggestions Suggestion[]
|
||||
hiveSetup HiveSetup?
|
||||
voiceCaptures VoiceCapture[]
|
||||
|
||||
@@index([apiaryId])
|
||||
@@index([status])
|
||||
@@ -501,7 +505,8 @@ model Inspection {
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tasks Task[]
|
||||
tasks Task[]
|
||||
hiveChange HiveChange?
|
||||
|
||||
@@index([colonyId, date])
|
||||
}
|
||||
@@ -2182,3 +2187,202 @@ model ComplianceAcknowledgement {
|
||||
|
||||
@@index([itemKey, subjectId])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Beute in 3D: Beutensysteme, Teile-Arten, Aufbau je Volk (Spec 2026-10-15)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
model HiveSystem {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
frameWidthMm Int // Oberträger
|
||||
frameHeightMm Int
|
||||
footprintLMm Int
|
||||
footprintWMm Int
|
||||
template Json // Aufbau-JSON ohne Ids, Schema 5.2
|
||||
active Boolean @default(true)
|
||||
partTypes HivePartType[]
|
||||
setups HiveSetup[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model HivePartType {
|
||||
id String @id @default(cuid())
|
||||
systemId String
|
||||
system HiveSystem @relation(fields: [systemId], references: [id])
|
||||
kind HivePartKind
|
||||
name String
|
||||
articleRef String? // z. B. Holtermann-Art.-Nr.
|
||||
lengthMm Int
|
||||
widthMm Int
|
||||
heightMm Int
|
||||
boxDepth HiveBoxDepth?
|
||||
frameSlots Int? // nur BOX
|
||||
slotWidth Int? // nur FRAME_FEEDER: belegte Rähmchen-Plätze
|
||||
stateSchema Json // Abschnitt 4.2
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([systemId, name])
|
||||
}
|
||||
|
||||
model HiveSetup {
|
||||
colonyId String @id
|
||||
colony Colony @relation(fields: [colonyId], references: [id])
|
||||
systemId String
|
||||
system HiveSystem @relation(fields: [systemId], references: [id])
|
||||
version Int @default(1)
|
||||
state Json // Abschnitt 5.2, immer vollständig
|
||||
updatedAt DateTime @updatedAt
|
||||
changes HiveChange[]
|
||||
}
|
||||
|
||||
model HiveChange {
|
||||
id String @id @default(cuid())
|
||||
colonyId String
|
||||
setup HiveSetup @relation(fields: [colonyId], references: [colonyId])
|
||||
version Int // Version nach der Änderung
|
||||
occurredOn DateTime // fachlicher Tag (Durchsicht oder Eingabe)
|
||||
source HiveChangeSource
|
||||
inspectionId String? @unique
|
||||
inspection Inspection? @relation(fields: [inspectionId], references: [id])
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
ops Json // angewandte Operationen, Abschnitt 6
|
||||
stateAfter Json
|
||||
note String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([colonyId, version])
|
||||
@@index([colonyId, occurredOn])
|
||||
}
|
||||
|
||||
enum HivePartKind {
|
||||
FLOOR
|
||||
BOX
|
||||
DIVIDER_BOARD
|
||||
QUEEN_EXCLUDER
|
||||
FEEDER
|
||||
COVER_SHEET
|
||||
PROPOLIS_SCREEN
|
||||
INNER_LID
|
||||
INSULATION
|
||||
ROOF
|
||||
FLOOR_INSERT
|
||||
EVAPORATOR
|
||||
FRAME_FEEDER
|
||||
}
|
||||
|
||||
enum HiveBoxDepth {
|
||||
FULL
|
||||
SHALLOW
|
||||
}
|
||||
|
||||
enum HiveChangeSource {
|
||||
INIT
|
||||
CARD
|
||||
EDITOR
|
||||
INSPECTION
|
||||
AI_SUGGESTION
|
||||
}
|
||||
|
||||
enum VoiceCaptureSource {
|
||||
AUDIO
|
||||
TEXT
|
||||
}
|
||||
|
||||
enum VoiceCaptureStatus {
|
||||
QUEUED // audio uploaded, waits for Whisper
|
||||
TRANSCRIBING // claimed by the CronJob
|
||||
TRANSCRIBED // text present, waits for Claude (also source TEXT)
|
||||
EXTRACTING // claimed by the worker
|
||||
READY // suggestions present, waits for the user
|
||||
CONFIRMED // inspection saved, content deleted
|
||||
DISCARDED // discarded by the user, content deleted
|
||||
FAILED // finally failed, code in lastErrorCode
|
||||
EXPIRED // deadline passed, content deleted
|
||||
}
|
||||
|
||||
// A voice note. Audio and text are personal data: content lives here only
|
||||
// encrypted and only until it is taken over, at most 7 days. The row itself
|
||||
// holds no content and stays 90 days for the error and cost overview.
|
||||
model VoiceCapture {
|
||||
id String @id @default(cuid())
|
||||
ownerUserId String
|
||||
owner User @relation(fields: [ownerUserId], references: [id])
|
||||
colonyId String
|
||||
colony Colony @relation(fields: [colonyId], references: [id])
|
||||
inspectionDate DateTime @db.Date // date of the inspection per the form
|
||||
source VoiceCaptureSource
|
||||
status VoiceCaptureStatus @default(QUEUED)
|
||||
|
||||
audioMime String? // audio/webm, audio/ogg, audio/mp4
|
||||
audioBytes Int?
|
||||
durationSec Int? // reported by the device; Whisper measures again
|
||||
|
||||
transcriptEnc String? // key-ring ciphertext
|
||||
suggestionEnc String? // key-ring ciphertext of the checked suggestion
|
||||
|
||||
attempts Int @default(0)
|
||||
availableAt DateTime @default(now())
|
||||
claimedAt DateTime?
|
||||
claimToken String? // guards against completion by a process that lost the claim
|
||||
lastErrorCode String? // fixed code, never content
|
||||
|
||||
model String? // e.g. "claude-sonnet-5-5"
|
||||
inputTokens Int?
|
||||
outputTokens Int?
|
||||
|
||||
inspectionId String? // set when CONFIRMED
|
||||
createdAt DateTime @default(now())
|
||||
readyAt DateTime?
|
||||
contentExpiresAt DateTime // createdAt + 7 days
|
||||
contentDeletedAt DateTime? // when audio/text/suggestion were deleted
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
audio VoiceCaptureAudio?
|
||||
|
||||
@@index([status, availableAt])
|
||||
@@index([ownerUserId, status])
|
||||
@@index([contentExpiresAt])
|
||||
}
|
||||
|
||||
// The audio lives in its own table: deleting is a DELETE of the row (not
|
||||
// nulling a big column), and the row is never loaded into lists.
|
||||
model VoiceCaptureAudio {
|
||||
captureId String @id
|
||||
capture VoiceCapture @relation(fields: [captureId], references: [id], onDelete: Cascade)
|
||||
dataEnc Bytes // key-ring ciphertext of the audio, at most 2 MB plaintext
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
// What a call to the language model counts as. The daily budget counts every
|
||||
// outcome except AUTH and ABORTED (those never reached the model).
|
||||
enum VoiceExtractionOutcome {
|
||||
OK
|
||||
REFUSED
|
||||
TRUNCATED
|
||||
INVALID
|
||||
RETRY
|
||||
BAD_REQUEST
|
||||
AUTH
|
||||
ABORTED
|
||||
}
|
||||
|
||||
// Ledger of paid model calls for voice extraction: the daily budget and the
|
||||
// cost card read it, not the notes (a retry or a refused call costs money
|
||||
// too). Ids and numbers only - no content, no foreign key (a note may be
|
||||
// deleted while the call stays counted). Rows go after 90 days (cleanup).
|
||||
model VoiceExtractionCall {
|
||||
id String @id @default(cuid())
|
||||
at DateTime @default(now())
|
||||
model String
|
||||
inputTokens Int @default(0)
|
||||
outputTokens Int @default(0)
|
||||
outcome VoiceExtractionOutcome
|
||||
captureId String?
|
||||
|
||||
@@index([at])
|
||||
}
|
||||
@@ -16,18 +16,28 @@ function generateRandomPassword(): string {
|
||||
}
|
||||
|
||||
// Only printed the moment the account is actually created (first deploy on
|
||||
// an empty database), never on later restarts: by then the real admin may
|
||||
// an empty user table), never on later restarts: by then the real admin may
|
||||
// have changed the password via /profile, so repeating it would just be
|
||||
// stale and would leak credentials into logs indefinitely for no reason.
|
||||
function printLoginBanner(email: string, password: string): void {
|
||||
// An operator-supplied password is never printed: the operator already has
|
||||
// it, and the log would only be one more place it leaks to.
|
||||
function printLoginBanner(
|
||||
email: string,
|
||||
password: string | null,
|
||||
log: (line: string) => void,
|
||||
): void {
|
||||
const url = process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000';
|
||||
const line = '='.repeat(60);
|
||||
console.log(line);
|
||||
console.log('Seed: Admin-Zugang eingerichtet.');
|
||||
console.log(` URL: ${url}`);
|
||||
console.log(` Benutzer: ${email}`);
|
||||
console.log(` Passwort: ${password}`);
|
||||
console.log(line);
|
||||
log(line);
|
||||
log('Seed: Admin-Zugang eingerichtet.');
|
||||
log(` URL: ${url}`);
|
||||
log(` Benutzer: ${email}`);
|
||||
log(
|
||||
password === null
|
||||
? ' Passwort: wie in SEED_ADMIN_PASSWORD gesetzt'
|
||||
: ` Passwort: ${password}`,
|
||||
);
|
||||
log(line);
|
||||
}
|
||||
|
||||
// Duplicated from src/task-templates/recurrence.ts rather than imported:
|
||||
@@ -189,22 +199,26 @@ async function seedDefaultTaskTemplates(prisma: PrismaClient): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const prisma = new PrismaClient({
|
||||
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
|
||||
});
|
||||
type AdminSeedClient = Pick<PrismaClient, 'user' | 'role'>;
|
||||
|
||||
await seedDefaultTaskTemplates(prisma);
|
||||
|
||||
const email = process.env.SEED_ADMIN_EMAIL ?? DEFAULT_ADMIN_EMAIL;
|
||||
const password = process.env.SEED_ADMIN_PASSWORD ?? generateRandomPassword();
|
||||
const existing = await prisma.user.findUnique({ where: { email } });
|
||||
if (existing) {
|
||||
console.log(`Seed: admin user ${email} already exists, skipping.`);
|
||||
await prisma.$disconnect();
|
||||
return;
|
||||
/**
|
||||
* Creates the first administrator, but only while the user table is still
|
||||
* empty. Checking SEED_ADMIN_EMAIL instead would add a second ADMIN (and log
|
||||
* its password) on every start after the real admin changed their address.
|
||||
*/
|
||||
export async function seedAdmin(
|
||||
prisma: AdminSeedClient,
|
||||
env: NodeJS.ProcessEnv,
|
||||
log: (line: string) => void,
|
||||
): Promise<'created' | 'skipped'> {
|
||||
if ((await prisma.user.count()) > 0) {
|
||||
log('Seed: Es gibt bereits Benutzer, kein Admin angelegt.');
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
const email = env.SEED_ADMIN_EMAIL ?? DEFAULT_ADMIN_EMAIL;
|
||||
const suppliedPassword = env.SEED_ADMIN_PASSWORD;
|
||||
const password = suppliedPassword ?? generateRandomPassword();
|
||||
const passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
|
||||
const adminRole = await prisma.role.findUniqueOrThrow({
|
||||
where: { key: 'ADMIN' },
|
||||
@@ -218,27 +232,42 @@ async function main(): Promise<void> {
|
||||
roleId: adminRole.id,
|
||||
},
|
||||
});
|
||||
printLoginBanner(email, password);
|
||||
} catch (error: unknown) {
|
||||
// P2002 = unique constraint violation on email: another replica's
|
||||
// startup won this race between the existence check above and this
|
||||
// create() call. The user now exists either way, so this is a no-op,
|
||||
// not a failure - container startup must not crash on it.
|
||||
// startup won this race between the count above and this create()
|
||||
// call. The user now exists either way, so this is a no-op, not a
|
||||
// failure - container startup must not crash on it.
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2002'
|
||||
) {
|
||||
console.log(
|
||||
`Seed: admin user ${email} was created concurrently, skipping.`,
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
log(`Seed: admin user ${email} was created concurrently, skipping.`);
|
||||
return 'skipped';
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
printLoginBanner(
|
||||
email,
|
||||
suppliedPassword === undefined ? password : null,
|
||||
log,
|
||||
);
|
||||
return 'created';
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const prisma = new PrismaClient({
|
||||
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
|
||||
});
|
||||
|
||||
await seedDefaultTaskTemplates(prisma);
|
||||
await seedAdmin(prisma, process.env, (line) => console.log(line));
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
// Only when run as `node dist/seed.js`, not when a test imports seedAdmin.
|
||||
if (require.main === module) {
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Evaluates voice extraction against the gold set (test/fixtures/voice/gold).
|
||||
*
|
||||
* npm run eval:voice offline, replays the expected answers, no network, free
|
||||
* npm run eval:voice -- --live --yes real Claude calls, costs money
|
||||
* npm run eval:voice -- --live --yes --model=claude-haiku-5-5
|
||||
*
|
||||
* Live mode needs ANTHROPIC_API_KEY in the environment AND both --live and
|
||||
* --yes; it is never the default and never runs in CI (no workflow calls
|
||||
* this script). This is an operator tool, not a request path: it lives
|
||||
* outside src/, so the network guard (src/common/no-network-outside-loops)
|
||||
* does not scan it, and it talks to the adapter only through the
|
||||
* StructuredModel port.
|
||||
*
|
||||
* Exit code: 0 pass, 1 below the thresholds (precision < 98 % or recall <
|
||||
* 85 %, see EVAL_THRESHOLDS), 2 usage or setup error.
|
||||
*/
|
||||
import { join } from 'node:path';
|
||||
import { anthropicApiKey } from '../src/voice/anthropic-config';
|
||||
import { formatReport } from '../src/voice/eval/eval-report';
|
||||
import { loadGoldCases } from '../src/voice/eval/gold-case';
|
||||
import { estimateCostUsd } from '../src/voice/eval/voice-prices';
|
||||
import {
|
||||
offlineModelFor,
|
||||
runEval,
|
||||
summarize,
|
||||
} from '../src/voice/eval/voice-eval';
|
||||
import { AnthropicStructuredModel } from '../src/voice/extraction/anthropic-structured-model';
|
||||
import { FAKE_USAGE } from '../src/voice/extraction/fake-structured-model';
|
||||
import { DEFAULT_VOICE_MODEL, VOICE_MODELS } from '../src/voice/voice-config';
|
||||
|
||||
const GOLD_DIR = join(__dirname, '..', 'test', 'fixtures', 'voice', 'gold');
|
||||
const EXIT_PASS = 0;
|
||||
const EXIT_BELOW_THRESHOLD = 1;
|
||||
const EXIT_USAGE = 2;
|
||||
|
||||
interface Options {
|
||||
live: boolean;
|
||||
yes: boolean;
|
||||
model: string;
|
||||
}
|
||||
|
||||
function parseArgs(args: readonly string[]): Options | string {
|
||||
let live = false;
|
||||
let yes = false;
|
||||
let model: string = DEFAULT_VOICE_MODEL;
|
||||
for (const arg of args) {
|
||||
if (arg === '--live') {
|
||||
live = true;
|
||||
} else if (arg === '--yes') {
|
||||
yes = true;
|
||||
} else if (arg.startsWith('--model=')) {
|
||||
model = arg.slice('--model='.length);
|
||||
} else {
|
||||
return `Unknown argument: ${arg}`;
|
||||
}
|
||||
}
|
||||
if (!(VOICE_MODELS as readonly string[]).includes(model)) {
|
||||
return `--model must be one of ${VOICE_MODELS.join(', ')}`;
|
||||
}
|
||||
return { live, yes, model };
|
||||
}
|
||||
|
||||
function fail(message: string): never {
|
||||
console.error(message);
|
||||
process.exit(EXIT_USAGE);
|
||||
}
|
||||
|
||||
async function main(): Promise<number> {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (typeof options === 'string') {
|
||||
return fail(options);
|
||||
}
|
||||
const cases = loadGoldCases(GOLD_DIR);
|
||||
if (!options.live) {
|
||||
const results = await runEval(
|
||||
cases,
|
||||
offlineModelFor(options.model),
|
||||
options.model,
|
||||
);
|
||||
console.log(
|
||||
formatReport(results, summarize(results), {
|
||||
mode: 'offline',
|
||||
model: options.model,
|
||||
}),
|
||||
);
|
||||
return summarizeExit(results);
|
||||
}
|
||||
const key = anthropicApiKey(process.env);
|
||||
if (key === null) {
|
||||
return fail('--live needs a valid ANTHROPIC_API_KEY in the environment.');
|
||||
}
|
||||
const estimate = estimateCostUsd(options.model, {
|
||||
input: FAKE_USAGE.input * cases.length,
|
||||
output: FAKE_USAGE.output * cases.length,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
});
|
||||
console.warn(
|
||||
`WARNING: ${cases.length} real Claude calls on ${options.model}, roughly ${
|
||||
estimate === null ? 'an unknown amount' : `$${estimate.toFixed(2)}`
|
||||
} of API cost.`,
|
||||
);
|
||||
if (!options.yes) {
|
||||
return fail('Add --yes to confirm and run the live evaluation.');
|
||||
}
|
||||
const live = new AnthropicStructuredModel(key);
|
||||
const results = await runEval(cases, () => live, options.model);
|
||||
console.log(
|
||||
formatReport(results, summarize(results), {
|
||||
mode: 'live',
|
||||
model: options.model,
|
||||
}),
|
||||
);
|
||||
return summarizeExit(results);
|
||||
}
|
||||
|
||||
function summarizeExit(results: Parameters<typeof summarize>[0]): number {
|
||||
return summarize(results).failures.length === 0
|
||||
? EXIT_PASS
|
||||
: EXIT_BELOW_THRESHOLD;
|
||||
}
|
||||
|
||||
main().then(
|
||||
(code) => process.exit(code),
|
||||
(error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : 'eval failed');
|
||||
process.exit(EXIT_USAGE);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Writes the Whisper initial prompt to ApiCore-Whisper/vocabulary.txt, so the
|
||||
* Python image uses the same word list as the backend (one source:
|
||||
* src/voice/core/voice-vocabulary.ts). Run `npm run export:voice-vocabulary`
|
||||
* after changing the list; voice-vocabulary-export.spec.ts fails until you do.
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { whisperInitialPrompt } from '../src/voice/core/voice-vocabulary';
|
||||
|
||||
const target = join(__dirname, '..', '..', 'ApiCore-Whisper', 'vocabulary.txt');
|
||||
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, `${whisperInitialPrompt()}\n`, 'utf8');
|
||||
console.log(`Wrote ${target}`);
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AutopilotModule } from '../autopilot/autopilot.module';
|
||||
import { DomainEventsModule } from '../domain-events/domain-events.module';
|
||||
import { HiveModule } from '../hive/hive.module';
|
||||
import { ApiariesController } from './apiaries.controller';
|
||||
import { ApiariesService } from './apiaries.service';
|
||||
import { ApiaryFieldService } from './apiary-field.service';
|
||||
|
||||
@Module({
|
||||
imports: [AutopilotModule, DomainEventsModule],
|
||||
imports: [AutopilotModule, DomainEventsModule, HiveModule],
|
||||
controllers: [ApiariesController],
|
||||
providers: [ApiariesService, ApiaryFieldService],
|
||||
exports: [ApiariesService],
|
||||
|
||||
@@ -4,6 +4,8 @@ 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;
|
||||
@@ -17,6 +19,8 @@ describe('ApiaryFieldService', () => {
|
||||
};
|
||||
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,
|
||||
@@ -87,10 +91,14 @@ describe('ApiaryFieldService', () => {
|
||||
),
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -129,6 +137,40 @@ describe('ApiaryFieldService', () => {
|
||||
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');
|
||||
|
||||
@@ -2,6 +2,8 @@ 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 {
|
||||
@@ -12,6 +14,15 @@ import type {
|
||||
|
||||
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 {
|
||||
@@ -33,6 +44,8 @@ export class ApiaryFieldService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissionResolver: PermissionResolverService,
|
||||
private readonly nextSteps: NextStepsService,
|
||||
private readonly hives: HiveService,
|
||||
private readonly hiveCatalogs: HiveCatalogService,
|
||||
) {}
|
||||
|
||||
async colonyOverview(
|
||||
@@ -45,12 +58,14 @@ export class ApiaryFieldService {
|
||||
async offlinePack(apiaryId: string, userId: string): Promise<OfflinePackV1> {
|
||||
const { apiary, colonies, permissions, overview, nextSteps } =
|
||||
await this.load(apiaryId, userId);
|
||||
const [queens, inspections, tasks] = await Promise.all([
|
||||
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,
|
||||
@@ -61,6 +76,8 @@ export class ApiaryFieldService {
|
||||
queens,
|
||||
inspections,
|
||||
tasks,
|
||||
hives,
|
||||
hiveCatalog: activeOrUsed(hiveCatalog, hives),
|
||||
nextSteps: Object.fromEntries(
|
||||
colonies.map((colony) => [
|
||||
colony.id,
|
||||
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
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';
|
||||
|
||||
@@ -106,4 +108,11 @@ export interface OfflinePackV1 {
|
||||
* 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[];
|
||||
}
|
||||
@@ -60,9 +60,11 @@ import { SuggestionsModule } from './suggestions/suggestions.module';
|
||||
import { AutopilotModule } from './autopilot/autopilot.module';
|
||||
import { SystemModule } from './system/system.module';
|
||||
import { WithdrawalModule } from './withdrawal/withdrawal.module';
|
||||
import { HiveModule } from './hive/hive.module';
|
||||
import { VeterinaryMedicinesModule } from './veterinary-medicines/veterinary-medicines.module';
|
||||
import { ComplianceModule } from './compliance/compliance.module';
|
||||
import { ReadinessModule } from './compliance/readiness/readiness.module';
|
||||
import { VoiceModule } from './voice/voice.module';
|
||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
||||
import { PermissionsModule } from './auth/permissions/permissions.module';
|
||||
@@ -136,8 +138,10 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
|
||||
ApiKeysModule,
|
||||
SystemModule,
|
||||
VeterinaryMedicinesModule,
|
||||
HiveModule,
|
||||
ComplianceModule,
|
||||
ReadinessModule,
|
||||
VoiceModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [
|
||||
|
||||
@@ -412,6 +412,11 @@ export const PERMISSION_CATALOG = [
|
||||
],
|
||||
3800,
|
||||
),
|
||||
// Beute in 3D: hive systems and their part-type catalog. Reading the
|
||||
// catalog and writing a colony's setup use the colonies permissions.
|
||||
...group('Beutensysteme', 'hive-systems', [['manage', 'Pflegen']], 3900),
|
||||
// T7 Freihand: voice notes. Every endpoint also needs inspections:create.
|
||||
...group('Sprache', 'voice-captures', [['use', 'Benutzen']], 4000),
|
||||
] as const satisfies readonly PermissionCatalogEntry[];
|
||||
|
||||
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException, Logger } from '@nestjs/common';
|
||||
import * as path from 'node:path';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import {
|
||||
BackupBundle,
|
||||
BackupService,
|
||||
LEGACY_UNSEALED_TABLES,
|
||||
MODELS_EXCLUDED_FROM_BACKUP,
|
||||
@@ -113,7 +114,15 @@ describe('BackupService', () => {
|
||||
document: { findMany: jest.Mock; count: jest.Mock };
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let tx: Record<string, { upsert: jest.Mock; update?: jest.Mock }> & {
|
||||
let tx: Record<
|
||||
string,
|
||||
{
|
||||
upsert: jest.Mock;
|
||||
update?: jest.Mock;
|
||||
create?: jest.Mock;
|
||||
deleteMany?: jest.Mock;
|
||||
}
|
||||
> & {
|
||||
permission: { findUnique: jest.Mock };
|
||||
user: { upsert: jest.Mock; findMany: jest.Mock };
|
||||
queen: { upsert: jest.Mock; update: jest.Mock; findMany: jest.Mock };
|
||||
@@ -139,6 +148,10 @@ describe('BackupService', () => {
|
||||
(tx as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
for (const key of ['hiveChange', 'hiveSetup']) {
|
||||
tx[key].create = jest.fn().mockResolvedValue({});
|
||||
tx[key].deleteMany = jest.fn().mockResolvedValue({ count: 0 });
|
||||
}
|
||||
tx.queen.update = jest.fn().mockResolvedValue({});
|
||||
tx.queen.findMany = jest.fn().mockResolvedValue([]);
|
||||
tx.document.findUnique = jest.fn().mockResolvedValue(null);
|
||||
@@ -1523,6 +1536,29 @@ describe('BackupService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('excludes VoiceCapture and VoiceCaptureAudio with a reason', async () => {
|
||||
const bundle = await service.exportAll();
|
||||
|
||||
for (const [model, key] of [
|
||||
['VoiceCapture', 'voiceCapture'],
|
||||
['VoiceCaptureAudio', 'voiceCaptureAudio'],
|
||||
] as const) {
|
||||
expect(bundle.data).not.toHaveProperty(key);
|
||||
expect(MODELS_EXCLUDED_FROM_BACKUP[model]).toMatch(
|
||||
/ephemeral, personal data, not deliverable after a restore/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('excludes the VoiceExtractionCall ledger with a reason', async () => {
|
||||
const bundle = await service.exportAll();
|
||||
|
||||
expect(bundle.data).not.toHaveProperty('voiceExtractionCall');
|
||||
expect(MODELS_EXCLUDED_FROM_BACKUP.VoiceExtractionCall).toMatch(
|
||||
/Operational ledger/,
|
||||
);
|
||||
});
|
||||
|
||||
it('restores phenology reports after the apiaries, remapping a user that does not exist', async () => {
|
||||
await importData({
|
||||
apiary: [{ id: 'apiary-1' }],
|
||||
@@ -1948,6 +1984,80 @@ describe('BackupService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('importAll hive models', () => {
|
||||
const hiveBundle = (): BackupBundle => ({
|
||||
version: 3,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
hiveSystem: [{ id: 'hive-system-1', name: 'System' }],
|
||||
hivePartType: [{ id: 'hive-part-1', systemId: 'hive-system-1' }],
|
||||
colony: [{ id: 'colony-1' }, { id: 'colony-2' }],
|
||||
hiveSetup: [
|
||||
{ colonyId: 'colony-1', systemId: 'hive-system-1', version: 2 },
|
||||
{ colonyId: 'colony-other', systemId: 'hive-system-1', version: 1 },
|
||||
],
|
||||
hiveChange: [
|
||||
{ id: 'change-1', colonyId: 'colony-1', userId: 'known-user' },
|
||||
{ id: 'change-2', colonyId: 'colony-1', userId: 'gone-user' },
|
||||
{ id: 'change-3', colonyId: 'colony-other', userId: 'known-user' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
it('upserts the catalog by id so seeded rows are neither duplicated nor rejected', async () => {
|
||||
await service.importAll(hiveBundle(), 'admin-1', TRUSTED);
|
||||
|
||||
expect(tx.hiveSystem.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'hive-system-1' } }),
|
||||
);
|
||||
expect(tx.hivePartType.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'hive-part-1' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('replaces setup and history of each restored colony, remapping the history user', async () => {
|
||||
const summary = await service.importAll(hiveBundle(), 'admin-1', TRUSTED);
|
||||
|
||||
expect(tx.hiveChange.deleteMany).toHaveBeenCalledWith({
|
||||
where: { colonyId: { in: ['colony-1'] } },
|
||||
});
|
||||
expect(tx.hiveSetup.deleteMany).toHaveBeenCalledWith({
|
||||
where: { colonyId: { in: ['colony-1'] } },
|
||||
});
|
||||
expect(tx.hiveSetup.create).toHaveBeenCalledTimes(1);
|
||||
const created = tx.hiveChange.create!.mock.calls.map(
|
||||
([arg]: [{ data: { id: string; userId: string } }]) => arg.data,
|
||||
);
|
||||
expect(created).toEqual([
|
||||
expect.objectContaining({ id: 'change-1', userId: 'known-user' }),
|
||||
expect.objectContaining({ id: 'change-2', userId: 'admin-1' }),
|
||||
]);
|
||||
expect(summary.imported).toMatchObject({
|
||||
hiveSystem: 1,
|
||||
hivePartType: 1,
|
||||
hiveSetup: 2,
|
||||
hiveChange: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('takes no hive table from an unsealed file and reports them', async () => {
|
||||
const summary = await service.importAll(hiveBundle(), 'admin-1', {
|
||||
trusted: false,
|
||||
});
|
||||
|
||||
expect(tx.hiveSetup.create).not.toHaveBeenCalled();
|
||||
expect(tx.hiveSystem.upsert).not.toHaveBeenCalled();
|
||||
expect(summary.skipped).toEqual(
|
||||
expect.arrayContaining([
|
||||
'hiveSystem',
|
||||
'hivePartType',
|
||||
'hiveSetup',
|
||||
'hiveChange',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Drift guard: a model added to the schema has to be backed up or listed
|
||||
// in MODELS_EXCLUDED_FROM_BACKUP with its reason - never silently missing.
|
||||
describe('model coverage', () => {
|
||||
|
||||
@@ -158,6 +158,12 @@ export const MODELS_EXCLUDED_FROM_BACKUP: Readonly<
|
||||
NotificationDelivery:
|
||||
'Delivery queue - restoring it would resend old messages.',
|
||||
TelegramPollState: 'Update offset and poll lease of this deployment.',
|
||||
VoiceCapture:
|
||||
'Voice notes are ephemeral, personal data, not deliverable after a restore (content is deleted after at most 7 days).',
|
||||
VoiceCaptureAudio:
|
||||
'Encrypted audio of voice notes: ephemeral, personal data, not deliverable after a restore.',
|
||||
VoiceExtractionCall:
|
||||
'Operational ledger of paid model calls (daily budget, cost card), kept 90 days, meaningless after a restore.',
|
||||
};
|
||||
|
||||
const DOCUMENT_ENTITY_TYPES = new Set<string>(
|
||||
@@ -329,9 +335,13 @@ export class BackupService {
|
||||
data.colony = await this.prisma.colony.findMany();
|
||||
data.colonyApiaryHistory = await this.prisma.colonyApiaryHistory.findMany();
|
||||
data.colonyLineage = await this.prisma.colonyLineage.findMany();
|
||||
data.hiveSystem = await this.prisma.hiveSystem.findMany();
|
||||
data.hivePartType = await this.prisma.hivePartType.findMany();
|
||||
data.hiveSetup = await this.prisma.hiveSetup.findMany();
|
||||
data.queenEvaluation = await this.prisma.queenEvaluation.findMany();
|
||||
data.queenColonyHistory = await this.prisma.queenColonyHistory.findMany();
|
||||
data.inspection = await this.prisma.inspection.findMany();
|
||||
data.hiveChange = await this.prisma.hiveChange.findMany();
|
||||
data.taskTemplate = await this.prisma.taskTemplate.findMany();
|
||||
data.suggestion = await this.prisma.suggestion.findMany();
|
||||
data.task = await this.prisma.task.findMany();
|
||||
@@ -608,6 +618,26 @@ export class BackupService {
|
||||
}
|
||||
imported.queen = rowsOf('queen').length;
|
||||
|
||||
// Catalog first: a fresh install already carries the seeded rows with
|
||||
// the same fixed ids, so upsert by id neither fails nor duplicates.
|
||||
for (const row of rowsOf('hiveSystem')) {
|
||||
await tx.hiveSystem.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.hiveSystem = rowsOf('hiveSystem').length;
|
||||
|
||||
for (const row of rowsOf('hivePartType')) {
|
||||
await tx.hivePartType.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.hivePartType = rowsOf('hivePartType').length;
|
||||
|
||||
// Restore deliberately emits no ColonyCreated (no domain events).
|
||||
for (const row of rowsOf('colony')) {
|
||||
await tx.colony.upsert({
|
||||
@@ -618,6 +648,33 @@ export class BackupService {
|
||||
}
|
||||
imported.colony = rowsOf('colony').length;
|
||||
|
||||
// A restore replaces, never merges: the backup's setup wins, so the
|
||||
// existing setup and history of each restored colony go first (the
|
||||
// history rows reference the setup). Their backup history follows
|
||||
// after the inspections.
|
||||
const hiveSetupRows = rowsOf('hiveSetup');
|
||||
const restoredColonyIds = new Set(
|
||||
rowsOf('colony').map((row) => row.id as string),
|
||||
);
|
||||
const setupsToReplace = hiveSetupRows.filter((row) =>
|
||||
restoredColonyIds.has(row.colonyId as string),
|
||||
);
|
||||
const replacedColonyIds = setupsToReplace.map(
|
||||
(row) => row.colonyId as string,
|
||||
);
|
||||
if (replacedColonyIds.length > 0) {
|
||||
await tx.hiveChange.deleteMany({
|
||||
where: { colonyId: { in: replacedColonyIds } },
|
||||
});
|
||||
await tx.hiveSetup.deleteMany({
|
||||
where: { colonyId: { in: replacedColonyIds } },
|
||||
});
|
||||
}
|
||||
for (const row of setupsToReplace) {
|
||||
await tx.hiveSetup.create({ data: row as never });
|
||||
}
|
||||
imported.hiveSetup = hiveSetupRows.length;
|
||||
|
||||
for (const row of rowsOf('queen')) {
|
||||
if (row.motherQueenId && !preexistingQueenIds.has(row.id as string)) {
|
||||
await tx.queen.update({
|
||||
@@ -692,6 +749,21 @@ export class BackupService {
|
||||
}
|
||||
imported.inspection = rowsOf('inspection').length;
|
||||
|
||||
const replacedColonies = new Set(replacedColonyIds);
|
||||
const hiveChangeRows = rowsOf('hiveChange');
|
||||
for (const row of hiveChangeRows) {
|
||||
if (!replacedColonies.has(row.colonyId as string)) {
|
||||
continue;
|
||||
}
|
||||
await tx.hiveChange.create({
|
||||
data: {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
imported.hiveChange = hiveChangeRows.length;
|
||||
|
||||
for (const row of rowsOf('taskTemplate')) {
|
||||
await tx.taskTemplate.upsert({
|
||||
where: { id: row.id as string },
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
|
||||
/**
|
||||
* Secrets the server must read back (TOTP secrets, webhook signing secrets,
|
||||
* backup files) are encrypted with a key of their own per purpose, never
|
||||
* voice notes, backup files) are encrypted with a key of their own per purpose, never
|
||||
* with a JWT secret. Each purpose has a key ring: the current key from
|
||||
* `<VARIABLE>` seals, the previous one from `<VARIABLE>_PREVIOUS` still
|
||||
* opens, so a key can be rotated without losing data. A sealed value names
|
||||
@@ -33,13 +33,14 @@ export const ENCRYPTION_KEY_VARIABLES = {
|
||||
mfa: 'MFA_ENCRYPTION_KEY',
|
||||
webhook: 'WEBHOOK_ENCRYPTION_KEY',
|
||||
backup: 'BACKUP_ENCRYPTION_KEY',
|
||||
voice: 'VOICE_ENCRYPTION_KEY',
|
||||
} as const;
|
||||
|
||||
export type EncryptionKeyVariable =
|
||||
(typeof ENCRYPTION_KEY_VARIABLES)[keyof typeof ENCRYPTION_KEY_VARIABLES];
|
||||
|
||||
/** Purposes sealed in the `k…:` string format; backups have their own envelope (backup-crypto.ts). */
|
||||
export type SecretPurpose = 'mfa' | 'webhook';
|
||||
export type SecretPurpose = 'mfa' | 'webhook' | 'voice';
|
||||
|
||||
export interface RingKey {
|
||||
id: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { matchesFileSignature } from './file-signature';
|
||||
import { detectAudioType, matchesFileSignature } from './file-signature';
|
||||
|
||||
describe('matchesFileSignature', () => {
|
||||
it('accepts a buffer starting with the PNG signature', () => {
|
||||
@@ -47,3 +47,63 @@ describe('matchesFileSignature', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectAudioType', () => {
|
||||
it('knows EBML, OggS and ftyp and nothing else', () => {
|
||||
const webm = Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 0x9f, 0x42]);
|
||||
const ogg = Buffer.concat([Buffer.from('OggS', 'ascii'), Buffer.alloc(8)]);
|
||||
const mp4 = Buffer.concat([
|
||||
Buffer.from([0x00, 0x00, 0x00, 0x20]),
|
||||
Buffer.from('ftypM4A ', 'ascii'),
|
||||
]);
|
||||
expect(detectAudioType(webm)).toBe('audio/webm');
|
||||
expect(detectAudioType(ogg)).toBe('audio/ogg');
|
||||
expect(detectAudioType(mp4)).toBe('audio/mp4');
|
||||
expect(
|
||||
detectAudioType(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a])),
|
||||
).toBeNull();
|
||||
expect(detectAudioType(Buffer.from('RIFF....WAVE', 'ascii'))).toBeNull();
|
||||
expect(detectAudioType(Buffer.from('%PDF-1.7', 'ascii'))).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['heic', 'avif', 'mif1', 'qt ', '3gp4'])(
|
||||
'rejects an ftyp file with the brand %s',
|
||||
(brand) => {
|
||||
const file = Buffer.concat([
|
||||
Buffer.from([0, 0, 0, 0x20]),
|
||||
Buffer.from(`ftyp${brand}`, 'latin1'),
|
||||
]);
|
||||
expect(detectAudioType(file)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['M4A ', 'isom', 'iso2', 'iso6', 'mp41', 'mp42', 'dash'])(
|
||||
'accepts the audio brand %s',
|
||||
(brand) => {
|
||||
const file = Buffer.concat([
|
||||
Buffer.from([0, 0, 0, 0x20]),
|
||||
Buffer.from(`ftyp${brand}`, 'latin1'),
|
||||
]);
|
||||
expect(detectAudioType(file)).toBe('audio/mp4');
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects buffers that are too short for any signature', () => {
|
||||
expect(detectAudioType(Buffer.alloc(0))).toBeNull();
|
||||
expect(detectAudioType(Buffer.from([0x1a, 0x45, 0xdf]))).toBeNull();
|
||||
expect(
|
||||
detectAudioType(Buffer.from('OggS', 'ascii').subarray(0, 3)),
|
||||
).toBeNull();
|
||||
expect(
|
||||
detectAudioType(Buffer.from([0, 0, 0, 0x20, 0x66, 0x74])),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('does not take a signature from the middle of the buffer', () => {
|
||||
const late = Buffer.concat([
|
||||
Buffer.alloc(16),
|
||||
Buffer.from('OggS', 'ascii'),
|
||||
]);
|
||||
expect(detectAudioType(late)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -33,3 +33,47 @@ export function matchesFileSignature(
|
||||
const check = SIGNATURE_CHECKS[declaredMimeType];
|
||||
return check ? check(buffer) : true;
|
||||
}
|
||||
|
||||
export type VoiceAudioMime = 'audio/webm' | 'audio/ogg' | 'audio/mp4';
|
||||
|
||||
// Major brands of MP4/M4A audio; HEIC, AVIF and other ISO-BMFF files are not.
|
||||
const MP4_AUDIO_BRANDS: ReadonlySet<string> = new Set([
|
||||
'M4A ',
|
||||
'isom',
|
||||
'iso2',
|
||||
'iso3',
|
||||
'iso4',
|
||||
'iso5',
|
||||
'iso6',
|
||||
'mp41',
|
||||
'mp42',
|
||||
'dash',
|
||||
]);
|
||||
|
||||
const EBML_MAGIC = Buffer.from([0x1a, 0x45, 0xdf, 0xa3]);
|
||||
|
||||
/**
|
||||
* Names the audio container by its first bytes - strict, unlike
|
||||
* matchesFileSignature: anything unknown is null, and the declared type of
|
||||
* the upload plays no part. EBML (WebM/Matroska), "OggS" and "ftyp" at byte 4
|
||||
* (MP4/M4A) are the containers MediaRecorder produces.
|
||||
*/
|
||||
export function detectAudioType(buffer: Buffer): VoiceAudioMime | null {
|
||||
if (buffer.length >= 4 && buffer.subarray(0, 4).equals(EBML_MAGIC)) {
|
||||
return 'audio/webm';
|
||||
}
|
||||
if (
|
||||
buffer.length >= 4 &&
|
||||
buffer.subarray(0, 4).toString('latin1') === 'OggS'
|
||||
) {
|
||||
return 'audio/ogg';
|
||||
}
|
||||
if (
|
||||
buffer.length >= 12 &&
|
||||
buffer.subarray(4, 8).toString('latin1') === 'ftyp' &&
|
||||
MP4_AUDIO_BRANDS.has(buffer.subarray(8, 12).toString('latin1'))
|
||||
) {
|
||||
return 'audio/mp4';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -12,7 +12,10 @@ import { dirname, join, resolve, sep } from 'node:path';
|
||||
* predate this guard,
|
||||
* - a *.handlers.ts file or a file under src/jobs/ imports no network
|
||||
* package and no module that itself reaches the network - checked on its
|
||||
* own, whatever the fetch rule says.
|
||||
* own, whatever the fetch rule says,
|
||||
* - the Anthropic SDK is imported by exactly one file, the voice adapter
|
||||
* (plan T7, contradiction 17); the worker reaches it only through the
|
||||
* StructuredModel port.
|
||||
*/
|
||||
const SRC = join(__dirname, '..');
|
||||
|
||||
@@ -23,7 +26,9 @@ interface SourceFile {
|
||||
|
||||
const WEATHER_HOSTS = /open-meteo\.com/i;
|
||||
const TELEGRAM_HOST = /api\.telegram\.org/i;
|
||||
const FETCH_USE = /(?<![\w$.])fetch\b(?!\s*:)|\bglobalThis\s*\.\s*fetch\b/;
|
||||
// `--phase=fetch` is the argument of the voice job, not a network call.
|
||||
const FETCH_USE =
|
||||
/(?<![\w$.])(?<!['"`]--phase=)fetch\b(?!\s*:)|\bglobalThis\s*\.\s*fetch\b/;
|
||||
const NETWORK_PACKAGES = new Set([
|
||||
'http',
|
||||
'https',
|
||||
@@ -32,14 +37,35 @@ const NETWORK_PACKAGES = new Set([
|
||||
'got',
|
||||
'node-fetch',
|
||||
]);
|
||||
const ANTHROPIC_SDK = '@anthropic-ai/sdk';
|
||||
const ANTHROPIC_ADAPTER = 'voice/extraction/anthropic-structured-model.ts';
|
||||
const IMPORT_SPECIFIER =
|
||||
/(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\s*\(\s*)['"]([^'"]+)['"]/g;
|
||||
/(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\s*\(\s*)(['"`])([^'"`]+)\1/g;
|
||||
// The files that may (transitively) import the Anthropic adapter.
|
||||
const ADAPTER_WIRING_FILES = [
|
||||
'voice/voice-worker.module.ts',
|
||||
'all-in-one.module.ts',
|
||||
'main.ts',
|
||||
'worker.ts',
|
||||
'runtime/root-module.ts',
|
||||
];
|
||||
const ADAPTER_WIRING_DIRECTORIES = ['worker/'];
|
||||
/**
|
||||
* Handlers that reach a network module only indirectly, through a service
|
||||
* whose network part runs elsewhere. A direct import of a network file is
|
||||
* never excused.
|
||||
*/
|
||||
const INDIRECT_NETWORK_EXCEPTIONS: Readonly<Record<string, string>> = {
|
||||
'calendar-sync/calendar-sync.handlers.ts':
|
||||
'calls only CalendarSyncService.enqueueIn(tx, ...); Graph calls run in the worker loop',
|
||||
};
|
||||
|
||||
const ALLOWED_DIRECTORIES = ['weather/', 'telegram/'];
|
||||
// Files that reach the network and predate the guard.
|
||||
const ALLOWED_FILES = [
|
||||
'calendar-sync/graph-client.service.ts',
|
||||
'common/password/password-policy.ts',
|
||||
ANTHROPIC_ADAPTER,
|
||||
'webhooks/webhook-delivery.ts',
|
||||
'worker/worker-health.ts',
|
||||
'worker/worker-health-server.service.ts',
|
||||
@@ -69,12 +95,21 @@ function sourceFiles(): SourceFile[] {
|
||||
});
|
||||
}
|
||||
|
||||
function isAnthropicSdk(specifier: string): boolean {
|
||||
return (
|
||||
specifier === ANTHROPIC_SDK || specifier.startsWith(`${ANTHROPIC_SDK}/`)
|
||||
);
|
||||
}
|
||||
|
||||
function isNetworkPackage(specifier: string): boolean {
|
||||
return NETWORK_PACKAGES.has(specifier.replace(/^node:/, ''));
|
||||
return (
|
||||
NETWORK_PACKAGES.has(specifier.replace(/^node:/, '')) ||
|
||||
isAnthropicSdk(specifier)
|
||||
);
|
||||
}
|
||||
|
||||
function importsOf(source: string): string[] {
|
||||
return [...source.matchAll(IMPORT_SPECIFIER)].map((match) => match[1]);
|
||||
return [...source.matchAll(IMPORT_SPECIFIER)].map((match) => match[2]);
|
||||
}
|
||||
|
||||
function inAllowedPlace(path: string): boolean {
|
||||
@@ -109,15 +144,60 @@ function resolveImport(
|
||||
.find((candidate) => known.has(candidate));
|
||||
}
|
||||
|
||||
/**
|
||||
* The seeds plus every file that reaches one of them through relative
|
||||
* imports, however many steps away.
|
||||
*/
|
||||
function reachingClosure(
|
||||
files: readonly SourceFile[],
|
||||
seeds: Iterable<string>,
|
||||
): Set<string> {
|
||||
const known = new Set(files.map((file) => file.path));
|
||||
const reaching = new Set(seeds);
|
||||
let grew = true;
|
||||
while (grew) {
|
||||
grew = false;
|
||||
for (const { path, source } of files) {
|
||||
if (reaching.has(path)) {
|
||||
continue;
|
||||
}
|
||||
const reachesSeed = importsOf(source).some((specifier) => {
|
||||
const target = resolveImport(path, specifier, known);
|
||||
return target !== undefined && reaching.has(target);
|
||||
});
|
||||
if (reachesSeed) {
|
||||
reaching.add(path);
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return reaching;
|
||||
}
|
||||
|
||||
function isAdapterWiring(path: string): boolean {
|
||||
return (
|
||||
path === ANTHROPIC_ADAPTER ||
|
||||
ADAPTER_WIRING_FILES.includes(path) ||
|
||||
ADAPTER_WIRING_DIRECTORIES.some((directory) => path.startsWith(directory))
|
||||
);
|
||||
}
|
||||
|
||||
/** Where the code breaks the rule; empty when it keeps it. */
|
||||
function violations(rawFiles: readonly SourceFile[]): string[] {
|
||||
const files = rawFiles.map(({ path, source }) => ({
|
||||
path,
|
||||
source: withoutComments(source),
|
||||
}));
|
||||
const networkFiles = new Set(
|
||||
const directNetworkFiles = new Set(
|
||||
files.filter(reachesNetwork).map((file) => file.path),
|
||||
);
|
||||
const networkFiles = reachingClosure(files, directNetworkFiles);
|
||||
const adapterReachers = reachingClosure(
|
||||
files,
|
||||
files.some((file) => file.path === ANTHROPIC_ADAPTER)
|
||||
? [ANTHROPIC_ADAPTER]
|
||||
: [],
|
||||
);
|
||||
return files.flatMap(({ path, source }) => {
|
||||
const found: string[] = [];
|
||||
if (WEATHER_HOSTS.test(source) && !path.startsWith('weather/')) {
|
||||
@@ -133,6 +213,16 @@ function violations(rawFiles: readonly SourceFile[]): string[] {
|
||||
if (specifiers.some(isNetworkPackage) && !inAllowedPlace(path)) {
|
||||
found.push(`${path}: network package import outside the allowed places`);
|
||||
}
|
||||
if (specifiers.some(isAnthropicSdk) && path !== ANTHROPIC_ADAPTER) {
|
||||
found.push(
|
||||
`${path}: ${ANTHROPIC_SDK} imported outside the voice adapter`,
|
||||
);
|
||||
}
|
||||
if (adapterReachers.has(path) && !isAdapterWiring(path)) {
|
||||
found.push(
|
||||
`${path}: reaches the Anthropic adapter outside the worker wiring`,
|
||||
);
|
||||
}
|
||||
if (isHandlerOrJob(path)) {
|
||||
if (FETCH_USE.test(source)) {
|
||||
found.push(`${path}: fetch in a handler or job`);
|
||||
@@ -140,11 +230,14 @@ function violations(rawFiles: readonly SourceFile[]): string[] {
|
||||
if (specifiers.some(isNetworkPackage)) {
|
||||
found.push(`${path}: network package imported by a handler or job`);
|
||||
}
|
||||
if (
|
||||
const importsAny = (targets: ReadonlySet<string>): boolean =>
|
||||
specifiers.some(
|
||||
(specifier) =>
|
||||
resolveImport(path, specifier, networkFiles) !== undefined,
|
||||
)
|
||||
(specifier) => resolveImport(path, specifier, targets) !== undefined,
|
||||
);
|
||||
if (
|
||||
importsAny(directNetworkFiles) ||
|
||||
(importsAny(networkFiles) &&
|
||||
!Object.hasOwn(INDIRECT_NETWORK_EXCEPTIONS, path))
|
||||
) {
|
||||
found.push(
|
||||
`${path}: handler or job imports a module that reaches the network`,
|
||||
@@ -177,9 +270,24 @@ describe('network calls stay inside their loop directories', () => {
|
||||
expect(existsSync(join(SRC, 'jobs'))).toBe(true);
|
||||
});
|
||||
|
||||
it('@anthropic-ai/sdk is imported only by anthropic-structured-model.ts', () => {
|
||||
const importers = sourceFiles()
|
||||
.filter(({ source }) =>
|
||||
importsOf(withoutComments(source)).some(isAnthropicSdk),
|
||||
)
|
||||
.map((file) => file.path);
|
||||
|
||||
expect(importers).toEqual([ANTHROPIC_ADAPTER]);
|
||||
});
|
||||
|
||||
describe('with planted violations', () => {
|
||||
it.each([
|
||||
['colonies/colonies.service.ts', 'await fetch(url);'],
|
||||
[
|
||||
'colonies/colonies.service.ts',
|
||||
"const a = '--phase=fetch'; await fetch(url);",
|
||||
],
|
||||
['colonies/colonies.service.ts', 'const a = --phase=fetch;'],
|
||||
['colonies/colonies.service.ts', 'const f = fetch;'],
|
||||
['colonies/colonies.service.ts', 'await globalThis.fetch(url);'],
|
||||
['colonies/colonies.service.ts', "import https from 'node:https';"],
|
||||
@@ -209,6 +317,26 @@ describe('network calls stay inside their loop directories', () => {
|
||||
'weather/weather.service.ts',
|
||||
"const u = 'https://api.telegram.org/bot';",
|
||||
],
|
||||
[
|
||||
'weather/weather.service.ts',
|
||||
"import Anthropic from '@anthropic-ai/sdk';",
|
||||
],
|
||||
[
|
||||
'voice/extraction/voice-extraction.service.ts',
|
||||
"import Anthropic from '@anthropic-ai/sdk';",
|
||||
],
|
||||
[
|
||||
'voice/extraction/voice-prompt.ts',
|
||||
"const m = require('@anthropic-ai/sdk/resources');",
|
||||
],
|
||||
[
|
||||
'voice/voice.handlers.ts',
|
||||
"import { APIError } from '@anthropic-ai/sdk';",
|
||||
],
|
||||
[
|
||||
'jobs/voice-transcribe.job.ts',
|
||||
"const s = await import('@anthropic-ai/sdk');",
|
||||
],
|
||||
])('flags %s: %s', (path, source) => {
|
||||
expect(violations([{ path, source }])).not.toEqual([]);
|
||||
});
|
||||
@@ -233,7 +361,160 @@ describe('network calls stay inside their loop directories', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('flags a handler or job importing the voice adapter', () => {
|
||||
const adapter = {
|
||||
path: ANTHROPIC_ADAPTER,
|
||||
source: "import Anthropic from '@anthropic-ai/sdk';",
|
||||
};
|
||||
const importers = [
|
||||
{
|
||||
path: 'voice/voice.handlers.ts',
|
||||
source:
|
||||
"import { AnthropicStructuredModel } from './extraction/anthropic-structured-model';",
|
||||
},
|
||||
{
|
||||
path: 'jobs/voice-transcribe.job.ts',
|
||||
source:
|
||||
"import { AnthropicStructuredModel } from '../voice/extraction/anthropic-structured-model';",
|
||||
},
|
||||
];
|
||||
expect(violations([adapter, ...importers])).toEqual(
|
||||
importers.flatMap(({ path }) => [
|
||||
`${path}: reaches the Anthropic adapter outside the worker wiring`,
|
||||
`${path}: handler or job imports a module that reaches the network`,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('flags a job reaching the adapter through the worker wiring', () => {
|
||||
const files = [
|
||||
{
|
||||
path: ANTHROPIC_ADAPTER,
|
||||
source: "import Anthropic from '@anthropic-ai/sdk';",
|
||||
},
|
||||
{
|
||||
path: 'voice/voice-worker.module.ts',
|
||||
source:
|
||||
"import { AnthropicStructuredModel } from './extraction/anthropic-structured-model';",
|
||||
},
|
||||
{
|
||||
path: 'jobs/voice-transcribe.job.ts',
|
||||
source:
|
||||
"import { VoiceWorkerModule } from '../voice/voice-worker.module';",
|
||||
},
|
||||
];
|
||||
|
||||
expect(violations(files)).toEqual([
|
||||
'jobs/voice-transcribe.job.ts: reaches the Anthropic adapter outside the worker wiring',
|
||||
'jobs/voice-transcribe.job.ts: handler or job imports a module that reaches the network',
|
||||
]);
|
||||
});
|
||||
|
||||
it('flags the API side reaching the adapter, but not the worker wiring', () => {
|
||||
const files = [
|
||||
{
|
||||
path: ANTHROPIC_ADAPTER,
|
||||
source: "import Anthropic from '@anthropic-ai/sdk';",
|
||||
},
|
||||
{
|
||||
path: 'voice/voice-worker.module.ts',
|
||||
source:
|
||||
"import { AnthropicStructuredModel } from './extraction/anthropic-structured-model';",
|
||||
},
|
||||
{
|
||||
path: 'worker/worker-runtime.module.ts',
|
||||
source:
|
||||
"import { VoiceWorkerModule } from '../voice/voice-worker.module';",
|
||||
},
|
||||
{
|
||||
path: 'voice/voice.module.ts',
|
||||
source: "import { VoiceWorkerModule } from './voice-worker.module';",
|
||||
},
|
||||
{
|
||||
path: 'app.module.ts',
|
||||
source: "import { VoiceModule } from './voice/voice.module';",
|
||||
},
|
||||
];
|
||||
|
||||
expect(violations(files)).toEqual([
|
||||
'voice/voice.module.ts: reaches the Anthropic adapter outside the worker wiring',
|
||||
'app.module.ts: reaches the Anthropic adapter outside the worker wiring',
|
||||
]);
|
||||
});
|
||||
|
||||
it('flags a handler reaching the network over several imports', () => {
|
||||
const files = [
|
||||
{ path: 'weather/weather.service.ts', source: 'await fetch(url);' },
|
||||
{
|
||||
path: 'colonies/colony-weather.ts',
|
||||
source:
|
||||
"import { WeatherService } from '../weather/weather.service';",
|
||||
},
|
||||
{
|
||||
path: 'colonies/colony-facts.ts',
|
||||
source: "export * from './colony-weather';",
|
||||
},
|
||||
{
|
||||
path: 'colonies/colonies.handlers.ts',
|
||||
source: "import { facts } from './colony-facts';",
|
||||
},
|
||||
];
|
||||
|
||||
expect(violations(files)).toEqual([
|
||||
'colonies/colonies.handlers.ts: handler or job imports a module that reaches the network',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads import specifiers in backticks', () => {
|
||||
const files = [
|
||||
{ path: 'weather/weather.service.ts', source: 'await fetch(url);' },
|
||||
{
|
||||
path: 'jobs/daily-evaluation.job.ts',
|
||||
source: 'const w = await import(`../weather/weather.service`);',
|
||||
},
|
||||
{
|
||||
path: 'colonies/colonies.service.ts',
|
||||
source: 'const h = require(`node:https`);',
|
||||
},
|
||||
];
|
||||
|
||||
expect(violations(files)).toEqual([
|
||||
'jobs/daily-evaluation.job.ts: handler or job imports a module that reaches the network',
|
||||
'colonies/colonies.service.ts: network package import outside the allowed places',
|
||||
]);
|
||||
});
|
||||
|
||||
it('excuses a listed handler only for an indirect path', () => {
|
||||
const graph = {
|
||||
path: 'calendar-sync/graph-client.service.ts',
|
||||
source: 'await fetch(url);',
|
||||
};
|
||||
const service = {
|
||||
path: 'calendar-sync/calendar-sync.service.ts',
|
||||
source: "import { GraphClient } from './graph-client.service';",
|
||||
};
|
||||
const indirect = {
|
||||
path: 'calendar-sync/calendar-sync.handlers.ts',
|
||||
source:
|
||||
"import { CalendarSyncService } from './calendar-sync.service';",
|
||||
};
|
||||
const direct = {
|
||||
...indirect,
|
||||
source: "import { GraphClient } from './graph-client.service';",
|
||||
};
|
||||
|
||||
expect(violations([graph, service, indirect])).toEqual([]);
|
||||
expect(violations([graph, service, direct])).toEqual([
|
||||
'calendar-sync/calendar-sync.handlers.ts: handler or job imports a module that reaches the network',
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[ANTHROPIC_ADAPTER, "import Anthropic from '@anthropic-ai/sdk';"],
|
||||
[
|
||||
'voice/extraction/voice-extraction.service.ts',
|
||||
"import { STRUCTURED_MODEL } from './structured-model.port';",
|
||||
],
|
||||
[
|
||||
'weather/weather.service.ts',
|
||||
"await fetch('https://api.open-meteo.com/v1');",
|
||||
@@ -248,6 +529,7 @@ describe('network calls stay inside their loop directories', () => {
|
||||
['colonies/colonies.service.ts', '// fetch the data later\nconst a = 1;'],
|
||||
['colonies/colonies.service.ts', "const scheme = 'https';"],
|
||||
['colonies/colonies.service.ts', 'const o = { fetch: 1 };'],
|
||||
['jobs/job-names.ts', "const a = ['--phase=fetch', '--phase=ingest'];"],
|
||||
])('lets %s pass: %s', (path, source) => {
|
||||
expect(violations([{ path, source }])).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
DEFAULT_RETRY_POLICY,
|
||||
MAX_ATTEMPTS,
|
||||
RETRY_DELAYS_MS,
|
||||
retryDelayMs,
|
||||
retryDelayMsFor,
|
||||
STALE_CLAIM_MS,
|
||||
} from './retry-policy';
|
||||
|
||||
@@ -26,4 +28,29 @@ describe('retry policy', () => {
|
||||
expect(() => retryDelayMs(attempts)).toThrow(RangeError);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps retryDelayMs identical to the default policy', () => {
|
||||
expect(DEFAULT_RETRY_POLICY).toEqual({
|
||||
delaysMs: RETRY_DELAYS_MS,
|
||||
maxAttempts: MAX_ATTEMPTS,
|
||||
staleClaimMs: STALE_CLAIM_MS,
|
||||
});
|
||||
for (const attempts of [1, 2, 3, 4, 5, 6, 9]) {
|
||||
expect(retryDelayMsFor(DEFAULT_RETRY_POLICY, attempts)).toBe(
|
||||
retryDelayMs(attempts),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("retryDelayMsFor uses the policy's delays and repeats the last one", () => {
|
||||
const policy = {
|
||||
delaysMs: [1_000, 5_000],
|
||||
maxAttempts: 4,
|
||||
staleClaimMs: 60_000,
|
||||
};
|
||||
expect([1, 2, 3].map((n) => retryDelayMsFor(policy, n))).toEqual([
|
||||
1_000, 5_000, 5_000,
|
||||
]);
|
||||
expect(() => retryDelayMsFor(policy, 0)).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
@@ -12,12 +12,36 @@ export const RETRY_DELAYS_MS = [
|
||||
export const MAX_ATTEMPTS = 8;
|
||||
export const STALE_CLAIM_MS = 300_000;
|
||||
|
||||
/** Delay after `attempts` failed attempts (1 = the first failure). */
|
||||
export function retryDelayMs(attempts: number): number {
|
||||
/**
|
||||
* A retry rule as data, for queues with their own pace (voice notes: 3
|
||||
* attempts, minutes instead of hours). `delaysMs[n - 1]` is the wait after
|
||||
* the n-th failure, the last entry repeats; the `maxAttempts`-th failure
|
||||
* ends in FAILED; a claim older than `staleClaimMs` belongs to a dead worker.
|
||||
*/
|
||||
export interface RetryPolicy {
|
||||
readonly delaysMs: readonly number[];
|
||||
readonly maxAttempts: number;
|
||||
readonly staleClaimMs: number;
|
||||
}
|
||||
|
||||
/** The rule of the outboxes (the constants above). */
|
||||
export const DEFAULT_RETRY_POLICY: RetryPolicy = {
|
||||
delaysMs: RETRY_DELAYS_MS,
|
||||
maxAttempts: MAX_ATTEMPTS,
|
||||
staleClaimMs: STALE_CLAIM_MS,
|
||||
};
|
||||
|
||||
/** Delay after `attempts` failed attempts (1 = the first failure) under `policy`. */
|
||||
export function retryDelayMsFor(policy: RetryPolicy, attempts: number): number {
|
||||
if (!Number.isInteger(attempts) || attempts < 1) {
|
||||
throw new RangeError(
|
||||
`Retry delay needs a positive number of failed attempts, got ${attempts}`,
|
||||
);
|
||||
}
|
||||
return RETRY_DELAYS_MS[Math.min(attempts, RETRY_DELAYS_MS.length) - 1];
|
||||
return policy.delaysMs[Math.min(attempts, policy.delaysMs.length) - 1];
|
||||
}
|
||||
|
||||
/** Delay after `attempts` failed attempts (1 = the first failure). */
|
||||
export function retryDelayMs(attempts: number): number {
|
||||
return retryDelayMsFor(DEFAULT_RETRY_POLICY, attempts);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export const WAKE_CHANNELS = {
|
||||
calendarSync: 'calendar_sync',
|
||||
webhookDeliveries: 'webhook_delivery',
|
||||
notificationDeliveries: 'notification_delivery',
|
||||
voiceExtraction: 'voice_extraction',
|
||||
} as const;
|
||||
|
||||
export type WakeChannel = (typeof WAKE_CHANNELS)[keyof typeof WAKE_CHANNELS];
|
||||
|
||||
@@ -347,6 +347,44 @@ describe('AuthorityNoticesService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('sent day in the future', () => {
|
||||
const FUTURE = '2999-01-01';
|
||||
const MESSAGE = 'Das Sendedatum darf nicht in der Zukunft liegen.';
|
||||
|
||||
it('create rejects it', async () => {
|
||||
await expect(
|
||||
service.create({ kind: 'REGISTRATION', sentOn: FUTURE }, 'u1'),
|
||||
).rejects.toMatchObject({ message: MESSAGE });
|
||||
expect(tx.authorityNotice.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mark-sent rejects it', async () => {
|
||||
await expect(
|
||||
service.markSent('n1', { sentOn: FUTURE }, 'u1'),
|
||||
).rejects.toMatchObject({ message: MESSAGE });
|
||||
expect(tx.authorityNotice.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update rejects it', async () => {
|
||||
await expect(
|
||||
service.update('n1', { sentOn: FUTURE }, 'u1'),
|
||||
).rejects.toMatchObject({ message: MESSAGE });
|
||||
expect(tx.authorityNotice.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts today (Berlin day) and clearing with null stays a different error', async () => {
|
||||
jest.useFakeTimers({ now: new Date('2027-03-01T23:30:00.000Z') });
|
||||
try {
|
||||
await service.markSent('n1', { sentOn: '2027-03-02' }, 'u1');
|
||||
await expect(
|
||||
service.markSent('n1', { sentOn: '2027-03-03' }, 'u1'),
|
||||
).rejects.toMatchObject({ message: MESSAGE });
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('mark-sent defaults the sent day to the Berlin day, not the UTC day', async () => {
|
||||
jest.useFakeTimers({ now: new Date('2027-03-01T23:30:00.000Z') });
|
||||
try {
|
||||
@@ -366,7 +404,7 @@ describe('AuthorityNoticesService', () => {
|
||||
it('mark-sent sets SENT, the sent day and drops the open key', async () => {
|
||||
await service.markSent(
|
||||
'n1',
|
||||
{ sentOn: '2027-02-02', sentVia: 'E-Mail' },
|
||||
{ sentOn: '2026-02-02', sentVia: 'E-Mail' },
|
||||
'u1',
|
||||
);
|
||||
|
||||
@@ -374,7 +412,7 @@ describe('AuthorityNoticesService', () => {
|
||||
where: { id: 'n1' },
|
||||
data: expect.objectContaining({
|
||||
status: 'SENT',
|
||||
sentOn: new Date('2027-02-02T00:00:00.000Z'),
|
||||
sentOn: new Date('2026-02-02T00:00:00.000Z'),
|
||||
sentVia: 'E-Mail',
|
||||
openKey: null,
|
||||
}) as unknown,
|
||||
@@ -519,7 +557,7 @@ describe('AuthorityNoticesService', () => {
|
||||
|
||||
it('a draft has no sent day', async () => {
|
||||
await expect(
|
||||
service.update('n1', { sentOn: '2027-01-01' }, 'u1'),
|
||||
service.update('n1', { sentOn: '2026-01-01' }, 'u1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
@@ -528,7 +566,7 @@ describe('AuthorityNoticesService', () => {
|
||||
notice({ kind: 'REGISTRATION', status: 'SENT', openKey: null }),
|
||||
);
|
||||
|
||||
await service.update('n1', { sentOn: '2027-01-02' }, 'u1');
|
||||
await service.update('n1', { sentOn: '2026-01-02' }, 'u1');
|
||||
|
||||
expect(lockMock.mock.calls.map((call: unknown[]) => call[1])).toEqual([
|
||||
'ComplianceSettings',
|
||||
|
||||
@@ -86,6 +86,17 @@ function assertYearFits(kind: AuthorityNoticeKind, year?: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** A notice cannot have been sent tomorrow: sentOn must not lie after the Berlin day. */
|
||||
function futureSentOnError(
|
||||
sentOn: string | null | undefined,
|
||||
): BadRequestException | undefined {
|
||||
return sentOn && sentOn > berlinToday()
|
||||
? new BadRequestException(
|
||||
'Das Sendedatum darf nicht in der Zukunft liegen.',
|
||||
)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthorityNoticesService {
|
||||
constructor(
|
||||
@@ -178,6 +189,10 @@ export class AuthorityNoticesService {
|
||||
actingUserId: string,
|
||||
): Promise<AuthorityNotice> {
|
||||
assertYearFits(dto.kind, dto.year);
|
||||
const futureError = futureSentOnError(dto.sentOn);
|
||||
if (futureError) {
|
||||
throw futureError;
|
||||
}
|
||||
const input = {
|
||||
kind: dto.kind,
|
||||
year: dto.year,
|
||||
@@ -234,6 +249,10 @@ export class AuthorityNoticesService {
|
||||
dto: MarkSentDto,
|
||||
actingUserId: string,
|
||||
): Promise<AuthorityNotice> {
|
||||
const futureError = futureSentOnError(dto.sentOn);
|
||||
if (futureError) {
|
||||
return Promise.reject(futureError);
|
||||
}
|
||||
return this.transition(id, actingUserId, { mutex: true }, (_tx, before) => {
|
||||
if (before.status !== 'DRAFT') {
|
||||
return Promise.reject(
|
||||
@@ -290,6 +309,10 @@ export class AuthorityNoticesService {
|
||||
): Promise<AuthorityNotice> {
|
||||
// A new sent day can change which notice is the baseline: the mutex
|
||||
// comes first, in lock order, whenever the sent day is touched.
|
||||
const futureError = futureSentOnError(dto.sentOn);
|
||||
if (futureError) {
|
||||
return Promise.reject(futureError);
|
||||
}
|
||||
const mutex = dto.sentOn !== undefined;
|
||||
return this.transition(id, actingUserId, { mutex }, (_tx, before) => {
|
||||
if (before.status === 'CANCELLED') {
|
||||
|
||||
@@ -65,6 +65,17 @@ describe('loadLots', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves out a lot whose medicine vanished between the two reads', async () => {
|
||||
const { db } = fakeDb(
|
||||
[lotRow({ id: 'l1' }), lotRow({ id: 'gone', medicine: null })],
|
||||
[],
|
||||
);
|
||||
|
||||
const { lots } = await loadLots(db);
|
||||
|
||||
expect(lots.map((lot) => lot.id)).toEqual(['l1']);
|
||||
});
|
||||
|
||||
it('reads only active medicines', async () => {
|
||||
const { db } = fakeDb(
|
||||
[],
|
||||
|
||||
@@ -57,12 +57,16 @@ export async function loadLots(db: DbClient): Promise<ReadinessLots> {
|
||||
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
||||
}),
|
||||
]);
|
||||
// Prisma reads the medicine in a second query: a lot deleted together with
|
||||
// its medicine in between comes back with medicine null (CI run 836, 500).
|
||||
// Such a lot is gone, so it is left out.
|
||||
const present = rows.filter((row) => (row.medicine as unknown) !== null);
|
||||
const totals = await lotTotals(
|
||||
db,
|
||||
rows.map((row) => row.id),
|
||||
present.map((row) => row.id),
|
||||
);
|
||||
return {
|
||||
lots: rows.map((row): ReadinessLot => ({
|
||||
lots: present.map((row): ReadinessLot => ({
|
||||
id: row.id,
|
||||
medicineId: row.medicineId,
|
||||
medicineName: row.medicine.name,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ColonyCondition,
|
||||
ColonyStatus,
|
||||
HiveChangeSource,
|
||||
PhaseSource,
|
||||
PhenologyPhase,
|
||||
Prisma,
|
||||
@@ -11,6 +12,21 @@ import {
|
||||
TaskStatus,
|
||||
} from '../../generated/prisma';
|
||||
|
||||
/**
|
||||
* The codes a VoiceCaptureFailed event may carry: terminal failures of a
|
||||
* note, plus EXTRACT_DISABLED (the operator notice). Waiting states
|
||||
* (DAILY_LIMIT) and per-attempt codes (EXTRACT_RETRY) are no events.
|
||||
*/
|
||||
export type VoiceFailureEventCode =
|
||||
| 'TRANSCRIBE_FAILED'
|
||||
| 'NO_SPEECH'
|
||||
| 'TOO_LONG'
|
||||
| 'AUDIO_EXPIRED'
|
||||
| 'EXTRACT_FAILED'
|
||||
| 'EXTRACT_INVALID'
|
||||
| 'REFUSED'
|
||||
| 'EXTRACT_DISABLED';
|
||||
|
||||
export interface ColonyStateSnapshot {
|
||||
status: ColonyStatus;
|
||||
healthStatus: ColonyCondition | null;
|
||||
@@ -144,6 +160,21 @@ export interface DomainEventPayloads {
|
||||
// reference day (referenceDay, 'YYYY-MM-DD') was reached. Not forwarded to
|
||||
// webhooks.
|
||||
TskReferenceDayReached: { year: number; referenceDay: string };
|
||||
// Beute in 3D, aggregate Colony: the hive setup of a colony changed (INIT
|
||||
// or a change). Ids and the source only, no content. Not forwarded to
|
||||
// webhooks.
|
||||
HiveChanged: {
|
||||
colonyId: string;
|
||||
version: number;
|
||||
source: HiveChangeSource;
|
||||
inspectionId: string | null;
|
||||
};
|
||||
// T7, aggregate VoiceCapture: a voice note can be reviewed. Ids only, never
|
||||
// the text, the quote or a suggestion. Not forwarded to webhooks.
|
||||
VoiceCaptureReady: { captureId: string; colonyId: string };
|
||||
// T7, aggregate VoiceCapture: a voice note failed for good. The fixed
|
||||
// error code only, never content. Not forwarded to webhooks.
|
||||
VoiceCaptureFailed: { captureId: string; code: VoiceFailureEventCode };
|
||||
}
|
||||
|
||||
export type DomainEventType = keyof DomainEventPayloads;
|
||||
@@ -188,6 +219,9 @@ const CATALOG: Record<DomainEventType, true> = {
|
||||
ColonyMoved: true,
|
||||
ApiaryChanged: true,
|
||||
TskReferenceDayReached: true,
|
||||
HiveChanged: true,
|
||||
VoiceCaptureReady: true,
|
||||
VoiceCaptureFailed: true,
|
||||
};
|
||||
|
||||
export const DOMAIN_EVENT_TYPES: readonly DomainEventType[] = Object.freeze(
|
||||
@@ -212,7 +246,8 @@ export type AggregateType =
|
||||
| 'PhenologyReport'
|
||||
| 'WinterCheck'
|
||||
| 'Apiary'
|
||||
| 'Compliance';
|
||||
| 'Compliance'
|
||||
| 'VoiceCapture';
|
||||
|
||||
export interface NewDomainEvent<T extends DomainEventType = DomainEventType> {
|
||||
type: T;
|
||||
|
||||
@@ -183,7 +183,7 @@ describe('DomainEventsService', () => {
|
||||
|
||||
describe('domain event catalog', () => {
|
||||
it('lists every event type exactly once', () => {
|
||||
expect(DOMAIN_EVENT_TYPES).toHaveLength(38);
|
||||
expect(DOMAIN_EVENT_TYPES).toHaveLength(41);
|
||||
expect(new Set(DOMAIN_EVENT_TYPES).size).toBe(DOMAIN_EVENT_TYPES.length);
|
||||
expect(DOMAIN_EVENT_TYPES).toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -206,6 +206,9 @@ describe('domain event catalog', () => {
|
||||
'ColonyMoved',
|
||||
'ApiaryChanged',
|
||||
'TskReferenceDayReached',
|
||||
'HiveChanged',
|
||||
'VoiceCaptureReady',
|
||||
'VoiceCaptureFailed',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { ArgumentMetadata, ValidationPipe } from '@nestjs/common';
|
||||
import { CreateHiveChangeDto } from './create-hive-change.dto';
|
||||
|
||||
const pipe = new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
});
|
||||
const metadata: ArgumentMetadata = {
|
||||
type: 'body',
|
||||
metatype: CreateHiveChangeDto,
|
||||
};
|
||||
|
||||
const body = (overrides: Record<string, unknown> = {}) => ({
|
||||
occurredOn: '2026-10-15',
|
||||
source: 'CARD',
|
||||
ops: [{ op: 'setLabel', id: 'p1', label: 'Brutraum' }],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const accepted = (value: unknown) => pipe.transform(value, metadata);
|
||||
const rejected = async (value: unknown) => {
|
||||
await expect(accepted(value)).rejects.toMatchObject({ status: 400 });
|
||||
};
|
||||
|
||||
describe('CreateHiveChangeDto', () => {
|
||||
it('accepts every op shape', async () => {
|
||||
const ops = [
|
||||
{ op: 'addPart', part: { id: 'a', partTypeId: 't', state: {} }, at: 1 },
|
||||
{ op: 'addInsert', hostId: 'h', part: { id: 'a' } },
|
||||
{ op: 'removePart', id: 'a' },
|
||||
{ op: 'movePart', id: 'a', to: 0 },
|
||||
{ op: 'setState', id: 'a', key: 'k', value: 0.5 },
|
||||
{ op: 'setState', id: 'a', key: 'k', value: 'OPEN' },
|
||||
{ op: 'setFrame', boxId: 'b', slot: 1, type: 'HONEY', name: null },
|
||||
{ op: 'setFrames', boxId: 'b', frames: [{ slot: 1, type: 'BROOD' }] },
|
||||
{ op: 'setLabel', id: 'a', label: null },
|
||||
];
|
||||
await expect(accepted(body({ ops }))).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('refuses AI_SUGGESTION, INIT and INSPECTION as source', async () => {
|
||||
for (const source of ['AI_SUGGESTION', 'INIT', 'INSPECTION']) {
|
||||
await rejected(body({ source }));
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses an unknown op name', async () => {
|
||||
await rejected(body({ ops: [{ op: 'explode' }] }));
|
||||
});
|
||||
|
||||
it('refuses wrong primitive types', async () => {
|
||||
await rejected(body({ ops: [{ op: 'removePart', id: 5 }] }));
|
||||
await rejected(body({ ops: [{ op: 'movePart', id: 'a', to: '1' }] }));
|
||||
await rejected(
|
||||
body({ ops: [{ op: 'setState', id: 'a', key: 'k', value: true }] }),
|
||||
);
|
||||
await rejected(body({ ops: [{ op: 'addPart', part: 'x', at: 0 }] }));
|
||||
});
|
||||
|
||||
it('refuses a missing required field of an op', async () => {
|
||||
await rejected(body({ ops: [{ op: 'removePart' }] }));
|
||||
});
|
||||
|
||||
it('refuses an empty ops list', async () => {
|
||||
await rejected(body({ ops: [] }));
|
||||
});
|
||||
|
||||
it('refuses more than 100 ops and a non-array', async () => {
|
||||
const op = { op: 'setLabel', id: 'p1', label: 'x' };
|
||||
await expect(
|
||||
accepted(body({ ops: Array.from({ length: 100 }, () => op) })),
|
||||
).resolves.toBeDefined();
|
||||
await rejected(body({ ops: Array.from({ length: 101 }, () => op) }));
|
||||
await rejected(body({ ops: 'nope' }));
|
||||
});
|
||||
|
||||
it('refuses a malformed day and unknown properties', async () => {
|
||||
await rejected(body({ occurredOn: '15.10.2026' }));
|
||||
await rejected(body({ extra: 1 }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { DAY_PATTERN } from './create-hive-setup.dto';
|
||||
import { HiveOpDto, MAX_OPS_PER_REQUEST } from './hive-op.dto';
|
||||
|
||||
// INIT and INSPECTION are set by the server; AI_SUGGESTION is not accepted yet.
|
||||
export const CLIENT_CHANGE_SOURCES = ['CARD', 'EDITOR'] as const;
|
||||
const MAX_NOTE_LENGTH = 500;
|
||||
|
||||
export class CreateHiveChangeDto {
|
||||
@ApiProperty({ description: 'Berlin calendar day, YYYY-MM-DD' })
|
||||
@Matches(DAY_PATTERN)
|
||||
occurredOn!: string;
|
||||
|
||||
@ApiProperty({ enum: CLIENT_CHANGE_SOURCES })
|
||||
@IsIn(CLIENT_CHANGE_SOURCES)
|
||||
source!: (typeof CLIENT_CHANGE_SOURCES)[number];
|
||||
|
||||
@ApiProperty({ type: [HiveOpDto], maxItems: MAX_OPS_PER_REQUEST })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(MAX_OPS_PER_REQUEST)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => HiveOpDto)
|
||||
ops!: HiveOpDto[];
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_NOTE_LENGTH)
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export const HIVE_PART_KINDS = [
|
||||
'FLOOR',
|
||||
'BOX',
|
||||
'DIVIDER_BOARD',
|
||||
'QUEEN_EXCLUDER',
|
||||
'FEEDER',
|
||||
'COVER_SHEET',
|
||||
'PROPOLIS_SCREEN',
|
||||
'INNER_LID',
|
||||
'INSULATION',
|
||||
'ROOF',
|
||||
'FLOOR_INSERT',
|
||||
'EVAPORATOR',
|
||||
'FRAME_FEEDER',
|
||||
] as const;
|
||||
export const HIVE_BOX_DEPTHS = ['FULL', 'SHALLOW'] as const;
|
||||
|
||||
const MAX_MM = 5000;
|
||||
const MAX_SCHEMA_FIELDS = 30;
|
||||
const MAX_FRAME_SLOTS = 20;
|
||||
const MAX_SLOT_WIDTH = 5;
|
||||
|
||||
export class CreateHivePartTypeDto {
|
||||
@ApiProperty({ enum: HIVE_PART_KINDS })
|
||||
@IsIn(HIVE_PART_KINDS)
|
||||
kind!: (typeof HIVE_PART_KINDS)[number];
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
articleRef?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(MAX_MM)
|
||||
lengthMm!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(MAX_MM)
|
||||
widthMm!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(MAX_MM)
|
||||
heightMm!: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: HIVE_BOX_DEPTHS })
|
||||
@IsOptional()
|
||||
@IsIn(HIVE_BOX_DEPTHS)
|
||||
boxDepth?: (typeof HIVE_BOX_DEPTHS)[number];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Required for kind BOX' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(MAX_FRAME_SLOTS)
|
||||
frameSlots?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Required for kind FRAME_FEEDER, forbidden otherwise (1-5)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(MAX_SLOT_WIDTH)
|
||||
slotWidth?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'State fields, spec 4.2' })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(MAX_SCHEMA_FIELDS)
|
||||
@IsObject({ each: true })
|
||||
stateSchema?: Record<string, unknown>[];
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsObject, IsString, Matches, MaxLength } from 'class-validator';
|
||||
|
||||
export const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
export class CreateHiveSetupDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
systemId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Complete hive state (schemaVersion 1); validated server side',
|
||||
})
|
||||
@IsObject()
|
||||
state!: Record<string, unknown>;
|
||||
|
||||
@ApiProperty({ description: 'Berlin calendar day, YYYY-MM-DD' })
|
||||
@Matches(DAY_PATTERN)
|
||||
occurredOn!: string;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsInt,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
const MAX_MM = 5000;
|
||||
|
||||
export class CreateHiveSystemDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ description: 'Top bar width in mm' })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(MAX_MM)
|
||||
frameWidthMm!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(MAX_MM)
|
||||
frameHeightMm!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(MAX_MM)
|
||||
footprintLMm!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(MAX_MM)
|
||||
footprintWMm!: number;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
ValidateBy,
|
||||
ValidateIf,
|
||||
buildMessage,
|
||||
} from 'class-validator';
|
||||
import { FRAME_TYPES, MAX_OPS } from '../hive-types';
|
||||
|
||||
export const HIVE_OP_NAMES = [
|
||||
'addPart',
|
||||
'addInsert',
|
||||
'removePart',
|
||||
'movePart',
|
||||
'setState',
|
||||
'setFrame',
|
||||
'setFrames',
|
||||
'setLabel',
|
||||
] as const;
|
||||
|
||||
export const MAX_OPS_PER_REQUEST = MAX_OPS;
|
||||
|
||||
const MAX_ID_LENGTH = 100;
|
||||
const MAX_TEXT = 200;
|
||||
const MAX_FRAMES = 100;
|
||||
|
||||
type OpName = (typeof HIVE_OP_NAMES)[number];
|
||||
|
||||
/** The property applies only to the listed operations. */
|
||||
const onlyFor =
|
||||
(...names: OpName[]) =>
|
||||
(dto: HiveOpDto) =>
|
||||
names.includes(dto.op);
|
||||
|
||||
function IsStringOrFiniteNumber() {
|
||||
return ValidateBy(
|
||||
{
|
||||
name: 'isStringOrFiniteNumber',
|
||||
validator: {
|
||||
validate: (value: unknown) =>
|
||||
typeof value === 'string' ||
|
||||
(typeof value === 'number' && Number.isFinite(value)),
|
||||
defaultMessage: buildMessage(
|
||||
(each) => `${each}$property must be a string or a finite number`,
|
||||
),
|
||||
},
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One hive operation. Only the shape is checked here (discriminator and
|
||||
* primitive types); the deep check is `applyHiveOps` (spec 6.2).
|
||||
*/
|
||||
export class HiveOpDto {
|
||||
@ApiProperty({ enum: HIVE_OP_NAMES })
|
||||
@IsIn(HIVE_OP_NAMES)
|
||||
op!: OpName;
|
||||
|
||||
@ApiPropertyOptional({ description: 'addPart, addInsert: the part' })
|
||||
@ValidateIf(onlyFor('addPart', 'addInsert'))
|
||||
@IsObject()
|
||||
part?: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional({ description: 'addPart: stack index' })
|
||||
@ValidateIf(onlyFor('addPart'))
|
||||
@IsInt()
|
||||
at?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'addInsert: id of the host part' })
|
||||
@ValidateIf(onlyFor('addInsert'))
|
||||
@IsString()
|
||||
@MaxLength(MAX_ID_LENGTH)
|
||||
hostId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'removePart, movePart, setState, setLabel: part id',
|
||||
})
|
||||
@ValidateIf(onlyFor('removePart', 'movePart', 'setState', 'setLabel'))
|
||||
@IsString()
|
||||
@MaxLength(MAX_ID_LENGTH)
|
||||
id?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'movePart: target stack index' })
|
||||
@ValidateIf(onlyFor('movePart'))
|
||||
@IsInt()
|
||||
to?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'setState: state field key' })
|
||||
@ValidateIf(onlyFor('setState'))
|
||||
@IsString()
|
||||
@MaxLength(MAX_ID_LENGTH)
|
||||
key?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'setState: number or string' })
|
||||
@ValidateIf(onlyFor('setState'))
|
||||
@IsStringOrFiniteNumber()
|
||||
value?: number | string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'setFrame, setFrames: box id' })
|
||||
@ValidateIf(onlyFor('setFrame', 'setFrames'))
|
||||
@IsString()
|
||||
@MaxLength(MAX_ID_LENGTH)
|
||||
boxId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'setFrame: slot number' })
|
||||
@ValidateIf(onlyFor('setFrame'))
|
||||
@IsInt()
|
||||
slot?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'setFrame: frame type' })
|
||||
@ValidateIf(onlyFor('setFrame'))
|
||||
@IsIn(FRAME_TYPES)
|
||||
type?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'setFrame: optional frame name' })
|
||||
@ValidateIf(onlyFor('setFrame'))
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_TEXT)
|
||||
name?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ description: 'setFrames: all frames of the box' })
|
||||
@ValidateIf(onlyFor('setFrames'))
|
||||
@IsArray()
|
||||
@ArrayMaxSize(MAX_FRAMES)
|
||||
@IsObject({ each: true })
|
||||
frames?: Record<string, unknown>[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'setLabel: new label or null' })
|
||||
@ValidateIf(onlyFor('setLabel'))
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_TEXT)
|
||||
label?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { DAY_PATTERN } from './create-hive-setup.dto';
|
||||
|
||||
export const MAX_HISTORY_LIMIT = 100;
|
||||
|
||||
export class HiveHistoryQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Opaque cursor of the previous page' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
cursor?: string;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 1, maximum: MAX_HISTORY_LIMIT })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(MAX_HISTORY_LIMIT)
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class HiveAtQueryDto {
|
||||
@ApiProperty({ description: 'Berlin calendar day, YYYY-MM-DD' })
|
||||
@Matches(DAY_PATTERN)
|
||||
day!: string;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ApiPropertyOptional, OmitType, PartialType } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
import { CreateHivePartTypeDto } from './create-hive-part-type.dto';
|
||||
|
||||
// `kind` cannot change after creation, so it is not part of the update DTO.
|
||||
export class UpdateHivePartTypeDto extends PartialType(
|
||||
OmitType(CreateHivePartTypeDto, ['kind'] as const),
|
||||
) {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
active?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsObject, IsOptional } from 'class-validator';
|
||||
import { CreateHiveSystemDto } from './create-hive-system.dto';
|
||||
|
||||
export class UpdateHiveSystemDto extends PartialType(CreateHiveSystemDto) {
|
||||
@ApiPropertyOptional({ description: 'Hive state JSON without ids (5.2)' })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
template?: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
active?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { HiveOp } from './hive-types';
|
||||
|
||||
/**
|
||||
* A copy of the op with only the fields its name defines, so stray client
|
||||
* fields never reach the state logic or the stored history.
|
||||
*/
|
||||
export function canonicalOp(op: HiveOp): HiveOp {
|
||||
switch (op.op) {
|
||||
case 'addPart':
|
||||
return { op: op.op, part: op.part, at: op.at };
|
||||
case 'addInsert':
|
||||
return { op: op.op, hostId: op.hostId, part: op.part };
|
||||
case 'removePart':
|
||||
return { op: op.op, id: op.id };
|
||||
case 'movePart':
|
||||
return { op: op.op, id: op.id, to: op.to };
|
||||
case 'setState':
|
||||
return { op: op.op, id: op.id, key: op.key, value: op.value };
|
||||
case 'setFrame':
|
||||
return {
|
||||
op: op.op,
|
||||
boxId: op.boxId,
|
||||
slot: op.slot,
|
||||
type: op.type,
|
||||
...(op.name !== undefined ? { name: op.name } : {}),
|
||||
};
|
||||
case 'setFrames':
|
||||
return { op: op.op, boxId: op.boxId, frames: op.frames };
|
||||
case 'setLabel':
|
||||
return { op: op.op, id: op.id, label: op.label };
|
||||
default:
|
||||
// Unknown names stay as they are; applyHiveOps reports UNKNOWN_OP.
|
||||
return { ...(op as object) } as HiveOp;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post } 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 { CreateHivePartTypeDto } from './dto/create-hive-part-type.dto';
|
||||
import { CreateHiveSystemDto } from './dto/create-hive-system.dto';
|
||||
import { UpdateHivePartTypeDto } from './dto/update-hive-part-type.dto';
|
||||
import { UpdateHiveSystemDto } from './dto/update-hive-system.dto';
|
||||
import { HiveCatalogService } from './hive-catalog.service';
|
||||
|
||||
@ApiTags('hive-catalog')
|
||||
@ApiBearerAuth()
|
||||
@Controller()
|
||||
export class HiveCatalogController {
|
||||
constructor(private readonly catalog: HiveCatalogService) {}
|
||||
|
||||
@RequirePermission('colonies:read')
|
||||
@Get('hive-systems')
|
||||
listSystems() {
|
||||
return this.catalog.listSystems();
|
||||
}
|
||||
|
||||
@RequirePermission('colonies:read')
|
||||
@Get('hive-systems/:id/template')
|
||||
template(@Param('id') id: string) {
|
||||
return this.catalog.template(id);
|
||||
}
|
||||
|
||||
@RequirePermission('hive-systems:manage')
|
||||
@Post('hive-systems')
|
||||
createSystem(
|
||||
@Body() dto: CreateHiveSystemDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.catalog.createSystem(dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('hive-systems:manage')
|
||||
@Patch('hive-systems/:id')
|
||||
updateSystem(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateHiveSystemDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.catalog.updateSystem(id, dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('hive-systems:manage')
|
||||
@Post('hive-systems/:id/part-types')
|
||||
createPartType(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CreateHivePartTypeDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.catalog.createPartType(id, dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('hive-systems:manage')
|
||||
@Patch('hive-part-types/:id')
|
||||
updatePartType(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateHivePartTypeDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.catalog.updatePartType(id, dto, actor.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { HiveCatalogService } from './hive-catalog.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { validateHiveState } from './hive-validate';
|
||||
import type { HiveState } from './hive-types';
|
||||
import { loadFixtureCatalog } from './hive-test-utils';
|
||||
|
||||
const SYSTEM_ID = 'hive-system-zander-liebig-10';
|
||||
const USER = 'user-1';
|
||||
|
||||
const fixtureRows = [...loadFixtureCatalog().partTypes.values()].map((t) => ({
|
||||
...t,
|
||||
systemId: SYSTEM_ID,
|
||||
}));
|
||||
|
||||
const FLOOR = 'hive-part-zl10-floor';
|
||||
const BOX = 'hive-part-zl10-box-10';
|
||||
const ROOF = 'hive-part-zl10-roof';
|
||||
const EVAPORATOR = 'hive-part-zl10-evaporator';
|
||||
|
||||
const frames = Array.from({ length: 10 }, (_, i) => ({
|
||||
slot: i + 1,
|
||||
type: 'FOUNDATION',
|
||||
}));
|
||||
|
||||
// Stored templates carry no ids (spec 4.4); one insert covers the insert path.
|
||||
const storedTemplate = {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
{ partTypeId: FLOOR, state: {} },
|
||||
{
|
||||
partTypeId: BOX,
|
||||
state: {},
|
||||
frames,
|
||||
inserts: [{ partTypeId: EVAPORATOR, state: {} }],
|
||||
},
|
||||
{ partTypeId: ROOF, state: {} },
|
||||
],
|
||||
};
|
||||
|
||||
const levelField = {
|
||||
key: 'syrupLiters',
|
||||
label: 'Fluessigfutter',
|
||||
kind: 'level',
|
||||
unit: 'l',
|
||||
max: 3,
|
||||
step: 0.5,
|
||||
};
|
||||
const choiceField = {
|
||||
key: 'entrance',
|
||||
label: 'Flugloch',
|
||||
kind: 'choice',
|
||||
options: ['OPEN', 'CLOSED'],
|
||||
optionLabels: ['offen', 'zu'],
|
||||
};
|
||||
|
||||
describe('HiveCatalogService', () => {
|
||||
let service: HiveCatalogService;
|
||||
let tx: {
|
||||
$queryRaw: jest.Mock;
|
||||
hiveSystem: {
|
||||
findMany: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
hivePartType: {
|
||||
findMany: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
};
|
||||
let prisma: { $transaction: jest.Mock } & typeof tx;
|
||||
let auditLog: { record: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
tx = {
|
||||
$queryRaw: jest.fn().mockResolvedValue([{ id: 'x' }]),
|
||||
hiveSystem: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: SYSTEM_ID,
|
||||
name: 'Zander',
|
||||
template: storedTemplate,
|
||||
active: true,
|
||||
}),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
hivePartType: {
|
||||
findMany: jest.fn().mockResolvedValue(fixtureRows),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
};
|
||||
prisma = {
|
||||
...tx,
|
||||
$transaction: jest.fn((cb: (t: typeof tx) => unknown) => cb(tx)),
|
||||
};
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new HiveCatalogService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
);
|
||||
});
|
||||
|
||||
describe('listSystems', () => {
|
||||
it('returns systems with all part types ordered by name', async () => {
|
||||
await service.listSystems();
|
||||
expect(prisma.hiveSystem.findMany).toHaveBeenCalledWith({
|
||||
include: { partTypes: { orderBy: { name: 'asc' } } },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCatalog', () => {
|
||||
it('maps rows including inactive part types', async () => {
|
||||
tx.hivePartType.findMany.mockResolvedValue([
|
||||
{ ...fixtureRows[0], active: false },
|
||||
]);
|
||||
const catalog = await service.loadCatalog(SYSTEM_ID);
|
||||
expect(catalog.systemId).toBe(SYSTEM_ID);
|
||||
expect(catalog.partTypes.get(fixtureRows[0].id)?.active).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the given transaction client', async () => {
|
||||
const other = {
|
||||
hiveSystem: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: SYSTEM_ID }),
|
||||
},
|
||||
hivePartType: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
await service.loadCatalog(
|
||||
SYSTEM_ID,
|
||||
other as unknown as Prisma.TransactionClient,
|
||||
);
|
||||
expect(other.hivePartType.findMany).toHaveBeenCalled();
|
||||
expect(tx.hivePartType.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a part type row with a malformed stateSchema', async () => {
|
||||
tx.hivePartType.findMany.mockResolvedValue([
|
||||
{ ...fixtureRows[0], stateSchema: { not: 'a list' } },
|
||||
]);
|
||||
await expect(service.loadCatalog(SYSTEM_ID)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws NotFound for an unknown system', async () => {
|
||||
tx.hiveSystem.findUnique.mockResolvedValue(null);
|
||||
await expect(service.loadCatalog('nope')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('template', () => {
|
||||
const ids = (state: HiveState): string[] =>
|
||||
state.stack.flatMap((p) => [p.id, ...(p.inserts ?? []).map((i) => i.id)]);
|
||||
|
||||
it('gives fresh ids on every call and is a valid state', async () => {
|
||||
const first = await service.template(SYSTEM_ID);
|
||||
const second = await service.template(SYSTEM_ID);
|
||||
const firstIds = ids(first);
|
||||
expect(firstIds).toHaveLength(4);
|
||||
expect(new Set(firstIds).size).toBe(4);
|
||||
expect(ids(second).some((id) => firstIds.includes(id))).toBe(false);
|
||||
expect(validateHiveState(first, loadFixtureCatalog())).toEqual([]);
|
||||
});
|
||||
|
||||
it('throws 409 TEMPLATE_NOT_SET for an empty stored stack', async () => {
|
||||
tx.hiveSystem.findUnique.mockResolvedValue({
|
||||
id: SYSTEM_ID,
|
||||
template: { schemaVersion: 1, stack: [] },
|
||||
});
|
||||
await expect(service.template(SYSTEM_ID)).rejects.toMatchObject({
|
||||
status: 409,
|
||||
response: expect.objectContaining({
|
||||
code: 'TEMPLATE_NOT_SET',
|
||||
}) as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mutate the stored template', async () => {
|
||||
await service.template(SYSTEM_ID);
|
||||
expect(storedTemplate.stack[0]).not.toHaveProperty('id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSystem', () => {
|
||||
it('rejects a template that fails validation with the messages', async () => {
|
||||
const bad = { schemaVersion: 1, stack: [{ partTypeId: BOX, state: {} }] };
|
||||
const error = await service
|
||||
.updateSystem(SYSTEM_ID, { template: bad }, USER)
|
||||
.catch((e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(BadRequestException);
|
||||
const body = (error as BadRequestException).getResponse() as {
|
||||
messages: string[];
|
||||
};
|
||||
expect(body.messages.length).toBeGreaterThan(0);
|
||||
expect(tx.hiveSystem.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an explicit empty-stack template with TEMPLATE_INVALID', async () => {
|
||||
await expect(
|
||||
service.updateSystem(
|
||||
SYSTEM_ID,
|
||||
{ template: { schemaVersion: 1, stack: [] } },
|
||||
USER,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
response: expect.objectContaining({
|
||||
code: 'TEMPLATE_INVALID',
|
||||
}) as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('stores a valid template and audits the update', async () => {
|
||||
tx.hiveSystem.update.mockResolvedValue({ id: SYSTEM_ID });
|
||||
await service.updateSystem(SYSTEM_ID, { template: storedTemplate }, USER);
|
||||
expect(tx.hiveSystem.update).toHaveBeenCalled();
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'HiveSystem',
|
||||
action: 'UPDATE',
|
||||
entityId: SYSTEM_ID,
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('deactivates without deleting', async () => {
|
||||
tx.hiveSystem.update.mockResolvedValue({ id: SYSTEM_ID, active: false });
|
||||
await service.updateSystem(SYSTEM_ID, { active: false }, USER);
|
||||
expect(tx.hiveSystem.update).toHaveBeenCalledWith({
|
||||
where: { id: SYSTEM_ID },
|
||||
data: { active: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSystem', () => {
|
||||
it('creates with an empty stack and audits CREATE', async () => {
|
||||
tx.hiveSystem.create.mockResolvedValue({ id: 'new' });
|
||||
await service.createSystem(
|
||||
{
|
||||
name: 'Neu',
|
||||
frameWidthMm: 1,
|
||||
frameHeightMm: 1,
|
||||
footprintLMm: 1,
|
||||
footprintWMm: 1,
|
||||
},
|
||||
USER,
|
||||
);
|
||||
expect(tx.hiveSystem.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
template: { schemaVersion: 1, stack: [] },
|
||||
}) as unknown,
|
||||
});
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ entityType: 'HiveSystem', action: 'CREATE' }),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('part types', () => {
|
||||
const existing = {
|
||||
id: 'pt-1',
|
||||
systemId: SYSTEM_ID,
|
||||
kind: 'FEEDER',
|
||||
name: 'Futter',
|
||||
stateSchema: [levelField, choiceField],
|
||||
active: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
tx.hivePartType.findUnique.mockResolvedValue(existing);
|
||||
tx.hivePartType.update.mockResolvedValue(existing);
|
||||
});
|
||||
|
||||
it('rejects removing a state field with SCHEMA_NOT_ADDITIVE', async () => {
|
||||
const error = await service
|
||||
.updatePartType('pt-1', { stateSchema: [levelField] }, USER)
|
||||
.catch((e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(BadRequestException);
|
||||
expect((error as BadRequestException).getResponse()).toMatchObject({
|
||||
code: 'SCHEMA_NOT_ADDITIVE',
|
||||
});
|
||||
expect(tx.hivePartType.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects changing an existing field definition', async () => {
|
||||
const changed = { ...levelField, max: 5 };
|
||||
await expect(
|
||||
service.updatePartType(
|
||||
'pt-1',
|
||||
{ stateSchema: [changed, choiceField] },
|
||||
USER,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
response: expect.objectContaining({
|
||||
code: 'SCHEMA_NOT_ADDITIVE',
|
||||
}) as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts appending a field and audits the update', async () => {
|
||||
const extra = { ...levelField, key: 'extra' };
|
||||
await service.updatePartType(
|
||||
'pt-1',
|
||||
{ stateSchema: [levelField, choiceField, extra] },
|
||||
USER,
|
||||
);
|
||||
expect(tx.hivePartType.update).toHaveBeenCalled();
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'HivePartType',
|
||||
action: 'UPDATE',
|
||||
entityId: 'pt-1',
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a duplicate key in the appended schema', async () => {
|
||||
await expect(
|
||||
service.updatePartType(
|
||||
'pt-1',
|
||||
{ stateSchema: [levelField, choiceField, levelField] },
|
||||
USER,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('locks the part type row before reading it', async () => {
|
||||
await service.updatePartType('pt-1', { active: false }, USER);
|
||||
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
tx.hivePartType.findUnique.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unknown feedType', { ...levelField, key: 'f', feedType: 'NOPE' }],
|
||||
[
|
||||
'duplicate options',
|
||||
{
|
||||
...choiceField,
|
||||
key: 'c',
|
||||
options: ['A', 'A'],
|
||||
optionLabels: ['a', 'b'],
|
||||
},
|
||||
],
|
||||
['key over 40', { ...levelField, key: 'k'.repeat(41) }],
|
||||
['label over 60', { ...levelField, key: 'l', label: 'x'.repeat(61) }],
|
||||
['unit over 10', { ...levelField, key: 'u', unit: 'x'.repeat(11) }],
|
||||
[
|
||||
'option over 40',
|
||||
{
|
||||
...choiceField,
|
||||
key: 'o',
|
||||
options: ['x'.repeat(41), 'B'],
|
||||
optionLabels: ['a', 'b'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'option label over 40',
|
||||
{
|
||||
...choiceField,
|
||||
key: 'p',
|
||||
options: ['A', 'B'],
|
||||
optionLabels: ['x'.repeat(41), 'b'],
|
||||
},
|
||||
],
|
||||
])('rejects a new field with %s', async (_name, field) => {
|
||||
await expect(
|
||||
service.updatePartType(
|
||||
'pt-1',
|
||||
{ stateSchema: [levelField, choiceField, field] },
|
||||
USER,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
response: expect.objectContaining({
|
||||
code: 'SCHEMA_INVALID',
|
||||
}) as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a valid feedType', async () => {
|
||||
const ok = { ...levelField, key: 'fed', feedType: 'FEED_PASTE' };
|
||||
await service.updatePartType(
|
||||
'pt-1',
|
||||
{ stateSchema: [levelField, choiceField, ok] },
|
||||
USER,
|
||||
);
|
||||
expect(tx.hivePartType.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deactivates by setting active false and never deletes', async () => {
|
||||
await service.updatePartType('pt-1', { active: false }, USER);
|
||||
expect(tx.hivePartType.update).toHaveBeenCalledWith({
|
||||
where: { id: 'pt-1' },
|
||||
data: { active: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws NotFound for an unknown part type', async () => {
|
||||
tx.hivePartType.findUnique.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.updatePartType('x', { active: false }, USER),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('creates a part type with audit CREATE', async () => {
|
||||
tx.hivePartType.create.mockResolvedValue({ id: 'new' });
|
||||
await service.createPartType(
|
||||
SYSTEM_ID,
|
||||
{
|
||||
kind: 'ROOF',
|
||||
name: 'Dach',
|
||||
lengthMm: 1,
|
||||
widthMm: 1,
|
||||
heightMm: 1,
|
||||
stateSchema: [],
|
||||
},
|
||||
USER,
|
||||
);
|
||||
expect(tx.hivePartType.create).toHaveBeenCalled();
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'HivePartType',
|
||||
action: 'CREATE',
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('requires frameSlots for a BOX', async () => {
|
||||
await expect(
|
||||
service.createPartType(
|
||||
SYSTEM_ID,
|
||||
{
|
||||
kind: 'BOX',
|
||||
name: 'Zarge',
|
||||
lengthMm: 1,
|
||||
widthMm: 1,
|
||||
heightMm: 1,
|
||||
},
|
||||
USER,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('requires slotWidth for a FRAME_FEEDER', async () => {
|
||||
await expect(
|
||||
service.createPartType(
|
||||
SYSTEM_ID,
|
||||
{
|
||||
kind: 'FRAME_FEEDER',
|
||||
name: 'Tasche',
|
||||
lengthMm: 1,
|
||||
widthMm: 1,
|
||||
heightMm: 1,
|
||||
},
|
||||
USER,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects slotWidth on a part type that is no FRAME_FEEDER', async () => {
|
||||
await expect(
|
||||
service.createPartType(
|
||||
SYSTEM_ID,
|
||||
{
|
||||
kind: 'ROOF',
|
||||
name: 'Dach',
|
||||
lengthMm: 1,
|
||||
widthMm: 1,
|
||||
heightMm: 1,
|
||||
slotWidth: 2,
|
||||
},
|
||||
USER,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
service.updatePartType('pt-1', { slotWidth: 2 }, USER),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('creates a FRAME_FEEDER with its slotWidth', async () => {
|
||||
tx.hivePartType.create.mockResolvedValue({ id: 'new' });
|
||||
await service.createPartType(
|
||||
SYSTEM_ID,
|
||||
{
|
||||
kind: 'FRAME_FEEDER',
|
||||
name: 'Tasche',
|
||||
lengthMm: 477,
|
||||
widthMm: 70,
|
||||
heightMm: 220,
|
||||
slotWidth: 2,
|
||||
},
|
||||
USER,
|
||||
);
|
||||
const [{ data }] = tx.hivePartType.create.mock.calls[0] as [
|
||||
{ data: { kind: string; slotWidth: number } },
|
||||
];
|
||||
expect(data).toMatchObject({ kind: 'FRAME_FEEDER', slotWidth: 2 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { HivePartType, HiveSystem, Prisma } from '../../generated/prisma';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { CreateHivePartTypeDto } from './dto/create-hive-part-type.dto';
|
||||
import { CreateHiveSystemDto } from './dto/create-hive-system.dto';
|
||||
import { UpdateHivePartTypeDto } from './dto/update-hive-part-type.dto';
|
||||
import { UpdateHiveSystemDto } from './dto/update-hive-system.dto';
|
||||
import {
|
||||
isAdditiveSchema,
|
||||
parseStateSchema,
|
||||
stateSchemaProblems,
|
||||
} from './hive-state-schema';
|
||||
import { catalogFromRows } from './hive-types';
|
||||
import type { HiveCatalog, HiveState } from './hive-types';
|
||||
import { validateHiveState } from './hive-validate';
|
||||
|
||||
export type HiveSystemDto = HiveSystem & { partTypes: HivePartType[] };
|
||||
|
||||
const EMPTY_TEMPLATE = { schemaVersion: 1, stack: [] as never[] };
|
||||
const UNIQUE_VIOLATION = 'P2002';
|
||||
|
||||
type Obj = Record<string, unknown>;
|
||||
|
||||
const isObj = (value: unknown): value is Obj =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
/** Copy of a stored part (no id) with a fresh id on it and on its inserts. */
|
||||
function stampPart(part: unknown): unknown {
|
||||
if (!isObj(part)) return part;
|
||||
const stamped: Obj = { ...part, id: randomUUID() };
|
||||
if (Array.isArray(part.inserts)) {
|
||||
stamped.inserts = part.inserts.map(stampPart);
|
||||
}
|
||||
return stamped;
|
||||
}
|
||||
|
||||
function stampTemplate(template: unknown): unknown {
|
||||
if (!isObj(template) || !Array.isArray(template.stack)) return template;
|
||||
return { ...template, stack: template.stack.map(stampPart) };
|
||||
}
|
||||
|
||||
function conflictOnDuplicate(error: unknown, what: string): never {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === UNIQUE_VIOLATION
|
||||
) {
|
||||
throw new ConflictException(`${what} name already exists`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HiveCatalogService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
) {}
|
||||
|
||||
async listSystems(): Promise<HiveSystemDto[]> {
|
||||
return this.prisma.hiveSystem.findMany({
|
||||
include: { partTypes: { orderBy: { name: 'asc' } } },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
/** Part types of a system incl. inactive ones (`active: false`). */
|
||||
async loadCatalog(
|
||||
systemId: string,
|
||||
tx?: Prisma.TransactionClient,
|
||||
): Promise<HiveCatalog> {
|
||||
const db = tx ?? this.prisma;
|
||||
const system = await db.hiveSystem.findUnique({
|
||||
where: { id: systemId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!system) throw new NotFoundException('Hive system not found');
|
||||
const rows = await db.hivePartType.findMany({ where: { systemId } });
|
||||
return catalogFromRows(
|
||||
rows.map((row) => ({
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
name: row.name,
|
||||
lengthMm: row.lengthMm,
|
||||
widthMm: row.widthMm,
|
||||
heightMm: row.heightMm,
|
||||
frameSlots: row.frameSlots,
|
||||
slotWidth: row.slotWidth,
|
||||
stateSchema: parseStateSchema(row.stateSchema),
|
||||
active: row.active,
|
||||
})),
|
||||
systemId,
|
||||
);
|
||||
}
|
||||
|
||||
/** The system template as a hive state with fresh ids on every part. */
|
||||
async template(systemId: string): Promise<HiveState> {
|
||||
const system = await this.prisma.hiveSystem.findUnique({
|
||||
where: { id: systemId },
|
||||
});
|
||||
if (!system) throw new NotFoundException('Hive system not found');
|
||||
const stored = system.template;
|
||||
if (
|
||||
isObj(stored) &&
|
||||
Array.isArray(stored.stack) &&
|
||||
stored.stack.length === 0
|
||||
) {
|
||||
throw new ConflictException({
|
||||
code: 'TEMPLATE_NOT_SET',
|
||||
message: 'The hive system has no template yet',
|
||||
});
|
||||
}
|
||||
return stampTemplate(stored) as HiveState;
|
||||
}
|
||||
|
||||
async createSystem(
|
||||
dto: CreateHiveSystemDto,
|
||||
actingUserId: string,
|
||||
): Promise<HiveSystem> {
|
||||
return this.prisma
|
||||
.$transaction(async (tx) => {
|
||||
// A template needs part types of the system, so it starts empty and
|
||||
// is set with PATCH once the catalog exists.
|
||||
const system = await tx.hiveSystem.create({
|
||||
data: { ...dto, template: EMPTY_TEMPLATE },
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'HiveSystem',
|
||||
entityId: system.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: system,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return system;
|
||||
})
|
||||
.catch((error: unknown) => conflictOnDuplicate(error, 'System'));
|
||||
}
|
||||
|
||||
async updateSystem(
|
||||
id: string,
|
||||
dto: UpdateHiveSystemDto,
|
||||
actingUserId: string,
|
||||
): Promise<HiveSystem> {
|
||||
return this.prisma
|
||||
.$transaction(async (tx) => {
|
||||
const before = await tx.hiveSystem.findUnique({ where: { id } });
|
||||
if (!before) throw new NotFoundException('Hive system not found');
|
||||
if (dto.template !== undefined) {
|
||||
await this.assertValidTemplate(id, dto.template, tx);
|
||||
}
|
||||
const { template, ...rest } = dto;
|
||||
const system = await tx.hiveSystem.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...rest,
|
||||
...(template !== undefined
|
||||
? { template: template as Prisma.InputJsonValue }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'HiveSystem',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: system,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return system;
|
||||
})
|
||||
.catch((error: unknown) => conflictOnDuplicate(error, 'System'));
|
||||
}
|
||||
|
||||
async createPartType(
|
||||
systemId: string,
|
||||
dto: CreateHivePartTypeDto,
|
||||
actingUserId: string,
|
||||
): Promise<HivePartType> {
|
||||
if (dto.kind === 'BOX' && dto.frameSlots === undefined) {
|
||||
throw new BadRequestException('frameSlots is required for a BOX');
|
||||
}
|
||||
this.assertSlotWidth(dto.kind, dto.slotWidth);
|
||||
const stateSchema = dto.stateSchema ?? [];
|
||||
this.assertSchemaShape(stateSchema);
|
||||
return this.prisma
|
||||
.$transaction(async (tx) => {
|
||||
const system = await tx.hiveSystem.findUnique({
|
||||
where: { id: systemId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!system) throw new NotFoundException('Hive system not found');
|
||||
const partType = await tx.hivePartType.create({
|
||||
data: {
|
||||
...dto,
|
||||
systemId,
|
||||
stateSchema: stateSchema as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'HivePartType',
|
||||
entityId: partType.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: partType,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return partType;
|
||||
})
|
||||
.catch((error: unknown) => conflictOnDuplicate(error, 'Part type'));
|
||||
}
|
||||
|
||||
async updatePartType(
|
||||
id: string,
|
||||
dto: UpdateHivePartTypeDto,
|
||||
actingUserId: string,
|
||||
): Promise<HivePartType> {
|
||||
return this.prisma
|
||||
.$transaction(async (tx) => {
|
||||
await lockRowForUpdate(tx, 'HivePartType', id);
|
||||
const before = await tx.hivePartType.findUnique({ where: { id } });
|
||||
if (!before) throw new NotFoundException('Hive part type not found');
|
||||
const { stateSchema, ...rest } = dto;
|
||||
this.assertSlotWidth(before.kind, dto.slotWidth ?? before.slotWidth);
|
||||
if (stateSchema !== undefined) {
|
||||
this.assertSchemaShape(stateSchema);
|
||||
this.assertAdditive(before.stateSchema, stateSchema);
|
||||
}
|
||||
const partType = await tx.hivePartType.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...rest,
|
||||
...(stateSchema !== undefined
|
||||
? { stateSchema: stateSchema as Prisma.InputJsonValue }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'HivePartType',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: partType,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return partType;
|
||||
})
|
||||
.catch((error: unknown) => conflictOnDuplicate(error, 'Part type'));
|
||||
}
|
||||
|
||||
/** slotWidth belongs to FRAME_FEEDER part types and to no other kind. */
|
||||
private assertSlotWidth(
|
||||
kind: string,
|
||||
slotWidth: number | null | undefined,
|
||||
): void {
|
||||
if (kind === 'FRAME_FEEDER' && slotWidth == null) {
|
||||
throw new BadRequestException('slotWidth is required for a FRAME_FEEDER');
|
||||
}
|
||||
if (kind !== 'FRAME_FEEDER' && slotWidth != null) {
|
||||
throw new BadRequestException(
|
||||
'slotWidth is only allowed for a FRAME_FEEDER',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private assertSchemaShape(stateSchema: unknown): void {
|
||||
const problems = stateSchemaProblems(stateSchema);
|
||||
if (problems.length > 0) {
|
||||
throw new BadRequestException({
|
||||
code: 'SCHEMA_INVALID',
|
||||
messages: problems,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private assertAdditive(current: unknown, next: unknown): void {
|
||||
if (!isAdditiveSchema(parseStateSchema(current), parseStateSchema(next))) {
|
||||
throw new BadRequestException({
|
||||
code: 'SCHEMA_NOT_ADDITIVE',
|
||||
message:
|
||||
'The state schema may only append fields; existing fields stay unchanged',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async assertValidTemplate(
|
||||
systemId: string,
|
||||
template: unknown,
|
||||
tx: Prisma.TransactionClient,
|
||||
): Promise<void> {
|
||||
const catalog = await this.loadCatalog(systemId, tx);
|
||||
const messages = validateHiveState(stampTemplate(template), catalog);
|
||||
if (messages.length > 0) {
|
||||
throw new BadRequestException({ code: 'TEMPLATE_INVALID', messages });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { HiveState } from './hive-types';
|
||||
|
||||
/** 409: an operation failed against the current state (spec 6.2). */
|
||||
export class HiveConflictException extends ConflictException {
|
||||
constructor(
|
||||
opIndex: number,
|
||||
reason: string,
|
||||
current: { version: number; state: HiveState },
|
||||
) {
|
||||
super({
|
||||
statusCode: 409,
|
||||
code: 'HIVE_CONFLICT',
|
||||
message: 'Die Änderung passt nicht mehr zum aktuellen Aufbau.',
|
||||
opIndex,
|
||||
reason,
|
||||
current,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function hiveNotFound(): NotFoundException {
|
||||
return new NotFoundException({
|
||||
statusCode: 404,
|
||||
code: 'HIVE_NOT_FOUND',
|
||||
message: 'Für dieses Volk gibt es noch keinen Aufbau.',
|
||||
});
|
||||
}
|
||||
|
||||
export function hiveExists(): ConflictException {
|
||||
return new ConflictException({
|
||||
statusCode: 409,
|
||||
code: 'HIVE_EXISTS',
|
||||
message: 'Für dieses Volk gibt es schon einen Aufbau.',
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidDay(field: string): BadRequestException {
|
||||
return new BadRequestException(`${field} must be a valid YYYY-MM-DD day`);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { deriveHiveFacts } from './hive-facts';
|
||||
import type { FrameType, HivePart, HiveState } from './hive-types';
|
||||
import { loadFixtureCatalog } from './hive-test-utils';
|
||||
|
||||
const T = (slug: string) => `hive-part-zl10-${slug}`;
|
||||
const catalog = loadFixtureCatalog();
|
||||
|
||||
const makeBox = (types: FrameType[]): HivePart => ({
|
||||
id: 'b1',
|
||||
partTypeId: T('box-10'),
|
||||
state: {},
|
||||
frames: types.map((type, i) => ({ slot: i + 1, type })),
|
||||
});
|
||||
|
||||
const stackOf = (...parts: HivePart[]): HiveState => ({
|
||||
schemaVersion: 1,
|
||||
stack: [{ id: 'f1', partTypeId: T('floor'), state: {} }, ...parts],
|
||||
});
|
||||
|
||||
describe('deriveHiveFacts', () => {
|
||||
it('derives zero facts for the system template', () => {
|
||||
const state = stackOf(
|
||||
makeBox(Array<FrameType>(10).fill('FOUNDATION')),
|
||||
{ id: 'c1', partTypeId: T('cover-abdeckfix'), state: {} },
|
||||
{ id: 'l1', partTypeId: T('inner-lid'), state: {} },
|
||||
{ id: 'i1', partTypeId: T('insulation'), state: {} },
|
||||
{ id: 'r1', partTypeId: T('roof'), state: {} },
|
||||
);
|
||||
const facts = deriveHiveFacts(state, catalog);
|
||||
expect(facts.boxCount).toBe(1);
|
||||
expect(facts.broodFrames).toBe(0);
|
||||
expect(facts.honeyFrames).toBe(0);
|
||||
expect(facts.builtFrames).toBe(0);
|
||||
expect(facts.hasFloorInsert).toBe(false);
|
||||
expect(facts.evaporatorAcidMl).toBeNull();
|
||||
});
|
||||
|
||||
it('counts brood, honey and built frames', () => {
|
||||
const types: FrameType[] = [
|
||||
...Array<FrameType>(6).fill('BROOD'),
|
||||
'HONEY',
|
||||
'HONEY',
|
||||
'DIVIDER',
|
||||
'NONE',
|
||||
];
|
||||
const facts = deriveHiveFacts(stackOf(makeBox(types)), catalog);
|
||||
expect(facts.broodFrames).toBe(6);
|
||||
expect(facts.honeyFrames).toBe(2);
|
||||
expect(facts.builtFrames).toBe(8);
|
||||
});
|
||||
|
||||
it('reports a feeder level with its feed type', () => {
|
||||
const state = stackOf({
|
||||
id: 'fd1',
|
||||
partTypeId: T('feeder'),
|
||||
state: { syrupLiters: 2.5 },
|
||||
});
|
||||
const facts = deriveHiveFacts(state, catalog);
|
||||
const syrup = facts.levels.filter((l) => l.key === 'syrupLiters');
|
||||
expect(syrup).toEqual([
|
||||
{
|
||||
partId: 'fd1',
|
||||
key: 'syrupLiters',
|
||||
label: 'Flüssigfutter',
|
||||
unit: 'l',
|
||||
value: 2.5,
|
||||
max: 3,
|
||||
feedType: 'SUGAR_WATER',
|
||||
},
|
||||
]);
|
||||
// Missing level fields count as 0.
|
||||
expect(facts.levels.find((l) => l.key === 'fondantKg')?.value).toBe(0);
|
||||
});
|
||||
|
||||
it('detects a floor insert and reads evaporator acid', () => {
|
||||
const state: HiveState = {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
{
|
||||
id: 'f1',
|
||||
partTypeId: T('floor'),
|
||||
state: {},
|
||||
inserts: [{ id: 'fi1', partTypeId: T('floor-insert'), state: {} }],
|
||||
},
|
||||
{
|
||||
id: 'e1',
|
||||
partTypeId: T('box-empty'),
|
||||
state: {},
|
||||
frames: [],
|
||||
inserts: [
|
||||
{ id: 'ev1', partTypeId: T('evaporator'), state: { acidMl: 250 } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const facts = deriveHiveFacts(state, catalog);
|
||||
expect(facts.hasFloorInsert).toBe(true);
|
||||
expect(facts.evaporatorAcidMl).toBe(250);
|
||||
expect(facts.boxCount).toBe(1);
|
||||
});
|
||||
|
||||
it('reports 0 ml for an evaporator without acid state', () => {
|
||||
const state: HiveState = {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
{ id: 'f1', partTypeId: T('floor'), state: {} },
|
||||
{
|
||||
id: 'e1',
|
||||
partTypeId: T('box-empty'),
|
||||
state: {},
|
||||
frames: [],
|
||||
inserts: [{ id: 'ev1', partTypeId: T('evaporator'), state: {} }],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(deriveHiveFacts(state, catalog).evaporatorAcidMl).toBe(0);
|
||||
});
|
||||
|
||||
it('reports a frame feeder level with its feed type', () => {
|
||||
const state = stackOf({
|
||||
...makeBox(['NONE', 'NONE', ...Array<FrameType>(8).fill('BROOD')]),
|
||||
inserts: [
|
||||
{
|
||||
id: 'ff1',
|
||||
partTypeId: T('frame-feeder'),
|
||||
slot: 1,
|
||||
state: { syrupLiters: 1.5 },
|
||||
},
|
||||
],
|
||||
});
|
||||
const facts = deriveHiveFacts(state, catalog);
|
||||
expect(facts.levels).toEqual([
|
||||
{
|
||||
partId: 'ff1',
|
||||
key: 'syrupLiters',
|
||||
label: 'Flüssigfutter',
|
||||
unit: 'l',
|
||||
value: 1.5,
|
||||
max: 2,
|
||||
feedType: 'SUGAR_WATER',
|
||||
},
|
||||
]);
|
||||
// The covered NONE slots are not built frames.
|
||||
expect(facts.builtFrames).toBe(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import type {
|
||||
HiveCatalog,
|
||||
HiveFacts,
|
||||
HiveLevelFact,
|
||||
HivePart,
|
||||
HiveState,
|
||||
} from './hive-types';
|
||||
|
||||
const UNBUILT_FRAME_TYPES = new Set(['NONE', 'FOUNDATION', 'DIVIDER']);
|
||||
|
||||
function allParts(state: HiveState): HivePart[] {
|
||||
return state.stack.flatMap((part) => [part, ...(part.inserts ?? [])]);
|
||||
}
|
||||
|
||||
function levelsOf(part: HivePart, catalog: HiveCatalog): HiveLevelFact[] {
|
||||
const type = catalog.partTypes.get(part.partTypeId);
|
||||
if (!type) return [];
|
||||
return type.stateSchema.flatMap((field) => {
|
||||
if (field.kind !== 'level') return [];
|
||||
const raw = part.state[field.key];
|
||||
return [
|
||||
{
|
||||
partId: part.id,
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
unit: field.unit,
|
||||
value: typeof raw === 'number' ? raw : 0,
|
||||
max: field.max,
|
||||
...(field.feedType ? { feedType: field.feedType } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** Pure derivation of display facts (spec 5.3). Never written back to a colony. */
|
||||
export function deriveHiveFacts(
|
||||
state: HiveState,
|
||||
catalog: HiveCatalog,
|
||||
): HiveFacts {
|
||||
const parts = allParts(state);
|
||||
const kindOf = (part: HivePart) =>
|
||||
catalog.partTypes.get(part.partTypeId)?.kind;
|
||||
const frames = state.stack
|
||||
.filter((part) => kindOf(part) === 'BOX')
|
||||
.flatMap((part) => part.frames ?? []);
|
||||
const evaporators = parts.filter((part) => kindOf(part) === 'EVAPORATOR');
|
||||
const acid = evaporators.reduce((sum, part) => {
|
||||
const value = part.state.acidMl;
|
||||
return sum + (typeof value === 'number' ? value : 0);
|
||||
}, 0);
|
||||
return {
|
||||
boxCount: state.stack.filter((part) => kindOf(part) === 'BOX').length,
|
||||
broodFrames: frames.filter((f) => f.type === 'BROOD').length,
|
||||
honeyFrames: frames.filter((f) => f.type === 'HONEY').length,
|
||||
builtFrames: frames.filter((f) => !UNBUILT_FRAME_TYPES.has(f.type)).length,
|
||||
levels: parts.flatMap((part) => levelsOf(part, catalog)),
|
||||
hasFloorInsert: parts.some((part) => kindOf(part) === 'FLOOR_INSERT'),
|
||||
evaporatorAcidMl: evaporators.length > 0 ? acid : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { applyHiveOps } from './hive-ops';
|
||||
import { validateHiveState } from './hive-validate';
|
||||
import type { HiveOp, HiveState } from './hive-types';
|
||||
import { FIXTURE_DIR, deepFreeze, loadFixtureCatalog } from './hive-test-utils';
|
||||
|
||||
interface FixtureCase {
|
||||
name: string;
|
||||
inactivePartTypeIds?: string[];
|
||||
state: HiveState;
|
||||
ops: HiveOp[];
|
||||
expect:
|
||||
| { ok: true; state: HiveState }
|
||||
| { ok: false; opIndex: number; reasonCode: string };
|
||||
}
|
||||
|
||||
const cases = readdirSync(FIXTURE_DIR)
|
||||
.filter((file) => file.endsWith('.json') && file !== 'catalog.json')
|
||||
.sort()
|
||||
.map((file) => ({
|
||||
file,
|
||||
data: JSON.parse(
|
||||
readFileSync(join(FIXTURE_DIR, file), 'utf8'),
|
||||
) as FixtureCase,
|
||||
}));
|
||||
|
||||
function findCase(name: string): FixtureCase {
|
||||
const found = cases.find((c) => c.data.name === name);
|
||||
if (!found) throw new Error(`fixture not found: ${name}`);
|
||||
return found.data;
|
||||
}
|
||||
|
||||
describe('applyHiveOps fixtures', () => {
|
||||
it('has at least 20 cases', () => {
|
||||
expect(cases.length).toBeGreaterThanOrEqual(20);
|
||||
});
|
||||
|
||||
it.each(cases.map((c) => [c.file, c.data] as const))('%s', (_file, data) => {
|
||||
const catalog = loadFixtureCatalog(data.inactivePartTypeIds);
|
||||
const result = applyHiveOps(
|
||||
deepFreeze(structuredClone(data.state)),
|
||||
deepFreeze(structuredClone(data.ops)),
|
||||
catalog,
|
||||
);
|
||||
if (data.expect.ok) {
|
||||
expect(result).toEqual({ ok: true, state: data.expect.state });
|
||||
// The expected end state must itself be valid.
|
||||
expect(validateHiveState(data.expect.state, catalog)).toEqual([]);
|
||||
} else {
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.opIndex).toBe(data.expect.opIndex);
|
||||
expect(result.reason.split(':')[0]).toBe(data.expect.reasonCode);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyHiveOps purity', () => {
|
||||
it('does not mutate a deep-frozen input state or ops', () => {
|
||||
const catalog = loadFixtureCatalog();
|
||||
const sample = findCase('multiple ops apply in order');
|
||||
const state = deepFreeze(structuredClone(sample.state));
|
||||
const ops = deepFreeze(structuredClone(sample.ops));
|
||||
const before = JSON.stringify(state);
|
||||
const result = applyHiveOps(state, ops, catalog);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(JSON.stringify(state)).toBe(before);
|
||||
});
|
||||
|
||||
it('does not share references between input and result', () => {
|
||||
const catalog = loadFixtureCatalog();
|
||||
const state = structuredClone(
|
||||
findCase('setLabel sets and clears a label').state,
|
||||
);
|
||||
const result = applyHiveOps(state, [], catalog);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.state).not.toBe(state);
|
||||
expect(result.state.stack[0]).not.toBe(state.stack[0]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateHiveState', () => {
|
||||
const catalog = loadFixtureCatalog();
|
||||
|
||||
it('rejects non-objects and wrong schemaVersion', () => {
|
||||
expect(validateHiveState(null, catalog).length).toBeGreaterThan(0);
|
||||
expect(
|
||||
validateHiveState({ schemaVersion: 2, stack: [] }, catalog).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('collects several violations', () => {
|
||||
const state = {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
{ id: 'a', partTypeId: 'hive-part-zl10-roof', state: {} },
|
||||
{ id: 'a', partTypeId: 'hive-part-zl10-floor', state: { x: 1 } },
|
||||
],
|
||||
};
|
||||
const codes = validateHiveState(state, catalog).map((r) => r.split(':')[0]);
|
||||
expect(codes).toEqual(
|
||||
expect.arrayContaining([
|
||||
'DUPLICATE_ID',
|
||||
'RULE_FLOOR_BOTTOM',
|
||||
'RULE_ROOF_TOP',
|
||||
'STATE_UNKNOWN_KEY',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import { MAX_OPS } from './hive-types';
|
||||
import type {
|
||||
HiveApplyResult,
|
||||
HiveCatalog,
|
||||
HiveFrame,
|
||||
HiveOp,
|
||||
HivePart,
|
||||
HiveState,
|
||||
} from './hive-types';
|
||||
import { validateHiveState } from './hive-validate';
|
||||
|
||||
interface Location {
|
||||
part: HivePart;
|
||||
list: HivePart[];
|
||||
index: number;
|
||||
isInsert: boolean;
|
||||
}
|
||||
|
||||
/** Result of one op on the working copy: a reason string, or null on success. */
|
||||
type OpOutcome = string | null;
|
||||
|
||||
function locate(state: HiveState, id: unknown): Location | null {
|
||||
for (let index = 0; index < state.stack.length; index++) {
|
||||
const part = state.stack[index];
|
||||
if (part.id === id) {
|
||||
return { part, list: state.stack, index, isInsert: false };
|
||||
}
|
||||
const inserts = part.inserts ?? [];
|
||||
for (let i = 0; i < inserts.length; i++) {
|
||||
if (inserts[i].id === id) {
|
||||
return { part: inserts[i], list: inserts, index: i, isInsert: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function missing(id: unknown): string {
|
||||
return `MISSING_ID: no part with id ${String(id)}`;
|
||||
}
|
||||
|
||||
function clamp(value: unknown, max: number): number {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return max;
|
||||
return Math.min(Math.max(Math.trunc(value), 0), max);
|
||||
}
|
||||
|
||||
/** Catalog membership and active check for a part to be added (and its inserts). */
|
||||
function checkAddable(part: HivePart, catalog: HiveCatalog): OpOutcome {
|
||||
if (typeof part !== 'object' || part === null) {
|
||||
return 'MISSING_ID: part is not an object';
|
||||
}
|
||||
const type = catalog.partTypes.get(part.partTypeId);
|
||||
if (!type) {
|
||||
return `FOREIGN_PART_TYPE: ${String(part.partTypeId)} is not in this system`;
|
||||
}
|
||||
if (!type.active) {
|
||||
return `INACTIVE_PART_TYPE: ${type.id} is inactive`;
|
||||
}
|
||||
for (const insert of part.inserts ?? []) {
|
||||
const nested = checkAddable(insert, catalog);
|
||||
if (nested) return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Deep copy of an incoming part without empty `inserts` arrays. */
|
||||
function normalisedCopy(part: HivePart): HivePart {
|
||||
const copy = structuredClone(part);
|
||||
if (Array.isArray(copy.inserts)) {
|
||||
if (copy.inserts.length === 0) delete copy.inserts;
|
||||
else copy.inserts = copy.inserts.map(normalisedCopy);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
function applyAddPart(
|
||||
state: HiveState,
|
||||
op: Extract<HiveOp, { op: 'addPart' }>,
|
||||
catalog: HiveCatalog,
|
||||
): OpOutcome {
|
||||
const problem = checkAddable(op.part, catalog);
|
||||
if (problem) return problem;
|
||||
const at = clamp(op.at, state.stack.length);
|
||||
state.stack.splice(at, 0, normalisedCopy(op.part));
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyAddInsert(
|
||||
state: HiveState,
|
||||
op: Extract<HiveOp, { op: 'addInsert' }>,
|
||||
catalog: HiveCatalog,
|
||||
): OpOutcome {
|
||||
const host = locate(state, op.hostId);
|
||||
if (!host || host.isInsert) return missing(op.hostId);
|
||||
const problem = checkAddable(op.part, catalog);
|
||||
if (problem) return problem;
|
||||
host.part.inserts = [...(host.part.inserts ?? []), normalisedCopy(op.part)];
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyRemovePart(
|
||||
state: HiveState,
|
||||
op: Extract<HiveOp, { op: 'removePart' }>,
|
||||
): OpOutcome {
|
||||
const found = locate(state, op.id);
|
||||
if (!found) return missing(op.id);
|
||||
found.list.splice(found.index, 1);
|
||||
if (found.isInsert) {
|
||||
const host = state.stack.find((p) => p.inserts === found.list);
|
||||
if (host && found.list.length === 0) delete host.inserts;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyMovePart(
|
||||
state: HiveState,
|
||||
op: Extract<HiveOp, { op: 'movePart' }>,
|
||||
): OpOutcome {
|
||||
const found = locate(state, op.id);
|
||||
if (!found) return missing(op.id);
|
||||
if (found.isInsert) {
|
||||
return `RULE_INSERT_HOST: ${op.id} is an insert and cannot be moved`;
|
||||
}
|
||||
const [moved] = state.stack.splice(found.index, 1);
|
||||
state.stack.splice(clamp(op.to, state.stack.length), 0, moved);
|
||||
return null;
|
||||
}
|
||||
|
||||
function applySetState(
|
||||
state: HiveState,
|
||||
op: Extract<HiveOp, { op: 'setState' }>,
|
||||
): OpOutcome {
|
||||
const found = locate(state, op.id);
|
||||
if (!found) return missing(op.id);
|
||||
found.part.state = { ...found.part.state, [op.key]: op.value };
|
||||
return null;
|
||||
}
|
||||
|
||||
function locateBox(state: HiveState, boxId: unknown): Location | string {
|
||||
const found = locate(state, boxId);
|
||||
if (!found) return missing(boxId);
|
||||
if (found.isInsert || !Array.isArray(found.part.frames)) {
|
||||
return `FRAMES_LENGTH: ${String(boxId)} is not a box with frames`;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function applySetFrame(
|
||||
state: HiveState,
|
||||
op: Extract<HiveOp, { op: 'setFrame' }>,
|
||||
): OpOutcome {
|
||||
const box = locateBox(state, op.boxId);
|
||||
if (typeof box === 'string') return box;
|
||||
const frames = box.part.frames as HiveFrame[];
|
||||
if (!Number.isInteger(op.slot) || op.slot < 1 || op.slot > frames.length) {
|
||||
return `FRAMES_LENGTH: slot ${String(op.slot)} is outside 1..${frames.length}`;
|
||||
}
|
||||
// name: undefined keeps the current name, null removes it.
|
||||
const current = frames[op.slot - 1];
|
||||
const name = op.name === undefined ? current.name : (op.name ?? undefined);
|
||||
frames[op.slot - 1] =
|
||||
name === undefined
|
||||
? { slot: op.slot, type: op.type }
|
||||
: { slot: op.slot, type: op.type, name };
|
||||
return null;
|
||||
}
|
||||
|
||||
function applySetFrames(
|
||||
state: HiveState,
|
||||
op: Extract<HiveOp, { op: 'setFrames' }>,
|
||||
): OpOutcome {
|
||||
const box = locateBox(state, op.boxId);
|
||||
if (typeof box === 'string') return box;
|
||||
box.part.frames = structuredClone(op.frames);
|
||||
return null;
|
||||
}
|
||||
|
||||
function applySetLabel(
|
||||
state: HiveState,
|
||||
op: Extract<HiveOp, { op: 'setLabel' }>,
|
||||
): OpOutcome {
|
||||
const found = locate(state, op.id);
|
||||
if (!found) return missing(op.id);
|
||||
if (op.label === null) {
|
||||
delete found.part.label;
|
||||
} else {
|
||||
found.part.label = op.label;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyOne(
|
||||
state: HiveState,
|
||||
op: HiveOp,
|
||||
catalog: HiveCatalog,
|
||||
): OpOutcome {
|
||||
switch (op?.op) {
|
||||
case 'addPart':
|
||||
return applyAddPart(state, op, catalog);
|
||||
case 'addInsert':
|
||||
return applyAddInsert(state, op, catalog);
|
||||
case 'removePart':
|
||||
return applyRemovePart(state, op);
|
||||
case 'movePart':
|
||||
return applyMovePart(state, op);
|
||||
case 'setState':
|
||||
return applySetState(state, op);
|
||||
case 'setFrame':
|
||||
return applySetFrame(state, op);
|
||||
case 'setFrames':
|
||||
return applySetFrames(state, op);
|
||||
case 'setLabel':
|
||||
return applySetLabel(state, op);
|
||||
default:
|
||||
return 'UNKNOWN_OP: unsupported operation';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies ops in order and validates the whole state after each one.
|
||||
* Never mutates its inputs: it works on a private deep copy.
|
||||
*/
|
||||
export function applyHiveOps(
|
||||
state: HiveState,
|
||||
ops: HiveOp[],
|
||||
catalog: HiveCatalog,
|
||||
): HiveApplyResult {
|
||||
if (ops.length > MAX_OPS) {
|
||||
return {
|
||||
ok: false,
|
||||
opIndex: MAX_OPS,
|
||||
reason: `LIMIT_OPS: at most ${MAX_OPS} operations per request`,
|
||||
};
|
||||
}
|
||||
const working = structuredClone(state);
|
||||
for (let opIndex = 0; opIndex < ops.length; opIndex++) {
|
||||
const failure =
|
||||
applyOne(working, ops[opIndex], catalog) ??
|
||||
validateHiveState(working, catalog)[0] ??
|
||||
null;
|
||||
if (failure) return { ok: false, opIndex, reason: failure };
|
||||
}
|
||||
return { ok: true, state: working };
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { FeedType } from '../../generated/prisma';
|
||||
import type { StateField } from './hive-types';
|
||||
|
||||
const MAX_KEY = 40;
|
||||
const MAX_LABEL = 60;
|
||||
const MAX_UNIT = 10;
|
||||
const MAX_OPTION = 40;
|
||||
const FEED_TYPES: readonly string[] = Object.values(FeedType);
|
||||
|
||||
type Obj = Record<string, unknown>;
|
||||
|
||||
const isObj = (value: unknown): value is Obj =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
const isPositive = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value > 0;
|
||||
|
||||
const isStringList = (value: unknown): value is string[] =>
|
||||
Array.isArray(value) && value.every((x) => typeof x === 'string');
|
||||
|
||||
function levelProblem(field: Obj, where: string): string | null {
|
||||
if (typeof field.unit !== 'string' || field.unit.length > MAX_UNIT) {
|
||||
return `${where}.unit must be a string of at most ${MAX_UNIT} characters`;
|
||||
}
|
||||
if (!isPositive(field.max)) return `${where}.max must be positive`;
|
||||
if (!isPositive(field.step)) return `${where}.step must be positive`;
|
||||
if (
|
||||
field.feedType !== undefined &&
|
||||
!(typeof field.feedType === 'string' && FEED_TYPES.includes(field.feedType))
|
||||
) {
|
||||
return `${where}.feedType must be one of ${FEED_TYPES.join('/')}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function choiceProblem(field: Obj, where: string): string | null {
|
||||
const { options, optionLabels } = field;
|
||||
if (!isStringList(options) || options.length === 0) {
|
||||
return `${where}.options must be a non-empty list of strings`;
|
||||
}
|
||||
if (new Set(options).size !== options.length) {
|
||||
return `${where}.options must be unique`;
|
||||
}
|
||||
if (!isStringList(optionLabels) || optionLabels.length !== options.length) {
|
||||
return `${where}.optionLabels must match options`;
|
||||
}
|
||||
if ([...options, ...optionLabels].some((text) => text.length > MAX_OPTION)) {
|
||||
return `${where} options and labels must have at most ${MAX_OPTION} characters`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function fieldProblem(field: unknown, index: number): string | null {
|
||||
const where = `stateSchema[${index}]`;
|
||||
if (!isObj(field)) return `${where} must be an object`;
|
||||
if (
|
||||
typeof field.key !== 'string' ||
|
||||
field.key === '' ||
|
||||
field.key.length > MAX_KEY
|
||||
) {
|
||||
return `${where}.key must be 1-${MAX_KEY} characters`;
|
||||
}
|
||||
if (
|
||||
typeof field.label !== 'string' ||
|
||||
field.label === '' ||
|
||||
field.label.length > MAX_LABEL
|
||||
) {
|
||||
return `${where}.label must be 1-${MAX_LABEL} characters`;
|
||||
}
|
||||
if (field.kind === 'level') return levelProblem(field, where);
|
||||
if (field.kind === 'choice') return choiceProblem(field, where);
|
||||
return `${where}.kind must be level or choice`;
|
||||
}
|
||||
|
||||
/** Returns the problems of a raw stateSchema value (empty list means valid). */
|
||||
export function stateSchemaProblems(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) return ['stateSchema must be a list'];
|
||||
const problems: string[] = [];
|
||||
const keys = new Set<string>();
|
||||
raw.forEach((field: unknown, index) => {
|
||||
const problem = fieldProblem(field, index);
|
||||
if (problem) {
|
||||
problems.push(problem);
|
||||
return;
|
||||
}
|
||||
const key = (field as Obj).key as string;
|
||||
if (keys.has(key)) problems.push(`duplicate key ${key}`);
|
||||
keys.add(key);
|
||||
});
|
||||
return problems;
|
||||
}
|
||||
|
||||
/** Casts a stored JSON value after the shape check; throws when malformed. */
|
||||
export function parseStateSchema(raw: unknown): StateField[] {
|
||||
const problems = stateSchemaProblems(raw);
|
||||
if (problems.length > 0) {
|
||||
throw new Error(`Malformed stateSchema: ${problems.join('; ')}`);
|
||||
}
|
||||
return raw as StateField[];
|
||||
}
|
||||
|
||||
/** Stable JSON text: object keys sorted, so key order does not matter. */
|
||||
function canonical(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
||||
if (isObj(value)) {
|
||||
return `{${Object.keys(value)
|
||||
.sort()
|
||||
.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`)
|
||||
.join(',')}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
/** True when `next` keeps every field of `current` unchanged and only appends. */
|
||||
export function isAdditiveSchema(
|
||||
current: StateField[],
|
||||
next: StateField[],
|
||||
): boolean {
|
||||
if (next.length < current.length) return false;
|
||||
return current.every((field, i) => canonical(field) === canonical(next[i]));
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { catalogFromRows } from './hive-types';
|
||||
import type { CatalogPartType, HiveCatalog } from './hive-types';
|
||||
|
||||
export const FIXTURE_DIR = join(__dirname, '../../test/fixtures/hive-ops');
|
||||
|
||||
interface CatalogFile {
|
||||
systemId: string;
|
||||
partTypes: CatalogPartType[];
|
||||
}
|
||||
|
||||
/** Loads the shared catalog fixture; `inactiveIds` marks part types inactive. */
|
||||
export function loadFixtureCatalog(inactiveIds: string[] = []): HiveCatalog {
|
||||
const file = JSON.parse(
|
||||
readFileSync(join(FIXTURE_DIR, 'catalog.json'), 'utf8'),
|
||||
) as CatalogFile;
|
||||
const rows = file.partTypes.map((row) => ({
|
||||
...row,
|
||||
active: !inactiveIds.includes(row.id),
|
||||
}));
|
||||
return catalogFromRows(rows, file.systemId);
|
||||
}
|
||||
|
||||
export function deepFreeze<T>(value: T): T {
|
||||
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
||||
Object.freeze(value);
|
||||
Object.values(value as Record<string, unknown>).forEach((child) =>
|
||||
deepFreeze(child),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// Pure domain types for the hive setup (spec 5.2, 6.1). No framework or
|
||||
// Prisma imports: the frontend mirrors this module.
|
||||
|
||||
export type HivePartKind =
|
||||
| 'FLOOR'
|
||||
| 'BOX'
|
||||
| 'DIVIDER_BOARD'
|
||||
| 'QUEEN_EXCLUDER'
|
||||
| 'FEEDER'
|
||||
| 'COVER_SHEET'
|
||||
| 'PROPOLIS_SCREEN'
|
||||
| 'INNER_LID'
|
||||
| 'INSULATION'
|
||||
| 'ROOF'
|
||||
| 'FLOOR_INSERT'
|
||||
| 'EVAPORATOR'
|
||||
| 'FRAME_FEEDER';
|
||||
|
||||
// Mirrors the Prisma enum HiveBoxDepth; used from Task 3 on.
|
||||
export type HiveBoxDepth = 'FULL' | 'SHALLOW';
|
||||
|
||||
export type FrameType =
|
||||
| 'BROOD'
|
||||
| 'DRONE'
|
||||
| 'HONEY'
|
||||
| 'POLLEN'
|
||||
| 'FEED'
|
||||
| 'FOUNDATION'
|
||||
| 'EMPTY_COMB'
|
||||
| 'DRONE_FRAME'
|
||||
| 'DIVIDER'
|
||||
| 'NONE';
|
||||
|
||||
export const FRAME_TYPES: readonly FrameType[] = [
|
||||
'BROOD',
|
||||
'DRONE',
|
||||
'HONEY',
|
||||
'POLLEN',
|
||||
'FEED',
|
||||
'FOUNDATION',
|
||||
'EMPTY_COMB',
|
||||
'DRONE_FRAME',
|
||||
'DIVIDER',
|
||||
'NONE',
|
||||
];
|
||||
|
||||
export const MAX_STACK_PARTS = 30;
|
||||
export const MAX_OPS = 100;
|
||||
export const MAX_TEXT_LENGTH = 40;
|
||||
export const LEVEL_TOLERANCE = 1e-9;
|
||||
|
||||
export interface HiveFrame {
|
||||
slot: number;
|
||||
type: FrameType;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface HivePart {
|
||||
id: string;
|
||||
partTypeId: string;
|
||||
label?: string;
|
||||
state: Record<string, number | string>;
|
||||
inserts?: HivePart[];
|
||||
frames?: HiveFrame[];
|
||||
// Only on FRAME_FEEDER inserts: first frame slot (1-based) the pocket covers.
|
||||
slot?: number;
|
||||
}
|
||||
|
||||
export interface HiveState {
|
||||
schemaVersion: 1;
|
||||
stack: HivePart[];
|
||||
}
|
||||
|
||||
export type HiveOp =
|
||||
| { op: 'addPart'; part: HivePart; at: number }
|
||||
| { op: 'addInsert'; hostId: string; part: HivePart }
|
||||
| { op: 'removePart'; id: string }
|
||||
| { op: 'movePart'; id: string; to: number }
|
||||
| { op: 'setState'; id: string; key: string; value: number | string }
|
||||
| {
|
||||
op: 'setFrame';
|
||||
boxId: string;
|
||||
slot: number;
|
||||
type: FrameType;
|
||||
name?: string | null;
|
||||
}
|
||||
| { op: 'setFrames'; boxId: string; frames: HiveFrame[] }
|
||||
| { op: 'setLabel'; id: string; label: string | null };
|
||||
|
||||
export interface LevelField {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: 'level';
|
||||
unit: string;
|
||||
max: number;
|
||||
step: number;
|
||||
feedType?: string;
|
||||
}
|
||||
|
||||
export interface ChoiceField {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: 'choice';
|
||||
options: string[];
|
||||
optionLabels: string[];
|
||||
}
|
||||
|
||||
export type StateField = LevelField | ChoiceField;
|
||||
|
||||
export interface CatalogPartType {
|
||||
id: string;
|
||||
kind: HivePartKind;
|
||||
name: string;
|
||||
lengthMm: number;
|
||||
widthMm: number;
|
||||
heightMm: number;
|
||||
frameSlots: number | null;
|
||||
// Only FRAME_FEEDER: number of consecutive frame slots the pocket covers.
|
||||
slotWidth: number | null;
|
||||
stateSchema: StateField[];
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface HiveCatalog {
|
||||
systemId: string;
|
||||
partTypes: Map<string, CatalogPartType>;
|
||||
}
|
||||
|
||||
export function catalogFromRows(
|
||||
rows: CatalogPartType[],
|
||||
systemId: string,
|
||||
): HiveCatalog {
|
||||
return {
|
||||
systemId,
|
||||
partTypes: new Map(rows.map((row) => [row.id, row])),
|
||||
};
|
||||
}
|
||||
|
||||
export interface HiveLevelFact {
|
||||
partId: string;
|
||||
key: string;
|
||||
label: string;
|
||||
unit: string;
|
||||
value: number;
|
||||
max: number;
|
||||
feedType?: string;
|
||||
}
|
||||
|
||||
export interface HiveFacts {
|
||||
boxCount: number;
|
||||
broodFrames: number;
|
||||
honeyFrames: number;
|
||||
builtFrames: number;
|
||||
levels: HiveLevelFact[];
|
||||
hasFloorInsert: boolean;
|
||||
evaporatorAcidMl: number | null;
|
||||
}
|
||||
|
||||
export type HiveApplyResult =
|
||||
| { ok: true; state: HiveState }
|
||||
| { ok: false; opIndex: number; reason: string };
|
||||
@@ -0,0 +1,369 @@
|
||||
import {
|
||||
FRAME_TYPES,
|
||||
LEVEL_TOLERANCE,
|
||||
MAX_STACK_PARTS,
|
||||
MAX_TEXT_LENGTH,
|
||||
} from './hive-types';
|
||||
import type { CatalogPartType, HiveCatalog, StateField } from './hive-types';
|
||||
|
||||
// Check order decides which code is reported first; the frontend port must
|
||||
// keep it: root shape (schemaVersion must be 1 -> STATE_OUT_OF_RANGE; extra
|
||||
// root keys -> UNKNOWN_FIELD), LIMIT_PARTS, then per stack part: id, unknown
|
||||
// fields (UNKNOWN_FIELD), type, label, state, frames (per frame: slot, unknown
|
||||
// fields, type, name), inserts (each insert in the same order; then per insert
|
||||
// RULE_INSERT_HOST, nested inserts and `slot` / RULE_FEEDER_SLOT; after all
|
||||
// inserts of a box the FRAME_FEEDER overlap check, also RULE_FEEDER_SLOT),
|
||||
// then FLOOR, then ROOF.
|
||||
// Text lengths are UTF-16 code units (`.length`); the frontend must do the same.
|
||||
const ROOT_KEYS = ['schemaVersion', 'stack'];
|
||||
const PART_KEYS = [
|
||||
'id',
|
||||
'partTypeId',
|
||||
'label',
|
||||
'state',
|
||||
'inserts',
|
||||
'frames',
|
||||
'slot',
|
||||
];
|
||||
const FRAME_KEYS = ['slot', 'type', 'name'];
|
||||
|
||||
function unknownFields(
|
||||
obj: Record<string, unknown>,
|
||||
allowed: string[],
|
||||
where: string,
|
||||
out: string[],
|
||||
): void {
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (!allowed.includes(key)) out.push(`UNKNOWN_FIELD: ${where}.${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
type Obj = Record<string, unknown>;
|
||||
|
||||
const isObj = (value: unknown): value is Obj =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
/** Checks one state value against its schema field; returns a reason or null. */
|
||||
export function checkStateValue(
|
||||
field: StateField,
|
||||
value: unknown,
|
||||
where: string,
|
||||
): string | null {
|
||||
if (field.kind === 'choice') {
|
||||
if (typeof value !== 'string' || !field.options.includes(value)) {
|
||||
return `STATE_OUT_OF_RANGE: ${where} must be one of ${field.options.join('/')}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return `STATE_OUT_OF_RANGE: ${where} must be a number`;
|
||||
}
|
||||
if (value < 0 || value > field.max) {
|
||||
return `STATE_OUT_OF_RANGE: ${where} must be between 0 and ${field.max}`;
|
||||
}
|
||||
const steps = value / field.step;
|
||||
if (Math.abs(steps - Math.round(steps)) * field.step > LEVEL_TOLERANCE) {
|
||||
return `STATE_STEP: ${where} must be a multiple of ${field.step}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateState(
|
||||
part: Obj,
|
||||
type: CatalogPartType,
|
||||
where: string,
|
||||
out: string[],
|
||||
): void {
|
||||
if (!isObj(part.state)) {
|
||||
out.push(`STATE_OUT_OF_RANGE: ${where} has no state object`);
|
||||
return;
|
||||
}
|
||||
for (const [key, value] of Object.entries(part.state)) {
|
||||
const field = type.stateSchema.find((f) => f.key === key);
|
||||
if (!field) {
|
||||
out.push(`STATE_UNKNOWN_KEY: ${where} has unknown key ${key}`);
|
||||
continue;
|
||||
}
|
||||
const problem = checkStateValue(field, value, `${where}.${key}`);
|
||||
if (problem) out.push(problem);
|
||||
}
|
||||
}
|
||||
|
||||
function validateFrames(
|
||||
part: Obj,
|
||||
type: CatalogPartType,
|
||||
where: string,
|
||||
out: string[],
|
||||
): void {
|
||||
const slots = type.kind === 'BOX' ? (type.frameSlots ?? 0) : 0;
|
||||
const frames = part.frames;
|
||||
if (type.kind !== 'BOX') {
|
||||
if (
|
||||
frames !== undefined &&
|
||||
!(Array.isArray(frames) && frames.length === 0)
|
||||
) {
|
||||
out.push(`FRAMES_LENGTH: ${where} is not a box and cannot have frames`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const list = frames === undefined && slots === 0 ? [] : frames;
|
||||
if (!Array.isArray(list) || list.length !== slots) {
|
||||
out.push(`FRAMES_LENGTH: ${where} needs exactly ${slots} frames`);
|
||||
return;
|
||||
}
|
||||
list.forEach((frame: unknown, index) => {
|
||||
if (!isObj(frame) || frame.slot !== index + 1) {
|
||||
out.push(`FRAMES_LENGTH: ${where} frame ${index + 1} has a wrong slot`);
|
||||
return;
|
||||
}
|
||||
unknownFields(frame, FRAME_KEYS, `${where}.frames[${index}]`, out);
|
||||
if (!(FRAME_TYPES as readonly unknown[]).includes(frame.type)) {
|
||||
out.push(
|
||||
`STATE_OUT_OF_RANGE: ${where} frame ${index + 1} has an unknown type`,
|
||||
);
|
||||
}
|
||||
if (frame.name !== undefined) {
|
||||
if (typeof frame.name !== 'string') {
|
||||
out.push(
|
||||
`STATE_OUT_OF_RANGE: ${where} frame ${index + 1} name must be a string`,
|
||||
);
|
||||
} else if (frame.name.length > MAX_TEXT_LENGTH) {
|
||||
out.push(`LIMIT_TEXT: ${where} frame ${index + 1} name is too long`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
interface WalkContext {
|
||||
catalog: HiveCatalog;
|
||||
seenIds: Set<string>;
|
||||
out: string[];
|
||||
}
|
||||
|
||||
/** Checks id, catalog membership, label, state, frames. Returns the type or null. */
|
||||
function validatePartCommon(
|
||||
part: unknown,
|
||||
where: string,
|
||||
ctx: WalkContext,
|
||||
): { part: Obj; type: CatalogPartType } | null {
|
||||
const { out } = ctx;
|
||||
if (!isObj(part)) {
|
||||
out.push(`MISSING_ID: ${where} is not an object`);
|
||||
return null;
|
||||
}
|
||||
if (typeof part.id !== 'string' || part.id === '') {
|
||||
out.push(`MISSING_ID: ${where} has no id`);
|
||||
} else if (ctx.seenIds.has(part.id)) {
|
||||
out.push(`DUPLICATE_ID: ${part.id}`);
|
||||
} else {
|
||||
ctx.seenIds.add(part.id);
|
||||
}
|
||||
unknownFields(part, PART_KEYS, where, out);
|
||||
const type =
|
||||
typeof part.partTypeId === 'string'
|
||||
? ctx.catalog.partTypes.get(part.partTypeId)
|
||||
: undefined;
|
||||
if (!type) {
|
||||
out.push(`FOREIGN_PART_TYPE: ${where} part type is not in this system`);
|
||||
return null;
|
||||
}
|
||||
if (part.label !== undefined) {
|
||||
if (typeof part.label !== 'string') {
|
||||
out.push(`STATE_OUT_OF_RANGE: ${where} label must be a string`);
|
||||
} else if (part.label.length > MAX_TEXT_LENGTH) {
|
||||
out.push(`LIMIT_TEXT: ${where} label is too long`);
|
||||
}
|
||||
}
|
||||
validateState(part, type, where, out);
|
||||
validateFrames(part, type, where, out);
|
||||
return { part, type };
|
||||
}
|
||||
|
||||
const INSERT_KINDS: Partial<Record<string, string[]>> = {
|
||||
FLOOR: ['FLOOR_INSERT'],
|
||||
BOX: ['EVAPORATOR', 'FRAME_FEEDER'],
|
||||
};
|
||||
|
||||
interface SlotRange {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* RULE_FEEDER_SLOT checks for one insert: `slot` only on FRAME_FEEDER, an
|
||||
* integer inside 1..frameSlots for the covered width, and every covered frame
|
||||
* NONE. Returns the covered range for the overlap check, or null.
|
||||
*/
|
||||
function validateFeederSlot(
|
||||
insert: Obj,
|
||||
type: CatalogPartType,
|
||||
host: Obj,
|
||||
hostType: CatalogPartType,
|
||||
where: string,
|
||||
out: string[],
|
||||
): SlotRange | null {
|
||||
if (type.kind !== 'FRAME_FEEDER') {
|
||||
if (insert.slot !== undefined) {
|
||||
out.push(`RULE_FEEDER_SLOT: ${where} has a slot but is no frame feeder`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (hostType.kind !== 'BOX') return null;
|
||||
const slot = insert.slot;
|
||||
const width = type.slotWidth;
|
||||
const slots = hostType.frameSlots ?? 0;
|
||||
if (typeof slot !== 'number' || !Number.isInteger(slot) || slot < 1) {
|
||||
out.push(`RULE_FEEDER_SLOT: ${where} needs an integer slot of 1 or more`);
|
||||
return null;
|
||||
}
|
||||
if (width === null || !Number.isInteger(width) || width < 1) {
|
||||
out.push(`RULE_FEEDER_SLOT: ${where} part type has no slot width`);
|
||||
return null;
|
||||
}
|
||||
const range = { from: slot, to: slot + width - 1 };
|
||||
if (range.to > slots) {
|
||||
out.push(
|
||||
`RULE_FEEDER_SLOT: ${where} covers slots ${range.from}-${range.to} but the box has ${slots}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const frames = Array.isArray(host.frames) ? (host.frames as Obj[]) : [];
|
||||
for (let at = range.from; at <= range.to; at++) {
|
||||
if (frames[at - 1]?.type !== 'NONE') {
|
||||
out.push(
|
||||
`RULE_FEEDER_SLOT: ${where} covers frame ${at} which is not NONE`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return range;
|
||||
}
|
||||
|
||||
function validateFeederOverlap(
|
||||
ranges: SlotRange[],
|
||||
where: string,
|
||||
out: string[],
|
||||
): void {
|
||||
const sorted = [...ranges].sort((a, b) => a.from - b.from);
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
if (sorted[i].from <= sorted[i - 1].to) {
|
||||
out.push(`RULE_FEEDER_SLOT: ${where} has overlapping frame feeders`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateInserts(
|
||||
host: Obj,
|
||||
hostType: CatalogPartType,
|
||||
where: string,
|
||||
ctx: WalkContext,
|
||||
): void {
|
||||
const { out } = ctx;
|
||||
const inserts = host.inserts;
|
||||
if (inserts === undefined) return;
|
||||
if (!Array.isArray(inserts)) {
|
||||
out.push(`RULE_INSERT_HOST: ${where} inserts must be a list`);
|
||||
return;
|
||||
}
|
||||
const allowed = INSERT_KINDS[hostType.kind] ?? null;
|
||||
if (inserts.length > 0 && allowed === null) {
|
||||
out.push(`RULE_INSERT_HOST: ${where} cannot hold inserts`);
|
||||
}
|
||||
if (hostType.kind === 'FLOOR' && inserts.length > 1) {
|
||||
out.push(`RULE_INSERT_LIMIT: ${where} holds at most one FLOOR_INSERT`);
|
||||
}
|
||||
const ranges: SlotRange[] = [];
|
||||
let evaporators = 0;
|
||||
inserts.forEach((insert: unknown, index) => {
|
||||
const here = `${where}.inserts[${index}]`;
|
||||
const checked = validatePartCommon(insert, here, ctx);
|
||||
if (!checked) return;
|
||||
if (checked.type.kind === 'EVAPORATOR') evaporators += 1;
|
||||
if (evaporators > 1 && checked.type.kind === 'EVAPORATOR') {
|
||||
out.push(`RULE_INSERT_LIMIT: ${where} holds at most one EVAPORATOR`);
|
||||
}
|
||||
if (allowed !== null && !allowed.includes(checked.type.kind)) {
|
||||
out.push(`RULE_INSERT_HOST: ${here} must be ${allowed.join(' or ')}`);
|
||||
}
|
||||
if (checked.part.inserts !== undefined) {
|
||||
const nested = checked.part.inserts;
|
||||
if (!Array.isArray(nested) || nested.length > 0) {
|
||||
out.push(`RULE_INSERT_HOST: ${here} cannot hold inserts`);
|
||||
}
|
||||
}
|
||||
const range = validateFeederSlot(
|
||||
checked.part,
|
||||
checked.type,
|
||||
host,
|
||||
hostType,
|
||||
here,
|
||||
out,
|
||||
);
|
||||
if (range) ranges.push(range);
|
||||
});
|
||||
validateFeederOverlap(ranges, where, out);
|
||||
}
|
||||
|
||||
function validateStackRules(
|
||||
kinds: (CatalogPartType | null)[],
|
||||
out: string[],
|
||||
): void {
|
||||
const floors = kinds.filter((t) => t?.kind === 'FLOOR').length;
|
||||
if (floors !== 1 || kinds[0]?.kind !== 'FLOOR') {
|
||||
out.push(
|
||||
'RULE_FLOOR_BOTTOM: the stack needs exactly one floor at the bottom',
|
||||
);
|
||||
}
|
||||
const roofIndexes = kinds
|
||||
.map((t, i) => (t?.kind === 'ROOF' ? i : -1))
|
||||
.filter((i) => i >= 0);
|
||||
if (
|
||||
roofIndexes.length > 1 ||
|
||||
(roofIndexes.length === 1 && roofIndexes[0] !== kinds.length - 1)
|
||||
) {
|
||||
out.push('RULE_ROOF_TOP: the roof must be the single top part');
|
||||
}
|
||||
}
|
||||
|
||||
/** Validates a whole hive state. Empty result means valid. */
|
||||
export function validateHiveState(
|
||||
state: unknown,
|
||||
catalog: HiveCatalog,
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
if (
|
||||
!isObj(state) ||
|
||||
state.schemaVersion !== 1 ||
|
||||
!Array.isArray(state.stack)
|
||||
) {
|
||||
return ['STATE_OUT_OF_RANGE: state needs schemaVersion 1 and a stack list'];
|
||||
}
|
||||
unknownFields(state, ROOT_KEYS, 'state', out);
|
||||
if (state.stack.length > MAX_STACK_PARTS) {
|
||||
out.push(`LIMIT_PARTS: at most ${MAX_STACK_PARTS} parts in the stack`);
|
||||
}
|
||||
const ctx: WalkContext = { catalog, seenIds: new Set(), out };
|
||||
const types: (CatalogPartType | null)[] = [];
|
||||
state.stack.forEach((entry: unknown, index) => {
|
||||
const where = `stack[${index}]`;
|
||||
const checked = validatePartCommon(entry, where, ctx);
|
||||
types.push(checked?.type ?? null);
|
||||
if (!checked) return;
|
||||
if (
|
||||
checked.type.kind === 'FLOOR_INSERT' ||
|
||||
checked.type.kind === 'EVAPORATOR' ||
|
||||
checked.type.kind === 'FRAME_FEEDER'
|
||||
) {
|
||||
out.push(
|
||||
`RULE_INSERT_HOST: ${where} must be an insert, not a stack part`,
|
||||
);
|
||||
}
|
||||
if (checked.part.slot !== undefined) {
|
||||
out.push(`RULE_FEEDER_SLOT: ${where} has a slot but is no frame feeder`);
|
||||
}
|
||||
validateInserts(checked.part, checked.type, where, ctx);
|
||||
});
|
||||
validateStackRules(types, out);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Body, Controller, Get, Param, 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 { Idempotent } from '../common/idempotency/idempotent.decorator';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateHiveChangeDto } from './dto/create-hive-change.dto';
|
||||
import { CreateHiveSetupDto } from './dto/create-hive-setup.dto';
|
||||
import { HiveAtQueryDto, HiveHistoryQueryDto } from './dto/hive-queries.dto';
|
||||
import { HiveService } from './hive.service';
|
||||
import type { HiveOp } from './hive-types';
|
||||
|
||||
@ApiTags('hive')
|
||||
@ApiBearerAuth()
|
||||
@Controller('colonies/:id/hive')
|
||||
export class HiveController {
|
||||
constructor(
|
||||
private readonly hive: HiveService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@RequirePermission('colonies:read')
|
||||
@Get()
|
||||
get(@Param('id') id: string) {
|
||||
return this.hive.get(id);
|
||||
}
|
||||
|
||||
@RequirePermission('colonies:update')
|
||||
@Idempotent()
|
||||
@Post()
|
||||
create(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CreateHiveSetupDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.hive.create(id, dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('colonies:update')
|
||||
@Idempotent()
|
||||
@Post('changes')
|
||||
applyChange(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CreateHiveChangeDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
// Plain objects only: the DTO instances carry class metadata.
|
||||
const ops = JSON.parse(JSON.stringify(dto.ops)) as HiveOp[];
|
||||
return this.prisma.$transaction((tx) =>
|
||||
this.hive.applyChange(
|
||||
tx,
|
||||
id,
|
||||
{
|
||||
occurredOn: dto.occurredOn,
|
||||
source: dto.source,
|
||||
ops,
|
||||
note: dto.note,
|
||||
},
|
||||
actor.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@RequirePermission('colonies:read')
|
||||
@Get('changes')
|
||||
history(@Param('id') id: string, @Query() query: HiveHistoryQueryDto) {
|
||||
return this.hive.history(id, query.cursor, query.limit);
|
||||
}
|
||||
|
||||
@RequirePermission('colonies:read')
|
||||
@Get('at')
|
||||
at(@Param('id') id: string, @Query() query: HiveAtQueryDto) {
|
||||
return this.hive.at(id, query.day);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HiveCatalogController } from './hive-catalog.controller';
|
||||
import { HiveCatalogService } from './hive-catalog.service';
|
||||
import { HiveController } from './hive.controller';
|
||||
import { HiveService } from './hive.service';
|
||||
|
||||
@Module({
|
||||
controllers: [HiveCatalogController, HiveController],
|
||||
providers: [HiveCatalogService, HiveService],
|
||||
exports: [HiveCatalogService, HiveService],
|
||||
})
|
||||
export class HiveModule {}
|
||||
@@ -0,0 +1,635 @@
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { addDays, berlinToday } from '../common/dates';
|
||||
import { HiveCatalogService } from './hive-catalog.service';
|
||||
import { HiveService } from './hive.service';
|
||||
import { loadFixtureCatalog } from './hive-test-utils';
|
||||
import type { HiveOp, HiveState } from './hive-types';
|
||||
|
||||
jest.mock('../prisma/row-lock');
|
||||
|
||||
const SYSTEM_ID = 'hive-system-zander-liebig-10';
|
||||
const COLONY = 'colony-1';
|
||||
const USER = 'user-1';
|
||||
const FLOOR = 'hive-part-zl10-floor';
|
||||
const BOX = 'hive-part-zl10-box-10';
|
||||
const ROOF = 'hive-part-zl10-roof';
|
||||
|
||||
const frames = Array.from({ length: 10 }, (_, i) => ({
|
||||
slot: i + 1,
|
||||
type: 'FOUNDATION',
|
||||
}));
|
||||
|
||||
const baseState = {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
{ id: 'p-floor', partTypeId: FLOOR, state: {} },
|
||||
{ id: 'p-box', partTypeId: BOX, state: {}, frames },
|
||||
{ id: 'p-roof', partTypeId: ROOF, state: {} },
|
||||
],
|
||||
} as unknown as HiveState;
|
||||
|
||||
const TODAY = berlinToday();
|
||||
const INIT_DAY = addDays(TODAY, -10);
|
||||
const setLabel = (label: string): HiveOp => ({
|
||||
op: 'setLabel',
|
||||
id: 'p-box',
|
||||
label,
|
||||
});
|
||||
|
||||
function dayDate(day: string): Date {
|
||||
return new Date(`${day}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
function argOf<T>(mock: jest.Mock, call: number, arg: number): T {
|
||||
return (mock.mock.calls as unknown[][])[call][arg] as T;
|
||||
}
|
||||
|
||||
interface WrittenChange {
|
||||
data: {
|
||||
stateAfter: { stack: { label?: string }[] };
|
||||
occurredOn: Date;
|
||||
inspectionId: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
const lockedTables = (mock: jest.Mock): unknown[] =>
|
||||
(mock.mock.calls as unknown[][]).map((call) => call[1]);
|
||||
|
||||
async function statusAndBody(promise: Promise<unknown>) {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) {
|
||||
return { status: error.getStatus(), body: error.getResponse() };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw new Error('expected the call to reject');
|
||||
}
|
||||
|
||||
describe('HiveService', () => {
|
||||
let service: HiveService;
|
||||
let tx: {
|
||||
colony: { findUnique: jest.Mock };
|
||||
hiveSystem: { findUnique: jest.Mock };
|
||||
hiveSetup: {
|
||||
findUnique: jest.Mock;
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
findMany: jest.Mock;
|
||||
};
|
||||
hiveChange: {
|
||||
findUnique: jest.Mock;
|
||||
findFirst: jest.Mock;
|
||||
findMany: jest.Mock;
|
||||
create: jest.Mock;
|
||||
};
|
||||
};
|
||||
let prisma: { $transaction: jest.Mock } & typeof tx;
|
||||
let events: { record: jest.Mock };
|
||||
let catalogs: { loadCatalog: jest.Mock };
|
||||
const lock = lockRowForUpdate as jest.Mock;
|
||||
|
||||
const setup = (version = 1) => ({
|
||||
colonyId: COLONY,
|
||||
systemId: SYSTEM_ID,
|
||||
version,
|
||||
state: baseState,
|
||||
updatedAt: new Date('2026-10-01T10:00:00.000Z'),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
tx = {
|
||||
colony: {
|
||||
findUnique: jest.fn().mockResolvedValue({ status: 'ACTIVE' }),
|
||||
},
|
||||
hiveSystem: {
|
||||
findUnique: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: SYSTEM_ID, active: true }),
|
||||
},
|
||||
hiveSetup: {
|
||||
findUnique: jest.fn().mockResolvedValue(setup()),
|
||||
findMany: jest.fn().mockResolvedValue([setup()]),
|
||||
create: jest.fn().mockImplementation(({ data }: { data: object }) => ({
|
||||
...data,
|
||||
updatedAt: new Date('2026-10-02T10:00:00.000Z'),
|
||||
})),
|
||||
update: jest.fn().mockImplementation(({ data }: { data: object }) => ({
|
||||
colonyId: COLONY,
|
||||
systemId: SYSTEM_ID,
|
||||
updatedAt: new Date('2026-10-03T10:00:00.000Z'),
|
||||
...data,
|
||||
})),
|
||||
},
|
||||
hiveChange: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
occurredOn: dayDate(INIT_DAY),
|
||||
}),
|
||||
findFirst: jest.fn(),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
create: jest.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
prisma = {
|
||||
...tx,
|
||||
$transaction: jest.fn((cb: (t: typeof tx) => unknown) => cb(tx)),
|
||||
};
|
||||
events = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
catalogs = {
|
||||
loadCatalog: jest.fn().mockResolvedValue(loadFixtureCatalog()),
|
||||
};
|
||||
lock.mockReset().mockResolvedValue(true);
|
||||
service = new HiveService(
|
||||
prisma as unknown as PrismaService,
|
||||
events as unknown as DomainEventsService,
|
||||
catalogs as unknown as HiveCatalogService,
|
||||
);
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('returns the setup with derived facts', async () => {
|
||||
const view = await service.get(COLONY);
|
||||
expect(view).toMatchObject({
|
||||
colonyId: COLONY,
|
||||
systemId: SYSTEM_ID,
|
||||
version: 1,
|
||||
updatedAt: '2026-10-01T10:00:00.000Z',
|
||||
});
|
||||
expect(view.state).toEqual(baseState);
|
||||
expect(view.facts.boxCount).toBe(1);
|
||||
});
|
||||
|
||||
it('answers 404 HIVE_NOT_FOUND without a setup', async () => {
|
||||
tx.hiveSetup.findUnique.mockResolvedValue(null);
|
||||
const { status, body } = await statusAndBody(service.get(COLONY));
|
||||
expect(status).toBe(404);
|
||||
expect(body).toMatchObject({ code: 'HIVE_NOT_FOUND' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMany', () => {
|
||||
it('returns the views of the colonies that have a setup, keyed by colony', async () => {
|
||||
const views = await service.getMany([COLONY, 'colony-without']);
|
||||
expect(tx.hiveSetup.findMany).toHaveBeenCalledWith({
|
||||
where: { colonyId: { in: [COLONY, 'colony-without'] } },
|
||||
});
|
||||
expect(Object.keys(views)).toEqual([COLONY]);
|
||||
expect(views[COLONY].facts.boxCount).toBe(1);
|
||||
});
|
||||
|
||||
it('loads each catalog once and skips the query for no colonies', async () => {
|
||||
tx.hiveSetup.findMany.mockResolvedValue([
|
||||
setup(),
|
||||
{ ...setup(2), colonyId: 'colony-2' },
|
||||
]);
|
||||
await service.getMany([COLONY, 'colony-2']);
|
||||
expect(catalogs.loadCatalog).toHaveBeenCalledTimes(1);
|
||||
|
||||
tx.hiveSetup.findMany.mockClear();
|
||||
await expect(service.getMany([])).resolves.toEqual({});
|
||||
expect(tx.hiveSetup.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
const input = (overrides = {}) => ({
|
||||
systemId: SYSTEM_ID,
|
||||
state: baseState,
|
||||
occurredOn: TODAY,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
tx.hiveSetup.findUnique.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it('writes version 1, the INIT change and the event in one transaction', async () => {
|
||||
const view = await service.create(COLONY, input(), USER);
|
||||
|
||||
expect(view.version).toBe(1);
|
||||
expect(tx.hiveSetup.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
colonyId: COLONY,
|
||||
systemId: SYSTEM_ID,
|
||||
version: 1,
|
||||
state: baseState,
|
||||
},
|
||||
});
|
||||
expect(tx.hiveChange.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
colonyId: COLONY,
|
||||
version: 1,
|
||||
source: 'INIT',
|
||||
userId: USER,
|
||||
occurredOn: dayDate(TODAY),
|
||||
ops: [],
|
||||
stateAfter: baseState,
|
||||
}) as unknown,
|
||||
});
|
||||
expect(events.record).toHaveBeenCalledWith(tx, {
|
||||
type: 'HiveChanged',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: COLONY,
|
||||
actorUserId: USER,
|
||||
payload: {
|
||||
colonyId: COLONY,
|
||||
version: 1,
|
||||
source: 'INIT',
|
||||
inspectionId: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('locks the colony before the setup', async () => {
|
||||
await service.create(COLONY, input(), USER);
|
||||
expect(lockedTables(lock)).toEqual(['Colony', 'HiveSetup']);
|
||||
});
|
||||
|
||||
it('answers 409 HIVE_EXISTS for a second setup, decided under the lock', async () => {
|
||||
tx.hiveSetup.findUnique.mockResolvedValue(setup());
|
||||
const { status, body } = await statusAndBody(
|
||||
service.create(COLONY, input(), USER),
|
||||
);
|
||||
expect(status).toBe(409);
|
||||
expect(body).toMatchObject({ code: 'HIVE_EXISTS' });
|
||||
expect(tx.hiveSetup.create).not.toHaveBeenCalled();
|
||||
expect(events.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers 409 for a colony that no longer lives', async () => {
|
||||
tx.colony.findUnique.mockResolvedValue({ status: 'DISSOLVED' });
|
||||
const { status } = await statusAndBody(
|
||||
service.create(COLONY, input(), USER),
|
||||
);
|
||||
expect(status).toBe(409);
|
||||
});
|
||||
|
||||
it('answers 404 for an unknown colony', async () => {
|
||||
lock.mockResolvedValueOnce(false);
|
||||
const { status } = await statusAndBody(
|
||||
service.create(COLONY, input(), USER),
|
||||
);
|
||||
expect(status).toBe(404);
|
||||
});
|
||||
|
||||
it('answers 400 with messages for a state the catalog refuses', async () => {
|
||||
const state = { schemaVersion: 1, stack: [] };
|
||||
state.stack.push({
|
||||
id: 'x',
|
||||
partTypeId: 'unknown-part',
|
||||
state: {},
|
||||
} as never);
|
||||
const { status, body } = await statusAndBody(
|
||||
service.create(COLONY, input({ state }), USER),
|
||||
);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toMatchObject({ messages: expect.any(Array) as unknown });
|
||||
});
|
||||
|
||||
it('answers 400 for an inactive system and 404 for an unknown one', async () => {
|
||||
tx.hiveSystem.findUnique.mockResolvedValueOnce({
|
||||
id: SYSTEM_ID,
|
||||
active: false,
|
||||
});
|
||||
expect(
|
||||
(await statusAndBody(service.create(COLONY, input(), USER))).status,
|
||||
).toBe(400);
|
||||
tx.hiveSystem.findUnique.mockResolvedValueOnce(null);
|
||||
expect(
|
||||
(await statusAndBody(service.create(COLONY, input(), USER))).status,
|
||||
).toBe(404);
|
||||
});
|
||||
|
||||
it('answers 400 for a day in the future (Berlin)', async () => {
|
||||
const { status } = await statusAndBody(
|
||||
service.create(COLONY, input({ occurredOn: addDays(TODAY, 1) }), USER),
|
||||
);
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
it('answers 400 for an impossible day', async () => {
|
||||
const { status } = await statusAndBody(
|
||||
service.create(COLONY, input({ occurredOn: '2026-02-30' }), USER),
|
||||
);
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyChange', () => {
|
||||
const change = (overrides = {}) => ({
|
||||
occurredOn: TODAY,
|
||||
source: 'CARD' as const,
|
||||
ops: [setLabel('Brutraum')],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('increments the version, stores the change and records the event', async () => {
|
||||
const view = await service.applyChange(
|
||||
tx as never,
|
||||
COLONY,
|
||||
change({ note: 'Test' }),
|
||||
USER,
|
||||
);
|
||||
|
||||
expect(view.version).toBe(2);
|
||||
const written = argOf<WrittenChange>(tx.hiveChange.create, 0, 0).data;
|
||||
expect(written).toMatchObject({
|
||||
colonyId: COLONY,
|
||||
version: 2,
|
||||
source: 'CARD',
|
||||
userId: USER,
|
||||
note: 'Test',
|
||||
occurredOn: dayDate(TODAY),
|
||||
inspectionId: null,
|
||||
});
|
||||
expect(written.stateAfter.stack[1].label).toBe('Brutraum');
|
||||
expect(tx.hiveSetup.update).toHaveBeenCalledWith({
|
||||
where: { colonyId: COLONY },
|
||||
data: { version: 2, state: written.stateAfter },
|
||||
});
|
||||
expect(events.record).toHaveBeenCalledWith(tx, {
|
||||
type: 'HiveChanged',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: COLONY,
|
||||
actorUserId: USER,
|
||||
payload: {
|
||||
colonyId: COLONY,
|
||||
version: 2,
|
||||
source: 'CARD',
|
||||
inspectionId: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('passes the inspection id on to the change and the event', async () => {
|
||||
await service.applyChange(
|
||||
tx as never,
|
||||
COLONY,
|
||||
change({ source: 'INSPECTION', inspectionId: 'insp-1' }),
|
||||
USER,
|
||||
);
|
||||
expect(
|
||||
argOf<WrittenChange>(tx.hiveChange.create, 0, 0).data.inspectionId,
|
||||
).toBe('insp-1');
|
||||
expect(
|
||||
argOf<{ payload: object }>(events.record, 0, 1).payload,
|
||||
).toMatchObject({
|
||||
source: 'INSPECTION',
|
||||
inspectionId: 'insp-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('stores and applies only the fields the op name defines', async () => {
|
||||
const ops = [
|
||||
{ ...setLabel('Sauber'), junk: 1, id: 'p-box' },
|
||||
] as unknown as HiveOp[];
|
||||
await service.applyChange(tx as never, COLONY, change({ ops }), USER);
|
||||
expect(
|
||||
argOf<{ data: { ops: unknown } }>(tx.hiveChange.create, 0, 0).data.ops,
|
||||
).toEqual([{ op: 'setLabel', id: 'p-box', label: 'Sauber' }]);
|
||||
});
|
||||
|
||||
it('answers 400 for INIT and AI_SUGGESTION as source', async () => {
|
||||
for (const source of ['INIT', 'AI_SUGGESTION']) {
|
||||
const { status } = await statusAndBody(
|
||||
service.applyChange(tx as never, COLONY, change({ source }), USER),
|
||||
);
|
||||
expect(status).toBe(400);
|
||||
}
|
||||
expect(lock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires inspectionId exactly for source INSPECTION', async () => {
|
||||
for (const input of [
|
||||
change({ source: 'INSPECTION' }),
|
||||
change({ source: 'CARD', inspectionId: 'insp-1' }),
|
||||
]) {
|
||||
const { status } = await statusAndBody(
|
||||
service.applyChange(tx as never, COLONY, input, USER),
|
||||
);
|
||||
expect(status).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
it('locks the colony, then the setup', async () => {
|
||||
await service.applyChange(tx as never, COLONY, change(), USER);
|
||||
expect(lockedTables(lock)).toEqual(['Colony', 'HiveSetup']);
|
||||
});
|
||||
|
||||
it('answers 409 for a colony that no longer lives', async () => {
|
||||
tx.colony.findUnique.mockResolvedValue({ status: 'SOLD' });
|
||||
const { status } = await statusAndBody(
|
||||
service.applyChange(tx as never, COLONY, change(), USER),
|
||||
);
|
||||
expect(status).toBe(409);
|
||||
expect(tx.hiveChange.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers 404 HIVE_NOT_FOUND without a setup', async () => {
|
||||
lock.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
|
||||
const { status, body } = await statusAndBody(
|
||||
service.applyChange(tx as never, COLONY, change(), USER),
|
||||
);
|
||||
expect(status).toBe(404);
|
||||
expect(body).toMatchObject({ code: 'HIVE_NOT_FOUND' });
|
||||
});
|
||||
|
||||
it('answers 409 HIVE_CONFLICT with the current state for a missing id', async () => {
|
||||
tx.hiveSetup.findUnique.mockResolvedValue(setup(4));
|
||||
const ops: HiveOp[] = [setLabel('ok'), { op: 'removePart', id: 'gone' }];
|
||||
const { status, body } = await statusAndBody(
|
||||
service.applyChange(tx as never, COLONY, change({ ops }), USER),
|
||||
);
|
||||
expect(status).toBe(409);
|
||||
expect(body).toMatchObject({
|
||||
code: 'HIVE_CONFLICT',
|
||||
opIndex: 1,
|
||||
reason: expect.stringMatching(/^MISSING_ID/) as unknown,
|
||||
current: { version: 4, state: baseState },
|
||||
});
|
||||
expect(tx.hiveChange.create).not.toHaveBeenCalled();
|
||||
expect(events.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers 400 for tomorrow (Berlin)', async () => {
|
||||
const { status } = await statusAndBody(
|
||||
service.applyChange(
|
||||
tx as never,
|
||||
COLONY,
|
||||
change({ occurredOn: addDays(TODAY, 1) }),
|
||||
USER,
|
||||
),
|
||||
);
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
it('answers 400 for a day before the INIT change', async () => {
|
||||
const { status } = await statusAndBody(
|
||||
service.applyChange(
|
||||
tx as never,
|
||||
COLONY,
|
||||
change({ occurredOn: addDays(INIT_DAY, -1) }),
|
||||
USER,
|
||||
),
|
||||
);
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
it('accepts the INIT day itself and a backdated day', async () => {
|
||||
await service.applyChange(
|
||||
tx as never,
|
||||
COLONY,
|
||||
change({ occurredOn: INIT_DAY }),
|
||||
USER,
|
||||
);
|
||||
expect(
|
||||
argOf<WrittenChange>(tx.hiveChange.create, 0, 0).data.occurredOn,
|
||||
).toEqual(dayDate(INIT_DAY));
|
||||
});
|
||||
|
||||
it('leaves no event behind when the change cannot be written', async () => {
|
||||
tx.hiveChange.create.mockRejectedValue(new Error('db down'));
|
||||
await expect(
|
||||
service.applyChange(tx as never, COLONY, change(), USER),
|
||||
).rejects.toThrow('db down');
|
||||
expect(events.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes the event with the caller transaction client only', async () => {
|
||||
await service.applyChange(tx as never, COLONY, change(), USER);
|
||||
expect(argOf<unknown>(events.record, 0, 0)).toBe(tx);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('history', () => {
|
||||
const row = (version: number, day: string) => ({
|
||||
id: `c${version}`,
|
||||
version,
|
||||
occurredOn: dayDate(day),
|
||||
source: 'CARD',
|
||||
inspectionId: null,
|
||||
userId: USER,
|
||||
user: { name: 'Nick' },
|
||||
ops: [],
|
||||
note: null,
|
||||
createdAt: new Date('2026-10-05T08:00:00.000Z'),
|
||||
});
|
||||
|
||||
it('lists newest first without stateAfter and maps the user name', async () => {
|
||||
tx.hiveChange.findMany.mockResolvedValue([row(3, '2026-10-05')]);
|
||||
const result = await service.history(COLONY);
|
||||
|
||||
expect(tx.hiveChange.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { colonyId: COLONY },
|
||||
orderBy: [{ occurredOn: 'desc' }, { version: 'desc' }],
|
||||
take: 21,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
argOf<{ select: Record<string, unknown> }>(tx.hiveChange.findMany, 0, 0)
|
||||
.select.stateAfter,
|
||||
).toBe(undefined);
|
||||
expect(result.items[0]).toEqual({
|
||||
id: 'c3',
|
||||
version: 3,
|
||||
occurredOn: '2026-10-05',
|
||||
source: 'CARD',
|
||||
inspectionId: null,
|
||||
userId: USER,
|
||||
userName: 'Nick',
|
||||
ops: [],
|
||||
note: null,
|
||||
createdAt: '2026-10-05T08:00:00.000Z',
|
||||
});
|
||||
expect(result.nextCursor).toBeNull();
|
||||
});
|
||||
|
||||
it('returns a cursor when more rows exist and continues after it', async () => {
|
||||
tx.hiveChange.findMany.mockResolvedValue([
|
||||
row(4, '2026-10-05'),
|
||||
row(2, '2026-10-04'),
|
||||
row(3, '2026-10-03'),
|
||||
]);
|
||||
const page = await service.history(COLONY, undefined, 2);
|
||||
expect(page.items.map((item) => item.version)).toEqual([4, 2]);
|
||||
expect(page.nextCursor).not.toBeNull();
|
||||
expect(
|
||||
JSON.parse(
|
||||
Buffer.from(page.nextCursor as string, 'base64url').toString(),
|
||||
),
|
||||
).toEqual({ occurredOn: '2026-10-04', version: 2 });
|
||||
|
||||
tx.hiveChange.findMany.mockResolvedValue([]);
|
||||
await service.history(COLONY, page.nextCursor as string, 2);
|
||||
expect(
|
||||
argOf<{ where: unknown }>(tx.hiveChange.findMany, 1, 0).where,
|
||||
).toEqual({
|
||||
colonyId: COLONY,
|
||||
OR: [
|
||||
{ occurredOn: { lt: dayDate('2026-10-04') } },
|
||||
{ occurredOn: dayDate('2026-10-04'), version: { lt: 2 } },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('answers 400 for a malformed cursor', async () => {
|
||||
const { status } = await statusAndBody(
|
||||
service.history(COLONY, 'not-a-cursor'),
|
||||
);
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
it('answers 404 HIVE_NOT_FOUND without a setup', async () => {
|
||||
tx.hiveSetup.findUnique.mockResolvedValue(null);
|
||||
const { status } = await statusAndBody(service.history(COLONY));
|
||||
expect(status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('at', () => {
|
||||
it('returns the highest version with occurredOn <= day', async () => {
|
||||
tx.hiveChange.findFirst.mockResolvedValue({
|
||||
version: 3,
|
||||
occurredOn: dayDate('2026-10-04'),
|
||||
stateAfter: baseState,
|
||||
});
|
||||
const result = await service.at(COLONY, '2026-10-05');
|
||||
|
||||
expect(tx.hiveChange.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
colonyId: COLONY,
|
||||
occurredOn: { lte: dayDate('2026-10-05') },
|
||||
},
|
||||
orderBy: { version: 'desc' },
|
||||
}),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
version: 3,
|
||||
occurredOn: '2026-10-04',
|
||||
state: baseState,
|
||||
});
|
||||
expect(result.facts.boxCount).toBe(1);
|
||||
});
|
||||
|
||||
it('answers 404 before the INIT day', async () => {
|
||||
tx.hiveChange.findFirst.mockResolvedValue(null);
|
||||
const { status, body } = await statusAndBody(
|
||||
service.at(COLONY, '2020-01-01'),
|
||||
);
|
||||
expect(status).toBe(404);
|
||||
expect(body).toMatchObject({ code: 'HIVE_NOT_FOUND' });
|
||||
});
|
||||
|
||||
it('answers 400 for a malformed day', async () => {
|
||||
const { status } = await statusAndBody(service.at(COLONY, '05.10.2026'));
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,497 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { HiveChangeSource, Prisma } from '../../generated/prisma';
|
||||
import { berlinToday, dayToDate } from '../common/dates';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { isLivingColonyStatus } from '../colonies/colony-status';
|
||||
import {
|
||||
HiveConflictException,
|
||||
hiveExists,
|
||||
hiveNotFound,
|
||||
invalidDay,
|
||||
} from './hive-errors';
|
||||
import { deriveHiveFacts } from './hive-facts';
|
||||
import { HiveCatalogService } from './hive-catalog.service';
|
||||
import { canonicalOp } from './hive-canonical';
|
||||
import { applyHiveOps } from './hive-ops';
|
||||
import type { HiveCatalog, HiveFacts, HiveOp, HiveState } from './hive-types';
|
||||
import { validateHiveState } from './hive-validate';
|
||||
|
||||
export interface HiveSetupView {
|
||||
colonyId: string;
|
||||
systemId: string;
|
||||
version: number;
|
||||
state: HiveState;
|
||||
facts: HiveFacts;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface HiveChangeListItem {
|
||||
id: string;
|
||||
version: number;
|
||||
occurredOn: string;
|
||||
source: HiveChangeSource;
|
||||
inspectionId: string | null;
|
||||
userId: string;
|
||||
userName: string;
|
||||
ops: unknown;
|
||||
note: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ApplyChangeInput {
|
||||
occurredOn: string;
|
||||
source: HiveChangeSource;
|
||||
ops: HiveOp[];
|
||||
note?: string;
|
||||
inspectionId?: string;
|
||||
}
|
||||
|
||||
export interface CreateSetupInput {
|
||||
systemId: string;
|
||||
state: unknown;
|
||||
occurredOn: string;
|
||||
}
|
||||
|
||||
const DEFAULT_HISTORY_LIMIT = 20;
|
||||
const MAX_HISTORY_LIMIT = 100;
|
||||
|
||||
interface HistoryCursor {
|
||||
occurredOn: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
const isoDay = (date: Date): string => date.toISOString().slice(0, 10);
|
||||
|
||||
function parseDay(day: unknown, field: string): Date {
|
||||
if (typeof day !== 'string') throw invalidDay(field);
|
||||
try {
|
||||
return dayToDate(day);
|
||||
} catch {
|
||||
throw invalidDay(field);
|
||||
}
|
||||
}
|
||||
|
||||
/** A day (YYYY-MM-DD) that is valid and not after today in Berlin. */
|
||||
function parseOccurredOn(day: unknown): Date {
|
||||
const date = parseDay(day, 'occurredOn');
|
||||
if (isoDay(date) > berlinToday()) {
|
||||
throw new BadRequestException('occurredOn must not be in the future');
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
function encodeCursor(cursor: HistoryCursor): string {
|
||||
return Buffer.from(JSON.stringify(cursor)).toString('base64url');
|
||||
}
|
||||
|
||||
function decodeCursor(raw: string): HistoryCursor {
|
||||
try {
|
||||
const value: unknown = JSON.parse(
|
||||
Buffer.from(raw, 'base64url').toString('utf8'),
|
||||
);
|
||||
const { occurredOn, version } = value as Partial<HistoryCursor>;
|
||||
if (
|
||||
typeof occurredOn === 'string' &&
|
||||
typeof version === 'number' &&
|
||||
Number.isInteger(version)
|
||||
) {
|
||||
dayToDate(occurredOn);
|
||||
return { occurredOn, version };
|
||||
}
|
||||
} catch {
|
||||
// falls through to the 400 below
|
||||
}
|
||||
throw new BadRequestException('cursor is not valid');
|
||||
}
|
||||
|
||||
/** INIT and AI_SUGGESTION are not accepted here; an inspection id goes with INSPECTION only. */
|
||||
function assertSourceFits(input: ApplyChangeInput): void {
|
||||
if (input.source === 'INIT' || input.source === 'AI_SUGGESTION') {
|
||||
throw new BadRequestException(`source ${input.source} is not allowed`);
|
||||
}
|
||||
if ((input.source === 'INSPECTION') !== (input.inspectionId !== undefined)) {
|
||||
throw new BadRequestException(
|
||||
'inspectionId is required for, and only allowed with, source INSPECTION',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HiveService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly events: DomainEventsService,
|
||||
private readonly catalogs: HiveCatalogService,
|
||||
) {}
|
||||
|
||||
async get(colonyId: string): Promise<HiveSetupView> {
|
||||
const setup = await this.prisma.hiveSetup.findUnique({
|
||||
where: { colonyId },
|
||||
});
|
||||
if (!setup) throw hiveNotFound();
|
||||
return this.view(setup);
|
||||
}
|
||||
|
||||
/** The setups of the given colonies, keyed by colony id; colonies without one are left out. */
|
||||
async getMany(
|
||||
colonyIds: readonly string[],
|
||||
): Promise<Record<string, HiveSetupView>> {
|
||||
if (colonyIds.length === 0) return {};
|
||||
const setups = await this.prisma.hiveSetup.findMany({
|
||||
where: { colonyId: { in: [...colonyIds] } },
|
||||
});
|
||||
const catalogs = new Map<string, Promise<HiveCatalog>>();
|
||||
const catalogOf = (systemId: string) => {
|
||||
let catalog = catalogs.get(systemId);
|
||||
if (!catalog) {
|
||||
catalog = this.catalogs.loadCatalog(systemId);
|
||||
catalogs.set(systemId, catalog);
|
||||
}
|
||||
return catalog;
|
||||
};
|
||||
const views = await Promise.all(
|
||||
setups.map(async (setup) =>
|
||||
this.viewWith(setup, await catalogOf(setup.systemId)),
|
||||
),
|
||||
);
|
||||
return Object.fromEntries(views.map((view) => [view.colonyId, view]));
|
||||
}
|
||||
|
||||
async create(
|
||||
colonyId: string,
|
||||
input: CreateSetupInput,
|
||||
userId: string,
|
||||
): Promise<HiveSetupView> {
|
||||
const occurredOn = parseOccurredOn(input.occurredOn);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await this.lockLivingColony(tx, colonyId);
|
||||
await lockRowForUpdate(tx, 'HiveSetup', colonyId);
|
||||
const existing = await tx.hiveSetup.findUnique({ where: { colonyId } });
|
||||
if (existing) throw hiveExists();
|
||||
|
||||
const catalog = await this.loadActiveSystemCatalog(tx, input.systemId);
|
||||
const messages = validateHiveState(input.state, catalog);
|
||||
if (messages.length > 0) {
|
||||
throw new BadRequestException({ code: 'STATE_INVALID', messages });
|
||||
}
|
||||
const state = input.state as HiveState;
|
||||
|
||||
const setup = await tx.hiveSetup.create({
|
||||
data: {
|
||||
colonyId,
|
||||
systemId: input.systemId,
|
||||
version: 1,
|
||||
state: state as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
await tx.hiveChange.create({
|
||||
data: {
|
||||
colonyId,
|
||||
version: 1,
|
||||
occurredOn,
|
||||
source: 'INIT',
|
||||
userId,
|
||||
ops: [],
|
||||
stateAfter: state as unknown as Prisma.InputJsonValue,
|
||||
note: null,
|
||||
},
|
||||
});
|
||||
await this.recordChanged(tx, colonyId, 1, 'INIT', null, userId);
|
||||
return {
|
||||
colonyId,
|
||||
systemId: setup.systemId,
|
||||
version: setup.version,
|
||||
state,
|
||||
facts: deriveHiveFacts(state, catalog),
|
||||
updatedAt: setup.updatedAt.toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies ops to the current state under the Colony and HiveSetup locks and
|
||||
* writes the change plus the event in the caller's transaction (also used
|
||||
* by the inspection flow).
|
||||
*/
|
||||
async applyChange(
|
||||
tx: Prisma.TransactionClient,
|
||||
colonyId: string,
|
||||
input: ApplyChangeInput,
|
||||
userId: string,
|
||||
): Promise<HiveSetupView> {
|
||||
assertSourceFits(input);
|
||||
const occurredOn = parseOccurredOn(input.occurredOn);
|
||||
const ops = input.ops.map(canonicalOp);
|
||||
await this.lockLivingColony(tx, colonyId);
|
||||
if (!(await lockRowForUpdate(tx, 'HiveSetup', colonyId))) {
|
||||
throw hiveNotFound();
|
||||
}
|
||||
const setup = await tx.hiveSetup.findUnique({ where: { colonyId } });
|
||||
if (!setup) throw hiveNotFound();
|
||||
await this.assertNotBeforeInit(tx, colonyId, occurredOn);
|
||||
|
||||
const catalog = await this.catalogs.loadCatalog(setup.systemId, tx);
|
||||
const current = setup.state as unknown as HiveState;
|
||||
const result = applyHiveOps(current, ops, catalog);
|
||||
if (!result.ok) {
|
||||
throw new HiveConflictException(result.opIndex, result.reason, {
|
||||
version: setup.version,
|
||||
state: current,
|
||||
});
|
||||
}
|
||||
|
||||
const version = setup.version + 1;
|
||||
const stateJson = result.state as unknown as Prisma.InputJsonValue;
|
||||
await tx.hiveChange.create({
|
||||
data: {
|
||||
colonyId,
|
||||
version,
|
||||
occurredOn,
|
||||
source: input.source,
|
||||
inspectionId: input.inspectionId ?? null,
|
||||
userId,
|
||||
ops: ops as unknown as Prisma.InputJsonValue,
|
||||
stateAfter: stateJson,
|
||||
note: input.note ?? null,
|
||||
},
|
||||
});
|
||||
const updated = await tx.hiveSetup.update({
|
||||
where: { colonyId },
|
||||
data: { version, state: stateJson },
|
||||
});
|
||||
await this.recordChanged(
|
||||
tx,
|
||||
colonyId,
|
||||
version,
|
||||
input.source,
|
||||
input.inspectionId ?? null,
|
||||
userId,
|
||||
);
|
||||
return {
|
||||
colonyId,
|
||||
systemId: updated.systemId,
|
||||
version,
|
||||
state: result.state,
|
||||
facts: deriveHiveFacts(result.state, catalog),
|
||||
updatedAt: updated.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async history(
|
||||
colonyId: string,
|
||||
cursor?: string,
|
||||
limit = DEFAULT_HISTORY_LIMIT,
|
||||
): Promise<{ items: HiveChangeListItem[]; nextCursor: string | null }> {
|
||||
const take = Math.min(Math.max(Math.trunc(limit), 1), MAX_HISTORY_LIMIT);
|
||||
const after = cursor === undefined ? null : decodeCursor(cursor);
|
||||
await this.assertSetupExists(colonyId);
|
||||
|
||||
const rows = await this.prisma.hiveChange.findMany({
|
||||
where: {
|
||||
colonyId,
|
||||
...(after
|
||||
? {
|
||||
OR: [
|
||||
{ occurredOn: { lt: dayToDate(after.occurredOn) } },
|
||||
{
|
||||
occurredOn: dayToDate(after.occurredOn),
|
||||
version: { lt: after.version },
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: [{ occurredOn: 'desc' }, { version: 'desc' }],
|
||||
take: take + 1,
|
||||
select: {
|
||||
id: true,
|
||||
version: true,
|
||||
occurredOn: true,
|
||||
source: true,
|
||||
inspectionId: true,
|
||||
userId: true,
|
||||
user: { select: { name: true } },
|
||||
ops: true,
|
||||
note: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const page = rows.slice(0, take);
|
||||
const last = page[page.length - 1];
|
||||
const nextCursor =
|
||||
rows.length > take && last
|
||||
? encodeCursor({
|
||||
occurredOn: isoDay(last.occurredOn),
|
||||
version: last.version,
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
items: page.map((row) => ({
|
||||
id: row.id,
|
||||
version: row.version,
|
||||
occurredOn: isoDay(row.occurredOn),
|
||||
source: row.source,
|
||||
inspectionId: row.inspectionId,
|
||||
userId: row.userId,
|
||||
userName: row.user.name,
|
||||
ops: row.ops,
|
||||
note: row.note,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
})),
|
||||
nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
/** The state at the end of a Berlin calendar day (spec 6.2). */
|
||||
async at(
|
||||
colonyId: string,
|
||||
day: string,
|
||||
): Promise<{
|
||||
version: number;
|
||||
state: HiveState;
|
||||
facts: HiveFacts;
|
||||
occurredOn: string;
|
||||
}> {
|
||||
const dayDate = parseDay(day, 'day');
|
||||
const setup = await this.prisma.hiveSetup.findUnique({
|
||||
where: { colonyId },
|
||||
select: { systemId: true },
|
||||
});
|
||||
if (!setup) throw hiveNotFound();
|
||||
const change = await this.prisma.hiveChange.findFirst({
|
||||
where: { colonyId, occurredOn: { lte: dayDate } },
|
||||
orderBy: { version: 'desc' },
|
||||
select: { version: true, occurredOn: true, stateAfter: true },
|
||||
});
|
||||
if (!change) throw hiveNotFound();
|
||||
const catalog = await this.catalogs.loadCatalog(setup.systemId);
|
||||
const state = change.stateAfter as unknown as HiveState;
|
||||
return {
|
||||
version: change.version,
|
||||
state,
|
||||
facts: deriveHiveFacts(state, catalog),
|
||||
occurredOn: isoDay(change.occurredOn),
|
||||
};
|
||||
}
|
||||
|
||||
private async view(setup: {
|
||||
colonyId: string;
|
||||
systemId: string;
|
||||
version: number;
|
||||
state: Prisma.JsonValue;
|
||||
updatedAt: Date;
|
||||
}): Promise<HiveSetupView> {
|
||||
return this.viewWith(
|
||||
setup,
|
||||
await this.catalogs.loadCatalog(setup.systemId),
|
||||
);
|
||||
}
|
||||
|
||||
private viewWith(
|
||||
setup: {
|
||||
colonyId: string;
|
||||
systemId: string;
|
||||
version: number;
|
||||
state: Prisma.JsonValue;
|
||||
updatedAt: Date;
|
||||
},
|
||||
catalog: HiveCatalog,
|
||||
): HiveSetupView {
|
||||
const state = setup.state as unknown as HiveState;
|
||||
return {
|
||||
colonyId: setup.colonyId,
|
||||
systemId: setup.systemId,
|
||||
version: setup.version,
|
||||
state,
|
||||
facts: deriveHiveFacts(state, catalog),
|
||||
updatedAt: setup.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Lock order: Colony first; a colony that no longer lives is read-only. */
|
||||
private async lockLivingColony(
|
||||
tx: Prisma.TransactionClient,
|
||||
colonyId: string,
|
||||
): Promise<void> {
|
||||
if (!(await lockRowForUpdate(tx, 'Colony', colonyId))) {
|
||||
throw new NotFoundException('Colony not found');
|
||||
}
|
||||
const colony = await tx.colony.findUnique({
|
||||
where: { id: colonyId },
|
||||
select: { status: true },
|
||||
});
|
||||
if (!colony) throw new NotFoundException('Colony not found');
|
||||
if (!isLivingColonyStatus(colony.status)) {
|
||||
throw new ConflictException(
|
||||
`Das Volk besteht nicht mehr (Status: ${colony.status})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadActiveSystemCatalog(
|
||||
tx: Prisma.TransactionClient,
|
||||
systemId: string,
|
||||
) {
|
||||
const system = await tx.hiveSystem.findUnique({
|
||||
where: { id: systemId },
|
||||
select: { id: true, active: true },
|
||||
});
|
||||
if (!system) throw new NotFoundException('Hive system not found');
|
||||
if (!system.active) {
|
||||
throw new BadRequestException({
|
||||
code: 'SYSTEM_INACTIVE',
|
||||
message: 'Das Beutensystem ist ausgeblendet.',
|
||||
});
|
||||
}
|
||||
return this.catalogs.loadCatalog(systemId, tx);
|
||||
}
|
||||
|
||||
private async assertNotBeforeInit(
|
||||
tx: Prisma.TransactionClient,
|
||||
colonyId: string,
|
||||
occurredOn: Date,
|
||||
): Promise<void> {
|
||||
const init = await tx.hiveChange.findUnique({
|
||||
where: { colonyId_version: { colonyId, version: 1 } },
|
||||
select: { occurredOn: true },
|
||||
});
|
||||
if (init && occurredOn < init.occurredOn) {
|
||||
throw new BadRequestException(
|
||||
'occurredOn must not be before the day the hive setup was created',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertSetupExists(colonyId: string): Promise<void> {
|
||||
const setup = await this.prisma.hiveSetup.findUnique({
|
||||
where: { colonyId },
|
||||
select: { colonyId: true },
|
||||
});
|
||||
if (!setup) throw hiveNotFound();
|
||||
}
|
||||
|
||||
private recordChanged(
|
||||
tx: Prisma.TransactionClient,
|
||||
colonyId: string,
|
||||
version: number,
|
||||
source: HiveChangeSource,
|
||||
inspectionId: string | null,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
return this.events.record(tx, {
|
||||
type: 'HiveChanged',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: colonyId,
|
||||
actorUserId: userId,
|
||||
payload: { colonyId, version, source, inspectionId },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
@@ -11,18 +12,41 @@ import {
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
COLONY_TAKEOVER_FIELDS,
|
||||
type ColonyTakeoverField,
|
||||
} from '../../colonies/colony-takeover';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { HiveOpDto, MAX_OPS_PER_REQUEST } from '../../hive/dto/hive-op.dto';
|
||||
import { ColonyCondition } from '../../../generated/prisma';
|
||||
import { feedConditionFromInput } from '../../common/feed-vocabulary';
|
||||
|
||||
/** Hive operations carried by an inspection (create only). */
|
||||
export class InspectionHiveChangeDto {
|
||||
@ApiProperty({ type: [HiveOpDto], maxItems: MAX_OPS_PER_REQUEST })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(MAX_OPS_PER_REQUEST)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => HiveOpDto)
|
||||
ops!: HiveOpDto[];
|
||||
}
|
||||
|
||||
export class CreateInspectionDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Sprachnotiz, die mit dem Speichern übernommen und gelöscht wird (gleiches Volk, gleicher Nutzer).',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[A-Za-z0-9_-]{1,40}$/)
|
||||
voiceCaptureId?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
colonyId!: string;
|
||||
@@ -172,4 +196,14 @@ export class CreateInspectionDto {
|
||||
@ArrayMaxSize(3)
|
||||
@IsIn(COLONY_TAKEOVER_FIELDS, { each: true })
|
||||
applyToColony?: ColonyTakeoverField[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: InspectionHiveChangeDto,
|
||||
description:
|
||||
'Hive operations saved together with the inspection in one transaction. Needs colonies:update.',
|
||||
})
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => InspectionHiveChangeDto)
|
||||
hiveChange?: InspectionHiveChangeDto;
|
||||
}
|
||||
@@ -8,6 +8,6 @@ import { CreateInspectionDto } from './create-inspection.dto';
|
||||
* not part of a correction: a correction takes nothing over to the colony.
|
||||
*/
|
||||
export class UpdateInspectionDto extends PartialType(
|
||||
OmitType(CreateInspectionDto, ['applyToColony'] as const),
|
||||
OmitType(CreateInspectionDto, ['applyToColony', 'hiveChange'] as const),
|
||||
{ skipNullProperties: false },
|
||||
) {}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TasksModule } from '../tasks/tasks.module';
|
||||
import { ColoniesModule } from '../colonies/colonies.module';
|
||||
import { HiveModule } from '../hive/hive.module';
|
||||
import { VoiceConfirmationModule } from '../voice/captures/voice-confirmation.module';
|
||||
import { InspectionsController } from './inspections.controller';
|
||||
import { ColonyInspectionsController } from './colony-inspections.controller';
|
||||
import { InspectionsService } from './inspections.service';
|
||||
@@ -11,7 +13,7 @@ import { InspectionsService } from './inspections.service';
|
||||
* PermissionResolverService comes from the global resolver module.
|
||||
*/
|
||||
@Module({
|
||||
imports: [TasksModule, ColoniesModule],
|
||||
imports: [TasksModule, ColoniesModule, HiveModule, VoiceConfirmationModule],
|
||||
controllers: [InspectionsController, ColonyInspectionsController],
|
||||
providers: [InspectionsService],
|
||||
exports: [InspectionsService],
|
||||
|
||||
@@ -6,7 +6,9 @@ import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { TasksService } from '../tasks/tasks.service';
|
||||
import { ColoniesService } from '../colonies/colonies.service';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { HiveService } from '../hive/hive.service';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { VoiceConfirmationService } from '../voice/captures/voice-confirmation.service';
|
||||
|
||||
describe('InspectionsService', () => {
|
||||
let service: InspectionsService;
|
||||
@@ -35,6 +37,8 @@ describe('InspectionsService', () => {
|
||||
let tasks: { createInTx: jest.Mock };
|
||||
let colonies: { updateIn: jest.Mock };
|
||||
let resolver: { getPermissions: jest.Mock };
|
||||
let hive: { applyChange: jest.Mock };
|
||||
let voice: { confirmIn: jest.Mock };
|
||||
|
||||
const inspection = {
|
||||
id: 'inspection-1',
|
||||
@@ -74,6 +78,8 @@ describe('InspectionsService', () => {
|
||||
resolver = {
|
||||
getPermissions: jest.fn().mockResolvedValue(new Set(['colonies:update'])),
|
||||
};
|
||||
hive = { applyChange: jest.fn().mockResolvedValue({ version: 2 }) };
|
||||
voice = { confirmIn: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new InspectionsService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
@@ -81,6 +87,8 @@ describe('InspectionsService', () => {
|
||||
tasks as unknown as TasksService,
|
||||
colonies as unknown as ColoniesService,
|
||||
resolver as unknown as PermissionResolverService,
|
||||
hive as unknown as HiveService,
|
||||
voice as unknown as VoiceConfirmationService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -94,6 +102,8 @@ describe('InspectionsService', () => {
|
||||
TasksService,
|
||||
ColoniesService,
|
||||
PermissionResolverService,
|
||||
HiveService,
|
||||
VoiceConfirmationService,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -147,6 +157,74 @@ describe('InspectionsService', () => {
|
||||
expect(tasks.createInTx).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('voiceCaptureId', () => {
|
||||
it('confirms the capture in the same transaction, after the inspection and its event, and never stores the id on the inspection', async () => {
|
||||
tx.inspection.create.mockResolvedValue(inspection);
|
||||
|
||||
await service.create(
|
||||
{ colonyId: 'colony-1', date: '2026-01-01', voiceCaptureId: 'cap-1' },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
const createArg = (
|
||||
tx.inspection.create.mock.calls[0] as [{ data: object }]
|
||||
)[0].data;
|
||||
expect(createArg).not.toHaveProperty('voiceCaptureId');
|
||||
expect(voice.confirmIn).toHaveBeenCalledWith(
|
||||
tx,
|
||||
{ kind: 'capture', id: 'cap-1' },
|
||||
{
|
||||
userId: 'user-1',
|
||||
colonyId: 'colony-1',
|
||||
inspectionId: 'inspection-1',
|
||||
},
|
||||
);
|
||||
expect(voice.confirmIn.mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
events.record.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('confirms last, after a hive change (the capture lock comes last in the lock order)', async () => {
|
||||
tx.inspection.create.mockResolvedValue(inspection);
|
||||
|
||||
await service.create(
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
date: '2026-01-01',
|
||||
voiceCaptureId: 'cap-1',
|
||||
hiveChange: { ops: [{}] } as never,
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(voice.confirmIn.mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
hive.applyChange.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('lets the whole transaction fail when the capture cannot be confirmed', async () => {
|
||||
tx.inspection.create.mockResolvedValue(inspection);
|
||||
voice.confirmIn.mockRejectedValue(new Error('gone'));
|
||||
|
||||
await expect(
|
||||
service.create(
|
||||
{ colonyId: 'colony-1', date: '2026-01-01', voiceCaptureId: 'cap-1' },
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toThrow('gone');
|
||||
});
|
||||
|
||||
it('does not touch the voice service without voiceCaptureId', async () => {
|
||||
tx.inspection.create.mockResolvedValue(inspection);
|
||||
await service.create(
|
||||
{ colonyId: 'colony-1', date: '2026-01-01' },
|
||||
'user-1',
|
||||
);
|
||||
expect(voice.confirmIn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyToColony', () => {
|
||||
const saved = {
|
||||
...inspection,
|
||||
@@ -314,6 +392,81 @@ describe('InspectionsService', () => {
|
||||
).rejects.toThrow('outbox down');
|
||||
});
|
||||
|
||||
describe('hiveChange', () => {
|
||||
const ops = [{ op: 'setLabel', id: 'box', label: 'x' }];
|
||||
const dto = {
|
||||
colonyId: 'colony-1',
|
||||
date: '2026-03-10T10:00:00.000Z',
|
||||
hiveChange: { ops },
|
||||
} as never;
|
||||
|
||||
beforeEach(() => {
|
||||
tx.inspection.create.mockResolvedValue(inspection);
|
||||
});
|
||||
|
||||
it('locks the colony, saves the inspection, then applies the ops with the inspection id', async () => {
|
||||
const result = await service.create(dto, 'user-1');
|
||||
|
||||
const lockOrder = tx.$queryRaw.mock.invocationCallOrder[0];
|
||||
expect(lockOrder).toBeLessThan(
|
||||
tx.inspection.create.mock.invocationCallOrder[0],
|
||||
);
|
||||
const [[created]] = tx.inspection.create.mock.calls as [
|
||||
[{ data: object }],
|
||||
];
|
||||
expect(created.data).not.toHaveProperty('hiveChange');
|
||||
expect(hive.applyChange).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'colony-1',
|
||||
{
|
||||
occurredOn: '2026-03-10',
|
||||
source: 'INSPECTION',
|
||||
ops,
|
||||
inspectionId: 'inspection-1',
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
id: 'inspection-1',
|
||||
hive: { version: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('answers 403 HIVE_FORBIDDEN without colonies:update and writes nothing', async () => {
|
||||
resolver.getPermissions.mockResolvedValue(new Set());
|
||||
|
||||
await expect(service.create(dto, 'user-1')).rejects.toMatchObject({
|
||||
status: 403,
|
||||
response: { code: 'HIVE_FORBIDDEN' },
|
||||
});
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves permissions once when hiveChange and applyToColony are both used', async () => {
|
||||
await service.create(
|
||||
{ ...(dto as object), applyToColony: ['feedStatus'] } as never,
|
||||
'user-1',
|
||||
);
|
||||
expect(resolver.getPermissions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('propagates an applyChange failure so the transaction aborts', async () => {
|
||||
hive.applyChange.mockRejectedValue(new NotFoundException());
|
||||
await expect(service.create(dto, 'user-1')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('omits hive from the response without hiveChange', async () => {
|
||||
const result = await service.create(
|
||||
{ colonyId: 'colony-1', date: '2026-01-01' },
|
||||
'user-1',
|
||||
);
|
||||
expect(result).not.toHaveProperty('hive');
|
||||
expect(hive.applyChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('update writes the correction, audit entry and InspectionCorrected in one transaction', async () => {
|
||||
tx.inspection.findUnique.mockResolvedValue(inspection);
|
||||
// Re-filed against another colony: the event names the colony it is
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { isLivingColonyStatus } from '../colonies/colony-status';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { DbClient } from '../prisma/db-client';
|
||||
@@ -18,9 +22,15 @@ import { CreateInspectionDto } from './dto/create-inspection.dto';
|
||||
import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto';
|
||||
import { UpdateInspectionDto } from './dto/update-inspection.dto';
|
||||
import { optionalDate } from '../common/patch-values';
|
||||
import { berlinDayOf } from '../common/dates';
|
||||
import type { HiveOp } from '../hive/hive-types';
|
||||
import { HiveService, HiveSetupView } from '../hive/hive.service';
|
||||
import { VoiceConfirmationService } from '../voice/captures/voice-confirmation.service';
|
||||
|
||||
export type InspectionWithTakeover = Inspection & {
|
||||
colonyTakeover: ColonyTakeover;
|
||||
/** Present only when the request carried a hiveChange. */
|
||||
hive?: HiveSetupView;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -41,6 +51,8 @@ export class InspectionsService {
|
||||
private readonly tasks: TasksService,
|
||||
private readonly colonies: ColoniesService,
|
||||
private readonly permissionResolver: PermissionResolverService,
|
||||
private readonly hiveService: HiveService,
|
||||
private readonly voiceConfirmation: VoiceConfirmationService,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<Inspection[]> {
|
||||
@@ -62,16 +74,27 @@ export class InspectionsService {
|
||||
dto: CreateInspectionDto,
|
||||
actingUserId: string,
|
||||
): Promise<InspectionWithTakeover> {
|
||||
const { applyToColony = [], ...data } = dto;
|
||||
// Resolved before the transaction (it may query the database): the
|
||||
// permission is only needed when a takeover was requested.
|
||||
// voiceCaptureId is not an Inspection column: it only triggers the
|
||||
// confirmation of the note below.
|
||||
const { applyToColony = [], hiveChange, voiceCaptureId, ...data } = dto;
|
||||
// Resolved before the transaction (it may query the database), once for
|
||||
// both features: a takeover only applies with colonies:update, a hive
|
||||
// change is refused without it.
|
||||
const needsPermission =
|
||||
applyToColony.length > 0 || hiveChange !== undefined;
|
||||
const canUpdateColony =
|
||||
applyToColony.length > 0 &&
|
||||
needsPermission &&
|
||||
(await this.permissionResolver.getPermissions(actingUserId)).has(
|
||||
'colonies:update',
|
||||
);
|
||||
if (hiveChange && !canUpdateColony) {
|
||||
throw new ForbiddenException({
|
||||
code: 'HIVE_FORBIDDEN',
|
||||
message: 'Beute-Änderungen brauchen das Recht colonies:update.',
|
||||
});
|
||||
}
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
if (applyToColony.length > 0) {
|
||||
if (applyToColony.length > 0 || hiveChange) {
|
||||
// Colony first (before the inspection row takes a key-share lock on
|
||||
// it): serializes concurrent inspections of one colony, so "is there
|
||||
// a newer inspection" below is decided on a stable state.
|
||||
@@ -104,7 +127,36 @@ export class InspectionsService {
|
||||
canUpdateColony,
|
||||
actingUserId,
|
||||
);
|
||||
return { ...inspection, colonyTakeover };
|
||||
const hive = hiveChange
|
||||
? await this.hiveService.applyChange(
|
||||
tx,
|
||||
dto.colonyId,
|
||||
{
|
||||
occurredOn: berlinDayOf(new Date(dto.date)),
|
||||
source: 'INSPECTION',
|
||||
// Plain objects only: the DTO instances carry class metadata.
|
||||
ops: JSON.parse(JSON.stringify(hiveChange.ops)) as HiveOp[],
|
||||
inspectionId: inspection.id,
|
||||
},
|
||||
actingUserId,
|
||||
)
|
||||
: undefined;
|
||||
if (voiceCaptureId) {
|
||||
// Last: VoiceCapture is the last table in the lock order. A note that
|
||||
// cannot be confirmed fails the whole save.
|
||||
await this.voiceConfirmation.confirmIn(
|
||||
tx,
|
||||
{ kind: 'capture', id: voiceCaptureId },
|
||||
{
|
||||
userId: actingUserId,
|
||||
colonyId: inspection.colonyId,
|
||||
inspectionId: inspection.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
return hive
|
||||
? { ...inspection, colonyTakeover, hive }
|
||||
: { ...inspection, colonyTakeover };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import * as voiceRetention from '../voice/voice-retention';
|
||||
import { JobRunService } from '../system/job-run.service';
|
||||
import { WEATHER_RETENTION_YEARS } from '../weather/history/weather-history.config';
|
||||
import {
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
FAILED_RETENTION_DAYS,
|
||||
JOB_RUN_RETENTION_DAYS,
|
||||
JOB_RUN_STALE_HOURS,
|
||||
VOICE_IDLE_RUN_RETENTION_HOURS,
|
||||
PENDING_DELIVERY_MAX_AGE_DAYS,
|
||||
WEBHOOK_DELIVERY_RETENTION_DAYS,
|
||||
} from './cleanup.job';
|
||||
@@ -37,6 +39,14 @@ describe('CleanupJob', () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(voiceRetention, 'expireVoiceContent').mockResolvedValue({
|
||||
audioExpired: 1,
|
||||
contentExpired: 2,
|
||||
inactiveOwnersWiped: 3,
|
||||
});
|
||||
jest.spyOn(voiceRetention, 'deleteOldVoiceRows').mockResolvedValue(4);
|
||||
jest.spyOn(voiceRetention, 'deleteOldVoiceCalls').mockResolvedValue(6);
|
||||
jest.spyOn(voiceRetention, 'deleteIdleVoiceRuns').mockResolvedValue(21);
|
||||
let count = 10;
|
||||
executeRaw = jest.fn<Promise<number>, RawCall>(() =>
|
||||
Promise.resolve(count++),
|
||||
@@ -62,6 +72,18 @@ describe('CleanupJob', () => {
|
||||
expect(JOB_RUN_STALE_HOURS).toBe(6);
|
||||
});
|
||||
|
||||
it('cleanup runs the voice retention and prunes idle voice-transcribe runs only', async () => {
|
||||
await job.run();
|
||||
|
||||
expect(voiceRetention.expireVoiceContent).toHaveBeenCalledTimes(1);
|
||||
expect(voiceRetention.deleteOldVoiceRows).toHaveBeenCalledTimes(1);
|
||||
expect(voiceRetention.deleteOldVoiceCalls).toHaveBeenCalledTimes(1);
|
||||
expect(voiceRetention.deleteIdleVoiceRuns).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
VOICE_IDLE_RUN_RETENTION_HOURS,
|
||||
);
|
||||
});
|
||||
|
||||
it('cleanup deletes weather days before January 1st four years back', async () => {
|
||||
await job.run();
|
||||
|
||||
@@ -94,6 +116,12 @@ describe('CleanupJob', () => {
|
||||
'jobRuns.aborted': 1,
|
||||
'jobRuns.deleted': 19,
|
||||
weatherDays: 20,
|
||||
'voice.audioExpired': 1,
|
||||
'voice.contentExpired': 2,
|
||||
'voice.inactiveOwnersWiped': 3,
|
||||
'voice.rowsDeleted': 4,
|
||||
'voice.callsDeleted': 6,
|
||||
'jobRuns.voiceIdle': 21,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { JobRunService } from '../system/job-run.service';
|
||||
import {
|
||||
deleteIdleVoiceRuns,
|
||||
deleteOldVoiceCalls,
|
||||
deleteOldVoiceRows,
|
||||
expireVoiceContent,
|
||||
} from '../voice/voice-retention';
|
||||
import { WEATHER_RETENTION_YEARS } from '../weather/history/weather-history.config';
|
||||
import { Job, JobResult } from './job';
|
||||
import { JobName } from './job-names';
|
||||
@@ -18,6 +24,8 @@ export const JOB_RUN_RETENTION_DAYS = 90;
|
||||
export const PENDING_DELIVERY_MAX_AGE_DAYS = 7;
|
||||
/** Far beyond activeDeadlineSeconds (at most 1 h) of every job CronJob. */
|
||||
export const JOB_RUN_STALE_HOURS = 6;
|
||||
/** A voice-transcribe run (every 2 min) that did nothing is noise after a day. */
|
||||
export const VOICE_IDLE_RUN_RETENTION_HOURS = 24;
|
||||
|
||||
/**
|
||||
* Daily housekeeping (CronJob 04:00). Every age is compared against now(),
|
||||
@@ -100,6 +108,18 @@ export class CleanupJob implements Job {
|
||||
DELETE FROM "ApiaryWeatherDay"
|
||||
WHERE "day" < make_date(
|
||||
extract(year FROM now() AT TIME ZONE 'Europe/Berlin')::int - ${WEATHER_RETENTION_YEARS}, 1, 1)`;
|
||||
// The same retention rules the voice CronJob runs every 2 minutes (audio
|
||||
// over 24 h, content over 7 days, deactivated users), plus the rows
|
||||
// after 90 days.
|
||||
const voiceRetention = await expireVoiceContent(this.prisma);
|
||||
const voiceRowsDeleted = await deleteOldVoiceRows(this.prisma);
|
||||
const voiceCallsDeleted = await deleteOldVoiceCalls(this.prisma);
|
||||
// 1440 runs a day, nearly all without work: keep only runs that did
|
||||
// something (a counter above 0) or failed, for the usual 90 days.
|
||||
const voiceIdleRuns = await deleteIdleVoiceRuns(
|
||||
this.prisma,
|
||||
VOICE_IDLE_RUN_RETENTION_HOURS,
|
||||
);
|
||||
return {
|
||||
idempotencyRecords,
|
||||
consumedTokens,
|
||||
@@ -115,6 +135,12 @@ export class CleanupJob implements Job {
|
||||
'jobRuns.aborted': jobRunsAborted,
|
||||
'jobRuns.deleted': jobRunsDeleted,
|
||||
weatherDays,
|
||||
'voice.audioExpired': voiceRetention.audioExpired,
|
||||
'voice.contentExpired': voiceRetention.contentExpired,
|
||||
'voice.inactiveOwnersWiped': voiceRetention.inactiveOwnersWiped,
|
||||
'voice.rowsDeleted': voiceRowsDeleted,
|
||||
'voice.callsDeleted': voiceCallsDeleted,
|
||||
'jobRuns.voiceIdle': voiceIdleRuns,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -28,12 +28,14 @@ describe('job names', () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('only rekey accepts an argument, and only --dry-run', () => {
|
||||
it('only rekey and voice-transcribe accept arguments, and only their own', () => {
|
||||
expect(Object.keys(JOB_ALLOWED_ARGS)).toEqual([...JOB_NAMES]);
|
||||
for (const name of JOB_NAMES) {
|
||||
expect(JOB_ALLOWED_ARGS[name]).toEqual(
|
||||
name === 'rekey' ? ['--dry-run'] : [],
|
||||
);
|
||||
const expected: Record<string, string[]> = {
|
||||
rekey: ['--dry-run'],
|
||||
'voice-transcribe': ['--phase=fetch', '--phase=ingest'],
|
||||
};
|
||||
expect(JOB_ALLOWED_ARGS[name]).toEqual(expected[name] ?? []);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ export const JOB_NAMES = [
|
||||
'cleanup',
|
||||
'app-backup',
|
||||
'rekey',
|
||||
'voice-transcribe',
|
||||
] as const;
|
||||
|
||||
export type JobName = (typeof JOB_NAMES)[number];
|
||||
@@ -18,6 +19,7 @@ export const JOB_LABELS_DE: Record<JobName, string> = {
|
||||
cleanup: 'Aufräumen',
|
||||
'app-backup': 'App-Backup',
|
||||
rekey: 'Schlüssel umstellen',
|
||||
'voice-transcribe': 'Sprachnotizen abschreiben',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -32,6 +34,7 @@ export const JOB_FAILURE_PERMISSION: Record<JobName, PermissionKey> = {
|
||||
cleanup: 'system:manage',
|
||||
'app-backup': 'backup:configure',
|
||||
rekey: 'system:manage',
|
||||
'voice-transcribe': 'system:manage',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -45,6 +48,7 @@ export const JOB_ALLOWED_ARGS: Record<JobName, readonly string[]> = {
|
||||
cleanup: [],
|
||||
'app-backup': [],
|
||||
rekey: ['--dry-run'],
|
||||
'voice-transcribe': ['--phase=fetch', '--phase=ingest'],
|
||||
};
|
||||
|
||||
export function isJobName(value: string): value is JobName {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { JobRegistry, JOBS } from './job-registry';
|
||||
import { RekeyJob } from './rekey.job';
|
||||
import { TimeEventsStep } from './steps/time-events.step';
|
||||
import { TaskTemplatesJob } from './task-templates.job';
|
||||
import { VoiceTranscribeJob } from './voice-transcribe.job';
|
||||
|
||||
const JOB_CLASSES = [
|
||||
DailyEvaluationJob,
|
||||
@@ -28,6 +29,7 @@ const JOB_CLASSES = [
|
||||
CleanupJob,
|
||||
AppBackupJob,
|
||||
RekeyJob,
|
||||
VoiceTranscribeJob,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,14 @@ import {
|
||||
openWebhookSecret,
|
||||
sealWebhookSecret,
|
||||
} from '../webhooks/webhook-secret';
|
||||
import {
|
||||
isVoiceSealedWithCurrentKey,
|
||||
openVoiceBytes,
|
||||
openVoiceText,
|
||||
sealVoiceBytes,
|
||||
sealVoiceText,
|
||||
voiceAad,
|
||||
} from '../voice/core/voice-crypto';
|
||||
import { RekeyJob } from './rekey.job';
|
||||
|
||||
// Written by the code before the key ring, with JWT_ACCESS_SECRET = 'x' * 40
|
||||
@@ -67,11 +75,139 @@ function sealedWithPrevious(variable: string, seal: () => string): string {
|
||||
}
|
||||
}
|
||||
|
||||
interface CaptureRow {
|
||||
id: string;
|
||||
status: string;
|
||||
transcriptEnc: string | null;
|
||||
suggestionEnc: string | null;
|
||||
}
|
||||
|
||||
interface AudioRow {
|
||||
captureId: string;
|
||||
dataEnc: Buffer;
|
||||
}
|
||||
|
||||
interface VoiceFixture {
|
||||
captures: CaptureRow[];
|
||||
audio: AudioRow[];
|
||||
}
|
||||
|
||||
interface StatusFilter {
|
||||
status?: { notIn: string[] };
|
||||
capture?: { status: { notIn: string[] } };
|
||||
transcriptEnc?: { not: null } | string;
|
||||
suggestionEnc?: { not: null } | string;
|
||||
}
|
||||
|
||||
interface CaptureUpdate {
|
||||
where: { id: string; status: { notIn: string[] } } & Record<string, unknown>;
|
||||
data: Record<string, string>;
|
||||
}
|
||||
|
||||
interface AudioUpdate {
|
||||
where: {
|
||||
captureId: string;
|
||||
dataEnc: Buffer;
|
||||
capture: { status: { notIn: string[] } };
|
||||
};
|
||||
data: { dataEnc: Buffer };
|
||||
}
|
||||
|
||||
/** The voice tables, in memory, with the where-shapes the job uses. */
|
||||
function fakeVoice({ captures, audio }: VoiceFixture) {
|
||||
const captureRows = new Map(captures.map((row) => [row.id, { ...row }]));
|
||||
const audioRows = new Map(audio.map((row) => [row.captureId, { ...row }]));
|
||||
const open = (id: string, notIn: string[]): boolean => {
|
||||
const row = captureRows.get(id);
|
||||
return row !== undefined && !notIn.includes(row.status);
|
||||
};
|
||||
const voiceCapture = {
|
||||
findMany: jest.fn(({ where }: { where: StatusFilter }) =>
|
||||
Promise.resolve(
|
||||
[...captureRows.values()].filter(
|
||||
(row) =>
|
||||
open(row.id, where.status?.notIn ?? []) &&
|
||||
(where.transcriptEnc === undefined || row.transcriptEnc !== null) &&
|
||||
(where.suggestionEnc === undefined || row.suggestionEnc !== null),
|
||||
),
|
||||
),
|
||||
),
|
||||
updateMany: jest.fn<Promise<UpdateResult>, [CaptureUpdate]>(
|
||||
({ where, data }) => {
|
||||
const row = captureRows.get(where.id);
|
||||
const column =
|
||||
'transcriptEnc' in where ? 'transcriptEnc' : 'suggestionEnc';
|
||||
if (
|
||||
!row ||
|
||||
!open(where.id, where.status.notIn) ||
|
||||
row[column] !== where[column]
|
||||
) {
|
||||
return Promise.resolve({ count: 0 });
|
||||
}
|
||||
captureRows.set(where.id, { ...row, [column]: data[column] });
|
||||
return Promise.resolve({ count: 1 });
|
||||
},
|
||||
),
|
||||
};
|
||||
const voiceCaptureAudio = {
|
||||
findMany: jest.fn(({ where }: { where: StatusFilter }) =>
|
||||
Promise.resolve(
|
||||
[...audioRows.values()]
|
||||
.filter((row) =>
|
||||
open(row.captureId, where.capture?.status.notIn ?? []),
|
||||
)
|
||||
.map((row) => ({ captureId: row.captureId })),
|
||||
),
|
||||
),
|
||||
findFirst: jest.fn(
|
||||
({ where }: { where: { captureId: string } & StatusFilter }) => {
|
||||
const row = audioRows.get(where.captureId);
|
||||
return Promise.resolve(
|
||||
row && open(row.captureId, where.capture?.status.notIn ?? [])
|
||||
? { ...row }
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
updateMany: jest.fn<Promise<UpdateResult>, [AudioUpdate]>(
|
||||
({ where, data }) => {
|
||||
const row = audioRows.get(where.captureId);
|
||||
if (
|
||||
!row ||
|
||||
!open(where.captureId, where.capture.status.notIn) ||
|
||||
!row.dataEnc.equals(where.dataEnc)
|
||||
) {
|
||||
return Promise.resolve({ count: 0 });
|
||||
}
|
||||
audioRows.set(where.captureId, { ...row, dataEnc: data.dataEnc });
|
||||
return Promise.resolve({ count: 1 });
|
||||
},
|
||||
),
|
||||
};
|
||||
return {
|
||||
voiceCapture,
|
||||
voiceCaptureAudio,
|
||||
transcript: (id: string): string =>
|
||||
captureRows.get(id)?.transcriptEnc ?? '',
|
||||
suggestion: (id: string): string =>
|
||||
captureRows.get(id)?.suggestionEnc ?? '',
|
||||
audio: (id: string): Buffer =>
|
||||
audioRows.get(id)?.dataEnc ?? Buffer.alloc(0),
|
||||
captureRows,
|
||||
audioRows,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The two tables, in memory. updateMany behaves like the SQL compare-and-
|
||||
* The tables, in memory. updateMany behaves like the SQL compare-and-
|
||||
* swap: it writes only while id and old value still match.
|
||||
*/
|
||||
function fakeDb(users: UserRow[], subscriptions: SubscriptionRow[]) {
|
||||
function fakeDb(
|
||||
users: UserRow[],
|
||||
subscriptions: SubscriptionRow[],
|
||||
voiceFixture: VoiceFixture = { captures: [], audio: [] },
|
||||
) {
|
||||
const voice = fakeVoice(voiceFixture);
|
||||
const userRows = new Map(users.map((row) => [row.id, row]));
|
||||
const subscriptionRows = new Map(subscriptions.map((row) => [row.id, row]));
|
||||
const user = {
|
||||
@@ -105,9 +241,15 @@ function fakeDb(users: UserRow[], subscriptions: SubscriptionRow[]) {
|
||||
),
|
||||
};
|
||||
return {
|
||||
prisma: { user, webhookSubscription } as unknown as PrismaService,
|
||||
prisma: {
|
||||
user,
|
||||
webhookSubscription,
|
||||
voiceCapture: voice.voiceCapture,
|
||||
voiceCaptureAudio: voice.voiceCaptureAudio,
|
||||
} as unknown as PrismaService,
|
||||
user,
|
||||
webhookSubscription,
|
||||
voice,
|
||||
userRows,
|
||||
subscriptionRows,
|
||||
mfaSecret: (id: string): string => userRows.get(id)?.mfaSecret ?? '',
|
||||
@@ -118,6 +260,15 @@ function fakeDb(users: UserRow[], subscriptions: SubscriptionRow[]) {
|
||||
|
||||
const keyIdOf = (stored: string): string => stored.split(':')[0];
|
||||
|
||||
const NO_VOICE = {
|
||||
'voice.total': 0,
|
||||
'voice.rekeyed': 0,
|
||||
'voice.alreadyCurrent': 0,
|
||||
'voice.plaintextSealed': 0,
|
||||
'voice.changedMeanwhile': 0,
|
||||
'voice.failed': 0,
|
||||
};
|
||||
|
||||
describe('RekeyJob', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
let warn: jest.SpyInstance<void, unknown[]>;
|
||||
@@ -129,6 +280,8 @@ describe('RekeyJob', () => {
|
||||
process.env.MFA_ENCRYPTION_KEY_PREVIOUS = 'n'.repeat(40);
|
||||
process.env.WEBHOOK_ENCRYPTION_KEY = 'w'.repeat(40);
|
||||
process.env.WEBHOOK_ENCRYPTION_KEY_PREVIOUS = 'v'.repeat(40);
|
||||
process.env.VOICE_ENCRYPTION_KEY = 'c'.repeat(40);
|
||||
process.env.VOICE_ENCRYPTION_KEY_PREVIOUS = 'p'.repeat(40);
|
||||
warn = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
@@ -192,6 +345,7 @@ describe('RekeyJob', () => {
|
||||
'webhook.plaintextSealed': 1,
|
||||
'webhook.changedMeanwhile': 0,
|
||||
'webhook.failed': 0,
|
||||
...NO_VOICE,
|
||||
};
|
||||
|
||||
it('rekeys legacy MFA and webhook secrets and seals legacy plaintext webhook secrets', async () => {
|
||||
@@ -411,7 +565,7 @@ describe('RekeyJob', () => {
|
||||
|
||||
it('dry-run opens them and fails the run on a value that does not open', async () => {
|
||||
await expect(new RekeyJob(db.prisma).run(['--dry-run'])).rejects.toThrow(
|
||||
/^rekey: 2 secret\(s\) could not be rekeyed - dryRun=true, mfa\.total=2, mfa\.rekeyed=0, mfa\.alreadyCurrent=1, .*mfa\.failed=1, webhook\.total=2, webhook\.rekeyed=0, webhook\.alreadyCurrent=1, .*webhook\.failed=1$/,
|
||||
/^rekey: 2 secret\(s\) could not be rekeyed - dryRun=true, mfa\.total=2, mfa\.rekeyed=0, mfa\.alreadyCurrent=1, .*mfa\.failed=1, webhook\.total=2, webhook\.rekeyed=0, webhook\.alreadyCurrent=1, .*webhook\.failed=1, voice\..*$/,
|
||||
);
|
||||
expect(db.user.updateMany).not.toHaveBeenCalled();
|
||||
expect(db.webhookSubscription.updateMany).not.toHaveBeenCalled();
|
||||
@@ -490,7 +644,7 @@ describe('RekeyJob', () => {
|
||||
|
||||
it('dry-run reports them too and writes nothing', async () => {
|
||||
await expect(new RekeyJob(db.prisma).run(['--dry-run'])).rejects.toThrow(
|
||||
/^rekey: 3 secret\(s\) could not be rekeyed - dryRun=true, .*mfa\.rekeyed=1, .*mfa\.failed=2, .*webhook\.plaintextSealed=1, .*webhook\.failed=1$/,
|
||||
/^rekey: 3 secret\(s\) could not be rekeyed - dryRun=true, .*mfa\.rekeyed=1, .*mfa\.failed=2, .*webhook\.plaintextSealed=1, .*webhook\.failed=1, voice\..*$/,
|
||||
);
|
||||
expect(db.user.updateMany).not.toHaveBeenCalled();
|
||||
expect(db.webhookSubscription.updateMany).not.toHaveBeenCalled();
|
||||
@@ -566,4 +720,253 @@ describe('RekeyJob', () => {
|
||||
expect(db.user.findMany).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
describe('voice content', () => {
|
||||
const TRANSCRIPT = 'Koenigin gesehen, acht Waben besetzt';
|
||||
const SUGGESTION = '{"suggestions":[{"field":"queenSeen"}]}';
|
||||
const AUDIO = Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 9, 8, 7]);
|
||||
|
||||
/** Seals with the key that VOICE_ENCRYPTION_KEY_PREVIOUS names, as before a rotation. */
|
||||
function underPrevious<T>(seal: () => T): T {
|
||||
const current = process.env.VOICE_ENCRYPTION_KEY;
|
||||
process.env.VOICE_ENCRYPTION_KEY =
|
||||
process.env.VOICE_ENCRYPTION_KEY_PREVIOUS;
|
||||
try {
|
||||
return seal();
|
||||
} finally {
|
||||
process.env.VOICE_ENCRYPTION_KEY = current;
|
||||
}
|
||||
}
|
||||
|
||||
function openFixture(): ReturnType<typeof fakeDb> {
|
||||
return fakeDb([], [], {
|
||||
captures: [
|
||||
{
|
||||
// READY, everything under the previous key.
|
||||
id: 'c1',
|
||||
status: 'READY',
|
||||
transcriptEnc: underPrevious(() =>
|
||||
sealVoiceText(TRANSCRIPT, voiceAad('c1', 'transcript')),
|
||||
),
|
||||
suggestionEnc: underPrevious(() =>
|
||||
sealVoiceText(SUGGESTION, voiceAad('c1', 'suggestion')),
|
||||
),
|
||||
},
|
||||
{
|
||||
// QUEUED, audio under the previous key, no text yet.
|
||||
id: 'c2',
|
||||
status: 'QUEUED',
|
||||
transcriptEnc: null,
|
||||
suggestionEnc: null,
|
||||
},
|
||||
{
|
||||
// TRANSCRIBED, already current.
|
||||
id: 'c3',
|
||||
status: 'TRANSCRIBED',
|
||||
transcriptEnc: sealVoiceText(
|
||||
TRANSCRIPT,
|
||||
voiceAad('c3', 'transcript'),
|
||||
),
|
||||
suggestionEnc: null,
|
||||
},
|
||||
{
|
||||
// CONFIRMED rows hold no content any more; a leftover is not touched.
|
||||
id: 'c4',
|
||||
status: 'CONFIRMED',
|
||||
transcriptEnc: underPrevious(() =>
|
||||
sealVoiceText(TRANSCRIPT, voiceAad('c4', 'transcript')),
|
||||
),
|
||||
suggestionEnc: null,
|
||||
},
|
||||
],
|
||||
audio: [
|
||||
{
|
||||
captureId: 'c2',
|
||||
dataEnc: underPrevious(() =>
|
||||
sealVoiceBytes(AUDIO, voiceAad('c2', 'audio')),
|
||||
),
|
||||
},
|
||||
{
|
||||
captureId: 'c4',
|
||||
dataEnc: underPrevious(() =>
|
||||
sealVoiceBytes(AUDIO, voiceAad('c4', 'audio')),
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
it('rekey reseals open voice content and skips confirmed rows', async () => {
|
||||
const db = openFixture();
|
||||
const confirmedTranscript = db.voice.transcript('c4');
|
||||
const confirmedAudio = db.voice.audio('c4');
|
||||
|
||||
const result = await new RekeyJob(db.prisma).run([]);
|
||||
|
||||
// c1: transcript + suggestion, c2: audio, c3: already current.
|
||||
expect(result).toMatchObject({
|
||||
'voice.total': 4,
|
||||
'voice.rekeyed': 3,
|
||||
'voice.alreadyCurrent': 1,
|
||||
'voice.plaintextSealed': 0,
|
||||
'voice.changedMeanwhile': 0,
|
||||
'voice.failed': 0,
|
||||
});
|
||||
expect(isVoiceSealedWithCurrentKey(db.voice.transcript('c1'))).toBe(true);
|
||||
expect(isVoiceSealedWithCurrentKey(db.voice.suggestion('c1'))).toBe(true);
|
||||
expect(isVoiceSealedWithCurrentKey(db.voice.audio('c2'))).toBe(true);
|
||||
expect(db.voice.transcript('c4')).toBe(confirmedTranscript);
|
||||
expect(db.voice.audio('c4').equals(confirmedAudio)).toBe(true);
|
||||
// Every filter excludes the three finished states.
|
||||
expect(db.voice.voiceCapture.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
status: { notIn: ['CONFIRMED', 'DISCARDED', 'EXPIRED'] },
|
||||
}) as unknown,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves voice content that opens to the original after the previous key is gone', async () => {
|
||||
const db = openFixture();
|
||||
await new RekeyJob(db.prisma).run([]);
|
||||
delete process.env.VOICE_ENCRYPTION_KEY_PREVIOUS;
|
||||
|
||||
expect(
|
||||
openVoiceText(db.voice.transcript('c1'), voiceAad('c1', 'transcript')),
|
||||
).toBe(TRANSCRIPT);
|
||||
expect(
|
||||
openVoiceText(db.voice.suggestion('c1'), voiceAad('c1', 'suggestion')),
|
||||
).toBe(SUGGESTION);
|
||||
expect(
|
||||
openVoiceBytes(db.voice.audio('c2'), voiceAad('c2', 'audio')).equals(
|
||||
AUDIO,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('dry-run counts voice content and writes nothing', async () => {
|
||||
const db = openFixture();
|
||||
const before = db.voice.transcript('c1');
|
||||
|
||||
const result = await new RekeyJob(db.prisma).run(['--dry-run']);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
dryRun: true,
|
||||
'voice.total': 4,
|
||||
'voice.rekeyed': 3,
|
||||
'voice.alreadyCurrent': 1,
|
||||
});
|
||||
expect(db.voice.voiceCapture.updateMany).not.toHaveBeenCalled();
|
||||
expect(db.voice.voiceCaptureAudio.updateMany).not.toHaveBeenCalled();
|
||||
expect(db.voice.transcript('c1')).toBe(before);
|
||||
});
|
||||
|
||||
it('does not overwrite voice content that changed since it was read', async () => {
|
||||
const db = openFixture();
|
||||
const newer = sealVoiceText('neu', voiceAad('c1', 'transcript'));
|
||||
db.voice.voiceCapture.findMany.mockImplementationOnce(() => {
|
||||
const read = [...db.voice.captureRows.values()]
|
||||
.filter(
|
||||
(row) => row.transcriptEnc !== null && row.status !== 'CONFIRMED',
|
||||
)
|
||||
.map((row) => ({ ...row }));
|
||||
const row = db.voice.captureRows.get('c1');
|
||||
if (row) {
|
||||
db.voice.captureRows.set('c1', { ...row, transcriptEnc: newer });
|
||||
}
|
||||
return Promise.resolve(read);
|
||||
});
|
||||
|
||||
const result = await new RekeyJob(db.prisma).run([]);
|
||||
|
||||
expect(result).toMatchObject({ 'voice.changedMeanwhile': 1 });
|
||||
expect(db.voice.transcript('c1')).toBe(newer);
|
||||
});
|
||||
|
||||
it('fails the run on a voice value that cannot be opened and never logs content', async () => {
|
||||
const db = fakeDb([], [], {
|
||||
captures: [
|
||||
{
|
||||
id: 'c1',
|
||||
status: 'READY',
|
||||
// Bound to another capture: does not authenticate here.
|
||||
transcriptEnc: underPrevious(() =>
|
||||
sealVoiceText(TRANSCRIPT, voiceAad('other', 'transcript')),
|
||||
),
|
||||
suggestionEnc: null,
|
||||
},
|
||||
],
|
||||
audio: [],
|
||||
});
|
||||
|
||||
const message = await new RekeyJob(db.prisma).run([]).then(
|
||||
() => '',
|
||||
(failure: Error) => failure.message,
|
||||
);
|
||||
|
||||
expect(message).toContain('voice.failed=1');
|
||||
expect(logged()).toContainEqual(
|
||||
expect.stringContaining(
|
||||
'VoiceCapture c1: voice secret not rekeyed: ciphertext failed authentication',
|
||||
),
|
||||
);
|
||||
for (const text of [message, ...logged()]) {
|
||||
expect(text).not.toContain('Koenigin');
|
||||
}
|
||||
});
|
||||
|
||||
it('reads audio one blob at a time and never selects the data in the id listing', async () => {
|
||||
const db = openFixture();
|
||||
|
||||
await new RekeyJob(db.prisma).run([]);
|
||||
|
||||
expect(db.voice.voiceCaptureAudio.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ select: { captureId: true } }),
|
||||
);
|
||||
expect(db.voice.voiceCaptureAudio.findFirst).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not write audio of a capture that was confirmed since it was read', async () => {
|
||||
const db = openFixture();
|
||||
const before = db.voice.audio('c2');
|
||||
db.voice.voiceCaptureAudio.findFirst.mockImplementationOnce(
|
||||
({ where }: { where: { captureId: string } }) => {
|
||||
const read = { captureId: where.captureId, dataEnc: before };
|
||||
const row = db.voice.captureRows.get(where.captureId);
|
||||
if (row) {
|
||||
db.voice.captureRows.set(where.captureId, {
|
||||
...row,
|
||||
status: 'CONFIRMED',
|
||||
});
|
||||
}
|
||||
return Promise.resolve(read);
|
||||
},
|
||||
);
|
||||
|
||||
const result = await new RekeyJob(db.prisma).run([]);
|
||||
|
||||
expect(result).toMatchObject({ 'voice.changedMeanwhile': 1 });
|
||||
expect(db.voice.audio('c2').equals(before)).toBe(true);
|
||||
expect(db.voice.voiceCaptureAudio.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
capture: {
|
||||
status: { notIn: ['CONFIRMED', 'DISCARDED', 'EXPIRED'] },
|
||||
},
|
||||
}) as unknown,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('fails before reading any row when the voice key is unusable', async () => {
|
||||
const db = openFixture();
|
||||
process.env.VOICE_ENCRYPTION_KEY = 'too-short';
|
||||
|
||||
await expect(new RekeyJob(db.prisma).run([])).rejects.toThrow(
|
||||
'VOICE_ENCRYPTION_KEY must be at least 32 characters',
|
||||
);
|
||||
expect(db.voice.voiceCapture.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,13 +13,29 @@ import {
|
||||
sealWebhookSecret,
|
||||
webhookSecretNeedsRekey,
|
||||
} from '../webhooks/webhook-secret';
|
||||
import { VoiceCaptureStatus } from '../../generated/prisma';
|
||||
import {
|
||||
isVoiceSealedWithCurrentKey,
|
||||
openVoiceBytes,
|
||||
openVoiceText,
|
||||
sealVoiceBytes,
|
||||
sealVoiceText,
|
||||
voiceAad,
|
||||
} from '../voice/core/voice-crypto';
|
||||
import { Job, JobResult } from './job';
|
||||
import { JobName } from './job-names';
|
||||
|
||||
/** `rekey --dry-run`: open and count every secret, write none. */
|
||||
export const REKEY_DRY_RUN_ARG = '--dry-run';
|
||||
|
||||
/** What one run did with the secrets of one purpose (mfa, webhook). */
|
||||
/** Voice content of these states is already deleted (or about to be) and is not resealed. */
|
||||
const FINISHED_VOICE_STATUSES: VoiceCaptureStatus[] = [
|
||||
VoiceCaptureStatus.CONFIRMED,
|
||||
VoiceCaptureStatus.DISCARDED,
|
||||
VoiceCaptureStatus.EXPIRED,
|
||||
];
|
||||
|
||||
/** What one run did with the secrets of one purpose (mfa, webhook, voice). */
|
||||
export interface RekeyCounts {
|
||||
/** Rows with a stored secret. */
|
||||
total: number;
|
||||
@@ -51,6 +67,13 @@ interface SecretColumn {
|
||||
/** How log lines name a row: "<entity> <id>". */
|
||||
entity: string;
|
||||
read(): Promise<StoredSecret[]>;
|
||||
/**
|
||||
* Instead of read(): the ids first, then one row at a time with readOne(),
|
||||
* so large values (audio) are never held all at once. readOne gives null
|
||||
* for a row that is gone or no longer eligible.
|
||||
*/
|
||||
readIds?(): Promise<string[]>;
|
||||
readOne?(id: string): Promise<StoredSecret | null>;
|
||||
/**
|
||||
* Writes `next` only while the row still holds `previous` - one UPDATE
|
||||
* with both in its WHERE, so a value written meanwhile (a new MFA setup,
|
||||
@@ -59,8 +82,9 @@ interface SecretColumn {
|
||||
swap(id: string, previous: string, next: string): Promise<boolean>;
|
||||
needsRekey(stored: string): boolean;
|
||||
isPlaintext(stored: string): boolean;
|
||||
open(stored: string): string;
|
||||
seal(plaintext: string): string;
|
||||
/** `id` binds voice content to its row (associated data); the other purposes ignore it. */
|
||||
open(stored: string, id: string): string;
|
||||
seal(plaintext: string, id: string): string;
|
||||
}
|
||||
|
||||
/** Refuses anything but --dry-run: a typo must never start the real run. */
|
||||
@@ -87,6 +111,20 @@ function countOutcomes(outcomes: readonly RowOutcome[]): RekeyCounts {
|
||||
};
|
||||
}
|
||||
|
||||
/** Sums the counts of columns that share one purpose (voice has three). */
|
||||
function sumCounts(all: readonly RekeyCounts[]): RekeyCounts {
|
||||
const sum = (key: keyof RekeyCounts): number =>
|
||||
all.reduce((total, each) => total + each[key], 0);
|
||||
return {
|
||||
total: sum('total'),
|
||||
rekeyed: sum('rekeyed'),
|
||||
alreadyCurrent: sum('alreadyCurrent'),
|
||||
plaintextSealed: sum('plaintextSealed'),
|
||||
changedMeanwhile: sum('changedMeanwhile'),
|
||||
failed: sum('failed'),
|
||||
};
|
||||
}
|
||||
|
||||
/** `{ dryRun, 'mfa.total', …, 'webhook.failed' }` - counts only, never values. */
|
||||
function toJobResult(
|
||||
dryRun: boolean,
|
||||
@@ -112,7 +150,8 @@ function describeResult(result: JobResult): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reseals every stored MFA (TOTP) and webhook signing secret with the
|
||||
* Reseals every stored MFA (TOTP) and webhook signing secret, and the
|
||||
* content of open voice notes (transcript, suggestion, audio), with the
|
||||
* current key of its purpose (common/crypto/key-ring.ts): values sealed
|
||||
* with `*_PREVIOUS` or before the key ring (MFA without key id, webhook
|
||||
* `enc:v1:`, both under sha256(JWT_ACCESS_SECRET)), and webhook secrets
|
||||
@@ -135,15 +174,30 @@ export class RekeyJob implements Job {
|
||||
|
||||
async run(args: readonly string[]): Promise<JobResult> {
|
||||
const dryRun = isDryRun(args);
|
||||
const columns = [this.mfaColumn(), this.webhookColumn()];
|
||||
const columns = [
|
||||
this.mfaColumn(),
|
||||
this.webhookColumn(),
|
||||
...this.voiceColumns(),
|
||||
];
|
||||
// A key that is set but unusable fails the run before any row is read.
|
||||
for (const column of columns) {
|
||||
loadKeyRing(column.purpose);
|
||||
for (const purpose of new Set(columns.map((column) => column.purpose))) {
|
||||
loadKeyRing(purpose);
|
||||
}
|
||||
const counts: (readonly [SecretPurpose, RekeyCounts])[] = [];
|
||||
const perColumn: (readonly [SecretPurpose, RekeyCounts])[] = [];
|
||||
for (const column of columns) {
|
||||
counts.push([column.purpose, await this.rekeyColumn(column, dryRun)]);
|
||||
perColumn.push([column.purpose, await this.rekeyColumn(column, dryRun)]);
|
||||
}
|
||||
const counts = (['mfa', 'webhook', 'voice'] as const).map(
|
||||
(purpose) =>
|
||||
[
|
||||
purpose,
|
||||
sumCounts(
|
||||
perColumn
|
||||
.filter(([each]) => each === purpose)
|
||||
.map(([, each]) => each),
|
||||
),
|
||||
] as const,
|
||||
);
|
||||
const result = toJobResult(dryRun, counts);
|
||||
const failed = counts.reduce((sum, [, each]) => sum + each.failed, 0);
|
||||
if (failed > 0) {
|
||||
@@ -158,9 +212,17 @@ export class RekeyJob implements Job {
|
||||
column: SecretColumn,
|
||||
dryRun: boolean,
|
||||
): Promise<RekeyCounts> {
|
||||
const rows = await column.read();
|
||||
const outcomes: RowOutcome[] = [];
|
||||
for (const row of rows) {
|
||||
if (column.readIds && column.readOne) {
|
||||
for (const id of await column.readIds()) {
|
||||
const row = await column.readOne(id);
|
||||
if (row !== null) {
|
||||
outcomes.push(await this.rekeyRow(column, row, dryRun));
|
||||
}
|
||||
}
|
||||
return countOutcomes(outcomes);
|
||||
}
|
||||
for (const row of await column.read()) {
|
||||
outcomes.push(await this.rekeyRow(column, row, dryRun));
|
||||
}
|
||||
return countOutcomes(outcomes);
|
||||
@@ -185,13 +247,13 @@ export class RekeyJob implements Job {
|
||||
const subject = `${column.entity} ${row.id}: ${column.purpose} secret`;
|
||||
try {
|
||||
// Opened in a dry-run too: it reports what a real run could not reseal.
|
||||
const plaintext = column.open(row.stored);
|
||||
const plaintext = column.open(row.stored, row.id);
|
||||
if (dryRun) {
|
||||
return outcome;
|
||||
}
|
||||
const resealed = column.seal(plaintext);
|
||||
const resealed = column.seal(plaintext, row.id);
|
||||
// The swap replaces the only copy of the old value.
|
||||
if (column.open(resealed) !== plaintext) {
|
||||
if (column.open(resealed, row.id) !== plaintext) {
|
||||
throw new Error('resealed secret does not open to the same value');
|
||||
}
|
||||
if (await column.swap(row.id, row.stored, resealed)) {
|
||||
@@ -262,4 +324,96 @@ export class RekeyJob implements Job {
|
||||
seal: sealWebhookSecret,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcript, suggestion and audio of open voice notes. Audio is handled
|
||||
* as base64 text so it shares the row logic; it is read only for open
|
||||
* notes (at most a few small files).
|
||||
*/
|
||||
private voiceColumns(): SecretColumn[] {
|
||||
const openStatus = { notIn: FINISHED_VOICE_STATUSES };
|
||||
const textColumn = (
|
||||
field: 'transcriptEnc' | 'suggestionEnc',
|
||||
kind: 'transcript' | 'suggestion',
|
||||
): SecretColumn => ({
|
||||
purpose: 'voice',
|
||||
entity: 'VoiceCapture',
|
||||
read: async () => {
|
||||
const rows = await this.prisma.voiceCapture.findMany({
|
||||
where: { status: openStatus, [field]: { not: null } },
|
||||
select: { id: true, [field]: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
return rows.flatMap((row) => {
|
||||
const stored = row[field];
|
||||
return typeof stored === 'string' ? [{ id: row.id, stored }] : [];
|
||||
});
|
||||
},
|
||||
swap: async (id, previous, next) => {
|
||||
const { count } = await this.prisma.voiceCapture.updateMany({
|
||||
where: { id, status: openStatus, [field]: previous },
|
||||
data: { [field]: next },
|
||||
});
|
||||
return count === 1;
|
||||
},
|
||||
needsRekey: (stored) => !isVoiceSealedWithCurrentKey(stored),
|
||||
isPlaintext: () => false,
|
||||
open: (stored, id) => openVoiceText(stored, voiceAad(id, kind)),
|
||||
seal: (plaintext, id) => sealVoiceText(plaintext, voiceAad(id, kind)),
|
||||
});
|
||||
const audioColumn: SecretColumn = {
|
||||
purpose: 'voice',
|
||||
entity: 'VoiceCapture',
|
||||
read: () => Promise.resolve([]),
|
||||
readIds: async () => {
|
||||
const rows = await this.prisma.voiceCaptureAudio.findMany({
|
||||
where: { capture: { status: openStatus } },
|
||||
select: { captureId: true },
|
||||
orderBy: { captureId: 'asc' },
|
||||
});
|
||||
return rows.map((row) => row.captureId);
|
||||
},
|
||||
readOne: async (id) => {
|
||||
const row = await this.prisma.voiceCaptureAudio.findFirst({
|
||||
where: { captureId: id, capture: { status: openStatus } },
|
||||
select: { captureId: true, dataEnc: true },
|
||||
});
|
||||
return row
|
||||
? {
|
||||
id: row.captureId,
|
||||
stored: Buffer.from(row.dataEnc).toString('base64'),
|
||||
}
|
||||
: null;
|
||||
},
|
||||
swap: async (id, previous, next) => {
|
||||
const { count } = await this.prisma.voiceCaptureAudio.updateMany({
|
||||
where: {
|
||||
captureId: id,
|
||||
dataEnc: Buffer.from(previous, 'base64'),
|
||||
capture: { status: openStatus },
|
||||
},
|
||||
data: { dataEnc: Buffer.from(next, 'base64') },
|
||||
});
|
||||
return count === 1;
|
||||
},
|
||||
needsRekey: (stored) =>
|
||||
!isVoiceSealedWithCurrentKey(Buffer.from(stored, 'base64')),
|
||||
isPlaintext: () => false,
|
||||
open: (stored, id) =>
|
||||
openVoiceBytes(
|
||||
Buffer.from(stored, 'base64'),
|
||||
voiceAad(id, 'audio'),
|
||||
).toString('base64'),
|
||||
seal: (plaintext, id) =>
|
||||
sealVoiceBytes(
|
||||
Buffer.from(plaintext, 'base64'),
|
||||
voiceAad(id, 'audio'),
|
||||
).toString('base64'),
|
||||
};
|
||||
return [
|
||||
textColumn('transcriptEnc', 'transcript'),
|
||||
textColumn('suggestionEnc', 'suggestion'),
|
||||
audioColumn,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { mkdtemp, readdir, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import * as claims from '../voice/claims/voice-claims';
|
||||
import * as retention from '../voice/voice-retention';
|
||||
import { VoiceTranscribeJob } from './voice-transcribe.job';
|
||||
|
||||
describe('VoiceTranscribeJob', () => {
|
||||
let dir: string;
|
||||
let job: VoiceTranscribeJob;
|
||||
let retentionSpy: jest.SpyInstance;
|
||||
let staleSpy: jest.SpyInstance;
|
||||
let claimSpy: jest.SpyInstance;
|
||||
const tx = {};
|
||||
const prisma = {
|
||||
$transaction: (fn: (t: typeof tx) => Promise<unknown>) => fn(tx),
|
||||
} as unknown as PrismaService;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), 'voice-job-'));
|
||||
process.env.VOICE_WORK_DIR = dir;
|
||||
retentionSpy = jest
|
||||
.spyOn(retention, 'expireVoiceContent')
|
||||
.mockResolvedValue({
|
||||
audioExpired: 2,
|
||||
contentExpired: 1,
|
||||
inactiveOwnersWiped: 0,
|
||||
});
|
||||
staleSpy = jest
|
||||
.spyOn(claims, 'releaseStaleVoiceClaims')
|
||||
.mockResolvedValue([]);
|
||||
claimSpy = jest.spyOn(claims, 'claimVoiceCaptures').mockResolvedValue([]);
|
||||
job = new VoiceTranscribeJob(prisma);
|
||||
});
|
||||
afterEach(async () => {
|
||||
delete process.env.VOICE_WORK_DIR;
|
||||
jest.restoreAllMocks();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('is named voice-transcribe', () => {
|
||||
expect(job.name).toBe('voice-transcribe');
|
||||
});
|
||||
|
||||
it('refuses a missing or unknown phase', async () => {
|
||||
await expect(job.run([])).rejects.toThrow(/exactly one phase argument/);
|
||||
await expect(job.run(['--phase=whisper'])).rejects.toThrow(
|
||||
/exactly one phase argument/,
|
||||
);
|
||||
await expect(job.run(['--phase=fetch', '--phase=ingest'])).rejects.toThrow(
|
||||
/exactly one phase argument/,
|
||||
);
|
||||
});
|
||||
|
||||
it('fetch ends quickly without work and writes no manifest', async () => {
|
||||
const result = await job.run(['--phase=fetch']);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
claimed: 0,
|
||||
audioExpired: 2,
|
||||
contentExpired: 1,
|
||||
});
|
||||
expect(retentionSpy).toHaveBeenCalledTimes(1);
|
||||
expect(staleSpy).toHaveBeenCalledWith(tx, 'transcribe', {});
|
||||
expect(claimSpy).toHaveBeenCalledWith(prisma, 'transcribe', {
|
||||
limit: 5,
|
||||
maxTotalSec: 600,
|
||||
});
|
||||
expect(await readdir(dir)).not.toContain('manifest.json');
|
||||
await expect(readdir(path.join(dir, 'in'))).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('ingest without a manifest does nothing', async () => {
|
||||
const result = await job.run(['--phase=ingest']);
|
||||
|
||||
expect(result).toEqual({ ingested: 0, failed: 0, retried: 0, lost: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
claimVoiceCaptures,
|
||||
releaseStaleVoiceClaims,
|
||||
settleVoiceFailure,
|
||||
settleVoiceSuccess,
|
||||
VoiceClaim,
|
||||
} from '../voice/claims/voice-claims';
|
||||
import { sealTranscript } from '../voice/core/transcript';
|
||||
import { openVoiceBytes, voiceAad } from '../voice/core/voice-crypto';
|
||||
import {
|
||||
clearWorkDir,
|
||||
readManifest,
|
||||
readResults,
|
||||
WhisperResult,
|
||||
workDirFromEnv,
|
||||
writeInputs,
|
||||
} from '../voice/transcribe/work-dir';
|
||||
import { recordVoiceCaptureFailed } from '../voice/voice-failure-hook';
|
||||
import { expireVoiceContent } from '../voice/voice-retention';
|
||||
import { Job, JobResult } from './job';
|
||||
import { JobName } from './job-names';
|
||||
|
||||
/** Per run: at most 5 notes and 600 seconds of audio (spec 4.2). */
|
||||
export const VOICE_FETCH_LIMIT = 5;
|
||||
export const VOICE_FETCH_MAX_SEC = 600;
|
||||
/** Whisper text shorter than this is no speech (spec 6.3). */
|
||||
const MIN_TEXT_CHARS = 5;
|
||||
const MIN_DURATION_SEC = 1;
|
||||
const MAX_DURATION_SEC = 240;
|
||||
|
||||
type Phase = 'claim' | 'ingest';
|
||||
type ItemOutcome = 'ingested' | 'failed' | 'retried' | 'lost';
|
||||
|
||||
export interface VoiceFetchOptions {
|
||||
/** Extra condition on "VoiceCapture" (tests scope their runs to own rows). */
|
||||
filter?: Prisma.Sql;
|
||||
}
|
||||
|
||||
function parsePhase(args: readonly string[]): Phase {
|
||||
if (args.length === 1 && args[0] === '--phase=fetch') return 'claim';
|
||||
if (args.length === 1 && args[0] === '--phase=ingest') return 'ingest';
|
||||
throw new Error('voice-transcribe needs exactly one phase argument');
|
||||
}
|
||||
|
||||
/**
|
||||
* CronJob `apicore-job-voice-transcribe`, the two Node halves around the
|
||||
* Whisper container. `fetch` runs the retention rules, claims notes,
|
||||
* decrypts their audio in memory and hands it over in the RAM work dir;
|
||||
* `ingest` takes Whisper's results, seals the text, deletes the audio and
|
||||
* wakes the worker - each note in one transaction, guarded by its claim
|
||||
* token. Content is never logged: only ids, counts and fixed codes.
|
||||
*/
|
||||
@Injectable()
|
||||
export class VoiceTranscribeJob implements Job {
|
||||
private readonly logger = new Logger(VoiceTranscribeJob.name);
|
||||
readonly name: JobName = 'voice-transcribe';
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async run(args: readonly string[]): Promise<JobResult> {
|
||||
return parsePhase(args) === 'claim' ? this.runFetchPhase() : this.ingest();
|
||||
}
|
||||
|
||||
async runFetchPhase(options: VoiceFetchOptions = {}): Promise<JobResult> {
|
||||
const dir = workDirFromEnv();
|
||||
await clearWorkDir(dir);
|
||||
const filter = options.filter ? { filter: options.filter } : {};
|
||||
const retention = await expireVoiceContent(this.prisma, filter);
|
||||
const staleReleased = await this.releaseStale(filter);
|
||||
const claims = await claimVoiceCaptures(this.prisma, 'transcribe', {
|
||||
limit: VOICE_FETCH_LIMIT,
|
||||
maxTotalSec: VOICE_FETCH_MAX_SEC,
|
||||
...filter,
|
||||
});
|
||||
const inputs: { claim: VoiceClaim; audio: Buffer }[] = [];
|
||||
for (const claim of claims) {
|
||||
const audio = await this.decryptAudio(claim);
|
||||
if (audio === null) {
|
||||
await this.failAttempt(claim, 'TRANSCRIBE_FAILED');
|
||||
} else {
|
||||
inputs.push({ claim, audio });
|
||||
}
|
||||
}
|
||||
if (inputs.length > 0) {
|
||||
await writeInputs(dir, inputs);
|
||||
}
|
||||
return {
|
||||
claimed: claims.length,
|
||||
written: inputs.length,
|
||||
staleReleased,
|
||||
...retention,
|
||||
};
|
||||
}
|
||||
|
||||
async ingest(): Promise<JobResult> {
|
||||
const dir = workDirFromEnv();
|
||||
const counts: Record<ItemOutcome, number> = {
|
||||
ingested: 0,
|
||||
failed: 0,
|
||||
retried: 0,
|
||||
lost: 0,
|
||||
};
|
||||
try {
|
||||
const manifest = await readManifest(dir);
|
||||
if (manifest === null) {
|
||||
return counts;
|
||||
}
|
||||
const results = await readResults(dir, manifest);
|
||||
let errors = 0;
|
||||
for (const item of manifest.items) {
|
||||
try {
|
||||
const outcome = await this.ingestOne(
|
||||
item.captureId,
|
||||
item.claimToken,
|
||||
results.get(item.captureId) ?? null,
|
||||
);
|
||||
counts[outcome] += 1;
|
||||
} catch (error: unknown) {
|
||||
errors += 1;
|
||||
// The message of a failed seal or query holds no content, but
|
||||
// keep to the id anyway.
|
||||
this.logger.error(
|
||||
`Ingest failed for voice note ${item.captureId}: ${error instanceof Error ? error.name : 'error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (errors > 0) {
|
||||
throw new Error(
|
||||
`voice ingest failed for ${errors} of ${manifest.items.length} notes (${JSON.stringify(counts)})`,
|
||||
);
|
||||
}
|
||||
return counts;
|
||||
} finally {
|
||||
await clearWorkDir(dir);
|
||||
}
|
||||
}
|
||||
|
||||
private releaseStale(filter: VoiceFetchOptions): Promise<number> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const released = await releaseStaleVoiceClaims(tx, 'transcribe', filter);
|
||||
for (const { id, code } of released) {
|
||||
if (code !== null) {
|
||||
await recordVoiceCaptureFailed(tx, id, 'TRANSCRIBE_FAILED');
|
||||
}
|
||||
}
|
||||
return released.length;
|
||||
});
|
||||
}
|
||||
|
||||
private async decryptAudio(claim: VoiceClaim): Promise<Buffer | null> {
|
||||
const row = await this.prisma.voiceCaptureAudio.findUnique({
|
||||
where: { captureId: claim.id },
|
||||
});
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return openVoiceBytes(
|
||||
Buffer.from(row.dataEnc),
|
||||
voiceAad(claim.id, 'audio'),
|
||||
);
|
||||
} catch {
|
||||
this.logger.warn(`Audio of voice note ${claim.id} cannot be opened`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private failAttempt(
|
||||
claim: VoiceClaim,
|
||||
code: 'TRANSCRIBE_FAILED',
|
||||
): Promise<'retry' | 'failed' | 'lost'> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const outcome = await settleVoiceFailure(tx, 'transcribe', claim, code);
|
||||
if (outcome === 'failed') {
|
||||
await recordVoiceCaptureFailed(tx, claim.id, code);
|
||||
}
|
||||
return outcome;
|
||||
});
|
||||
}
|
||||
|
||||
/** The claim as the database holds it now; null if it is gone. */
|
||||
private async loadClaim(
|
||||
captureId: string,
|
||||
claimToken: string,
|
||||
): Promise<VoiceClaim | null> {
|
||||
const rows = await this.prisma.$queryRaw<VoiceClaim[]>(Prisma.sql`
|
||||
SELECT "id", "claimToken", "attempts", "ownerUserId", "colonyId",
|
||||
"audioMime", "durationSec"
|
||||
FROM "VoiceCapture"
|
||||
WHERE "id" = ${captureId} AND "claimToken" = ${claimToken}
|
||||
AND "status" = 'TRANSCRIBING'`);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async ingestOne(
|
||||
captureId: string,
|
||||
claimToken: string,
|
||||
result: WhisperResult | null,
|
||||
): Promise<ItemOutcome> {
|
||||
const claim = await this.loadClaim(captureId, claimToken);
|
||||
if (claim === null) {
|
||||
return 'lost';
|
||||
}
|
||||
if (result === null || result.error === 'DECODE_FAILED') {
|
||||
return this.outcomeOf(await this.failAttempt(claim, 'TRANSCRIBE_FAILED'));
|
||||
}
|
||||
const noSpeech =
|
||||
result.error ??
|
||||
(result.text.trim().length < MIN_TEXT_CHARS ? 'NO_SPEECH' : null);
|
||||
if (noSpeech === 'NO_SPEECH' || noSpeech === 'TOO_LONG') {
|
||||
return this.finishFailed(claim, noSpeech);
|
||||
}
|
||||
if (result.durationSec > MAX_DURATION_SEC) {
|
||||
return this.finishFailed(claim, 'TOO_LONG');
|
||||
}
|
||||
return this.finishTranscribed(claim, result);
|
||||
}
|
||||
|
||||
private outcomeOf(settled: 'retry' | 'failed' | 'lost'): ItemOutcome {
|
||||
return settled === 'retry'
|
||||
? 'retried'
|
||||
: settled === 'failed'
|
||||
? 'failed'
|
||||
: 'lost';
|
||||
}
|
||||
|
||||
/** Terminal failure of the note itself: FAILED with the code, audio gone. */
|
||||
private finishFailed(
|
||||
claim: VoiceClaim,
|
||||
code: 'NO_SPEECH' | 'TOO_LONG',
|
||||
): Promise<ItemOutcome> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const changed = await tx.$executeRaw`
|
||||
UPDATE "VoiceCapture"
|
||||
SET "status" = 'FAILED', "lastErrorCode" = ${code},
|
||||
"attempts" = "attempts" + 1, "updatedAt" = now(),
|
||||
"claimedAt" = NULL, "claimToken" = NULL
|
||||
WHERE "id" = ${claim.id} AND "status" = 'TRANSCRIBING'
|
||||
AND "claimToken" = ${claim.claimToken}`;
|
||||
if (changed !== 1) {
|
||||
return 'lost';
|
||||
}
|
||||
await tx.voiceCaptureAudio.deleteMany({ where: { captureId: claim.id } });
|
||||
await recordVoiceCaptureFailed(tx, claim.id, code);
|
||||
return 'failed';
|
||||
});
|
||||
}
|
||||
|
||||
private finishTranscribed(
|
||||
claim: VoiceClaim,
|
||||
result: WhisperResult,
|
||||
): Promise<ItemOutcome> {
|
||||
const durationSec = Math.max(
|
||||
MIN_DURATION_SEC,
|
||||
Math.round(result.durationSec),
|
||||
);
|
||||
const sealed = sealTranscript(claim.id, {
|
||||
v: 1,
|
||||
text: result.text,
|
||||
durationSec: result.durationSec,
|
||||
segments: result.segments,
|
||||
});
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const settled = await settleVoiceSuccess(
|
||||
tx,
|
||||
'transcribe',
|
||||
claim,
|
||||
Prisma.sql`"transcriptEnc" = ${sealed}, "durationSec" = ${durationSec}`,
|
||||
);
|
||||
if (!settled) {
|
||||
return 'lost';
|
||||
}
|
||||
await tx.voiceCaptureAudio.deleteMany({ where: { captureId: claim.id } });
|
||||
await notifyWorker(tx, WAKE_CHANNELS.voiceExtraction);
|
||||
return 'ingested';
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ describe('NotificationsService', () => {
|
||||
user: { findMany: jest.Mock };
|
||||
$connect: jest.Mock;
|
||||
$transaction: jest.Mock;
|
||||
$queryRaw: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -52,10 +53,19 @@ describe('NotificationsService', () => {
|
||||
// A transaction client lacks $connect; the root client has it.
|
||||
$connect: jest.fn(),
|
||||
$transaction: jest.fn(),
|
||||
// The FOR KEY SHARE lock on the recipients: every user still exists.
|
||||
$queryRaw: jest.fn().mockImplementation(() => {
|
||||
const found = prisma.user.findMany.mock.results.at(-1);
|
||||
return Promise.resolve(found ? (found.value as unknown) : []);
|
||||
}),
|
||||
};
|
||||
prisma.$transaction.mockImplementation((work: (tx: unknown) => unknown) =>
|
||||
Promise.resolve(work(prisma)),
|
||||
);
|
||||
prisma.$transaction.mockImplementation((work: (tx: unknown) => unknown) => {
|
||||
// A transaction client has no $connect; the code tells the two apart.
|
||||
const txClient = Object.fromEntries(
|
||||
Object.entries(prisma).filter(([key]) => key !== '$connect'),
|
||||
);
|
||||
return Promise.resolve(work(txClient));
|
||||
});
|
||||
held = new Set();
|
||||
service = new NotificationsService(
|
||||
prisma as unknown as PrismaService,
|
||||
@@ -78,6 +88,28 @@ describe('NotificationsService', () => {
|
||||
void service.record({ priority: 'INFO', category: 'SYSTEM', message: 'm' });
|
||||
});
|
||||
|
||||
describe('recipient locking', () => {
|
||||
it('locks the recipients in the write transaction and skips a user deleted meanwhile', async () => {
|
||||
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]);
|
||||
prisma.$queryRaw.mockResolvedValue([{ id: 'u1' }]);
|
||||
|
||||
const rows = await service.recordForUsers(['u1', 'u2'], {
|
||||
priority: 'INFO',
|
||||
category: 'SYSTEM',
|
||||
message: 'm',
|
||||
});
|
||||
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
(prisma.$queryRaw.mock.calls[0] as [{ sql: string }])[0].sql,
|
||||
).toContain('FOR KEY SHARE');
|
||||
expect(rows.map((row) => (row as { userId: string }).userId)).toEqual([
|
||||
'u1',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('record', () => {
|
||||
it('writes one row for an eligible recipient and returns it', async () => {
|
||||
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
|
||||
@@ -138,6 +170,7 @@ describe('NotificationsService', () => {
|
||||
it('writes through the transaction client when one is given', async () => {
|
||||
const tx = {
|
||||
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
|
||||
$queryRaw: jest.fn().mockResolvedValue([{ id: 'u1' }]),
|
||||
notification: {
|
||||
createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n' }]),
|
||||
},
|
||||
@@ -180,7 +213,10 @@ describe('NotificationsService', () => {
|
||||
const rows = await service.recordForUsers(['u1', 'u2'], base);
|
||||
|
||||
expect(planner.planIn).toHaveBeenCalledTimes(1);
|
||||
expect(planner.planIn).toHaveBeenCalledWith(prisma, rows);
|
||||
expect(planner.planIn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ notification: prisma.notification }),
|
||||
rows,
|
||||
);
|
||||
expect(
|
||||
prisma.notification.createManyAndReturn.mock.invocationCallOrder[0],
|
||||
).toBeLessThan(planner.planIn.mock.invocationCallOrder[0]);
|
||||
@@ -189,6 +225,7 @@ describe('NotificationsService', () => {
|
||||
it('plans with the caller transaction and opens none of its own', async () => {
|
||||
const tx = {
|
||||
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
|
||||
$queryRaw: jest.fn().mockResolvedValue([{ id: 'u1' }]),
|
||||
notification: {
|
||||
createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n' }]),
|
||||
},
|
||||
@@ -316,6 +353,7 @@ describe('NotificationsService', () => {
|
||||
it('reads and writes through the transaction client when one is given', async () => {
|
||||
const tx = {
|
||||
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
|
||||
$queryRaw: jest.fn().mockResolvedValue([{ id: 'u1' }]),
|
||||
notification: {
|
||||
createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n1' }]),
|
||||
},
|
||||
@@ -342,6 +380,7 @@ describe('NotificationsService', () => {
|
||||
let calls: string[];
|
||||
let tx: {
|
||||
$executeRaw: jest.Mock;
|
||||
$queryRaw: jest.Mock;
|
||||
user: { findMany: jest.Mock };
|
||||
notification: { findMany: jest.Mock; createManyAndReturn: jest.Mock };
|
||||
};
|
||||
@@ -353,6 +392,11 @@ describe('NotificationsService', () => {
|
||||
calls.push('lock');
|
||||
return Promise.resolve(1);
|
||||
}),
|
||||
// The key-share lock keeps every user the read just returned.
|
||||
$queryRaw: jest.fn().mockImplementation(() => {
|
||||
const read = tx.user.findMany.mock.results.at(-1);
|
||||
return Promise.resolve(read ? (read.value as unknown) : []);
|
||||
}),
|
||||
user: {
|
||||
// Like the database: the id filter narrows the three eligible users.
|
||||
findMany: jest
|
||||
|
||||
@@ -104,17 +104,18 @@ export class NotificationsService {
|
||||
input: CreateNotificationInput,
|
||||
tx?: Prisma.TransactionClient,
|
||||
): Promise<Notification | null> {
|
||||
const db: DbClient = tx ?? this.prisma;
|
||||
const { userId, dedupeKey, ...content } = input;
|
||||
const keys = uniqueSorted(content.requiredPermissions ?? []);
|
||||
const recipients = await this.recipients(db, keys, [userId]);
|
||||
const [row] = await this.write(
|
||||
db,
|
||||
recipients.map((id) => ({ userId: id, dedupeKey: dedupeKey ?? null })),
|
||||
content,
|
||||
keys,
|
||||
);
|
||||
return row ?? null;
|
||||
return this.inTransaction(tx, async (db) => {
|
||||
const recipients = await this.recipients(db, keys, [userId]);
|
||||
const [row] = await this.write(
|
||||
db,
|
||||
recipients.map((id) => ({ userId: id, dedupeKey: dedupeKey ?? null })),
|
||||
content,
|
||||
keys,
|
||||
);
|
||||
return row ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
/** The given users, minus those missing a permission the content needs. */
|
||||
@@ -127,7 +128,9 @@ export class NotificationsService {
|
||||
return [];
|
||||
}
|
||||
const keys = uniqueSorted(input.requiredPermissions ?? []);
|
||||
return this.fanOut(tx ?? this.prisma, keys, input, userIds);
|
||||
return this.inTransaction(tx, (db) =>
|
||||
this.fanOut(db, keys, input, userIds),
|
||||
);
|
||||
}
|
||||
|
||||
/** Every user holding all of `permission` (and the content's permissions). */
|
||||
@@ -146,7 +149,9 @@ export class NotificationsService {
|
||||
...recipientKeys,
|
||||
...(input.requiredPermissions ?? []),
|
||||
]);
|
||||
return this.fanOut(tx ?? this.prisma, keys, input, undefined);
|
||||
return this.inTransaction(tx, (db) =>
|
||||
this.fanOut(db, keys, input, undefined),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -264,8 +269,20 @@ export class NotificationsService {
|
||||
return { updated: count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Recipients are read and then inserted: both steps belong to one
|
||||
* transaction, in which recipients() locks the user rows, so a user deleted
|
||||
* in between cannot turn the insert into a foreign key violation.
|
||||
*/
|
||||
private inTransaction<T>(
|
||||
tx: Prisma.TransactionClient | undefined,
|
||||
work: (db: Prisma.TransactionClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return tx ? work(tx) : this.prisma.$transaction((own) => work(own));
|
||||
}
|
||||
|
||||
private async fanOut(
|
||||
db: DbClient,
|
||||
db: Prisma.TransactionClient,
|
||||
keys: readonly PermissionKey[],
|
||||
input: FanOutNotificationInput,
|
||||
userIds: readonly string[] | undefined,
|
||||
@@ -287,7 +304,7 @@ export class NotificationsService {
|
||||
|
||||
/** Active user, active role, every key granted - in one query. */
|
||||
private async recipients(
|
||||
db: DbClient,
|
||||
db: Prisma.TransactionClient,
|
||||
keys: readonly PermissionKey[],
|
||||
userIds: readonly string[] | undefined,
|
||||
): Promise<string[]> {
|
||||
@@ -305,7 +322,15 @@ export class NotificationsService {
|
||||
select: { id: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
return users.map((user) => user.id);
|
||||
if (users.length === 0) {
|
||||
return [];
|
||||
}
|
||||
// FOR KEY SHARE: a concurrent DELETE of one of these users waits for this
|
||||
// transaction; one that already committed drops out of the result.
|
||||
const locked = await db.$queryRaw<{ id: string }[]>(
|
||||
Prisma.sql`SELECT "id" FROM "User" WHERE "id" = ANY(${users.map((user) => user.id)}) ORDER BY "id" FOR KEY SHARE`,
|
||||
);
|
||||
return locked.map((user) => user.id);
|
||||
}
|
||||
|
||||
private async write(
|
||||
|
||||
@@ -226,10 +226,12 @@ describe('lock keys per table', () => {
|
||||
expect(tx.$queryRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the map in lock order: Apiary before Colony, the T6 tables last', () => {
|
||||
it('keeps the map in lock order: Apiary before Colony, HiveSetup right after Colony, HivePartType after HiveSetup, the T6 tables last', () => {
|
||||
expect(LOCK_ORDER).toEqual([
|
||||
'Apiary',
|
||||
'Colony',
|
||||
'HiveSetup',
|
||||
'HivePartType',
|
||||
'Suggestion',
|
||||
'Task',
|
||||
'Queen',
|
||||
@@ -257,12 +259,16 @@ describe('lock keys per table', () => {
|
||||
'AuthorityNotice',
|
||||
'ApiaryRestriction',
|
||||
'ComplianceAcknowledgement',
|
||||
'VoiceCapture',
|
||||
]);
|
||||
});
|
||||
|
||||
it('every table of the map is lockable by id and each appears once', async () => {
|
||||
expect(new Set(LOCK_ORDER).size).toBe(LOCK_ORDER.length);
|
||||
const keyed = new Map([['NotificationPreference', 'userId']]);
|
||||
const keyed = new Map([
|
||||
['NotificationPreference', 'userId'],
|
||||
['HiveSetup', 'colonyId'],
|
||||
]);
|
||||
for (const table of LOCK_ORDER) {
|
||||
const tx = { $queryRaw: jest.fn().mockResolvedValue([]) };
|
||||
|
||||
|
||||
@@ -19,6 +19,11 @@ function lockable(table: string, key = 'id') {
|
||||
const LOCKABLE_TABLES = {
|
||||
Apiary: lockable('Apiary'),
|
||||
Colony: lockable('Colony'),
|
||||
// Beute in 3D: one setup row per colony, keyed by the colony. Locked right
|
||||
// after the colony row it belongs to.
|
||||
HiveSetup: lockable('HiveSetup', 'colonyId'),
|
||||
// Catalog part types: locked while their stateSchema is changed.
|
||||
HivePartType: lockable('HivePartType'),
|
||||
Suggestion: lockable('Suggestion'),
|
||||
Task: lockable('Task'),
|
||||
Queen: lockable('Queen'),
|
||||
@@ -54,6 +59,7 @@ const LOCKABLE_TABLES = {
|
||||
AuthorityNotice: lockable('AuthorityNotice'),
|
||||
ApiaryRestriction: lockable('ApiaryRestriction'),
|
||||
ComplianceAcknowledgement: lockable('ComplianceAcknowledgement'),
|
||||
VoiceCapture: lockable('VoiceCapture'),
|
||||
} as const;
|
||||
|
||||
export type LockableTable = keyof typeof LOCKABLE_TABLES;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AllInOneModule } from '../all-in-one.module';
|
||||
import { AppModule } from '../app.module';
|
||||
import { ThrottlerStorageModule } from '../common/throttler/throttler-storage.module';
|
||||
import { DomainEventRegistry } from '../domain-events/domain-event-registry';
|
||||
import { VoiceWorkerModule } from '../voice/voice-worker.module';
|
||||
import { WorkerModule } from '../worker/worker.module';
|
||||
import { WorkerRuntimeModule } from '../worker/worker-runtime.module';
|
||||
import { isHttpRole, rootModuleFor } from './root-module';
|
||||
@@ -76,6 +77,8 @@ describe('rootModuleFor', () => {
|
||||
const reachable = await reachableModules(AppModule);
|
||||
|
||||
expect(reachable).not.toContain(WorkerRuntimeModule);
|
||||
// The Anthropic adapter (and so the key) exists in the worker only.
|
||||
expect(reachable).not.toContain(VoiceWorkerModule);
|
||||
// The walk really goes deep: into feature modules and dynamic modules.
|
||||
expect(reachable).toContain(DiscoveryModule);
|
||||
expect(reachable).toContain(ThrottlerStorageModule);
|
||||
@@ -94,6 +97,7 @@ describe('rootModuleFor', () => {
|
||||
const reachable = await reachableModules(WorkerModule);
|
||||
|
||||
expect(reachable).toContain(WorkerRuntimeModule);
|
||||
expect(reachable).toContain(VoiceWorkerModule);
|
||||
expect(reachable).not.toContain(AppModule);
|
||||
});
|
||||
|
||||
@@ -110,6 +114,10 @@ describe('rootModuleFor', () => {
|
||||
const worker = await discoveredHandlerIds(WorkerModule);
|
||||
|
||||
expect(api).toContain('webhooks.forward');
|
||||
// voice handlers are registered in the worker
|
||||
expect(worker).toEqual(
|
||||
expect.arrayContaining(['voice.notify-ready', 'voice.notify-failed']),
|
||||
);
|
||||
expect(worker).toEqual(api);
|
||||
} finally {
|
||||
if (previousSecret === undefined) {
|
||||
|
||||
@@ -6,6 +6,7 @@ const KEYS = {
|
||||
MFA_ENCRYPTION_KEY: 'm'.repeat(32),
|
||||
WEBHOOK_ENCRYPTION_KEY: 'w'.repeat(32),
|
||||
BACKUP_ENCRYPTION_KEY: 'b'.repeat(32),
|
||||
VOICE_ENCRYPTION_KEY: 'v'.repeat(32),
|
||||
};
|
||||
const PROD_ENV = {
|
||||
...BASE_ENV,
|
||||
@@ -81,16 +82,17 @@ describe('assertStartupEnvironment', () => {
|
||||
});
|
||||
|
||||
describe('encryption keys', () => {
|
||||
it('production refuses to start without MFA_/WEBHOOK_/BACKUP_ENCRYPTION_KEY', () => {
|
||||
it('production refuses to start without MFA_/WEBHOOK_/BACKUP_/VOICE_ENCRYPTION_KEY', () => {
|
||||
expect(() =>
|
||||
assertStartupEnvironment('worker', {
|
||||
...PROD_ENV,
|
||||
MFA_ENCRYPTION_KEY: undefined,
|
||||
WEBHOOK_ENCRYPTION_KEY: undefined,
|
||||
BACKUP_ENCRYPTION_KEY: undefined,
|
||||
VOICE_ENCRYPTION_KEY: undefined,
|
||||
}),
|
||||
).toThrow(
|
||||
'Missing required environment variable(s): MFA_ENCRYPTION_KEY, WEBHOOK_ENCRYPTION_KEY, BACKUP_ENCRYPTION_KEY',
|
||||
'Missing required environment variable(s): MFA_ENCRYPTION_KEY, WEBHOOK_ENCRYPTION_KEY, BACKUP_ENCRYPTION_KEY, VOICE_ENCRYPTION_KEY',
|
||||
);
|
||||
expect(() =>
|
||||
assertStartupEnvironment('job', {
|
||||
@@ -126,7 +128,7 @@ describe('assertStartupEnvironment', () => {
|
||||
WEBHOOK_ENCRYPTION_KEY: KEYS.MFA_ENCRYPTION_KEY,
|
||||
}),
|
||||
).toThrow(
|
||||
'MFA_ENCRYPTION_KEY, WEBHOOK_ENCRYPTION_KEY and BACKUP_ENCRYPTION_KEY must be pairwise different',
|
||||
'MFA_ENCRYPTION_KEY, WEBHOOK_ENCRYPTION_KEY, BACKUP_ENCRYPTION_KEY and VOICE_ENCRYPTION_KEY must be pairwise different',
|
||||
);
|
||||
expect(() =>
|
||||
assertStartupEnvironment('api', {
|
||||
@@ -198,9 +200,11 @@ describe('assertStartupEnvironment', () => {
|
||||
'MFA_ENCRYPTION_KEY',
|
||||
'WEBHOOK_ENCRYPTION_KEY',
|
||||
'BACKUP_ENCRYPTION_KEY',
|
||||
'VOICE_ENCRYPTION_KEY',
|
||||
'MFA_ENCRYPTION_KEY_PREVIOUS',
|
||||
'WEBHOOK_ENCRYPTION_KEY_PREVIOUS',
|
||||
'BACKUP_ENCRYPTION_KEY_PREVIOUS',
|
||||
'VOICE_ENCRYPTION_KEY_PREVIOUS',
|
||||
])('production refuses a change-me placeholder in %s', (name) => {
|
||||
expect(() =>
|
||||
assertStartupEnvironment('api', { ...PROD_ENV, [name]: PLACEHOLDER }),
|
||||
@@ -360,4 +364,70 @@ describe('assertStartupEnvironment', () => {
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('voice', () => {
|
||||
it('production requires VOICE_ENCRYPTION_KEY distinct from the others', () => {
|
||||
expect(() =>
|
||||
assertStartupEnvironment('api', {
|
||||
...PROD_ENV,
|
||||
VOICE_ENCRYPTION_KEY: undefined,
|
||||
}),
|
||||
).toThrow(
|
||||
'Missing required environment variable(s): VOICE_ENCRYPTION_KEY',
|
||||
);
|
||||
for (const other of [
|
||||
'MFA_ENCRYPTION_KEY',
|
||||
'WEBHOOK_ENCRYPTION_KEY',
|
||||
'BACKUP_ENCRYPTION_KEY',
|
||||
] as const) {
|
||||
expect(() =>
|
||||
assertStartupEnvironment('job', {
|
||||
...PROD_ENV,
|
||||
VOICE_ENCRYPTION_KEY: KEYS[other],
|
||||
}),
|
||||
).toThrow('must be pairwise different');
|
||||
}
|
||||
expect(() =>
|
||||
assertStartupEnvironment('worker', {
|
||||
...PROD_ENV,
|
||||
VOICE_ENCRYPTION_KEY: PROD_ENV.JWT_REFRESH_SECRET,
|
||||
}),
|
||||
).toThrow(
|
||||
'VOICE_ENCRYPTION_KEY must differ from JWT_ACCESS_SECRET and JWT_REFRESH_SECRET',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a change-me VOICE_ENCRYPTION_KEY in production', () => {
|
||||
expect(() =>
|
||||
assertStartupEnvironment('api', {
|
||||
...PROD_ENV,
|
||||
VOICE_ENCRYPTION_KEY: 'change-me-to-a-fifth-random-value-32-chars',
|
||||
}),
|
||||
).toThrow(
|
||||
'Secret(s) still set to an example placeholder: VOICE_ENCRYPTION_KEY',
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses an unknown VOICE_MODEL in every role', () => {
|
||||
for (const role of ['api', 'worker', 'job', 'all'] as const) {
|
||||
expect(() =>
|
||||
assertStartupEnvironment(role, {
|
||||
...PROD_ENV,
|
||||
VOICE_MODEL: 'claude-unknown',
|
||||
}),
|
||||
).toThrow('VOICE_MODEL must be one of');
|
||||
}
|
||||
});
|
||||
|
||||
it('a bad ANTHROPIC_API_KEY never stops a pod', () => {
|
||||
for (const role of ['api', 'worker', 'job', 'all'] as const) {
|
||||
expect(() =>
|
||||
assertStartupEnvironment(role, {
|
||||
...PROD_ENV,
|
||||
ANTHROPIC_API_KEY: 'change-me-anthropic',
|
||||
}),
|
||||
).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
readRingSecrets,
|
||||
} from '../common/crypto/key-ring';
|
||||
import { assertTelegramToken } from '../telegram/telegram-config';
|
||||
import { assertAnthropicKey } from '../voice/anthropic-config';
|
||||
import { voiceConfig } from '../voice/voice-config';
|
||||
import type { ApicoreRole } from './role';
|
||||
|
||||
const REQUIRED_ENV_VARS = ['DATABASE_URL', 'JWT_ACCESS_SECRET'] as const;
|
||||
@@ -132,8 +134,9 @@ function assertEncryptionKeys(env: NodeJS.ProcessEnv): void {
|
||||
/**
|
||||
* Startup checks shared by every entry point (API, worker, CLI job). All
|
||||
* roles load the same modules and secrets today, so the checks hardly differ
|
||||
* per role; the Telegram bot token is the first role-specific one (only the
|
||||
* worker holds it).
|
||||
* per role; the Telegram bot token and the Anthropic key are role-specific
|
||||
* (only the worker holds them, both optional). The voice settings are
|
||||
* validated in every role: a mistyped model id stops the pod, not a night job.
|
||||
*/
|
||||
export function assertStartupEnvironment(
|
||||
role: ApicoreRole,
|
||||
@@ -144,4 +147,6 @@ export function assertStartupEnvironment(
|
||||
assertNoPlaceholderSecrets(env);
|
||||
assertEncryptionKeys(env);
|
||||
assertTelegramToken(role, env);
|
||||
voiceConfig(env);
|
||||
assertAnthropicKey(role, env);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Prisma } from '../generated/prisma';
|
||||
import { seedAdmin } from '../prisma/seed';
|
||||
|
||||
type SeedClient = Parameters<typeof seedAdmin>[0];
|
||||
|
||||
function fakeClient(
|
||||
userCount: number,
|
||||
create = jest.fn().mockResolvedValue({}),
|
||||
) {
|
||||
const client = {
|
||||
user: { count: jest.fn().mockResolvedValue(userCount), create },
|
||||
role: {
|
||||
findUniqueOrThrow: jest.fn().mockResolvedValue({ id: 'role-admin' }),
|
||||
},
|
||||
};
|
||||
return { client, prisma: client as unknown as SeedClient };
|
||||
}
|
||||
|
||||
describe('seedAdmin (prisma/seed.ts)', () => {
|
||||
it('creates no admin once any user exists, even when SEED_ADMIN_EMAIL is unknown', async () => {
|
||||
const { client, prisma } = fakeClient(1);
|
||||
const lines: string[] = [];
|
||||
|
||||
const result = await seedAdmin(
|
||||
prisma,
|
||||
{ SEED_ADMIN_EMAIL: '[email protected]' },
|
||||
(line) => lines.push(line),
|
||||
);
|
||||
|
||||
expect(result).toBe('skipped');
|
||||
expect(client.user.create).not.toHaveBeenCalled();
|
||||
expect(lines.join('\n')).not.toMatch(/Passwort/);
|
||||
});
|
||||
|
||||
it('creates the admin on an empty user table and prints a generated password once', async () => {
|
||||
const { client, prisma } = fakeClient(0);
|
||||
const lines: string[] = [];
|
||||
|
||||
const result = await seedAdmin(prisma, {}, (line) => lines.push(line));
|
||||
|
||||
expect(result).toBe('created');
|
||||
const [args] = client.user.create.mock.calls[0] as [
|
||||
{ data: { email: string; roleId: string } },
|
||||
];
|
||||
expect(args.data).toMatchObject({
|
||||
email: '[email protected]',
|
||||
roleId: 'role-admin',
|
||||
});
|
||||
const passwordLine = lines.find((line) => line.includes('Passwort:'));
|
||||
expect(passwordLine).toMatch(/Passwort: +\S{20,}/);
|
||||
});
|
||||
|
||||
it('never prints an operator-supplied password', async () => {
|
||||
const { prisma } = fakeClient(0);
|
||||
const lines: string[] = [];
|
||||
|
||||
await seedAdmin(
|
||||
prisma,
|
||||
{
|
||||
SEED_ADMIN_EMAIL: '[email protected]',
|
||||
SEED_ADMIN_PASSWORD: 'geheim-vom-betrieb',
|
||||
},
|
||||
(line) => lines.push(line),
|
||||
);
|
||||
|
||||
const output = lines.join('\n');
|
||||
expect(output).toContain('[email protected]');
|
||||
expect(output).not.toContain('geheim-vom-betrieb');
|
||||
expect(output).toContain('SEED_ADMIN_PASSWORD');
|
||||
});
|
||||
|
||||
it('treats a concurrent create by another replica as done', async () => {
|
||||
const conflict = new Prisma.PrismaClientKnownRequestError('unique', {
|
||||
code: 'P2002',
|
||||
clientVersion: 'test',
|
||||
});
|
||||
const { prisma } = fakeClient(0, jest.fn().mockRejectedValue(conflict));
|
||||
const lines: string[] = [];
|
||||
|
||||
const result = await seedAdmin(prisma, {}, (line) => lines.push(line));
|
||||
|
||||
expect(result).toBe('skipped');
|
||||
expect(lines.join('\n')).not.toMatch(/Passwort/);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { JOB_NAMES } from '../jobs/job-names';
|
||||
import { JobRunService } from './job-run.service';
|
||||
import { SystemQueueService } from './system-queue.service';
|
||||
|
||||
@@ -67,7 +68,7 @@ describe('SystemQueueService', () => {
|
||||
oldestPendingAgeSeconds: 42.5,
|
||||
});
|
||||
expect(overview.webhookDeliveries).toEqual({ pending: 4, failed: 1 });
|
||||
expect(overview.jobs).toHaveLength(5);
|
||||
expect(overview.jobs).toHaveLength(JOB_NAMES.length);
|
||||
const sql = sqlText(prisma.$queryRaw.mock.calls[0]);
|
||||
expect(sql).toContain('EXTRACT(EPOCH FROM now() - MIN("occurredAt")');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { anthropicApiKey, assertAnthropicKey } from './anthropic-config';
|
||||
|
||||
const KEY = 'sk-ant-api03-abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
|
||||
function fakeLogger() {
|
||||
return { warn: jest.fn(), error: jest.fn() } satisfies Pick<
|
||||
Logger,
|
||||
'warn' | 'error'
|
||||
>;
|
||||
}
|
||||
|
||||
describe('anthropicApiKey', () => {
|
||||
it('returns a well-formed key, trimmed', () => {
|
||||
expect(anthropicApiKey({ ANTHROPIC_API_KEY: ` ${KEY} ` })).toBe(KEY);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unset', {}],
|
||||
['empty', { ANTHROPIC_API_KEY: ' ' }],
|
||||
['a placeholder', { ANTHROPIC_API_KEY: 'change-me-anthropic-key' }],
|
||||
['the wrong format', { ANTHROPIC_API_KEY: 'sk-ant-short' }],
|
||||
['dummy', { ANTHROPIC_API_KEY: 'dummy' }],
|
||||
])('returns null for %s', (_label, env) => {
|
||||
expect(anthropicApiKey(env)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertAnthropicKey', () => {
|
||||
it('assertAnthropicKey never throws and disables on a placeholder', () => {
|
||||
for (const value of ['change-me-anthropic-key', 'dummy', '', 'sk-ant-x']) {
|
||||
const logger = fakeLogger();
|
||||
expect(() =>
|
||||
assertAnthropicKey(
|
||||
'worker',
|
||||
{ NODE_ENV: 'production', ANTHROPIC_API_KEY: value },
|
||||
logger,
|
||||
),
|
||||
).not.toThrow();
|
||||
}
|
||||
const logger = fakeLogger();
|
||||
assertAnthropicKey(
|
||||
'worker',
|
||||
{ NODE_ENV: 'production', ANTHROPIC_API_KEY: 'change-me-x' },
|
||||
logger,
|
||||
);
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
expect(anthropicApiKey({ ANTHROPIC_API_KEY: 'change-me-x' })).toBeNull();
|
||||
});
|
||||
|
||||
it('logs the variable name once, never the value', () => {
|
||||
const logger = fakeLogger();
|
||||
assertAnthropicKey(
|
||||
'worker',
|
||||
{ ANTHROPIC_API_KEY: 'sk-ant-short-secret' },
|
||||
logger,
|
||||
);
|
||||
const output = JSON.stringify([
|
||||
logger.warn.mock.calls,
|
||||
logger.error.mock.calls,
|
||||
]);
|
||||
expect(output).toContain('ANTHROPIC_API_KEY');
|
||||
expect(output).not.toContain('short-secret');
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('api and job roles do not check the key format and stay silent without a key', () => {
|
||||
for (const role of ['api', 'job'] as const) {
|
||||
const logger = fakeLogger();
|
||||
assertAnthropicKey(role, {}, logger);
|
||||
assertAnthropicKey(role, { ANTHROPIC_API_KEY: ' ' }, logger);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('warns once, by name, when an api or job role holds the key', () => {
|
||||
for (const role of ['api', 'job'] as const) {
|
||||
const logger = fakeLogger();
|
||||
assertAnthropicKey(role, { ANTHROPIC_API_KEY: KEY }, logger);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
const text = String((logger.warn.mock.calls[0] as unknown[])[0]);
|
||||
expect(text).toContain(`ANTHROPIC_API_KEY is set for role ${role}`);
|
||||
expect(text).toContain('apicore-anthropic');
|
||||
expect(text).not.toContain(KEY);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts a good key silently in the worker and in all-in-one mode', () => {
|
||||
for (const role of ['worker', 'all'] as const) {
|
||||
const logger = fakeLogger();
|
||||
assertAnthropicKey(role, { ANTHROPIC_API_KEY: KEY }, logger);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('warns that extraction stays off when the worker has no key', () => {
|
||||
const logger = fakeLogger();
|
||||
assertAnthropicKey('worker', {}, logger);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { ApicoreRole } from '../runtime/role';
|
||||
|
||||
/** Key format issued by Anthropic: "sk-ant-" followed by URL-safe characters. */
|
||||
export const ANTHROPIC_KEY_PATTERN = /^sk-ant-[A-Za-z0-9_-]{20,}$/;
|
||||
|
||||
const PLACEHOLDER_PATTERN = /^change-me/i;
|
||||
const WORKER_ROLES: readonly ApicoreRole[] = ['worker', 'all'];
|
||||
|
||||
function trimmed(value: string | undefined): string | null {
|
||||
const text = value?.trim();
|
||||
return text ? text : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The API key for voice extraction, or null if it is unset, a placeholder or
|
||||
* malformed - extraction then stays off (code EXTRACT_DISABLED). Only the
|
||||
* worker holds the key (Secret apicore-anthropic, optional).
|
||||
*/
|
||||
export function anthropicApiKey(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string | null {
|
||||
const key = trimmed(env.ANTHROPIC_API_KEY);
|
||||
return key !== null && ANTHROPIC_KEY_PATTERN.test(key) ? key : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Startup check, same rules as the Telegram token: optional, a bad value
|
||||
* never stops a pod. In the worker, a missing key is a warning and a
|
||||
* placeholder or malformed key an error, once, naming the variable and
|
||||
* never its value. API and job pods must not hold the key: if one does, a
|
||||
* warning says so (name only) and nothing else is checked.
|
||||
*/
|
||||
export function assertAnthropicKey(
|
||||
role: ApicoreRole,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
logger: Pick<Logger, 'warn' | 'error'> = new Logger('AnthropicStartup'),
|
||||
): void {
|
||||
const raw = trimmed(env.ANTHROPIC_API_KEY);
|
||||
if (!WORKER_ROLES.includes(role)) {
|
||||
if (raw !== null) {
|
||||
logger.warn(
|
||||
`ANTHROPIC_API_KEY is set for role ${role}; it belongs only in the worker (secret apicore-anthropic).`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (raw === null) {
|
||||
logger.warn(
|
||||
'ANTHROPIC_API_KEY is not set: voice extraction stays off (EXTRACT_DISABLED).',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (PLACEHOLDER_PATTERN.test(raw)) {
|
||||
logger.error(
|
||||
'ANTHROPIC_API_KEY is still set to an example placeholder: voice extraction stays off - set the real key or remove it.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!ANTHROPIC_KEY_PATTERN.test(raw)) {
|
||||
logger.error(
|
||||
'ANTHROPIC_API_KEY is malformed (expected "sk-ant-..."): voice extraction stays off.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional } from 'class-validator';
|
||||
import { VoiceCaptureStatus } from '../../../../generated/prisma';
|
||||
|
||||
export class ListVoiceCapturesQuery {
|
||||
@ApiPropertyOptional({ enum: VoiceCaptureStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(VoiceCaptureStatus)
|
||||
status?: VoiceCaptureStatus;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, Matches, MaxLength } from 'class-validator';
|
||||
import {
|
||||
DAY_PATTERN,
|
||||
ID_PATTERN,
|
||||
MAX_ID_LENGTH,
|
||||
} from './upload-voice-capture.dto';
|
||||
|
||||
/** Rough bound before normalisation; the exact 20-4000 rule runs after it. */
|
||||
export const MAX_RAW_TEXT_LENGTH = 8000;
|
||||
|
||||
export class SubmitVoiceTextDto {
|
||||
@ApiProperty({ description: 'Das Volk der Durchsicht (lebend)' })
|
||||
@IsString()
|
||||
@MaxLength(MAX_ID_LENGTH)
|
||||
@Matches(ID_PATTERN)
|
||||
colonyId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Datum der Durchsicht (YYYY-MM-DD, höchstens 7 Tage zurück)',
|
||||
})
|
||||
@IsString()
|
||||
@Matches(DAY_PATTERN)
|
||||
inspectionDate!: string;
|
||||
|
||||
@ApiProperty({ description: 'Diktierter Text, 20 bis 4000 Zeichen' })
|
||||
@IsString()
|
||||
@MaxLength(MAX_RAW_TEXT_LENGTH)
|
||||
text!: string;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsString, Matches, Max, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export const MAX_ID_LENGTH = 64;
|
||||
export const ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
export const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
export const MAX_DURATION_SEC = 240;
|
||||
|
||||
/** The text fields beside the `audio` part of the multipart upload. */
|
||||
export class UploadVoiceCaptureDto {
|
||||
@ApiProperty({ description: 'Das Volk der Durchsicht (lebend)' })
|
||||
@IsString()
|
||||
@MaxLength(MAX_ID_LENGTH)
|
||||
@Matches(ID_PATTERN)
|
||||
colonyId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Datum der Durchsicht (YYYY-MM-DD, höchstens 7 Tage zurück)',
|
||||
})
|
||||
@IsString()
|
||||
@Matches(DAY_PATTERN)
|
||||
inspectionDate!: string;
|
||||
|
||||
@ApiProperty({ description: 'Vom Gerät gemeldete Dauer in Sekunden' })
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(MAX_DURATION_SEC)
|
||||
durationSec!: number;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import { InspectionCreateGuard } from './inspection-create.guard';
|
||||
|
||||
function contextOf(user: { id: string } | undefined): ExecutionContext {
|
||||
return {
|
||||
switchToHttp: () => ({ getRequest: () => ({ user }) }),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
describe('InspectionCreateGuard', () => {
|
||||
it('asks for inspections:create for the signed-in person', async () => {
|
||||
const hasAny = jest.fn().mockResolvedValue(true);
|
||||
const guard = new InspectionCreateGuard({ hasAny } as never);
|
||||
await expect(guard.canActivate(contextOf({ id: 'u1' }))).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
expect(hasAny).toHaveBeenCalledWith('u1', ['inspections:create']);
|
||||
});
|
||||
|
||||
it('denies a person without the right and a request without a person', async () => {
|
||||
const hasAny = jest.fn().mockResolvedValue(false);
|
||||
const guard = new InspectionCreateGuard({ hasAny } as never);
|
||||
await expect(guard.canActivate(contextOf({ id: 'u1' }))).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
await expect(guard.canActivate(contextOf(undefined))).resolves.toBe(false);
|
||||
expect(hasAny).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { PermissionResolverService } from '../../auth/permissions/permission-resolver.service';
|
||||
import type { AuthenticatedUser } from '../../auth/types/authenticated-user';
|
||||
|
||||
interface RequestWithUser {
|
||||
user?: AuthenticatedUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Voice notes end in an inspection, so every voice endpoint also needs
|
||||
* inspections:create (spec 3) on top of voice-captures:use. @RequirePermission
|
||||
* is "any of", so the second right is its own guard. A request without a
|
||||
* signed-in person (an API key) never passes.
|
||||
*/
|
||||
@Injectable()
|
||||
export class InspectionCreateGuard implements CanActivate {
|
||||
constructor(private readonly permissionResolver: PermissionResolverService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const user = context.switchToHttp().getRequest<RequestWithUser>().user;
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
return this.permissionResolver.hasAny(user.id, ['inspections:create']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpStatus,
|
||||
PayloadTooLargeException,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { VoiceErrorCode } from '../voice-error-codes';
|
||||
|
||||
/**
|
||||
* Multer's own "file too large" reaches Nest as a bare 413. The voice upload
|
||||
* answers with the fixed code instead (TOO_LARGE), like the service does.
|
||||
*/
|
||||
@Catch(PayloadTooLargeException)
|
||||
export class UploadTooLargeFilter implements ExceptionFilter {
|
||||
catch(exception: PayloadTooLargeException, host: ArgumentsHost): void {
|
||||
const response = host.switchToHttp().getResponse<Response>();
|
||||
const body = exception.getResponse();
|
||||
const hasCode = typeof body === 'object' && body !== null && 'code' in body;
|
||||
response.status(HttpStatus.PAYLOAD_TOO_LARGE).json(
|
||||
hasCode
|
||||
? body
|
||||
: {
|
||||
statusCode: HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
code: 'TOO_LARGE' satisfies VoiceErrorCode,
|
||||
message: 'Die Aufnahme ist zu groß (höchstens 2 MB).',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 1021 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user