Compare commits

..
6 Commits
Author SHA1 Message Date
Nick Szaunig 7097fcd97c Merge remote-tracking branch 'origin/main'
Backend Image / build-and-push (push) Failing after 3m11s
Frontend Image / build-and-push (push) Successful in 7m5s
Manifests / validate (push) Successful in 17s
2026-10-14 11:44:21 +02:00
Nick SzaunigandClaude Opus 5.5 2d5c740f7f Merge feat/erp-inventar-export: read-only inventory mirror for ERPNext
Brings the ERPNext inventory export (T8) into main after its review:
strict seit format, settle window on the DB clock, BatchMovement
(createdAt, id) index, one scope per new API key and the failed-attempt
lockout on the shared throttler storage. Conflicts resolved in the
permission catalog (System and ERP-Anbindung groups) and ARCHITECTURE.md
(lockout is Annahme 30).

Gates on the merged state: backend lint, typecheck, 1909 unit, 471 e2e,
build, no schema drift; frontend 929 tests, lint, typecheck, build.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 11:44:03 +02:00
Nick SzaunigandClaude Opus 5.5 cdd30cf3ee feat: platform backbone and field capture complete (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed:
- webhooks with delivery table, retries and stable delivery id
- calendar handlers; a finished task removes its Outlook event
- tasks, automations, inspections, varroa, treatments, feedings,
  harvests, colonies, wintering, stock and bottling record domain events
  in the same transaction; legacy post-commit calls removed
- row locks through one helper (lockRowForUpdate, lockRowsForShare,
  lockRowsForUpdate)
- job framework with JobRun, cli exit codes, cleanup, task templates,
  daily evaluation, hourly app-backup and the rekey job
- system API and page "Verwaltung > System" with retry
- @nestjs/schedule removed, guard tests against in-process schedulers

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed:
- inspection photo offline without metadata
- bulk capture for treatments, feedings and varroa (backend + frontend)
- offline stand mode with service worker, offline pages and packs
- offline session, cold start and logout on shared devices

Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md.
Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema
drift; frontend lint, typecheck, 913 tests, build. Not rolled out.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 11:36:38 +02:00
Nick SzaunigandClaude Opus 5.5 25d95b0473 fix(erp-export): strict seit format, DB clock for settle window, movement index, one scope per key
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 23:24:05 +02:00
Nick SzaunigandClaude Opus 5.5 53fab102f2 Merge main checkpoint c01a388 into feat/erp-inventar-export
Brings the ERP inventory export up to date with the platform backbone and
field capture work. Conflicts in ARCHITECTURE.md, the API key service and
the API key settings tab resolved by keeping both sides:
- read and write keys stay separate; erp-inventory:read is read-only and
  cannot be combined with a write scope
- the failed-attempt lockout counts in the shared ThrottlerHit table so it
  holds across API pods
- the UI offers only scopes the user holds and creates one scope per key

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 22:49:53 +02:00
Nick SzaunigandClaude Opus 5.5 c01a38835a feat: platform backbone and field capture, first part (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks:
- run roles api/worker/job/all with shared startup checks (Task 1)
- schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2)
- audit, notifications and domain events written in the same transaction (Task 3)
- dispatcher with SKIP LOCKED claims, retries and stale release (Task 4)
- worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5)
- worker process and entry points (Task 6)
- throttler counters in Postgres (Task 19)
- separate keys per purpose with key ring (Task 20)
- public origin page without exact location (Task 22)
- shop keys split into read and write (Task 23)
- Kustomize base, components, overlay encry, CI validation (Task 26)

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks:
- brood frames, read endpoints, apiary overview and offline pack
- network basics, offline queue v3, navigation sheets, form building blocks
- new inspection with last-time hints and weather, varroa per day,
  treatment prefill, apiary page with round
- inspection photo (Task 10) is implemented, review pending

Not rolled out. Remaining tasks follow in later commits.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 17:57:54 +02:00
564 changed files with 65353 additions and 4726 deletions

No files matched your search

-1
View File
@@ -54,7 +54,6 @@ jobs:
with:
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,format=short
type=semver,pattern={{version}}
-1
View File
@@ -54,7 +54,6 @@ jobs:
with:
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,format=short
type=semver,pattern={{version}}
+74
View File
@@ -0,0 +1,74 @@
name: Manifests
# Prueft deploy/k8s ohne Cluster-Zugriff: Overlay und jeden Rollout-Schritt
# rendern, gegen die Kubernetes-Schemas validieren und die Regeln testen
# (NetworkPolicies, Pod-Haertung, Ressourcen, CronJobs, Digests, Live-Selektoren).
#
# Bewusst kein pull_request-Trigger: der Runner laeuft im Cluster, und ein
# Fork-PR braechte eigenen Code (auch eine geaenderte Fassung dieser Datei,
# also ohne jeden if-Waechter) darauf zur Ausfuehrung. push setzt
# Schreibrechte am Repo voraus; jeder Branch-Push wird geprueft, damit auch
# Aenderungen vor dem Merge nach main durch die CI gehen.
on:
push:
paths: ['deploy/k8s/**', '.gitea/workflows/manifests.yml']
workflow_dispatch:
jobs:
validate:
runs-on: docker-builder
env:
# Werkzeuge mit festen Versionen; jede Datei wird gegen ihre SHA-256 von
# der offiziellen Release-Seite geprueft, bevor sie laeuft:
# kubectl: https://dl.k8s.io/release/<v>/bin/linux/amd64/kubectl.sha256
# kubeconform: CHECKSUMS im GitHub-Release yannh/kubeconform
# yq: checksums (Spalte SHA-256) im GitHub-Release mikefarah/yq
# Ein Update aendert Version und Pruefsumme zusammen.
KUBECTL_VERSION: v1.36.5
KUBECTL_SHA256: 33bc88a24c3b09cf55bfd59e6ca977881b61fc3f25ab65bc80c65b430248648e
KUBECONFORM_VERSION: v0.8.0
KUBECONFORM_SHA256: 9bc2bffbf71f261128533edaf912153948b7ff238f9a531ae6d34466ec287883
YQ_VERSION: v4.53.6
YQ_SHA256: c5f056448f973ae7d39b5401949648a78f2dc1947d6a8eb65be60d5c504b9385
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Node einrichten
uses: actions/setup-node@v4
with:
node-version: 22
- name: kubectl, kubeconform und yq laden
run: |
mkdir -p "$RUNNER_TEMP/bin"
cd "$RUNNER_TEMP"
curl -fsSLo kubectl "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl"
echo "${KUBECTL_SHA256} kubectl" | sha256sum -c -
curl -fsSLo kubeconform.tar.gz "https://github.com/yannh/kubeconform/releases/download/${KUBECONFORM_VERSION}/kubeconform-linux-amd64.tar.gz"
echo "${KUBECONFORM_SHA256} kubeconform.tar.gz" | sha256sum -c -
tar -xzf kubeconform.tar.gz kubeconform
curl -fsSLo yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64"
echo "${YQ_SHA256} yq" | sha256sum -c -
install -m 0755 kubectl kubeconform yq "$RUNNER_TEMP/bin/"
- name: Overlay rendern
run: |
"$RUNNER_TEMP/bin/kubectl" kustomize deploy/k8s/overlays/encry > "$RUNNER_TEMP/rendered.yaml"
"$RUNNER_TEMP/bin/yq" ea -o=json '[.]' "$RUNNER_TEMP/rendered.yaml" > "$RUNNER_TEMP/rendered.json"
- name: Rollout-Schritte rendern
# Runbook Schritte 1, 2, 3 und 5: Overlay mit den bis dahin
# freigegebenen Komponenten (siehe render-stages.mjs).
run: |
export KUBECTL="$RUNNER_TEMP/bin/kubectl" YQ="$RUNNER_TEMP/bin/yq"
node deploy/k8s/tests/render-stages.mjs "$RUNNER_TEMP/stages"
- name: Gegen Kubernetes-Schemas pruefen
run: |
"$RUNNER_TEMP/bin/kubeconform" -strict -summary "$RUNNER_TEMP/rendered.yaml" "$RUNNER_TEMP"/stages/*.yaml
- name: Regeln testen
run: |
export RENDERED_MANIFESTS="$RUNNER_TEMP/rendered.json" RENDERED_STAGES="$RUNNER_TEMP/stages/stages.json"
node --test deploy/k8s/tests/manifest-policy.test.mjs
+193 -19
View File
@@ -51,10 +51,20 @@ app/
│ ├── honey-batches/[id]/
│ ├── storage/
│ ├── search/
│ └── settings/users/ nur Administrator
│ ├── offline/ Offline-Seiten (Start, apiary?id=, colony?id=)
│ ├── nav-config.ts einzige Quelle aller Navigationspunkte
│ └── settings/
│ ├── users-roles/ nur Administrator
│ └── system/ „Verwaltung > System“ (Jobs, Ereignisse, Laufrollen)
```
Zentrale Schnellaktion "+ Neu" (Abschnitt 22): permanent sichtbarer Floating-Button in der mobilen Bottom-Nav, öffnet Auswahl-Sheet (Durchsicht, Fütterung, Behandlung, Varroamessung, Aufgabe, Ernte, Volk, Königin) → direkt zum jeweiligen Schnellerfassungs-Formular, mobil möglichst 1-2 Taps bis zum ersten Eingabefeld.
Zentrale Schnellaktion "+ Neu" (Abschnitt 22): permanent sichtbarer Floating-Button in der mobilen Bottom-Nav, öffnet ein Auswahl-Sheet mit Icons und 56-px-Zeilen (auch am Desktop; `quick-add-sheet.tsx`, Einträge aus `quick-actions.ts`) (Durchsicht, Fütterung, Behandlung, Varroamessung, Aufgabe, Ernte, Volk, Königin) → direkt zum jeweiligen Schnellerfassungs-Formular, mobil möglichst 1-2 Taps bis zum ersten Eingabefeld.
**Navigation aus einer Quelle:** `app/(app)/nav-config.ts` ist die einzige Quelle für Seitenleiste, Bottom-Nav, „Mehr“-Sheet und „+ Neu“; der Berechtigungsfilter gilt überall gleich. Neue Seiten (auch „Verwaltung > System“) werden nur dort eingetragen. Auf allen `new`-Seiten führt ein Zurück-Link zur Liste, bei einem Entwurf mit Rückfrage. „Mehr“ und „+ Neu“ teilen sich die Sheet-Hülle `components/nav/nav-sheet.tsx`.
**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"`).
**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.
Datenzugriff ausschließlich über TanStack Query gegen die Backend-API (kein direkter DB-Zugriff, kein Next.js-eigenes Datenbank-Layer) – erzwingt API-first auch praktisch.
@@ -94,45 +104,117 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
2. **Treatment auf Einzelvolk statt "Völkergruppe"**: Abschnitt 4.11 erlaubt "Volk oder Völkergruppe". MVP modelliert `Treatment` pro Volk (`colonyId` Pflichtfeld); eine Gruppenbehandlung wird im MVP als mehrere Treatment-Datensätze mit gleicher `batchNumber`/Notiz erfasst statt eigener Gruppen-Entität. Spätere Migration kann eine `TreatmentGroup`-Entität nachziehen, falls das in der Praxis zu unhandlich ist.
3. **Lagerverwaltung MVP-Umfang**: "grundlegende Lagerverwaltung" (Abschnitt 26 Punkt 12) wird als generisches `StockItem` + `StockMovement` (Kategorie Honig/Verbrauchsmaterial/Imkereimaterial) umgesetzt. Das detaillierte Inventar mit Wartungsdaten (Abschnitt 4.22) bleibt v1.x/"SOLL", wie im Lastenheft vorgesehen.
4. **API-Client-Rolle im MVP**: kein vollwertiger OAuth2/Client-Credentials-Server. Ein API-Client ist ein `User` mit Rolle `API_CLIENT` und einem langlebigen Service-Refresh-Token; vollwertiges Scopes-/Client-Management ist SOLL/KANN.
5. **Backup-fähige Struktur**: kein eigenes Backup-Tooling im Code. Umgesetzt als: reproduzierbare Prisma-Migrationen (kein manuelles Schema-Drift), dokumentierte `pg_dump`/PITR-Strategie in diesem Dokument (Betrieb obliegt dem bestehenden Cluster/DBA), `/health`-Endpoint für Betriebsüberwachung.
5. **Backup-fähige Struktur**: reproduzierbare Prisma-Migrationen (kein manuelles Schema-Drift), anwendungsseitiges Backup (`BackupModule`, Abschnitt 8) und der CronJob `apicore-db-backup` mit `pg_dump` (Abschnitt 7), `/health`-Endpoint für Betriebsüberwachung.
6. **Deviations vom Leitentscheidungs-Rahmen**: bisher keine. Wird ergänzt, falls während der Umsetzung ein konkreter Grund für eine Abweichung von NestJS/Prisma/Next.js/Tailwind/shadcn/TanStack Query auftritt.
7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder.
8. **Entra ID SSO ohne Auto-Provisioning**: `POST/GET /api/v1/auth/sso/*` implementiert Authorization Code + PKCE gegen Entra ID über `openid-client`. Der E-Mail-Claim aus dem ID-Token wird ausschließlich gegen einen bereits existierenden, aktiven `User`-Datensatz gematcht – es wird kein neuer Benutzer automatisch angelegt (Just-in-Time-Provisioning), passend zum bestehenden Modell, dass Admins Benutzer manuell anlegen. Eine erfolgreiche SSO-Anmeldung überspringt die App-eigene TOTP-MFA-Abfrage, da der Identity Provider bereits eine eigene (typischerweise stärkere) Authentifizierung durchgesetzt hat. Der PKCE-`code_verifier` wird kurzlebig (5 min) in einem httpOnly-`SameSite=Lax`-Cookie zwischengespeichert statt serverseitig, damit der Flow ohne Sticky-Sessions über mehrere Backend-Replicas hinweg funktioniert; der Rückweg vom Callback zum Frontend nutzt ein kurzlebiges (60 s), zweckgebundenes JWT statt des echten Access-Tokens in der Redirect-URL. `OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`/`OIDC_REDIRECT_URI` kommen ausschließlich aus Environment-Variablen; fehlen sie, meldet `GET /api/v1/auth/sso/status` `enabled: false` und die Login-Seite blendet den "Mit Microsoft anmelden"-Button aus, statt beim Start zu scheitern.
9. **M365-Kalender-Synchronisation als einseitiger, asynchroner Push**: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als `CalendarSyncModule` (`ApiCore-Backend/src/calendar-sync/`): App-only Graph-API-Zugriff per Client-Credentials-Flow (`GraphClientService`) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Nutzt bewusst dieselbe Entra-App-Registrierung wie das SSO-Login (`OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`, ausschließlich Env-Variablen) statt einer zweiten, separaten App-Registrierung – ein Enterprise-App-Objekt im Tenant genügt, sofern es zusätzlich zu den delegierten Login-Scopes auch die Application-Permission „Calendars.ReadWrite“ (Admin-Consent) erhält; die Tenant-ID wird dafür aus dem Pfad von `OIDC_ISSUER_URL` abgeleitet, statt eine weitere Konfigurationsvariable zu benötigen. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über `groupId`); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad (wie beim Webhooks-Modul) nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster mit minütlichem `@Cron`-Worker: Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag `FAILED` bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. Die Outbox ist sicher bei mehreren Replicas: Jeder Eintrag wird vor der Arbeit per bedingtem Update (`claimedAt`) reserviert, ein Claim älter als 5 min gilt als verwaist und wird übernommen, Graph-Requests haben ein 30-s-Timeout. `revision` sorgt dafür, dass eine Änderung während des Syncs nicht verloren geht (Eintrag bleibt PENDING, dasselbe Event wird beim nächsten Lauf aktualisiert); ein Abbruch während des Syncs löscht das gerade geschriebene Event. Ein neues Graph-Event entsteht beim Aktualisieren nur, wenn das alte nicht mehr existiert (404/410); andere Fehler gelten als fehlgeschlagener Versuch. Jedes Anlegen schickt eine `transactionId` (aus Eintrag, Revision und ersetztem Event), damit Graph eine Wiederholung nach einem Timeout nicht doppelt anlegt; „Jetzt synchronisieren“ startet den Lauf im Hintergrund (202, `running` im Status) und setzt FAILED-Einträge auf PENDING zurück. `groupId` muss eine Entra-Objekt-ID (GUID) sein; `""` oder `null` löscht sie; sie wird in Graph-URLs kodiert. Fehler aus Prisma werden nur mit Name, Code und Modell geloggt (`src/common/loggable-error.ts`), nie mit Abfragewerten. **Offene Entscheidung (2026-10-12):** Laut Microsoft-Doku (`group-post-events`) unterstützt `POST /groups/{id}/events` keine Application-Permission, nur delegiertes `Group.ReadWrite.All`. Mit dem App-only-Token oben kann das Anlegen im Gruppenkalender deshalb in Produktion scheitern (nicht gegen einen echten Tenant geprüft). Auswege: Zielkalender auf ein Ressourcen- oder Benutzerpostfach umstellen (`/users/{upn}/calendar/events`, App-only erlaubt, per Application Access Policy begrenzt) oder delegierter Zugriff über ein Dienstkonto. Pro Quelltyp (Aufgabe/Durchsicht/Behandlung/Fütterung/Ernte/Königinnen-Zuordnung) ist die Synchronisation einzeln über die GUI (Verwaltung > Automatisierung > Kalender-Sync) abschaltbar; Kalendereinträge werden als ganztägige Graph-Events (`isAllDay: true`) angelegt, um Zeitzonen-/Uhrzeit-Mehrdeutigkeiten zu vermeiden, da die zugrundeliegenden Fachdaten selbst keine Uhrzeit führen.
9. **M365-Kalender-Synchronisation als einseitiger, asynchroner Push**: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als `CalendarSyncModule` (`ApiCore-Backend/src/calendar-sync/`): App-only Graph-API-Zugriff per Client-Credentials-Flow (`GraphClientService`) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Nutzt bewusst dieselbe Entra-App-Registrierung wie das SSO-Login (`OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`, ausschließlich Env-Variablen) statt einer zweiten, separaten App-Registrierung – ein Enterprise-App-Objekt im Tenant genügt, sofern es zusätzlich zu den delegierten Login-Scopes auch die Application-Permission „Calendars.ReadWrite“ (Admin-Consent) erhält; die Tenant-ID wird dafür aus dem Pfad von `OIDC_ISSUER_URL` abgeleitet, statt eine weitere Konfigurationsvariable zu benötigen. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über `groupId`); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster, das die Worker-Schleife `calendar-outbox` abarbeitet (alle 60 s und auf `pg_notify('calendar_sync', '')`, siehe Abschnitt 10): Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag `FAILED` bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. Die Outbox ist sicher bei mehreren Replicas: Jeder Eintrag wird vor der Arbeit per bedingtem Update (`claimedAt`) reserviert, ein Claim älter als 5 min gilt als verwaist und wird übernommen, Graph-Requests haben ein 30-s-Timeout. `revision` sorgt dafür, dass eine Änderung während des Syncs nicht verloren geht (Eintrag bleibt PENDING, dasselbe Event wird beim nächsten Lauf aktualisiert); ein Abbruch während des Syncs löscht das gerade geschriebene Event. Abmelden eines Eintrags aus dem Kalender geschieht durch erneutes Einreihen: Wird eine Quelle (z. B. eine Aufgabe, die erledigt oder storniert wird oder ihr Fälligkeitsdatum verliert, oder ein gelöschter Datensatz) erneut in die Kalender-Outbox eingereiht, findet der Worker in `buildEventInput` nichts mehr anzuzeigen. Hat der Eintrag eine `graphEventId`, löscht der Worker das Graph-Event (404/410 gelten als bereits gelöscht, jeder andere Fehler zählt als fehlgeschlagener Versuch) und setzt den Eintrag auf `SKIPPED` mit `graphEventId = null`. Die API ruft Graph dafür nie auf; die Einreihung selbst erfolgt in der Handler-Transaktion der Domain-Events (`calendar-sync.*`-Handler über `CalendarSyncService.enqueueIn(tx, …)`). Nur die Königin-Zuordnung reiht noch nach dem Commit über `CalendarSyncService.enqueue` ein (Annahme 11). Ein neues Graph-Event entsteht beim Aktualisieren nur, wenn das alte nicht mehr existiert (404/410); andere Fehler gelten als fehlgeschlagener Versuch. Jedes Anlegen schickt eine `transactionId` (aus Eintrag, Revision und ersetztem Event), damit Graph eine Wiederholung nach einem Timeout nicht doppelt anlegt; „Jetzt synchronisieren“ startet den Lauf im Hintergrund (202, `running` im Status) und setzt FAILED-Einträge auf PENDING zurück. `groupId` muss eine Entra-Objekt-ID (GUID) sein; `""` oder `null` löscht sie; sie wird in Graph-URLs kodiert. Fehler aus Prisma werden nur mit Name, Code und Modell geloggt (`src/common/loggable-error.ts`), nie mit Abfragewerten. **Offene Entscheidung (2026-10-12):** Laut Microsoft-Doku (`group-post-events`) unterstützt `POST /groups/{id}/events` keine Application-Permission, nur delegiertes `Group.ReadWrite.All`. Mit dem App-only-Token oben kann das Anlegen im Gruppenkalender deshalb in Produktion scheitern (nicht gegen einen echten Tenant geprüft). Auswege: Zielkalender auf ein Ressourcen- oder Benutzerpostfach umstellen (`/users/{upn}/calendar/events`, App-only erlaubt, per Application Access Policy begrenzt) oder delegierter Zugriff über ein Dienstkonto. Pro Quelltyp (Aufgabe/Durchsicht/Behandlung/Fütterung/Ernte/Königinnen-Zuordnung) ist die Synchronisation einzeln über die GUI (Verwaltung > Automatisierung > Kalender-Sync) abschaltbar; Kalendereinträge werden als ganztägige Graph-Events (`isAllDay: true`) angelegt, um Zeitzonen-/Uhrzeit-Mehrdeutigkeiten zu vermeiden, da die zugrundeliegenden Fachdaten selbst keine Uhrzeit führen.
10. **Wartezeit manuell statt aus Wirkstoff-Datenbank abgeleitet**: `Treatment.withdrawalPeriodDays` wird pro Behandlung von Hand eingetragen (laut Packungsbeilage des verwendeten Mittels), statt automatisch aus Produktname/Wirkstoff hergeleitet zu werden. Eine hartkodierte Zuordnung Produkt→Wartezeit wäre eine faktische, rechtlich relevante Behauptung über Tierarzneimittel-Zulassungen, die im Code weder aktuell gehalten noch verlässlich verifiziert werden kann; die Verantwortung für den korrekten Wert bleibt beim Imker. Die App warnt in der Ernte-Erfassung nur, wenn ein Wert hinterlegt wurde und dessen Fenster (`endDate ?? date` + `withdrawalPeriodDays`) noch nicht abgelaufen ist – bezogen auf das Erntedatum (`GET /treatments/withdrawal-status?colonyIds=…&at=JJJJ-MM-TT`, ohne `at` gilt jetzt; Behandlungen, die erst nach diesem Datum beginnen, zählen nicht) – sie blockiert die Ernte nicht hart, da Ernte-Datensätze auch nachträglich für bereits erfolgte, reale Ernten erfasst werden.
11. **Idempotenz per Opt-in**: Anlege-Endpunkte, die Formulare und die Offline-Warteschlange wiederholen können (Durchsicht, Behandlung, Fütterung, Varroa, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung), tragen `@Idempotent()`. Der Client sendet je Vorgang einen `Idempotency-Key`; eine Wiederholung liefert die gespeicherte erste Antwort (`IdempotencyRecord`, 90 Tage). Bewusst kein globaler Interceptor, da gespeicherte Antworten sonst Geheimnisse (MFA-Setup, Tokens) enthalten könnten. Die Tabelle ist flüchtig und nicht Teil des Backups. Idempotenz schützt nur, wenn nach dem Commit nichts mehr fehlschlägt: Darum laufen alle Seiteneffekte nach dem Commit (`AutomationService.evaluate*`, `CalendarSyncService.enqueue`/`cancel`, `WebhooksService.dispatch`) best-effort über `runAfterCommit` (`src/common/after-commit.ts`) – sie loggen auf Error-Level und werfen nie. Was das Schreiben verhindern muss (z. B. `generateNumber`, Validierung), wirft weiter. Doppelte Automatik-Aufgaben verhindert eine Zeilensperre auf der Regel (`SELECT … FOR UPDATE`). Dauerhaft zugestellte Seiteneffekte bringt erst das Ereignis-Rückgrat (Spec `docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md`).
12. **Automatische Aufgaben sind Vorschläge**: `Task.sourceAutomationTrigger` kennzeichnet von Regeln erzeugte Aufgaben. Je Regel und Objekt existiert höchstens eine offene Aufgabe. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optional `config.dueInDays` je Regel. Die Varroa-Schwelle (Milben/Tag) wird nur mit Tagesraten des natürlichen Milbenfalls verglichen. Die Tagesrate wird bei jeder Korrektur einer Eingangsgröße (Methode, Anzahl, Dauer) neu berechnet; ein mitgesendeter, vom gespeicherten abweichender Wert gilt als manuell gesetzt und gewinnt.
14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`: `shop-sync:read`, `shop-sync:write`, `erp-inventory:read`); je angebundenem System ein eigener Schlüssel. Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt.
11. **Idempotenz per Opt-in**: Anlege-Endpunkte, die Formulare und die Offline-Warteschlange wiederholen können (Durchsicht, Behandlung, Fütterung, Varroa, Sammelerfassung von Behandlung, Fütterung und Varroa, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung, Herstellung, Rohstoffe, Überwinterung, Dokument-Upload), tragen `@Idempotent()`. Der Client sendet je Vorgang einen `Idempotency-Key`; eine Wiederholung liefert die gespeicherte erste Antwort (`IdempotencyRecord`, 90 Tage). Bewusst kein globaler Interceptor, da gespeicherte Antworten sonst Geheimnisse (MFA-Setup, Tokens) enthalten könnten. Die Tabelle ist flüchtig und nicht Teil des Backups. Idempotenz schützt nur, wenn nach dem Commit nichts mehr fehlschlägt. Darum ist das Ereignis-Rückgrat (Abschnitt 10) der Weg für Seiteneffekte (Webhooks, Kalender, Automationsregeln, Benachrichtigungen): Der Schreibpfad nimmt das Domänenereignis in derselben Transaktion auf, Handler im Worker setzen die Wirkung um. Aufgaben, Durchsichten, Varroamessungen, Behandlungen, Fütterungen, Ernten, Völker sowie Bestand und Abfüllung haben keine Post-Commit-Seiteneffekte mehr (Abschnitt 10, Absätze „Aufgaben“, „Durchsichten und Varroa“, „Behandlungen, Fütterungen und Ernten“, „Völker“, „Bestand und Abfüllung“, „Sammelerfassung am Stand“). Wiederkehrende Arbeit läuft als Kubernetes-CronJob über `dist/cli.js`, nie als Timer im Prozess: `@nestjs/schedule` und `ScheduleModule` gibt es nicht mehr, der Wächter `src/runtime/no-in-process-schedulers.spec.ts` hält das fest. `runAfterCommit` (`src/common/after-commit.ts`, best-effort: loggt auf Error-Level, wirft nie) bleibt nur für die Automatik `SWARM_MOOD_HIGH` und `STOCK_ITEM_LOW` (`AutomationService.evaluateSwarmControlCheck` und `evaluateStockItem`), den Webhook `colony.created` und den Kalendereintrag der Königin-Zuordnung (`CalendarSyncService.enqueue`). Nachzügler für eine spätere Stufe, die weiter über `WebhooksService.dispatch` laufen: Königinnen (`queen.created`, `queen.replaced`), Honigchargen (`batch.created`) und Lager (`product.stock_changed`). Der Wächter `src/common/after-commit-usage.spec.ts` hält die Aufrufer fest. Die früheren `AutomationService.evaluate*`-Wrapper für Durchsicht, Varroa, Behandlung, Fütterung, Volksgesundheit und Rohstoff sind entfernt. Was das Schreiben verhindern muss (z. B. `generateNumber`, Validierung), wirft weiter. Doppelte Automatik-Aufgaben verhindert eine Zeilensperre auf der Regel (`SELECT … FOR UPDATE`). Den Entwurf des Rückgrats beschreibt die Spec `docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md`.
12. **Automatische Aufgaben sind Vorschläge**: `Task.sourceAutomationTrigger` kennzeichnet von Regeln erzeugte Aufgaben. Je Regel und Objekt existiert höchstens eine offene Aufgabe. Geprüft wird unter einer Zeilensperre auf der Regel (`SELECT … FOR UPDATE`), die bis zum Commit gilt. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optional `config.dueInDays` je Regel. Die Varroa-Schwelle (Milben/Tag) wird nur mit Tagesraten des natürlichen Milbenfalls verglichen. Ihr Standardwert 10 und der Rückfall bei unbrauchbarer Einstellung kommen allein aus `varroaThresholdOf` (`automation/varroa-threshold.ts`). Die Tagesrate wird bei jeder Korrektur einer Eingangsgröße (Methode, Anzahl, Dauer) neu berechnet; ein mitgesendeter, vom gespeicherten abweichender Wert gilt als manuell gesetzt und gewinnt. Automatik-Aufgaben laufen wie jede andere Aufgabe über `TasksService.createInTx(tx, …, null)`: Audit-Eintrag ohne Benutzer und `TaskCreated`. Aus dem Ereignis folgen Kalendereintrag (`calendar-sync.task`) und Webhook `task.created` (`webhooks.forward`); die Regel ruft `WebhooksService` nicht selbst auf. Aufgabe, Warnung (`Notification`, Priorität `WARNUNG`) und der Audit-Eintrag der Regel (`AutomationRule CREATE`) entstehen in einer Transaktion: Scheitert ein Teil, entsteht nichts davon. Ausgewertet wird in den Handlern `automation.*` (`src/automation/automation.handlers.ts`) in der Zustell-Transaktion, bei Fehler mit Wiederholung; sie rufen `evaluate…In(tx, …)`. Nur `SWARM_MOOD_HIGH` und `STOCK_ITEM_LOW` laufen dauerhaft über `runAfterCommit` in eigener Transaktion (Annahme 11); sie rufen dieselben `evaluate…In`, werfen aber nie.
14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop, ERPNext) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); die Suche läuft über den Hash, Geheimnisse werden nie verglichen; der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`, `shop-sync:read`, `shop-sync:write`, `erp-inventory:read`); je angebundenem System ein eigener Schlüssel. Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt. Lesen und Buchen sind getrennte API-Schlüssel (`shop-sync:read` bzw. `shop-sync:write`; `erp-inventory:read` zählt als Lesen, Buchungsrecht ist nur `shop-sync:write`, Liste `API_KEY_WRITE_SCOPES`): Ein neuer Schlüssel trägt genau ein Recht: einer mit mehreren Rechten wird beim Anlegen mit 400 abgelehnt (mit Lese- und Buchungsrecht eigens begründet); bereits ausgestellte Schlüssel mit mehreren Rechten bleiben gültig, die Oberfläche markiert sie und empfiehlt getrennte Schlüssel (neue anlegen, im angebundenen System – Wabenhain bzw. ERPNext – eintragen, alten widerrufen). Ein geleakter Lese-Schlüssel kann so nicht buchen, ein Buchungs-Schlüssel nicht lesen. Die Oberfläche bietet nur Rechte an, die der Anlegende selbst hat (alles andere lehnt der Server ohnehin ab). Ungültige Schlüssel werden je Client-Netz gezählt und nach 20 Fehlversuchen in 15 min gesperrt: Annahme 30.
13. **Lebende vs. beendete Völker**: Nur `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` sperren Umsetzen/Teilen/Vereinigen/Königinzuweisung (`colonies/colony-status.ts`). `WEAK`, `CONSPICUOUS`, `QUEENLESS` bleiben bearbeitbar.
15. **Bestandsbuch aus Behandlungen, nicht als eigenes Register**: Das Bestandsbuch nach Art. 108 VO (EU) 2019/6 wird aus `Treatment` erzeugt (`export/treatment-log.ts`); Lieferant, Erwerbsbeleg und verschreibender Tierarzt sind Freitextfelder der Behandlung, Name/Anschrift des Halters liegen in `SiteSettings`. Der Stand am Behandlungstag wird beim Anlegen als `Treatment.apiaryId` festgehalten (Umsetzen ändert ihn nicht nachträglich); für Altbestand wird er aus `ColonyApiaryHistory` abgeleitet und im PDF bei Unsicherheit markiert. Fehlende Pflichtangaben werden als „FEHLT“ gedruckt statt still weggelassen. Keine Wartezeit-Automatik (siehe 10).
16. **Überwinterung als eigene Einträge statt Volksstatus**: `WinterCheck` (eindeutig je Volk, `winterYear`, Saison) hält Ein- und Auswinterung; ab Juli gehört ein Datum zum Winter desselben Jahres. Verlustursachen sind bewusst nur die COLOSS-Kategorien, keine Diagnosen. Die Übersicht gruppiert nach dem aktuellen Stand des Volkes. Ein Verlust ändert den Volksstatus nur auf ausdrücklichen Wunsch (`markColonyDeceased`). Neue Rechte vergibt die Migration an Nicht-System-Rollen, weil der Katalog-Sync nur System-Rollen berechtigt.
17. **Schutz gegen Rechteausweitung in der Benutzerverwaltung**: Wer Benutzer pflegt, darf nur Konten verwalten (Passwort/MFA zurücksetzen, E-Mail ändern, (de)aktivieren) und Rollen vergeben, deren Rechte er selbst vollständig hat (`UsersService.assertWithinCallerPermissions`), analog zur Rollenbearbeitung.
18. **SSO-Bindung an die IdP-Identität**: Die erste Microsoft-Anmeldung bindet das Konto an `"<iss>|<oid>"` (`User.ssoSubject`); danach zählt nur noch diese Identität, nicht der unverifizierte E-Mail-Claim. Mandantenübergreifende Issuer (`common`/`organizations`/`consumers`) schalten SSO ab. E-Mails werden ohne Groß-/Kleinschreibung verglichen.
19. **Backups versiegelt**: AES-256-GCM über das gzip-Bundle, Schlüssel `BACKUP_ENCRYPTION_KEY` (sonst per HKDF aus `JWT_ACCESS_SECRET`). 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 und jede künftige Tabelle) kommt nur aus versiegelten Dateien; die Oberfläche listet übersprungene Tabellen auf; Import verlangt Passwort (+ TOTP). Webhook-Secrets liegen verschlüsselt in der DB (Schlüssel aus `JWT_ACCESS_SECRET`) – für eine Wiederherstellung in einem anderen Deployment mit anderem `JWT_ACCESS_SECRET` müssen Webhook-Secrets und MFA neu eingerichtet werden.
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 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`.
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.
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).
**Job `rekey`** (`src/jobs/rekey.job.ts`, nur von Hand: `kubectl create job --from=cronjob/apicore-job-rekey` bzw. `…-rekey-dry-run`): versiegelt jedes MFA-Secret (`User.mfaSecret`) und jedes Webhook-Secret (`WebhookSubscription.secret`), das nicht mit dem aktuellen Schlüssel seines Zwecks versiegelt ist, neu – Werte unter `*_PREVIOUS`, Altchiffrate und Webhook-Secrets im Klartext. Zeile für Zeile, ohne Transaktion um den Lauf: Jede Schreiboperation ist ein Compare-and-Swap auf den gelesenen Wert (`UPDATE … WHERE id = … AND "mfaSecret" = <alter Wert>`, bei Webhooks `secret`), damit überschreibt der Job nie, was die App inzwischen geschrieben hat (z. B. eine neue MFA-Einrichtung). Solche Zeilen zählt er als `changedMeanwhile`; `changedMeanwhile > 0` ist ein harter Stopp, kein Erfolg: die Zeile kann noch mit dem alten Schlüssel versiegelt sein, darum Dry-Run (und bei Bedarf Lauf) wiederholen, bis alle Zähler 0 sind, erst dann rotieren. Vor dem Schreiben prüft der Job, dass der neue Wert zum selben Klartext öffnet. Ein abgebrochener Lauf behält, was er umgestellt hat; der nächste überspringt diese Zeilen ungeöffnet (`alreadyCurrent`). Ergebnis: `dryRun` und je Zweck `mfa.*`/`webhook.*` mit `total`, `rekeyed`, `alreadyCurrent`, `plaintextSealed`, `changedMeanwhile`, `failed`. Eine Zeile, die sich nicht öffnen oder schreiben lässt, behält ihren Wert; der Lauf macht weiter und endet bei `failed > 0` immer `FAILED` (`JobRun`, Zähler im Fehlertext, das Log nennt nur Zeilen-ID und Grund, z. B. `unknown key id`). `rekey --dry-run` schreibt nichts und öffnet jeden umzustellenden Wert, anders als der echte Lauf auch die Zeilen, die schon den aktuellen Schlüssel tragen (ein beschädigter Wert zählt als `failed`). Andere Argumente lehnen `dist/cli.js` (Exit 2) und der Job selbst ab. Werte, Chiffrate und Schlüssel-IDs erscheinen nie in Log oder `JobRun`. `updatedAt` umgestellter Zeilen ändert sich. Den Klartext-Rückfall in `openWebhookSecret` kann man entfernen, sobald der Dry-Run in Produktion `webhook.plaintextSealed: 0` zeigt.
24. **Offline-Standmodus**: „Stand laden“ legt ein Paket je Stand in IndexedDB ab (`GET /apiaries/:id/offline-pack`: lebende Völker, Königinnen, die letzten 3 Durchsichten je Volk, offene Aufgaben). Es enthält weder Koordinaten noch Adresse des Standes; Königinnen, Durchsichten und Aufgaben fehlen ohne das jeweilige Leserecht (Benutzer-ID der Durchsichten wird weggelassen). Ein Paket gilt nach 24 h als veraltet (Alter immer sichtbar), wird nach 1 h automatisch erneuert und nach 14 Tagen oder nach 403/404 gelöscht. Lehnt der Server einen Eintrag ab (bei der Sammelerfassung mit `invalidColonyIds`), erzwingt das ein Neuladen der betroffenen Pakete (frühestens nach 5 min). Der Service Worker (`public/sw.js`, `sw-routes.js`) speichert Seiten nur zusammen mit ihren Assets und Daten nur als `application/json`; nie gespeichert werden Auth-Antworten, Anfrage-Header (kein Bearer-Token im Cache), die Antworten, die ein Paket liefert (eine Offline-Datenquelle mit sichtbarem Alter), `/api/v1/apiaries` und `/apiaries/:id` (Standort), `/export/` und `/backup/`. Navigation hat routenabhängige Zeitlimits: App-Hülle 4 s, Offline-Seiten zuerst aus dem Cache, Deep Links etwa 10 s, übrige Seiten Netzfehler bzw. erst ab 15 s. Alle API-Anfragen haben eine Gesamtfrist inklusive Antwortkörper (Standard 4 s; Schreibzugriffe ohne Idempotenz-Schlüssel, darunter Login, MFA und Refresh, 15 s; Sammelerfassung 15 s; Upload 30 s; Download 60 s).
25. **Offline-Sitzung**: Ohne Netz bleibt der Benutzer mit der zwischengespeicherten Identität (Name, Rechte) 7 Tage angemeldet, nie mit einem Token; der Sync bleibt gesperrt, bis ein Refresh gelingt. Ein Refresh gilt nur für dieselbe Benutzer-ID; das Token eines anderen Kontos löscht das Gerät (Pakete, Warteschlange, Caches). Nur 401/403 beenden die Sitzung, 429/502/503 und Netzfehler zählen als Netzfehler. Ein Abmelden ohne Netz merkt den Widerruf vor und holt ihn beim nächsten Start vor allem anderen nach. Jeder Warteschlangen-Eintrag hat einen Besitzer (`ownerUserId`); fremde Einträge werden nie gesendet. Beim Abmelden und Benutzerwechsel werden Pakete, Rundenzustand, Query-Cache, Foto-Übergabemarker und die Cache-Storage-Einträge des Service Workers gelöscht (`WIPE_CACHES` mit Generationszähler, damit eine laufende Anfrage danach nichts mehr schreibt). Beim Sync beendet 401 den Lauf, ohne Einträge zu markieren; 408/429/502/503/504 lassen Einträge wartend; 409 „wird bereits verarbeitet“ heißt „später erneut“.
26. **Fotos**: Aufnahmen werden im Browser per Canvas neu kodiert (längste Kante 2560 px, JPEG, ohne EXIF/XMP/GPS), auch im bestehenden Dokument-Upload. Vor dem Upload prüft der Client Metadaten und höchstens 10 MB; der Upload ist idempotent. Ein Foto wartet in IndexedDB auf die Server-ID seiner Durchsicht und wird danach angehängt. Ein Übergabemarker in `localStorage` (mit `createdAt`, höchstens 3 Tage alt, je Benutzer) verhindert, dass ein bereits gespeicherter Entwurf samt Fotos ein zweites Mal gesendet wird. Offen: die Prüfung mit `exiftool` auf einem echten Telefon (auch älteres iOS Safari).
27. **Sammelerfassung am Stand**: `POST /treatments|feedings|varroa/bulk` schreibt alles oder nichts in einer Transaktion (`@Idempotent()`). Die Völker werden sortiert mit `lockRowsForShare` (`FOR SHARE`, `src/prisma/row-lock.ts`) gesperrt, der Ablauf steckt im Helfer `runBulk`; das Ereignis je Volk trägt `bulkId`. Entscheidung K1: Die Chargennummer des Arzneimittels (`batchNumber`) wird einmal eingegeben und nie erzeugt (rechtlich relevantes Bestandsbuch); gruppiert wird über `bulkId`. Entscheidung K2: Varroa nimmt je Volk einen eigenen Eintrag, ein gemeinsamer Milbenwert wäre erfundene Messung. Unbekannte oder beendete Völker meldet die 400-Antwort als `invalidColonyIds`. Der Idempotenz-Schlüssel bindet Benutzer, Schlüssel und Pfad, nicht den Inhalt: Der Client nimmt darum je abweichender Absendung einen neuen Schlüssel.
28. **Skalen und Vorbelegung**: Alle Bewertungsskalen der Durchsicht zeigen „5 = gut“ (Gespeichertes bleibt unverändert). Die Schwarmstimmung ist gespeichert umgekehrt (1 = ruhig, 5 = stark) und wird nur in der Anzeige gedreht (`RatingScale direction="lower-is-better"`; die Beschriftungen nennen die gespeicherten Enden); keine Datenmigration. Waben stehen per Plus/Minus in `colonyStrength` und neu `broodFrames`, das Altfeld `broodAmount` nur im Bearbeiten-Dialog. Werte der letzten Durchsicht erscheinen als sichtbarer Hinweis je Feld mit „Letztes Mal … übernehmen“ und „Alles wie letztes Mal“, nie stillschweigend vorbelegt.
29. **Ampeln**: Varroa wird gelb ab der halben Schwelle der aktiven Regel (`VARROA_WARN_FRACTION = 0.5`, in Milben/Tag), rot ab der Schwelle; der Trend zeigt Milben/Tag mit Schwellenlinie, Zeiträume 1/2/3/7 Tage oder „anders“. Die Ampel eines Volkes (`lib/colonies/status-light.ts`) nimmt den schlechtesten Befund: rot bei `QUEENLESS`, kritischem Gesundheits-, Futter- oder Brutzustand oder offener kritischer Meldung; gelb bei `WEAK`/`CONSPICUOUS`, schwachem Zustand, offener Warnung, noch keiner Durchsicht oder mehr als `INSPECTION_STALE_DAYS = 14` Tagen ohne Durchsicht (Letzteres nur mit Leserecht auf Durchsichten). Die Farbe steht nie allein (Symbol und Wort); die Kontraste der Farbtöne prüft ein Test.
30. **Fehlversuchs-Sperre für API-Schlüssel, über alle Pods**: Ungültige Schlüssel (unbekannt, widerrufen, abgelaufen) zählen je Client-Netz – IPv4-Adresse bzw. IPv6-/64, wie der Throttler (`rateLimitSubject`). Nach 20 ungültigen Schlüsseln in einem Fenster von 15 min (ab dem ersten Fehlversuch) bekommt das Netz für weitere ungültige Schlüssel 15 min lang `429` (`src/api-keys/failed-attempt-limiter.ts`, Grenzen `API_KEY_FAILURE_LIMIT`). Ein gültiger Schlüssel wird davon nie abgewiesen: Erst kommt die Suche über den Hash, nur ein ungültiger Schlüssel wird gezählt oder gesperrt. Sonst könnte jeder, der eine gemeinsame Adresse (Cluster-Ausgang, Proxy) nutzt oder vortäuscht, ERPNext oder den Shop aussperren; bei 256 Zufallsbits je Schlüssel kostet das nichts. Falsch aufgebaute Tokens werden ohne Datenbankzugriff mit 401 abgewiesen und zählen nicht. Die Zähler liegen wie die des Throttlers in der UNLOGGED-Tabelle `ThrottlerHit` (über `PrismaThrottlerStorage`: eigener Throttler-Name `api-key-failures`, Schlüssel-Präfix `api-key-failures:`, eine Upsert-Anweisung, nur DB-Uhr `now()`) und gelten damit für alle api-Pods gemeinsam; kein zweiter Zähler im Prozessspeicher. Bei einem DB-Fehler schlägt die Anfrage fehl (fail-closed, wie der Throttler). Abgewiesene Schlüssel werden nur mit Präfix und IP protokolliert, das Erreichen der Grenze einmal je Fenster.
## 7. Kubernetes
`deploy/k8s/` enthält je Projekt: `deployment.yaml`, `service.yaml`, `ingress.yaml`, sowie `configmap.example.yaml` und `secret.example.yaml` (Platzhalter, keine echten Werte). Backend exponiert `GET /health` (liveness) und `GET /health/ready` (readiness, prüft DB-Verbindung). Beide Projekte je ein schlankes Multi-Stage-Dockerfile (Build-Stage mit vollem `node_modules`, Runtime-Stage mit `npm ci --omit=dev` bzw. Next.js `output: 'standalone'`).
`deploy/k8s/` ist Kustomize und die Quelle der Wahrheit für den Cluster (Spec `docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md` §6):
`deploy/k8s/postgres/` enthält das PostgreSQL-`StatefulSet` (ein Replica, `volumeClaimTemplates` für persistente Daten, `postgres:18-alpine`), den zugehörigen headless `Service` (`apicore-postgres`, DNS-Name fürs Backend) sowie `secret.example.yaml` für `POSTGRES_USER`/`POSTGRES_PASSWORD`/`POSTGRES_DB`. `deploy/k8s/backend/secret.example.yaml`s `DATABASE_URL` zeigt auf diesen Service.
- `base/`: clusterneutral, ohne Namespace, Hosts und Digests. `backend/` (Deployment `apicore-backend`, Service, PVC `apicore-data`), `frontend/` (Deployment, Service), `postgres/` (StatefulSet, headless Service `apicore-postgres`, CronJob `apicore-db-backup` mit `pg_dump`). Die Vorlagen `configmap.example.yaml` und `secret.example.yaml` stehen nicht unter `resources`; Secrets bleiben außerhalb des Repos.
- Selektoren und Service-Ports entsprechen dem Cluster (ein Test hält sie fest): Backend und Frontend `app: apicore-backend|apicore-frontend`, Postgres `app.kubernetes.io/name: apicore-postgres` (Pod zusätzlich `encry.de/tier: database`, Service-Port `postgres`). Bis 2026-10-13 wich das Repo bei Postgres ab (`app: apicore-postgres`, ohne Härtung, `volumeClaimTemplates`). Seit 2026-10-13 ist `base/postgres` der Live-Stand: StatefulSet mit Härtung (uid/gid 70, nur lesbares Root-Dateisystem, begrenzte `emptyDir`s für Socket, `/tmp` und `/dev/shm`, kein Service-Account-Token, `enableServiceLinks: false`), Probes, Ressourcen und Affinität wie live, Service und das statisch gebundene PVC `apicore-postgres-data` (PV `pv-apicore-postgres`). Das PV und sein SMB-Secret gehören zur Cluster-Infrastruktur und liegen nicht im Repo. Ein Test verhindert, dass das Repo die Härtung abschwächt; Selektor und Volumes sind unveränderlich, darum Postgres-Änderungen immer zuerst per `kubectl diff` prüfen.
- `components/`, je Rollout-Schritt einschaltbar. Das Auskommentieren im Overlay ist nur lokal für `kubectl diff`/`apply` gedacht und wird nicht committet.
- `network-policies`: Default-Deny und die Freigaben aus der Tabelle unten. Setzt selbst das Label `db-client: "true"` an der api, damit Schritt 2 sie nicht von Postgres abschneidet.
- `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…`).
- `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.
**Rollen-Deployments** (ein Image, Rolle per `APICORE_ROLE`):
| Objekt | Rolle | Start | Ports | Pods | CPU Request/Limit | RAM Request/Limit |
|---|---|---|---|---|---|---|
| Deployment `apicore-backend` | `api` | Image-CMD `node dist/main.js` | 3001 (Service, Ingress) | 1, mit HPA 1–2 | 50m/500m | 192Mi/512Mi |
| Deployment `apicore-worker` | `worker` | `node dist/worker.js` | 3002 nur `/health` für die Probes; kein Service, kein Ingress | 1 | 20m/200m | 128Mi/256Mi |
| CronJobs `apicore-job-<name>` | `job` | `node dist/cli.js <name>` | keine | je Lauf ein Pod | 50m/500m | 192Mi/512Mi |
| Deployment `apicore-frontend` | – | `node server.js` | 3000 | 1 | 30m/300m | 128Mi/256Mi |
initContainer haben die Ressourcen der CronJob-Pods. Alle App-Pods laufen ohne Service-Account-Token, mit nur lesbarem Root-Dateisystem und `emptyDir` auf `/tmp` (`HOME=/tmp`), als uid 1000, mit `seccompProfile: RuntimeDefault` und ohne Capabilities. api und worker rollen mit `maxSurge: 1`, `maxUnavailable: 0` aus.
**CronJobs:** `timeZone: Europe/Berlin`, `concurrencyPolicy: Forbid`, `startingDeadlineSeconds: 600`, je 3 erfolgreiche und fehlgeschlagene Jobs, `ttlSecondsAfterFinished: 86400`, `restartPolicy: Never`.
| CronJob | Zeitplan | backoffLimit | activeDeadlineSeconds |
|---|---|---|---|
| `apicore-job-cleanup` | `0 4 * * *` | 1 | 1800 |
| `apicore-job-daily-evaluation` | `30 4 * * *` | 1 | 1800 |
| `apicore-job-task-templates` | `0 5 * * *` | 1 | 1800 |
| `apicore-job-app-backup` | `0 * * * *`, prüft selbst, ob fällig; `apicore-data` auf `/data` | 0 | 3600 |
| `apicore-job-rekey`, `apicore-job-rekey-dry-run` | `suspend: true`, nur von Hand (`kubectl create job --from=cronjob/…`) | 0 | 1800 |
**NetworkPolicies:** Default-Deny für Ingress und Egress im Namespace. Kubelet-Probes erlaubt Kubernetes immer.
| Pod (Selektor) | Ingress | Egress |
|---|---|---|
| alle | – | DNS: UDP und TCP 53 zu `k8s-app: kube-dns` in `kube-system` |
| frontend (`app: apicore-frontend`) | Traefik (`app.kubernetes.io/name: rke2-traefik` in `kube-system`) auf 3000 | `app: apicore-backend` auf 3001 |
| api (`app: apicore-backend`) | Traefik und frontend auf 3001 | Postgres 5432; TCP 443 zu öffentlichen Adressen |
| worker (`app: apicore-worker`) | keiner | Postgres 5432; TCP 443 zu öffentlichen Adressen |
| Jobs (`app in (apicore-job, apicore-db-backup)`) | keiner | Postgres 5432 |
| postgres (`app.kubernetes.io/name: apicore-postgres`) | nur Pods mit `db-client: "true"` auf 5432 | keiner außer DNS |
| cert-manager-Solver (`acme.cert-manager.io/http01-solver: "true"`) | Traefik auf 8089 (HTTP-01-Erneuerung) | keiner außer DNS |
„Öffentlich“ heißt `0.0.0.0/0` ohne `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `100.64.0.0/10`, `169.254.0.0/16`, `127.0.0.0/8` und `::/0` ohne `fc00::/7`, `fe80::/10`, `::1/128`. Canal kennt keine FQDN-Regeln; den SSRF-Schutz für Webhook-Ziele behält die App (`webhook-url-guard.ts`). `db-client: "true"` tragen nur api, worker, die App-Jobs und `apicore-db-backup`.
**Übergang:** Im Namespace liegen außerdem drei Policies aus dem Repo `rke2-cluster` (Label `encry.de/managed-by: cluster-hardening`): `default-deny-ingress`, `allow-from-traefik` und `allow-same-namespace`. NetworkPolicies addieren sich; bis diese drei in Runbook Schritt 2 dort entfernt werden, gelten sie neben den neuen.
- `allow-same-namespace` öffnet die Ingress-Seite jedes Pods für alle Pods im Namespace. Postgres nimmt dann auf der Ingress-Seite Verbindungen von jedem Pod an, nicht nur von `db-client`. Weiter sperrt nur die Egress-Seite: Jeder Pod im Namespace unterliegt dem neuen Default-Deny-Egress, das Frontend kommt also trotzdem nicht an Postgres. Die Absicherung hat dann eine statt zwei Schichten.
- `allow-from-traefik` lässt Traefik jeden Pod auf jedem Port erreichen, auch Postgres 5432 und den Worker auf 3002. Die Regel „Traefik nur auf 3000 und 3001“ gilt erst danach; ausnutzbar ist das nur über eine Ingress-Route.
- `default-deny-ingress` ist neben dem neuen `default-deny` überflüssig, schwächt aber nichts.
**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.
## 8. Backup & Restore
**Schema-Migrationen (Reproduzierbarkeit):** Ausschließlich `prisma/migrations/` als Quelle der Wahrheit. Lokal/Entwicklung: `npx prisma migrate dev`. Gegen jede Produktions- oder Staging-Datenbank ausschließlich `npx prisma migrate deploy` (idempotent, wendet nur fehlende Migrationen an, kein Schema-Drift). `prisma db push` wird an keiner Stelle im Code, in Skripten oder im Dockerfile verwendet und ist für Produktions-/Staging-Umgebungen untersagt. Der Backend-Container führt `migrate deploy` und danach `npm run seed` selbst beim Start aus, bevor der Server Verbindungen annimmt (Dockerfile-`CMD`: `npm run migrate:deploy && npm run seed && node dist/main.js`) – kein separater Kubernetes-`Job`. Bei `replicas: 2` starten mehrere Pods potenziell gleichzeitig; das ist unproblematisch, da Prisma für `migrate deploy` einen datenbankseitigen Advisory-Lock nutzt und `prisma/seed.ts` per Unique-Constraint auf `User.email` idempotent ist (verlierender Pod bekommt einen `P2002`-Fehler, den `seed.ts` abfängt und als No-op behandelt statt den Container abstürzen zu lassen) – eine Instanz wendet die Migrationen/den Seed an, die anderen finden danach nichts mehr zu tun. `deploy/k8s/backend/deployment.yaml` hat dafür eine `startupProbe` mit großzügigem `failureThreshold`, damit liveness/readiness den Container nicht neu starten, während die (beim allerersten Deploy ggf. länger laufende) Migration noch durchläuft.
**Schema-Migrationen (Reproduzierbarkeit):** Ausschließlich `prisma/migrations/` als Quelle der Wahrheit. Lokal/Entwicklung: `npx prisma migrate dev`. Gegen jede Produktions- oder Staging-Datenbank ausschließlich `npx prisma migrate deploy` (idempotent, wendet nur fehlende Migrationen an, kein Schema-Drift). `prisma db push` wird an keiner Stelle im Code, in Skripten oder im Dockerfile verwendet und ist für Produktions-/Staging-Umgebungen untersagt.
Die Migration läuft nicht mehr im Container-`CMD` (das ist nur noch `node dist/main.js`). Sie läuft als initContainer `migrate` (`node node_modules/prisma/build/index.js migrate deploy`, `HOME=/tmp`, `CHECKPOINT_DISABLE=1`) in den Pods von `api` und `worker` (`deploy/k8s/components/backend-roles`).
Starten mehrere dieser initContainer gleichzeitig, serialisiert sie der datenbankseitige Advisory-Lock von Prisma. Ein Lauf, der den Lock nicht binnen 10 s bekommt, bricht ab. Kubelet startet den initContainer dann neu, und der nächste Lauf findet nichts mehr zu tun. Das ist harmlos.
Der Seed (`node dist/seed.js`) läuft als zweiter initContainer `seed` nur im `api`-Pod. Er ist per Unique-Constraint auf `User.email` idempotent: Ein verlierender Pod bekommt `P2002`, `seed.ts` behandelt das als No-op.
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.
**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:
- **Export/Import:** `BackupService.exportAll()`/`.importAll()` sichert alle fachlichen und Konfigurationsmodelle (Betriebsdaten inkl. Herstellung, Rohstoffe, Bewegungsbuch und Shop-Anfragen, Nummernkreise, Benutzer/Rollen/Rechte, Audit-Log, Website-/Backup-/Kalender-Einstellungen samt Kalender-Outbox, hochgeladene Dokumente inkl. Dateiinhalt als Base64) als eine versionierte JSON-Datei (Bundle-Version 3; ältere Bundles bleiben einspielbar). Bewusst ausgenommen sind nur Permission (Katalog aus dem Code), RefreshToken, ConsumedToken, ApiKey (Annahme 14) und IdempotencyRecord (Annahme 11); die Liste mit Begründung steht in `MODELS_EXCLUDED_FROM_BACKUP`, ein Test erzwingt sie für jedes neue Prisma-Modell. Import ist upsert-basiert (Rollen/Berechtigungen über `key`/`resource+action` statt roher `id`, damit ein Restore auch in ein anderes Deployment funktioniert) und läuft in einer einzigen `$transaction`.
- **Automatischer Zeitplan:** `BackupSchedulerService` registriert einen `@nestjs/schedule`-Cron-Job, der die Bundle-Datei periodisch auf `BACKUP_STORAGE_PATH` schreibt und alte Stände nach `BACKUP_RETENTION_COUNT` rotiert. Pfad/Zeitplan/Aufbewahrung sind sowohl per Env-Variable als auch über die GUI (Verwaltung > Backup) konfigurierbar – eine gesetzte Env-Variable hat dabei immer Vorrang vor dem GUI-Wert. Bei `replicas: 2` verhindert ein Datei-Mtime-Guard (kein DB-Lock nötig), dass beide Pods im selben Tick doppelt sichern; ohne gesetzten Pfad bleibt der Zeitplan inaktiv, manueller Export/Import über die UI funktioniert davon unabhängig immer.
- **Export/Import:** `BackupService.exportAll()`/`.importAll()` sichert alle fachlichen und Konfigurationsmodelle (Betriebsdaten inkl. Herstellung, Rohstoffe, Bewegungsbuch und Shop-Anfragen, Nummernkreise, Benutzer/Rollen/Rechte, Audit-Log, Website-/Backup-/Kalender-Einstellungen samt Kalender-Outbox, hochgeladene Dokumente inkl. Dateiinhalt als Base64) als eine versionierte JSON-Datei (Bundle-Version 3; ältere Bundles bleiben einspielbar). Bewusst ausgenommen sind nur Permission (Katalog aus dem Code), RefreshToken, ConsumedToken, ApiKey (Annahme 14), IdempotencyRecord (Annahme 11) sowie die Betriebsmodelle des Plattform-Rückgrats DomainEvent und DomainEventDelivery (Outbox – Seiteneffekte dürfen nach einem Restore nicht erneut ausgelöst werden), WebhookDelivery (Zustellprotokoll und Wiederholungs-Warteschlange – sonst gingen alte Ereignisse erneut an Empfänger), JobRun (Laufhistorie der CronJobs dieses Deployments) und ThrottlerHit (UNLOGGED-Zähler des Rate-Limits); die Liste mit Begründung steht in `MODELS_EXCLUDED_FROM_BACKUP`, ein Test erzwingt sie für jedes neue Prisma-Modell. Import ist upsert-basiert (Rollen/Berechtigungen über `key`/`resource+action` statt roher `id`, damit ein Restore auch in ein anderes Deployment funktioniert) und läuft in einer einzigen `$transaction`.
- **Automatischer Zeitplan:** Kein Timer im API-Prozess. Der CronJob `apicore-job-app-backup` (`0 * * * *`, Europe/Berlin, `concurrencyPolicy: Forbid`) startet stündlich `node dist/cli.js app-backup` (`BackupSchedulerService.runIfDue`). Der Job liest Pfad (`BACKUP_STORAGE_PATH`), Cron-Ausdruck und Aufbewahrung (`BACKUP_RETENTION_COUNT`) frisch – Env-Variable vor GUI-Wert (Verwaltung > Backup) –, vergleicht den nächsten Termin nach der mtime der neuesten Backup-Datei (`isBackupDue`, Zeitzone Europe/Berlin, „jetzt“ = DB-Uhr) und schreibt nur, wenn er erreicht ist; alte Stände rotiert er nach der Aufbewahrung. Prüfung und Schreiben laufen unter einem DB-Advisory-Lock, zwei Job-Pods schreiben nie doppelt. Ohne Pfad endet der Lauf erfolgreich mit `{ written: false, reason: 'no-path' }`; ein Fehler lässt den `JobRun` fehlschlagen (Benachrichtigung an die Inhaber von `backup:configure`). GUI-Änderungen und Importe gelten ab der nächsten stündlichen Prüfung. „Jetzt sichern“ bleibt in der API und schreibt immer. `GET /backup/schedule` liefert zusätzlich `lastJobRun` (Status, Start, Ende, Fehler); die Seite zeigt „Wird stündlich geprüft · letzter Lauf …“. Die Lock-Transaktion der Fälligkeitsprüfung hält eine Verbindung bis zum Ende des Schreibens (Timeout 1 h = `activeDeadlineSeconds`; der Export nutzt andere Verbindungen, der Pool braucht mindestens zwei). Sie braucht weder `idle_in_transaction_session_timeout` noch einen Transaction-Mode-PgBouncer: Postgres wird direkt angesprochen, und der Lock hängt an der Transaktion. Scheitert der Commit, nachdem die Datei schon geschrieben wurde, bleibt die Datei bestehen, der Lauf steht aber kosmetisch auf `FAILED`; der nächste Lauf sieht die Datei an ihrer mtime. Das Aufräumen von Idempotenz-Einträgen (90 Tage) und verbrauchten Einmal-Tokens läuft einmal täglich im CronJob `apicore-job-cleanup` (Abschnitt 10, „Jobs“).
- **Restore:** Über `POST /backup/import` (bzw. den Import-Dialog unter Verwaltung > Backup) eine zuvor exportierte Bundle-Datei einspielen. Import-Limit `BACKUP_MAX_IMPORT_BYTES` (Standard 50 MB, höchstens 256 MB, weil der Upload vollständig im RAM des 512-Mi-Pods verarbeitet wird); Export und Zeitplan prüfen jede Sicherung dagegen und warnen in Log und UI (Verwaltung > Backup), bevor eine Datei entsteht, die sich im Notfall nicht mehr einspielen ließe.
Ein rohes `pg_dump`/PITR-Backup auf Infrastrukturebene bleibt als zusätzliche, komplementäre Absicherung für den Katastrophenfall (z. B. korrupte Datenbankdateien, nicht nur fachliche Daten) sinnvoll, ist aber nicht mehr der primäre, hier dokumentierte Mechanismus – die in-App-Lösung deckt den fachlichen Anwendungsfall "Daten sichern/übertragen" bereits vollständig ab.
Ein rohes `pg_dump`/PITR-Backup auf Infrastrukturebene bleibt als zusätzliche, komplementäre Absicherung für den Katastrophenfall (z. B. korrupte Datenbankdateien, nicht nur fachliche Daten) sinnvoll, ist aber (CronJob `apicore-db-backup`, Abschnitt 7) nicht der primäre, hier dokumentierte Mechanismus – die in-App-Lösung deckt den fachlichen Anwendungsfall "Daten sichern/übertragen" bereits vollständig ab.
## 9. API-Sicherheit (Stand Oktober 2026)
@@ -145,13 +227,105 @@ Grundlage: OWASP API Security Top 10 (2023) und OWASP ASVS Level 2. Umsetzung un
| Sitzungen | Refresh-Rotation atomar, Diebstahlerkennung, max. 7 Tage; Einmal-Tokens für SSO-Abschluss und MFA-Schritt |
| Step-up | Backup-Import, MFA ein/aus verlangen aktuelles Passwort (+ TOTP) |
| Autorisierung | fail-closed `PermissionsGuard`; keine Rechteausweitung über Benutzer-/Rollenpflege; Suche nur über lesbare Objektarten |
| Maschinen-Zugriff | API-Schlüssel nur als Hash (Suche über den Hash, kein Vergleich von Geheimnissen), Scope-Allowlist (`shop-sync:read`, `shop-sync:write`, `erp-inventory:read`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben; nach 20 ungültigen Schlüsseln in 15 min je IP (IPv6: je /64) `429` für weitere ungültige – gültige Schlüssel nie, falsch aufgebaute Tokens zählen nicht; Warnung beim Start, wenn `TRUST_PROXY` in Produktion fehlt; Logs nur mit Schlüssel-Präfix |
| Ausgehende Aufrufe | Webhooks: Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, 5 s Timeout, Signatur über Zeitstempel+ID+Body |
| Transport/Antworten | `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 |
| Rate-Limits | Throttler vor der Authentifizierung (100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über `TRUST_PROXY` |
| Maschinen-Zugriff | API-Schlüssel nur als Hash (Suche über den Hash, kein Vergleich von Geheimnissen), Scope-Allowlist (`shop-sync:read`, `shop-sync:write`, `erp-inventory:read`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben. Je neuem Schlüssel genau ein Recht: `ApiKeysService.create` lehnt mehrere Rechte mit 400 ab (Lesen und Buchen damit immer getrennt); bereits ausgestellte Schlüssel mit mehreren Rechten bleiben gültig und werden in der Oberfläche mit Hinweis markiert; `erp-inventory:read` zählt als Lesen. Nach 20 ungültigen Schlüsseln in 15 min je IP (IPv6: je /64) `429` für weitere ungültige – gültige Schlüssel nie, falsch aufgebaute Tokens zählen nicht; Zähler in `ThrottlerHit`, über alle Pods geteilt (Annahme 30); Warnung beim Start, wenn `TRUST_PROXY` in Produktion fehlt; Logs nur mit Schlüssel-Präfix |
| 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()` |
| 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 |
| 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 |
**Bekannte Restpunkte (bewusst nicht umgesetzt):**
- Rate-Limits zählen je Pod im Speicher; bei 2 Replikas verdoppelt sich das Limit. Brute-Force-Schutz hängt nicht daran, sondern an der Kontosperre in der DB. Ein gemeinsamer Speicher (Redis) wäre der nächste Schritt, falls Lastspitzen ein Thema werden.
- Eine E-Mail-Änderung im eigenen Profil meldet „bereits vergeben“, wenn die Adresse existiert. Das erfordert eine Anmeldung plus aktuelles Passwort; eine generische Meldung würde Nutzer nur verwirren.
## 10. Ereignis-Rückgrat und Laufrollen
### Ereignisse
**Outbox.** Fachliche Folgen werden nicht direkt nach dem Commit ausgeführt. Sie werden als `DomainEvent` geschrieben, in derselben Prisma-Transaktion wie die fachliche Änderung und der Audit-Eintrag (`DomainEventsService.record/recordAll`, nur mit Transaktions-Client). Kein Commit ohne Ereignis, kein Ereignis ohne Commit. Nach dem Commit weckt `pg_notify('domain_event', '')` den Worker. Ein verlorenes Wecken verzögert nur bis zum nächsten Takt (5 s).
**Claim.** `DomainEventDispatcher.runOnce()` übernimmt höchstens 50 fällige Ereignisse (`status = 'PENDING'`, `availableAt <= now()`), älteste zuerst (`occurredAt`). Das geschieht per `UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP LOCKED) RETURNING *` → `PROCESSING`, `claimedAt = now()`. Mehrere Worker übernehmen so nebeneinander disjunkte Mengen, ohne aufeinander zu warten. `drain()` wiederholt `runOnce()`, bis eine Runde nichts übernimmt, höchstens 20 Runden je Takt. Folgeereignisse der Handler kommen so in der nächsten Runde dran. Claim, Abschluss und Freigabe liegen für alle Outboxen in `src/common/outbox/outbox-claims.ts`, die Wiederholregel in `src/common/retry-policy.ts`. Alle Zeitvergleiche laufen in SQL gegen `now()` (DB-Uhr).
**Delivery-Sperre.** Je Ereignis laufen die Handler nacheinander in fester Reihenfolge (Handler-ID nach Codepunkt), jeder in einer eigenen Transaktion mit 10 s Timeout:
1. `DomainEventDelivery` (eventId, handler) anlegen, falls es sie noch nicht gibt (`skipDuplicates`).
2. Die Zeile `FOR UPDATE` sperren. Ist sie `DONE`, wird der Handler übersprungen.
3. Den Handler mit dem Transaktions-Client aufrufen; die Korrelations-ID steht im `domainEventContext`.
4. Die Delivery auf `DONE` setzen, `attempts + 1`.
Die Wirkung des Handlers und `DONE` werden gemeinsam committet. Die Exklusivität je (Ereignis, Handler) kommt allein von dieser `FOR UPDATE`-Sperre der Delivery-Zeile, nicht vom Claim: Ein Ereignis kann nach einer Stale-Freigabe von einem zweiten Worker übernommen werden, während der erste noch läuft. Der zweite Worker wartet dann auf die Sperre und findet danach `DONE`. So entsteht jede Wirkung höchstens einmal je (Ereignis, Handler), auch bei einem Retry oder einem wieder freigegebenen Ereignis. Scheitert ein Handler, rollt seine Transaktion zurück. Außerhalb davon zählt die Delivery `attempts + 1` und speichert `lastError`; die übrigen Handler laufen trotzdem.
**Backoff und `FAILED`.** Sind alle Deliveries `DONE` (oder hat der Typ keinen Handler), wird das Ereignis `DONE` (`processedAt = now()`). Sonst gilt `attempts + 1`, `lastError = "<handlerId>: <Meldung>"` (erster gescheiterter Handler) und `availableAt = now() +` Wartezeit: 30 s, 2 min, 10 min, 1 h, ab dem 5. Fehlversuch 6 h. Beim 8. Fehlversuch wird das Ereignis `FAILED`, ebenso seine offenen Deliveries. Erledigte Handler laufen beim Wiederholen nicht erneut. Der Abschluss ist ein Compare-and-Set auf `id` + `claimedAt`: Wer seinen Claim verloren hat, ändert nichts.
**Liegengebliebene Claims.** `releaseStaleClaims()` läuft in jedem Worker-Takt vor `drain()`. Es setzt Ereignisse, deren `claimedAt` älter als 5 min ist, sofort wieder auf `PENDING`, mit `attempts + 1` und `lastError = 'claim expired (worker restarted?)'`; ab 8 Versuchen werden sie `FAILED`, und ihre offenen Deliveries ebenfalls. So kann ein Ereignis, das seinen Worker abstürzen lässt, nicht endlos kreisen.
**Handler-Vertrag.** `@OnDomainEvent(types, '<consumer>.<name>')` steht nur auf Singleton-Providern. Ein Handler schreibt ausschließlich über den übergebenen `tx`; lesen darf er auch über `PrismaService`. Er macht keine HTTP- oder Graph-Aufrufe: Netzwerkarbeit kommt in eine eigene Outbox (`CalendarSyncOutboxEntry`, `WebhookDelivery`), die eine Worker-Schleife abarbeitet. Der Wächter `src/domain-events/handler-contract.spec.ts` prüft, dass `*.handlers.ts` keine Netzwerk-Clients importieren. Fehlertexte gehen nur über `loggableError()` in `lastError` und Logs, nie Payload oder Secrets.
**Aufgaben.** Jede Aufgabe, die die App anlegt oder bearbeitet, läuft über `TasksService` (auch die Aufgaben der Automatik-Regeln, siehe Annahme 12; nur die Backup-Wiederherstellung ist ausgenommen). `createInTx(tx, input, actingUserId)` schreibt Aufgabe, Audit-Eintrag und `TaskCreated` (`relatedType`, `relatedId`, `dueDate` als UTC-Tag, `utcDay()`) in der Transaktion des Aufrufers – Aufgabenseite, Folgeaufgabe aus der Durchsicht, Vorlage. `update` schreibt `TaskUpdated` (`status`, `dueDate`). `TaskCompleted` entsteht nur beim Übergang nach `DONE`, durch „Erledigt“ (`complete`) ebenso wie durch eine Bearbeitung mit `status: DONE`: beide setzen Status und `completedAt` über dasselbe bedingte Update (`status <> 'DONE'`), sodass die Erledigung auch bei zwei gleichzeitigen Anfragen genau einmal gemeldet wird. Kalender (`calendar-sync.task`) und Webhooks (`webhooks.forward`: `task.created`, `task.completed`) folgen ausschließlich aus diesen Ereignissen; direkte Aufrufe gibt es für Aufgaben nicht mehr. Wiederkehrende Vorlagen erzeugt `TasksService.generateFromTemplates(now)` (lazy vor `GET /tasks`): je Vorlage ein Compare-and-Swap auf `nextDueDate` und `createInTx` in einer Transaktion, sodass von mehreren Replicas genau eine das Vorkommen anlegt und ein gescheiterter Schreibversuch es nicht verliert. Solche Aufgaben haben keinen Ersteller, ihr Audit-Eintrag keinen Benutzer, ihr Ereignis `actorUserId = null`.
**Durchsichten und Varroa.** `InspectionsService` und `VarroaService` schreiben Datensatz, Audit-Eintrag und Ereignis in einer Transaktion: `create` → `InspectionRecorded` bzw. `VarroaMeasured`, `update` → `InspectionCorrected` bzw. `VarroaCorrected`, jeweils mit `{ colonyId }` (das Volk nach der Änderung, auch nach einer Umbuchung). `update` sperrt die Zeile zuerst (`lockRowForUpdate`) und liest „vorher“ danach über `tx`; auch die Tagesrate der Varroamessung wird aus dieser Zeile abgeleitet. Alle Folgen kommen aus Handlern: Regeln über `automation.inspection-rules` (nur `InspectionRecorded`; eine korrigierte Durchsicht wertet keine Regel aus) und `automation.varroa-threshold` (`VarroaMeasured` und `VarroaCorrected`, eine korrigierte Zählung kann die Schwelle überschreiten), Kalender über `calendar-sync.inspection` (beide Durchsicht-Ereignisse), Webhooks `inspection.created` und `varroa.measured` über `webhooks.forward`. Ein Handler wertet den Datensatz so aus, wie er bei der Zustellung in der Datenbank steht.
**Behandlungen, Fütterungen und Ernten.** Anlegen schreibt Datensatz, Audit-Eintrag und `TreatmentRecorded`, `FeedingRecorded` bzw. `HarvestRecorded` in einer Transaktion, Ändern `TreatmentCorrected`, `FeedingCorrected` bzw. `HarvestCorrected`. Kalendereintrag (`calendar-sync.*`), Webhook (`treatment.created`, `feeding.created`, `harvest.created`) und Folgekontrolle (`automation.treatment-follow-up`, `automation.feeding-follow-up`) entstehen ausschließlich in den Handlern; Korrekturen lösen nur den Kalender-Handler aus, keine Folgekontrolle und keinen Webhook. Die Fälligkeit der Folgekontrolle gilt ab dem Datum der Maßnahme. `update` sperrt die Zeile und liest „vorher“ danach über `tx`; die Behandlung sperrt beim Anlegen das Volk und liest es über `tx`, bei Ernten läuft die Gewichtsprüfung unter der Sperre.
**Völker.** `ColoniesService` und `WinterChecksService` schreiben Audit, Datenänderung und Ereignis in einer Transaktion. Ändert sich `status` oder `healthStatus` (update, merge je vereinigtem Volk, dissolve, reactivate, Auswinterungsverlust mit `markColonyDeceased`), entsteht `ColonyStatusChanged { from, to }`; beim Übergang von lebend nach beendet (`DISSOLVED`, `SOLD`, `DECEASED`, `MERGED`) zusätzlich `ColonyEnded { status }`. `automation.colony-health` hört auf `ColonyStatusChanged`. Die Operationen sperren das Volk (`lockColony`: Sperre, dann Neulesen über `tx`) und prüfen darunter, ob es noch lebt; `merge` sperrt alle vereinigten Völker mit einer Anweisung (`lockRowsForUpdate`). `colony.created` bleibt im Altpfad `WebhooksService.dispatch` (Annahme 11).
**Bestand und Abfüllung.** Bestandsbuchungen kündigen sich über `StockChanged { bottlingBatchIds, productIds, rawMaterialKinds }` an. Das Ereignis entsteht in derselben Transaktion wie Bewegung, Audit-Eintrag und Datenänderung, über `StockService.recordChange(tx, change, actingUserId, aggregate)`. Eine leere Änderung erzeugt kein Ereignis, jede ID und jede Sorte steht nur einmal darin.
- Entnahme und Inventur hängen das Ereignis an die Bewegung (`BatchMovement`). Bei einer Rohstoff-Charge steht deren Sorte in `rawMaterialKinds`.
- Jede angenommene Shop-Buchung (`ShopStockRequest`, `actorUserId = null`) schreibt ihr eigenes Ereignis. Abgelehnte und wiederholte Anfragen schreiben keines.
- Abfüllungen schreiben beim Anlegen `BatchBottled { honeyBatchId }`, bei Korrektur und Löschung `BottlingCorrected { honeyBatchId, removed }`, jeweils zusammen mit `StockChanged` in einem `recordAll`, also mit einer Korrelations-ID.
- Herstellungschargen schreiben `StockChanged` mit dem Artikel und den verbrauchten bzw. beim Löschen zurückgegebenen Rohstoff-Sorten.
Daraus folgen die Webhooks `batch.updated` und `stock.changed` (`webhooks.forward`; reine Rohstoff-Änderungen gehen nicht an den Shop) sowie die Rohstoff-Mindestmenge (`automation.raw-material-low`, je Sorte über `remainingByRawMaterialKind`). Korrekturen und Löschungen von Abfüllung, Herstellungscharge und Rohstoff-Charge sperren die Zeile (`lockRowForUpdate`) und lesen „vorher“ erst danach über `tx`; Mengenprüfung und Bewegungs-Check vor dem Löschen laufen ebenfalls unter dieser Sperre. Herstellungschargen sperren zuerst den Artikel (`StockService.lock`), dann die Charge: dieselbe Reihenfolge wie ein Shop-Verkauf, der den Artikel hält, während seine Bewegung die Charge referenziert. Reines Anlegen oder Ändern einer Rohstoff-Charge schreibt kein `StockChanged`; die Mindestmenge wird erst bei der nächsten Entnahme, Korrektur oder Verbrauchsbuchung dieser Sorte geprüft.
**Sammelerfassung am Stand.** `POST /treatments/bulk`, `/feedings/bulk` und `/varroa-measurements/bulk` legen in einer Transaktion (`runBulk`, `src/common/bulk/run-bulk.ts`) je Volk einen eigenen Datensatz an: höchstens `BULK_MAX_COLONIES` = 100 Völker, Timeout `BULK_TRANSACTION_TIMEOUT_MS` = 15 s. Es gilt alles oder nichts: `assertLivingColonies` sperrt die Völker per `lockRowsForShare` (`SELECT … FOR SHARE`, aufsteigend nach id) und liest sie neu. Ist ein Volk unbekannt oder beendet, wird nichts gespeichert, und die Antwort 400 nennt die Völker in `invalidColonyIds`; der Client kann ohne sie erneut senden. Jeder Datensatz bekommt seinen eigenen Audit-Eintrag und sein eigenes `TreatmentRecorded`, `FeedingRecorded` bzw. `VarroaMeasured`; diese Ereignisse tragen optional `bulkId` (`RecordedForColony`), bei Einzelanlage fehlt das Feld. Kalender, Folgekontrolle, Varroa-Schwelle und Webhooks folgen daher je Volk unverändert aus den Ereignissen. Einzel- und Sammelanlage nutzen dieselbe Datenaufbereitung; bei Behandlungen kommt der Stand je Volk aus der gesperrten Zeile (Bestandsbuch). Die Chargennummer ist die Arzneimittel-Charge der Packung: einmal eingegeben, für alle Datensätze gleich, nie erzeugt. Die Gruppierung leistet `bulkId`. Varroa nimmt `entries` mit Milbenzahl je Volk (keine gemeinsame Milbenzahl), die Tagesrate wird je Eintrag wie bei der Einzelmessung abgeleitet. Die Einzel-Endpunkte akzeptieren weiter beendete Völker, nur die Sammelerfassung prüft auf lebende.
**Zeilensperren.** `src/prisma/row-lock.ts` bietet `lockRowForUpdate` (eine Zeile, liefert, ob sie existiert), `lockRowsForUpdate` und `lockRowsForShare` (mehrere Zeilen mit einer Anweisung, aufsteigend nach id, damit sich überlappende Mengen nicht gegenseitig blockieren). Sie laufen nur mit einem Transaktions-Client, Tabellennamen kommen aus einer festen Liste (nie aus Eingaben). Muster für jedes Ändern: sperren, dann „vorher“ über `tx` neu lesen, nie die Vorab-Lesung verwenden.
**Tests.** e2e-Tests haben keinen Worker. Sie rufen nach der Anfrage `drainDomainEvents(app, [aggregateId])` aus `test/utils/domain-events.ts` auf. Das arbeitet nur die Ereignisse dieser Korrelationen ab, sodass parallele e2e-Dateien sich nicht stören.
### Webhook-Zustellung
**Einreihen.** `WebhooksService.enqueueDeliveries(db, event, payload)` legt je aktivem Abonnement des Webhook-Ereignisses eine `WebhookDelivery` an und weckt den Worker mit `pg_notify('webhook_delivery', '')`. Gesendet wird dabei nichts. Der Body `JSON.stringify({ event, data })` wird beim Einreihen festgehalten, damit jede Wiederholung dieselben Bytes sendet. Mit einem Transaktions-Client committen Zeilen und Wecken zusammen mit dem Aufrufer. Zwei Wege führen dorthin:
- Der Handler `webhooks.forward` (`src/webhooks/webhook.handlers.ts`) übersetzt Ereignisse nach `DOMAIN_EVENT_WEBHOOKS`: `InspectionRecorded` → `inspection.created`, `VarroaMeasured` → `varroa.measured`, `TreatmentRecorded` → `treatment.created`, `FeedingRecorded` → `feeding.created`, `HarvestRecorded` → `harvest.created`, `BatchBottled` und `BottlingCorrected` → `batch.updated`, `StockChanged` → `stock.changed`, `TaskCreated` → `task.created`, `TaskCompleted` → `task.completed`. `data` ist wie bisher der Datensatz, gelesen über `tx`: die Ernte mit ihren Völkern, bei `batch.updated` die Honigcharge, bei `stock.changed` nur `{ bottlingBatchIds, productIds }` und nur, wenn eine der Listen nicht leer ist. Fehlt der Datensatz, wird nichts eingereiht.
- Der Altpfad `WebhooksService.dispatch(event, payload)` für Erzeuger, die noch kein Ereignis schreiben (`colony.created`, `batch.created`, `queen.created`, `queen.replaced`, `product.stock_changed`), reiht nach dem Commit über `runAfterCommit` ein und wirft nie.
**Zustellung.** Die Worker-Schleife `webhook-deliveries` (alle 5 s und auf `webhook_delivery`) ruft `WebhookDeliveryWorker.releaseStaleClaims()` und dann `drain()` auf. Claim, Abschluss und Freigabe laufen über `outbox-claims.ts`: höchstens 50 fällige Zustellungen je Runde, älteste zuerst (`createdAt`), höchstens 20 Runden je Takt. Höchstens 5 Zustellungen laufen gleichzeitig. Jede wird mit Zeitstempel und ihrer ID signiert (`X-ApiCore-Signature: sha256=HMAC(secret, "<t>.<id>.<body>")`) und über `postWebhook` gesendet: keine Weiterleitungen, Ziel-IP beim Verbindungsaufbau geprüft, 5 s Leerlauf-Timeout und eine Frist von 10 s je Versuch. Bei Ablauf der Frist wird die Anfrage samt Verbindung abgebrochen, auch wenn der Empfänger seine Antwort nur tröpfchenweise sendet; so hält er weder einen der 5 Plätze noch einen Socket. `X-ApiCore-Delivery` ist die `WebhookDelivery.id` und bleibt über alle Wiederholungen gleich. Zugestellt wird mindestens einmal; Empfänger verwerfen Duplikate anhand dieser ID.
**Ergebnis.** Nur eine `2xx`-Antwort gilt als zugestellt: `DELIVERED`, `deliveredAt = now()`. Jede andere Antwort, ein Netzwerkfehler und ein pausiertes Abonnement (`subscription inactive`, ohne zu senden) sind Fehlversuche nach derselben Regel wie bei Ereignissen: `attempts + 1`, Wartezeit 30 s, 2 min, 10 min, 1 h, danach 6 h, beim 8. Fehlversuch `FAILED`. Ein Abonnement, das innerhalb dieses Fensters wieder aktiviert wird, bekommt seine Zustellungen also noch. `lastStatusCode` hält den Status der letzten Antwort (ohne Antwort `null`), `lastError` einen festen Text (`Receiver answered HTTP <status>`) oder die Meldung aus `loggableError()`; Antwortkörper, Secret und Payload stehen nie darin. Status-Code und Abschluss werden in einer Transaktion gesetzt, beide als Compare-and-Set auf `id` + `claimedAt`. Beim Herunterfahren holt `drain()` keine weitere Runde; die laufende wird zu Ende zugestellt.
**Zustellprotokoll.** `GET /webhooks/:id/deliveries?limit=1..100` (Standard 50, Recht `webhooks:read`) liefert die neuesten Zustellungen eines Abonnements ohne Body: `id`, `event`, `status`, `attempts`, `lastStatusCode`, `lastError`, `createdAt`, `deliveredAt`.
**Tests.** e2e-Tests rufen `deliverWebhooks(app, [subscriptionId])` aus `test/utils/webhooks.ts` auf. Das stellt nur die Zustellungen dieser Abonnements zu.
### Jobs
**Aufruf.** `node dist/cli.js <job> [args]` (`src/cli.ts`, Rolle `job`) prüft wie `main.ts` und `worker.ts` zuerst Rolle und Umgebung (`resolveRole('cli')`, `assertStartupEnvironment`) und startet dann `JobsModule` per `createApplicationContext`: die Module, die die Jobs brauchen, ohne HTTP-Schicht und ohne Worker-Schleifen. Jobs (`JOB_NAMES` in `src/jobs/job-names.ts`): `daily-evaluation`, `task-templates`, `cleanup`, `app-backup`, `rekey`. Weitere Argumente gehen unverändert an den Job, erlaubt sind nur die aus `JOB_ALLOWED_ARGS` (heute einzig `rekey --dry-run`). Exit-Code `0` Lauf erfolgreich, `1` Lauf gescheitert (auch wenn er gar nicht erst aufgezeichnet werden konnte, etwa bei einem Modul- oder DI-Fehler – der Kontext startet mit `abortOnError: false`), `2` falscher Aufruf (kein oder unbekannter Job oder ein nicht erlaubtes Argument wie `cleanup --dry-run` oder `rekey --dry-rn` – dann wird nichts verbunden und kein `JobRun` geschrieben). Shutdown-Hooks gibt es keine: Ein abgebrochener Pod hinterlässt seinen Lauf als `RUNNING`, `cleanup` erkennt ihn (siehe unten). `JobRegistry` prüft beim Start, dass es zu jedem Namen genau einen Job gibt.
**`JobRun`.** `JobRunService.run(job, work)` schreibt je Lauf eine Zeile: `RUNNING`, danach `SUCCEEDED` mit `result` (nur Zähler, Schalter, kurze Namen) oder `FAILED` mit `error` aus `loggableError()`; `finishedAt` setzt immer die DB (`now()`), auch bei abgebrochenen Läufen. Ein gescheiterter Lauf benachrichtigt die Inhaber des Job-Rechts (`JOB_FAILURE_PERMISSION`: `daily-evaluation` und `task-templates` → `automation:update`, `app-backup` → `backup:configure`, `cleanup` und `rekey` → `system:manage`) mit „Job „<Name>“ fehlgeschlagen“ (`WARNUNG`, `relatedType: 'JobRun'`, `relatedId` = Lauf). Höchstens eine solche Benachrichtigung je Job und UTC-Tag (DB-Uhr), sonst meldete die stündliche `app-backup`-Prüfung denselben Ausfall jede Stunde; Prüfung und Schreiben laufen in einer Transaktion hinter einem Advisory-Lock je Job. `recent(limit, job?)` und `latestPerJob()` lesen die Läufe für die System-Seite. `SystemModule` stellt `JobRunService` für API und Jobs bereit.
**`cleanup`** (täglich 04:00). Alle Altersvergleiche laufen in SQL gegen `now()`; `PENDING`- und `PROCESSING`-Einträge bleiben immer stehen.
- Läufe, die seit mehr als 6 h `RUNNING` sind (`JOB_RUN_STALE_HOURS`, weit über jeder `activeDeadlineSeconds`), werden `FAILED` mit `error: 'aborted (pod ended without result)'`; je betroffenem Job geht die übliche Benachrichtigung raus, damit ein Job, der jedes Mal abgeschossen wird, nicht still scheitert.
- Idempotenz-Einträge nach 90 Tagen und verbrauchte Tokens 1 h nach ihrem Ablauf (`IdempotencyCleanupService`; die Stunde Puffer deckt Uhrabweichungen der api-Pods ab, die den Ablauf mit ihrer eigenen Uhr prüfen).
- `ThrottlerHit`-Zeilen, deren Fenster abgelaufen ist und die keine aktive Sperre tragen (`"expiresAt" < now()` und `"blockedUntil"` leer oder `< now()`); eine noch laufende Sperre bleibt so erhalten.
- `DONE`-Ereignisse und `DELIVERED`-Zustellungen nach 30 Tagen (`occurredAt` bzw. `createdAt`), `FAILED`-Einträge beider Tabellen nach 90 Tagen; die Deliveries eines Ereignisses gehen per `ON DELETE CASCADE` mit. 30 Tage liegen bewusst über dem Nachholfenster der täglichen Auswertung, sonst würde ein gelöschter `dedupeKey` ein Vorkommen erneut erzeugen.
- `JobRun`-Zeilen nach 90 Tagen.
Das Ergebnis zählt jede Kategorie (`idempotencyRecords`, `consumedTokens`, `throttlerHits`, `domainEvents.done`, `domainEvents.failed`, `webhookDeliveries.delivered`, `webhookDeliveries.failed`, `jobRuns.aborted`, `jobRuns.deleted`).
**`task-templates`** (täglich 05:00) ruft `TasksService.generateFromTemplates()` auf und meldet `{ created }`. Wegen des Compare-and-Swap je Vorlage entsteht jedes Vorkommen genau einmal, auch bei zwei Läufen oder parallel zur Lazy-Erzeugung vor `GET /tasks`.
**`daily-evaluation`** (täglich 04:30) erzeugt die zeitbasierten Ereignisse. Regeln und Werte stehen in `src/jobs/daily-evaluation.rules.ts`, eine Zeile je Typ in `DAILY_EVALUATION_RULES`; jede Regel nennt je Vorkommen das Ereignis und den UTC-Tag des fachlichen Ereignisses:
- `WithdrawalEnded` (Aggregat `Treatment`, Payload `colonyId`, `endedOn`): `(endDate ?? date) + withdrawalPeriodDays`, nur mit Wartezeit.
- `TaskDue` (`Task`, `dueOn`): `dueDate` offener Aufgaben (`OPEN`, `IN_PROGRESS`).
- `QueenAged` (`Queen`, `hatchYear`, `colonyId`): Königin `ACTIVE` mit `Jahr − hatchYear ≥ 2` (`QUEEN_AGED_MIN_YEARS`), Tag `<Jahr>-01-01`, also höchstens einmal im Jahr und nur im Fenster 1.–8. Januar; wer später im Jahr `ACTIVE` wird oder sein `hatchYear` erhält, bekommt in diesem Jahr kein Ereignis.
- `VarroaMeasurementDue` (`Colony`, `dueOn`, `lastMeasuredOn`) und `InspectionOverdue` (`Colony`, `dueOn`, `lastInspectedOn`): lebende Völker (`LIVING_COLONY_WHERE`), letzte Messung bzw. Durchsicht plus k × 28 bzw. 21 Tage (k ≥ 1; ohne Eintrag ab `createdAt`), jeweils das späteste solche Datum bis heute. Nur wenn sein Monat in März–Oktober liegt (`ACTIVE_SEASON_MONTHS`); ein Vorkommen in der Winterpause entfällt, es wird nicht auf den 1. März verschoben.
„Heute“ ist der Tag der DB-Uhr (`SELECT now()`). Aufgezeichnet wird jedes Vorkommen, dessen Tag im Nachholfenster `[heute − 7, heute]` liegt (`CATCH_UP_DAYS`), mit `dedupeKey = "<type>:<aggregateId>:<YYYY-MM-DD>"` (`dailyDedupeKey`). Jeder Lauf im Fenster wiederholt denselben Schlüssel, der eindeutige `dedupeKey` (`recordAll`, `skipDuplicates`) hält ein Ereignis je Vorkommen: ausgefallene Läufe bis 7 Tage werden nachgeholt, wiederholte erzeugen nichts. Je Typ eine Transaktion; das Ergebnis zählt die neuen Ereignisse je Typ. Scheitert eine Regel, laufen die übrigen weiter und ihre Ereignisse bleiben; der Lauf scheitert dann mit `Daily evaluation failed for: <Typen>`. Handler für diese fünf Ereignisse gibt es noch nicht (spätere Stufe); bis dahin werden sie ohne Wirkung `DONE`, und `cleanup` löscht sie nach 30 Tagen.
**`app-backup`** (stündlich) prüft, ob ein Backup fällig ist, und schreibt es dann: siehe Abschnitt 8, „Automatischer Zeitplan“.
**`rekey`** (nur von Hand, CronJobs `apicore-job-rekey` und `apicore-job-rekey-dry-run` mit `suspend: true`): siehe Annahme 22.
**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 `DailyEvaluationJob.evaluate(Tag)` mit Tagen im Jahr 2001 auf: Der Job 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
Die Seite „Verwaltung > System“ (Rechte `system:read` zum Ansehen, `system:manage` zum Wiederholen; nur Systemrollen) zeigt je Job den letzten Lauf (`JobRun`), die Warteschlange der Domänenereignisse (offen, in Arbeit, fehlgeschlagen, Alter des ältesten offenen Eintrags nach DB-Uhr) und die offenen und fehlgeschlagenen Webhook-Zustellungen. Routen: `GET /system/overview`, `GET /system/job-runs?job=&limit=1..100` (Standard 50), `GET /system/domain-events/failed`, `POST /system/domain-events/:id/retry` (204), `GET /system/webhook-deliveries/failed`, `POST /system/webhook-deliveries/:id/retry` (204). Wiederholen ist nur für `FAILED` möglich (409 sonst, 404 unbekannt): Versuche und `availableAt` werden in SQL (`now()`) zurückgesetzt, fehlgeschlagene Handler-Zustellungen eines Ereignisses wieder `PENDING`, ein Audit-Eintrag (UPDATE) geschrieben und der Worker per `pg_notify` geweckt. Die Ansichten enthalten nie Payload, Webhook-Body, Secrets oder vollständige URLs (nur den Host); `lastError` steht wie gespeichert (`loggableError()`). API-Schlüssel erreichen `/system` nie (Scope-Allowlist).
+3
View File
@@ -1,5 +1,8 @@
node_modules
dist
# A stale build info makes `nest build` emit only changed files into the
# freshly deleted dist (tsconfig.json: incremental).
*.tsbuildinfo
generated
coverage
.devdb
+31 -7
View File
@@ -2,8 +2,10 @@
DATABASE_URL="postgresql://user:password@localhost:5432/apicore?schema=public"
# Mindestens 32 Zeichen (server-seitig erzwungen, siehe main.ts) - signiert
# jedes Access-/MFA-/SSO-Token UND verschluesselt gespeicherte TOTP-Secrets.
# Mindestens 32 Zeichen (server-seitig erzwungen, siehe
# src/runtime/startup-checks.ts) - signiert jedes Access-/MFA-/SSO-Token.
# TOTP- und Webhook-Secrets aus der Zeit vor den eigenen Schluesseln (unten)
# sind noch damit verschluesselt: erst nach dem rekey-Job rotieren.
# Erzeugen z.B. mit: openssl rand -hex 32
JWT_ACCESS_SECRET="change-me-to-a-random-value-at-least-32-characters-long"
JWT_REFRESH_SECRET="change-me-refresh-secret"
@@ -22,7 +24,7 @@ FRONTEND_ORIGIN="http://localhost:3000"
# trusted (client IP for rate limits and API-key IP allowlists). "1" for the
# single K8s ingress; a list such as "loopback, 10.0.0.0/8" also works.
# Behind Cloudflare + Traefik use "loopback, <pod CIDR>, <Cloudflare ranges>"
# (see deploy/k8s/backend/configmap.example.yaml); "1" would yield the
# (see deploy/k8s/base/backend/configmap.example.yaml); "1" would yield the
# Cloudflare edge IP that many clients share.
TRUST_PROXY=1
@@ -54,11 +56,25 @@ BACKUP_CRON_SCHEDULE="0 3 * * *"
# Wie viele der neuesten Backup-Dateien behalten werden, aeltere werden nach
# jedem Lauf geloescht (Standard: 14).
BACKUP_RETENTION_COUNT=14
# Backups are encrypted (AES-256-GCM). Without this key they are sealed with a
# key derived from JWT_ACCESS_SECRET and restore only into this deployment.
# Set the same value on another deployment to restore there. At least 32
# characters, e.g. "openssl rand -hex 32". Keep it outside the backup storage.
# Encryption keys, one per purpose (AES-256-GCM, key derived per purpose with
# HKDF): TOTP secrets, webhook signing secrets, backup files. Required in
# production: at least 32 characters each, all three different from each
# other and from JWT_ACCESS_SECRET/JWT_REFRESH_SECRET, e.g.
# "openssl rand -hex 32". Outside production a missing key falls back to one
# derived from JWT_ACCESS_SECRET.
# Rotation: move the old value to <NAME>_PREVIOUS (still opens), set a new
# <NAME>, run the rekey job, then drop <NAME>_PREVIOUS. Backup files are not
# resealed: keep BACKUP_ENCRYPTION_KEY_PREVIOUS while older files must restore.
# Set the same BACKUP_ENCRYPTION_KEY on another deployment to restore its
# backups there (MFA_/WEBHOOK_ENCRYPTION_KEY too, to keep MFA and webhook
# secrets). Keep the backup key outside the backup storage.
# MFA_ENCRYPTION_KEY=""
# WEBHOOK_ENCRYPTION_KEY=""
# BACKUP_ENCRYPTION_KEY=""
# MFA_ENCRYPTION_KEY_PREVIOUS=""
# WEBHOOK_ENCRYPTION_KEY_PREVIOUS=""
# BACKUP_ENCRYPTION_KEY_PREVIOUS=""
# Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert
# (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start).
@@ -84,3 +100,11 @@ OIDC_REDIRECT_URI="http://localhost:3001/api/v1/auth/sso/callback"
# gesamte API-Schema ungeschuetzt (kein JwtAuthGuard) preisgeben wuerde.
# Nur fuer lokale Entwicklung/Debugging explizit aktivieren.
ENABLE_SWAGGER=false
# Laufrolle des Prozesses: api (nur HTTP), worker, job (CLI) oder all (API +
# Worker in einem Prozess, nur fuer lokale Entwicklung und Tests). Ohne Wert
# gilt der Standard des Einstiegspunkts (main: all, worker: worker, cli: job).
# In Produktion Pflicht und "all" ist dort verboten.
# APICORE_ROLE=api
# Port des Health-Endpunkts des Worker-Prozesses.
# WORKER_HEALTH_PORT=3002
+24 -10
View File
@@ -1,6 +1,10 @@
# syntax=docker/dockerfile:1
FROM node:22-alpine AS builder
# Base-Image per Digest gepinnt (node:22-alpine, Multi-Arch-Index vom
# 2026-09-23): ein Rebuild zieht nie ungeprueft ein neues Image. Ein Update
# ist ein bewusster Commit mit neuem Digest, ermittelt per
# docker buildx imagetools inspect node:22-alpine
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
@@ -14,16 +18,16 @@ RUN npx prisma generate
RUN npx eslint "{src,test}/**/*.ts" && npm run typecheck && npm test -- --ci
RUN npm run build
FROM node:22-alpine AS runtime
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/generated ./generated
# schema.prisma, migrations/ und prisma.config.ts: noetig fuer
# `npm run migrate:deploy`, das der Container beim Start selbst ausfuehrt
# (siehe CMD unten). seed.ts selbst wird NICHT zur Laufzeit ausgefuehrt -
# schema.prisma, migrations/ und prisma.config.ts: noetig fuer den
# initContainer `migrate` (siehe Kommentar ueber CMD unten).
# seed.ts selbst wird NICHT zur Laufzeit ausgefuehrt -
# `npm run build` kompiliert es vorab zu dist/seed.js (siehe
# tsconfig.seed.json), das schon in ./dist enthalten ist. Frueher lief
# `ts-node prisma/seed.ts` direkt im Container: der TypeScript-Compiler
@@ -31,9 +35,19 @@ COPY --from=builder /app/generated ./generated
# fuehrte zu sporadischen OOM-Crashes beim Start.
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
EXPOSE 3001
# 3001: HTTP der api-Rolle. 3002: /health der worker-Rolle.
EXPOSE 3001 3002
USER node
# migrate deploy ist idempotent und nutzt einen DB-seitigen Advisory-Lock,
# seed ist idempotent per Unique-Constraint auf User.email (siehe seed.ts) -
# beides unproblematisch bei mehreren gleichzeitig startenden Pod-Replicas.
CMD ["sh", "-c", "npm run migrate:deploy && npm run seed && node dist/main.js"]
# Migration und Seed laufen nicht mehr hier, sondern als initContainer im
# Pod (deploy/k8s/components/backend-roles): `migrate` in api und worker
# (`node node_modules/prisma/build/index.js migrate deploy`, idempotent mit
# DB-seitigem Advisory-Lock), `seed` nur in der api (`node dist/seed.js`,
# idempotent per Unique-Constraint auf User.email). Scheitert die Migration,
# startet der neue Pod nicht und der alte laeuft weiter. Der Hauptprozess
# startet ohne Prisma-CLI; worker und Jobs ueberschreiben das Kommando
# (dist/worker.js, dist/cli.js <job>), die Rolle waehlt APICORE_ROLE.
# Dieses Image darf darum nur zusammen mit components/backend-roles live
# gehen (Runbook Schritt 3). Fehlt APICORE_ROLE, bricht der Start in
# Produktion absichtlich ab. APICORE_ROLE nicht von Hand in ein Deployment
# ohne die initContainer setzen: dann liefe keine Migration.
CMD ["node", "dist/main.js"]
+21 -35
View File
@@ -15,7 +15,6 @@
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^12.0.1",
"@nestjs/swagger": "^11.4.7",
"@nestjs/throttler": "^6.5.0",
"@prisma/adapter-pg": "^7.9.1",
@@ -24,12 +23,14 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"cookie-parser": "^1.4.7",
"cron": "^4.4.0",
"helmet": "^8.3.0",
"openid-client": "^6.8.4",
"otplib": "^13.5.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pdfkit": "^0.19.1",
"pg": "^8.23.1",
"prisma": "^7.9.1",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
@@ -49,6 +50,7 @@
"@types/node": "^24.0.0",
"@types/passport-jwt": "^4.0.1",
"@types/pdfkit": "^0.17.6",
"@types/pg": "^8.23.1",
"@types/qrcode": "^1.5.6",
"@types/supertest": "^7.0.0",
"eslint": "^9.18.0",
@@ -2478,22 +2480,6 @@
"@nestjs/core": "^11.0.0"
}
},
"node_modules/@nestjs/schedule": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-12.0.1.tgz",
"integrity": "sha512-v0z/li9L8oxpvuIDAGm5uOLaZ8KzsfZRzgWgppvs5vClneSinZP5IFl3C1F1DCeTCkNLiGN9g/oLUvzg8x++RQ==",
"license": "MIT",
"dependencies": {
"cron": "4.4.0"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@nestjs/common": "^11.0.0 || ^12.0.0",
"@nestjs/core": "^11.0.0 || ^12.0.0"
}
},
"node_modules/@nestjs/schematics": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz",
@@ -3601,9 +3587,9 @@
"license": "MIT"
},
"node_modules/@types/luxon": {
"version": "3.7.5",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.5.tgz",
"integrity": "sha512-jJ41Q4z6ZVO260MNDdHfW7+7a5iMiX8Mr6ZJHcmgrvhZha6dz5704o/lF2kKl6URjH6ivEL97w9xS/MgpJEphg==",
"version": "3.7.6",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.6.tgz",
"integrity": "sha512-6KSjliQAXK8ZLgFQO4B7iE4Rpf/B3rItzCFuXezBY/XIAGxOdLd7vRNK9/StRPwiS/yJsVbB7HKpW46B8tcEuQ==",
"license": "MIT"
},
"node_modules/@types/methods": {
@@ -10087,14 +10073,14 @@
"license": "MIT"
},
"node_modules/pg": {
"version": "8.23.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
"version": "8.23.1",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
"integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-connection-string": "^2.14.1",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.16.0",
"pg-protocol": "^1.16.1",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
@@ -10102,7 +10088,7 @@
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
"pg-cloudflare": "^1.4.1"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
@@ -10114,16 +10100,16 @@
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
"integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
"integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
"license": "MIT"
},
"node_modules/pg-int8": {
@@ -10145,9 +10131,9 @@
}
},
"node_modules/pg-protocol": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
"integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
"integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
"license": "MIT"
},
"node_modules/pg-types": {
+6 -2
View File
@@ -12,6 +12,8 @@
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"worker": "node dist/worker.js",
"job": "node dist/cli.js",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"typecheck": "tsc --noEmit",
"test": "jest",
@@ -29,7 +31,6 @@
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^12.0.1",
"@nestjs/swagger": "^11.4.7",
"@nestjs/throttler": "^6.5.0",
"@prisma/adapter-pg": "^7.9.1",
@@ -38,12 +39,14 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"cookie-parser": "^1.4.7",
"cron": "^4.4.0",
"helmet": "^8.3.0",
"openid-client": "^6.8.4",
"otplib": "^13.5.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pdfkit": "^0.19.1",
"pg": "^8.23.1",
"prisma": "^7.9.1",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
@@ -63,6 +66,7 @@
"@types/node": "^24.0.0",
"@types/passport-jwt": "^4.0.1",
"@types/pdfkit": "^0.17.6",
"@types/pg": "^8.23.1",
"@types/qrcode": "^1.5.6",
"@types/supertest": "^7.0.0",
"eslint": "^9.18.0",
@@ -92,7 +96,7 @@
"^.+\\.(t|j)s$": "ts-jest"
},
"transformIgnorePatterns": [
"node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose|@nestjs/schedule|cron)/)"
"node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose|cron)/)"
],
"collectCoverageFrom": [
"**/*.(t|j)s"
@@ -0,0 +1,139 @@
-- Platform backbone: domain-event outbox (DomainEvent + per-handler
-- DomainEventDelivery), webhook delivery queue, cron job run history and
-- throttler counters, plus two small columns (Apiary.publicLocationLabel,
-- SiteSettings.originCountry).
-- ThrottlerHit is UNLOGGED on purpose: no WAL, emptied after a crash - only
-- rate-limit counters get lost. DomainEvent.dedupeKey is a nullable UNIQUE
-- (not a partial index) so the schema and the database cannot drift apart.
-- CreateEnum
CREATE TYPE "DomainEventStatus" AS ENUM ('PENDING', 'PROCESSING', 'DONE', 'FAILED');
-- CreateEnum
CREATE TYPE "DomainEventDeliveryStatus" AS ENUM ('PENDING', 'DONE', 'FAILED');
-- CreateEnum
CREATE TYPE "WebhookDeliveryStatus" AS ENUM ('PENDING', 'PROCESSING', 'DELIVERED', 'FAILED');
-- CreateEnum
CREATE TYPE "JobRunStatus" AS ENUM ('RUNNING', 'SUCCEEDED', 'FAILED');
-- AlterTable
ALTER TABLE "Apiary" ADD COLUMN "publicLocationLabel" TEXT;
-- AlterTable
ALTER TABLE "SiteSettings" ADD COLUMN "originCountry" TEXT NOT NULL DEFAULT 'Deutschland';
-- CreateTable
CREATE TABLE "DomainEvent" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"aggregateType" TEXT NOT NULL,
"aggregateId" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"actorUserId" TEXT,
"correlationId" TEXT NOT NULL,
"dedupeKey" TEXT,
"occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"status" "DomainEventStatus" NOT NULL DEFAULT 'PENDING',
"attempts" INTEGER NOT NULL DEFAULT 0,
"availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"claimedAt" TIMESTAMP(3),
"processedAt" TIMESTAMP(3),
"lastError" TEXT,
CONSTRAINT "DomainEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DomainEventDelivery" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"handler" TEXT NOT NULL,
"status" "DomainEventDeliveryStatus" NOT NULL DEFAULT 'PENDING',
"attempts" INTEGER NOT NULL DEFAULT 0,
"lastError" TEXT,
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DomainEventDelivery_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WebhookDelivery" (
"id" TEXT NOT NULL,
"subscriptionId" TEXT NOT NULL,
"event" TEXT NOT NULL,
"body" TEXT NOT NULL,
"status" "WebhookDeliveryStatus" NOT NULL DEFAULT 'PENDING',
"attempts" INTEGER NOT NULL DEFAULT 0,
"availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"claimedAt" TIMESTAMP(3),
"lastStatusCode" INTEGER,
"lastError" TEXT,
"deliveredAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WebhookDelivery_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "JobRun" (
"id" TEXT NOT NULL,
"job" TEXT NOT NULL,
"status" "JobRunStatus" NOT NULL DEFAULT 'RUNNING',
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"finishedAt" TIMESTAMP(3),
"result" JSONB,
"error" TEXT,
CONSTRAINT "JobRun_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE UNLOGGED TABLE "ThrottlerHit" (
"key" TEXT NOT NULL,
"throttlerName" TEXT NOT NULL,
"hits" INTEGER NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"blockedUntil" TIMESTAMP(3),
CONSTRAINT "ThrottlerHit_pkey" PRIMARY KEY ("key","throttlerName")
);
-- CreateIndex
CREATE UNIQUE INDEX "DomainEvent_dedupeKey_key" ON "DomainEvent"("dedupeKey");
-- CreateIndex
CREATE INDEX "DomainEvent_status_availableAt_idx" ON "DomainEvent"("status", "availableAt");
-- CreateIndex
CREATE INDEX "DomainEvent_correlationId_idx" ON "DomainEvent"("correlationId");
-- CreateIndex
CREATE INDEX "DomainEvent_aggregateType_aggregateId_idx" ON "DomainEvent"("aggregateType", "aggregateId");
-- CreateIndex
CREATE UNIQUE INDEX "DomainEventDelivery_eventId_handler_key" ON "DomainEventDelivery"("eventId", "handler");
-- CreateIndex
CREATE INDEX "WebhookDelivery_status_availableAt_idx" ON "WebhookDelivery"("status", "availableAt");
-- CreateIndex
CREATE INDEX "WebhookDelivery_subscriptionId_createdAt_idx" ON "WebhookDelivery"("subscriptionId", "createdAt");
-- CreateIndex
CREATE INDEX "JobRun_job_startedAt_idx" ON "JobRun"("job", "startedAt");
-- CreateIndex
CREATE INDEX "JobRun_status_idx" ON "JobRun"("status");
-- CreateIndex
CREATE INDEX "ThrottlerHit_expiresAt_idx" ON "ThrottlerHit"("expiresAt");
-- AddForeignKey
ALTER TABLE "DomainEventDelivery" ADD CONSTRAINT "DomainEventDelivery_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "DomainEvent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WebhookDelivery" ADD CONSTRAINT "WebhookDelivery_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "WebhookSubscription"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1 @@
ALTER TABLE "Inspection" ADD COLUMN "broodFrames" INTEGER;
@@ -0,0 +1,3 @@
-- ERP-Export /bewegungen sortiert und blättert nach (createdAt, id).
-- CreateIndex
CREATE INDEX "BatchMovement_createdAt_id_idx" ON "BatchMovement"("createdAt", "id");
+155 -30
View File
@@ -78,8 +78,8 @@ model User {
// TOTP-Secret AES-256-GCM-verschluesselt gespeichert (siehe
// src/auth/mfa/mfa-crypto.util.ts), nie im Klartext in der DB.
mfaEnabled Boolean @default(false)
mfaSecret String?
mfaEnabled Boolean @default(false)
mfaSecret String?
// Zuletzt angenommener TOTP-Zeitschritt: derselbe Code (oder ein aelterer)
// wird danach nie wieder akzeptiert (Replay-Schutz).
mfaLastTimeStep Int?
@@ -88,7 +88,7 @@ model User {
// ab 5 ist das Konto bis authLockedUntil gesperrt.
failedAuthAttempts Int @default(0)
authLockedUntil DateTime?
avatarPath String?
avatarPath String?
// Gesetzt bei jedem Passwortwechsel; JwtAuthGuard verwirft Access-Tokens,
// die vor diesem Zeitpunkt ausgestellt wurden (siehe
@@ -170,16 +170,16 @@ model ConsumedToken {
}
model RefreshToken {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
tokenHash String @unique
expiresAt DateTime
createdAt DateTime @default(now())
revokedAt DateTime?
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
tokenHash String @unique
expiresAt DateTime
createdAt DateTime @default(now())
revokedAt DateTime?
// Anmeldezeitpunkt der Sitzung, an jede rotierte Nachfolge-Token vererbt:
// spaetestens SESSION_MAX_AGE danach ist neu anzumelden.
sessionStartedAt DateTime @default(now())
sessionStartedAt DateTime @default(now())
@@index([userId])
}
@@ -223,23 +223,25 @@ enum ApiaryStatus {
}
model Apiary {
id String @id @default(cuid())
name String
description String?
location String?
latitude Float?
longitude Float?
address String?
status ApiaryStatus @default(ACTIVE)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deactivatedAt DateTime?
id String @id @default(cuid())
name String
description String?
location String?
latitude Float?
longitude Float?
address String?
// Coarse, public-safe place name for storefront/shop output (never the exact address).
publicLocationLabel String?
status ApiaryStatus @default(ACTIVE)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deactivatedAt DateTime?
colonies Colony[]
colonyMoves ColonyApiaryHistory[]
harvests Harvest[]
treatments Treatment[]
colonies Colony[]
colonyMoves ColonyApiaryHistory[]
harvests Harvest[]
treatments Treatment[]
rawMaterialLots RawMaterialLot[]
@@index([status])
@@ -454,6 +456,7 @@ model Inspection {
weather String?
temperature Float?
colonyStrength Int?
broodFrames Int?
broodAmount String?
broodPattern Int?
openBroodPresent Boolean?
@@ -541,7 +544,7 @@ enum TaskTemplateRecurrenceType {
}
// Wiederkehrende Aufgaben-Vorlagen – erzeugen automatisch eine neue Task,
// sobald nextDueDate erreicht ist (siehe TasksService.generateDueTasks).
// sobald nextDueDate erreicht ist (siehe TasksService.generateFromTemplates).
model TaskTemplate {
id String @id @default(cuid())
title String
@@ -761,8 +764,8 @@ model Harvest {
notes String?
createdAt DateTime @default(now())
colonies HarvestColony[]
extractions ExtractionHarvest[]
colonies HarvestColony[]
extractions ExtractionHarvest[]
rawMaterialLots RawMaterialLot[]
@@index([apiaryId, date])
@@ -1207,6 +1210,8 @@ model BatchMovement {
@@index([productionBatchId])
@@index([rawMaterialLotId])
@@index([shopRequestId])
// ERP-Export /bewegungen: sortiert und blättert nach (createdAt, id).
@@index([createdAt, id])
}
enum ShopStockRequestKind {
@@ -1300,6 +1305,8 @@ model WebhookSubscription {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deliveries WebhookDelivery[]
@@index([active])
}
@@ -1403,6 +1410,8 @@ model SiteSettings {
// part of any public page.
holderName String?
holderAddress String?
// Country of origin printed on labels and shop output.
originCountry String @default("Deutschland")
updatedAt DateTime @updatedAt
}
@@ -1521,3 +1530,119 @@ model CalendarSyncOutboxEntry {
@@unique([sourceType, sourceId])
@@index([status])
}
// ---------------------------------------------------------------------------
// Plattform-Rückgrat: Domain-Events (Outbox), Webhook-Zustellung, Job-Läufe,
// Throttler-Zähler. Alle fünf Modelle sind vom Backup ausgenommen.
// ---------------------------------------------------------------------------
enum DomainEventStatus {
PENDING
PROCESSING
DONE
FAILED
}
model DomainEvent {
id String @id @default(cuid())
type String
aggregateType String
aggregateId String
payload Json
actorUserId String?
correlationId String
// Nullable @unique instead of a partial index: avoids the migration drift
// described in the backbone spec (section 3.5).
dedupeKey String? @unique
occurredAt DateTime @default(now())
status DomainEventStatus @default(PENDING)
attempts Int @default(0)
availableAt DateTime @default(now())
claimedAt DateTime?
processedAt DateTime?
lastError String?
deliveries DomainEventDelivery[]
@@index([status, availableAt])
@@index([correlationId])
@@index([aggregateType, aggregateId])
}
enum DomainEventDeliveryStatus {
PENDING
DONE
FAILED
}
model DomainEventDelivery {
id String @id @default(cuid())
eventId String
event DomainEvent @relation(fields: [eventId], references: [id], onDelete: Cascade)
handler String
status DomainEventDeliveryStatus @default(PENDING)
attempts Int @default(0)
lastError String?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([eventId, handler])
}
enum WebhookDeliveryStatus {
PENDING
PROCESSING
DELIVERED
FAILED
}
model WebhookDelivery {
id String @id @default(cuid())
subscriptionId String
subscription WebhookSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
event String
body String
status WebhookDeliveryStatus @default(PENDING)
attempts Int @default(0)
availableAt DateTime @default(now())
claimedAt DateTime?
lastStatusCode Int?
lastError String?
deliveredAt DateTime?
createdAt DateTime @default(now())
@@index([status, availableAt])
@@index([subscriptionId, createdAt])
}
enum JobRunStatus {
RUNNING
SUCCEEDED
FAILED
}
model JobRun {
id String @id @default(cuid())
job String
status JobRunStatus @default(RUNNING)
startedAt DateTime @default(now())
finishedAt DateTime?
result Json?
error String?
@@index([job, startedAt])
@@index([status])
}
// UNLOGGED in the migration: no WAL, emptied after a crash - only counters get lost.
model ThrottlerHit {
key String
throttlerName String
hits Int
expiresAt DateTime
blockedUntil DateTime?
@@id([key, throttlerName])
@@index([expiresAt])
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AppModule } from './app.module';
import { WorkerRuntimeModule } from './worker/worker-runtime.module';
/**
* Role "all": the API plus the worker loops in one process, for local
* development and tests. Production refuses it (runtime/role.ts) and runs
* api and worker as separate pods. No worker health server here - the
* API's /health endpoints serve the process.
*/
@Module({
imports: [AppModule, WorkerRuntimeModule],
})
export class AllInOneModule {}
@@ -19,6 +19,24 @@ export const API_KEY_SCOPES = [
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
/**
* Scopes that book (change data). Every other scope only reads. A new key
* never mixes both kinds: a leaked read key cannot book, a leaked booking
* key cannot read (ARCHITECTURE.md, Annahme 14).
*/
export const API_KEY_WRITE_SCOPES = [
'shop-sync:write',
] as const satisfies readonly ApiKeyScope[];
export function isApiKeyScope(value: string): value is ApiKeyScope {
return (API_KEY_SCOPES as readonly string[]).includes(value);
}
function isWriteScope(scope: string): boolean {
return (API_KEY_WRITE_SCOPES as readonly string[]).includes(scope);
}
/** True if the scopes hold at least one reading and one booking right. */
export function combinesReadAndWrite(scopes: readonly string[]): boolean {
return scopes.some(isWriteScope) && scopes.some((s) => !isWriteScope(s));
}
@@ -1,12 +1,17 @@
import { Global, Module } from '@nestjs/common';
import { ThrottlerStorageModule } from '../common/throttler/throttler-storage.module';
import { ApiKeysController } from './api-keys.controller';
import { ApiKeysService } from './api-keys.service';
import { FailedAttemptLimiter } from './failed-attempt-limiter';
// Global: the app-wide auth guards need ApiKeysService to recognise keys.
// ThrottlerStorageModule: the failure counters share the rate-limit table,
// so the limit holds across all api pods.
@Global()
@Module({
imports: [ThrottlerStorageModule],
controllers: [ApiKeysController],
providers: [ApiKeysService],
providers: [ApiKeysService, FailedAttemptLimiter],
exports: [ApiKeysService],
})
export class ApiKeysModule {}
@@ -1,13 +1,19 @@
import { ForbiddenException, HttpException, Logger } from '@nestjs/common';
import {
BadRequestException,
ForbiddenException,
HttpException,
Logger,
} from '@nestjs/common';
import { createHash } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
import { ApiKeysService, extractApiKeyToken } from './api-keys.service';
import {
API_KEY_FAILURE_LIMIT,
ApiKeysService,
extractApiKeyToken,
} from './api-keys.service';
FailedAttemptLimiter,
} from './failed-attempt-limiter';
// Synthetischer Schlüssel im echten Format - kein echter Zugang.
const TOKEN = `ack_abcd1234_${'A'.repeat(43)}`;
@@ -31,6 +37,38 @@ function storedKey(overrides: Record<string, unknown> = {}) {
};
}
/**
* Stand-in for the shared counter table with the same counting rule as
* PrismaThrottlerStorage within one window: count up to the limit, the hit
* after it blocks, a blocked source keeps its count. Time and the database
* itself are covered by the e2e tests.
*/
function inMemoryStorage(): PrismaThrottlerStorage {
const rows = new Map<string, { hits: number; blocked: boolean }>();
return {
increment: (
key: string,
ttl: number,
limit: number,
blockDuration: number,
throttlerName: string,
) => {
const id = `${throttlerName}|${key}`;
const row = rows.get(id) ?? { hits: 0, blocked: false };
const next = row.blocked
? row
: { hits: row.hits + 1, blocked: row.hits + 1 > limit };
rows.set(id, next);
return Promise.resolve({
totalHits: next.hits,
timeToExpire: ttl / 1000,
isBlocked: next.blocked,
timeToBlockExpire: next.blocked ? blockDuration / 1000 : 0,
});
},
} as unknown as PrismaThrottlerStorage;
}
describe('ApiKeysService.authenticate', () => {
let prisma: { apiKey: { findUnique: jest.Mock; update: jest.Mock } };
let service: ApiKeysService;
@@ -47,6 +85,7 @@ describe('ApiKeysService.authenticate', () => {
prisma as unknown as PrismaService,
{ record: jest.fn() } as unknown as AuditLogService,
{ getPermissions: jest.fn() } as unknown as PermissionResolverService,
new FailedAttemptLimiter(inMemoryStorage()),
);
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
});
@@ -166,6 +205,18 @@ describe('ApiKeysService.authenticate', () => {
).resolves.toBe(null);
});
it('meldet das Erreichen der Grenze einmal im Log', async () => {
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
await expect(
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
).rejects.toMatchObject({ status: 429 });
const limitMessages = warn.mock.calls.filter(([message]) =>
String(message).includes('get 429'),
);
expect(limitMessages).toHaveLength(1);
});
it('weist einen gültigen Schlüssel nie wegen der Sperre ab - sonst könnte jeder ERPNext oder den Shop aussperren', async () => {
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
await expect(
@@ -210,6 +261,101 @@ describe('ApiKeysService.authenticate', () => {
});
});
describe('ApiKeysService.create', () => {
let service: ApiKeysService;
let prisma: { apiKey: { create: jest.Mock } };
beforeEach(() => {
prisma = {
apiKey: {
create: jest.fn((args: { data: Record<string, unknown> }) =>
Promise.resolve({ id: 'key-1', keyHash: 'h', ...args.data }),
),
},
};
const permissionResolver = {
getPermissions: jest
.fn()
.mockResolvedValue(
new Set(['shop-sync:read', 'shop-sync:write', 'erp-inventory:read']),
),
};
service = new ApiKeysService(
prisma as unknown as PrismaService,
{ record: jest.fn() } as unknown as AuditLogService,
permissionResolver as unknown as PermissionResolverService,
new FailedAttemptLimiter(inMemoryStorage()),
);
});
it('refuses a new key with both shop scopes', async () => {
await expect(
service.create(
{
name: 'Shop',
scopes: ['shop-sync:read', 'shop-sync:write'],
},
'user-1',
),
).rejects.toThrow(
new BadRequestException(
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
),
);
expect(prisma.apiKey.create).not.toHaveBeenCalled();
});
it('refuses the read-only ERP scope together with booking', async () => {
await expect(
service.create(
{
name: 'ERPNext',
scopes: ['erp-inventory:read', 'shop-sync:write'],
},
'user-1',
),
).rejects.toThrow(
new BadRequestException(
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
),
);
expect(prisma.apiKey.create).not.toHaveBeenCalled();
});
it('refuses a new key with more than one scope, even if all of them read', async () => {
await expect(
service.create(
{ name: 'Alles', scopes: ['shop-sync:read', 'erp-inventory:read'] },
'user-1',
),
).rejects.toThrow(
new BadRequestException(
'Je Schlüssel nur eine Berechtigung - bitte getrennte Schlüssel anlegen',
),
);
expect(prisma.apiKey.create).not.toHaveBeenCalled();
});
it('treats the same scope given twice as one', async () => {
const created = await service.create(
{ name: 'ERPNext', scopes: ['erp-inventory:read', 'erp-inventory:read'] },
'user-1',
);
expect(created.apiKey.scopes).toEqual(['erp-inventory:read']);
});
it.each(['shop-sync:read', 'shop-sync:write', 'erp-inventory:read'])(
'still creates a key with only %s',
async (scope) => {
const created = await service.create(
{ name: 'Shop', scopes: [scope] },
'user-1',
);
expect(created.apiKey.scopes).toEqual([scope]);
},
);
});
describe('extractApiKeyToken', () => {
it('erkennt nur Bearer-Schlüssel im ApiCore-Format', () => {
expect(extractApiKeyToken(`Bearer ${TOKEN}`)).toBe(TOKEN);
@@ -1,4 +1,5 @@
import {
BadRequestException,
ForbiddenException,
HttpException,
HttpStatus,
@@ -11,11 +12,18 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { ApiKey } from '../../generated/prisma';
import { CreateApiKeyDto } from './dto/create-api-key.dto';
import { ApiKeyScope, isApiKeyScope } from './api-key-scopes';
import {
ApiKeyScope,
combinesReadAndWrite,
isApiKeyScope,
} from './api-key-scopes';
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { FailedAttemptLimiter } from './failed-attempt-limiter';
import {
API_KEY_FAILURE_LIMIT,
FailedAttemptLimiter,
} from './failed-attempt-limiter';
import { rateLimitSubject } from '../common/throttler/rate-limit-subject';
function normalizeAllowlist(entries: readonly string[] | undefined): string[] {
@@ -38,21 +46,6 @@ const LAST_USED_RESOLUTION_MS = 5 * 60 * 1000;
// revealing its secret part.
const LOGGABLE_PREFIX_LENGTH = 12;
/**
* Unknown, revoked or expired keys per client network before that network
* gets 429 for further invalid keys for the rest of the window - the same
* budget as the shop's ERP export. It slows down guessing and a
* misconfigured client; a valid key is never refused by it. Otherwise
* anyone able to send requests from (or forge) a shared address - the
* cluster egress, a proxy - could lock ERPNext or the shop out, and a key
* holds 256 random bits, so letting it through costs nothing.
*/
export const API_KEY_FAILURE_LIMIT = {
maxFailures: 20,
windowMs: 15 * 60 * 1000,
maxTrackedSources: 10_000,
} as const;
type RejectReason = 'unknown' | 'revoked' | 'expired';
/** What a request authenticated by an API key may do - it is not a user. */
@@ -91,12 +84,13 @@ function toSummary(apiKey: ApiKey): ApiKeySummary {
@Injectable()
export class ApiKeysService {
private readonly logger = new Logger(ApiKeysService.name);
private readonly failures = new FailedAttemptLimiter(API_KEY_FAILURE_LIMIT);
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly permissionResolver: PermissionResolverService,
// Counters shared by all api pods (ThrottlerHit), not process memory.
private readonly failures: FailedAttemptLimiter,
) {}
async findAll(): Promise<ApiKeySummary[]> {
@@ -110,6 +104,19 @@ export class ApiKeysService {
dto: CreateApiKeyDto,
actingUserId: string,
): Promise<CreatedApiKey> {
// One scope per new key: a leaked read key cannot book, a leaked booking
// key cannot read, and each connected system gets its own key.
// Existing multi-scope keys stay valid (ARCHITECTURE.md, Annahme 14).
if (combinesReadAndWrite(dto.scopes)) {
throw new BadRequestException(
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
);
}
if (new Set(dto.scopes).size > 1) {
throw new BadRequestException(
'Je Schlüssel nur eine Berechtigung - bitte getrennte Schlüssel anlegen',
);
}
// Nobody hands a machine more than they may do themselves.
const callerPermissions =
await this.permissionResolver.getPermissions(actingUserId);
@@ -224,20 +231,21 @@ export class ApiKeysService {
if (reason === null && apiKey) {
return this.accept(apiKey, clientIp, now);
}
this.recordRejection(token, reason ?? 'unknown', clientIp);
await this.recordRejection(token, reason ?? 'unknown', clientIp);
return null;
}
/** Counts an invalid key; 429 once the client network is over the limit. */
private recordRejection(
private async recordRejection(
token: string,
reason: RejectReason,
clientIp: string | undefined,
): void {
): Promise<void> {
// Counted per client network like the throttler (IPv6 /64), so rotating
// addresses inside one's own prefix does not reset the budget.
const subject = clientIp ? rateLimitSubject(clientIp) : 'unknown';
if (this.failures.isBlocked(subject)) {
const verdict = await this.failures.recordFailure(subject);
if (verdict.blocked) {
// Same body shape as the built-in 4xx exceptions (JSON object, not a
// bare string), so clients can read `message` uniformly.
throw new HttpException(
@@ -254,9 +262,9 @@ export class ApiKeysService {
this.logger.warn(
`API key rejected (${reason}): ${token.slice(0, LOGGABLE_PREFIX_LENGTH)} from ${clientIp ?? 'unknown'}`,
);
if (this.failures.recordFailure(subject)) {
if (verdict.limitReached) {
this.logger.warn(
`Invalid API keys from ${subject} refused for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes after ${API_KEY_FAILURE_LIMIT.maxFailures} failures`,
`Invalid API keys from ${subject} reached ${API_KEY_FAILURE_LIMIT.maxFailures} failures - further invalid keys get 429 for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes`,
);
}
}
@@ -1,112 +1,91 @@
import { FailedAttemptLimiter } from './failed-attempt-limiter';
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
import {
API_KEY_FAILURE_LIMIT,
API_KEY_FAILURE_THROTTLER,
FailedAttemptLimiter,
} from './failed-attempt-limiter';
describe('FailedAttemptLimiter', () => {
let now: number;
let limiter: FailedAttemptLimiter;
const increment = jest.fn();
const limiter = new FailedAttemptLimiter({
increment,
} as unknown as PrismaThrottlerStorage);
function record(totalHits: number, isBlocked: boolean) {
return {
totalHits,
timeToExpire: 600,
isBlocked,
timeToBlockExpire: isBlocked ? 900 : 0,
};
}
beforeEach(() => {
now = 1_000_000;
limiter = new FailedAttemptLimiter({
maxFailures: 3,
windowMs: 60_000,
maxTrackedSources: 100,
clock: () => now,
increment.mockReset();
increment.mockResolvedValue(record(1, false));
});
it('zählt in der gemeinsamen Zähler-Tabelle, getrennt vom Anfrage-Limit', async () => {
await limiter.recordFailure('203.0.113.7');
// Ein Aufruf = eine Upsert-Anweisung mit DB-Uhr (PrismaThrottlerStorage),
// unter eigenem Throttler-Namen und eigenem Schlüssel-Präfix.
expect(increment).toHaveBeenCalledTimes(1);
expect(increment).toHaveBeenCalledWith(
`${API_KEY_FAILURE_THROTTLER}:203.0.113.7`,
API_KEY_FAILURE_LIMIT.windowMs,
API_KEY_FAILURE_LIMIT.maxFailures,
API_KEY_FAILURE_LIMIT.windowMs,
API_KEY_FAILURE_THROTTLER,
);
expect(API_KEY_FAILURE_THROTTLER).toBe('api-key-failures');
});
it('behält die vereinbarten Grenzen: 20 Fehlversuche in 15 Minuten', () => {
expect(API_KEY_FAILURE_LIMIT).toEqual({
maxFailures: 20,
windowMs: 15 * 60 * 1000,
});
});
it('sperrt eine Quelle erst, wenn die Obergrenze erreicht ist', () => {
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
it('sperrt nicht unterhalb der Grenze', async () => {
increment.mockResolvedValueOnce(
record(API_KEY_FAILURE_LIMIT.maxFailures - 1, false),
);
// Der Fehlversuch, mit dem die Grenze erreicht wird, meldet die Sperre.
expect(limiter.recordFailure('203.0.113.7')).toBe(true);
expect(limiter.isBlocked('203.0.113.7')).toBe(true);
});
it('meldet die Sperre nur einmal, nicht bei jedem weiteren Versuch', () => {
limiter.recordFailure('203.0.113.7');
limiter.recordFailure('203.0.113.7');
expect(limiter.recordFailure('203.0.113.7')).toBe(true);
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
});
it('trennt Quellen voneinander', () => {
for (let i = 0; i < 3; i++) limiter.recordFailure('203.0.113.7');
expect(limiter.isBlocked('203.0.113.7')).toBe(true);
expect(limiter.isBlocked('198.51.100.1')).toBe(false);
});
it('gibt die Quelle nach Ablauf des Fensters wieder frei', () => {
for (let i = 0; i < 3; i++) limiter.recordFailure('203.0.113.7');
now += 60_001;
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
// Neues Fenster: der nächste Fehlversuch zählt wieder ab eins.
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
});
it('vergisst alte Quellen, statt unbegrenzt Speicher zu belegen', () => {
const small = new FailedAttemptLimiter({
maxFailures: 1,
windowMs: 60_000,
maxTrackedSources: 2,
clock: () => now,
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
blocked: false,
limitReached: false,
});
small.recordFailure('a');
small.recordFailure('b');
now += 60_001;
// Beim dritten Eintrag wird aufgeräumt: a und b sind abgelaufen.
small.recordFailure('c');
expect(small.trackedSources()).toBe(1);
expect(small.isBlocked('c')).toBe(true);
});
it('verdrängt bei voller Liste den ältesten Eintrag, auch wenn er noch läuft', () => {
const small = new FailedAttemptLimiter({
maxFailures: 5,
windowMs: 60_000,
maxTrackedSources: 2,
clock: () => now,
});
small.recordFailure('a');
small.recordFailure('b');
small.recordFailure('c');
expect(small.trackedSources()).toBe(2);
});
it('verdrängt bei voller Liste zuerst nicht gesperrte Quellen', () => {
const small = new FailedAttemptLimiter({
maxFailures: 2,
windowMs: 60_000,
maxTrackedSources: 2,
clock: () => now,
});
small.recordFailure('angreifer');
small.recordFailure('angreifer'); // gesperrt, ältester Eintrag
small.recordFailure('harmlos'); // ein Fehlversuch, nicht gesperrt
small.recordFailure('neu');
it('meldet genau den Fehlversuch, mit dem die Grenze erreicht ist', async () => {
increment.mockResolvedValueOnce(
record(API_KEY_FAILURE_LIMIT.maxFailures, false),
);
expect(small.trackedSources()).toBe(2);
// Die Sperre bleibt, obwohl sie der älteste Eintrag ist.
expect(small.isBlocked('angreifer')).toBe(true);
// Verdrängt wurde der nicht gesperrte Eintrag.
expect(small.recordFailure('harmlos')).toBe(false);
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
blocked: false,
limitReached: true,
});
});
it('verdrängt gesperrte Quellen erst, wenn alle gesperrt sind (älteste zuerst)', () => {
const small = new FailedAttemptLimiter({
maxFailures: 1,
windowMs: 60_000,
maxTrackedSources: 2,
clock: () => now,
});
small.recordFailure('a');
small.recordFailure('b');
small.recordFailure('c');
it('sperrt, sobald die Datenbank die Quelle als gesperrt meldet', async () => {
increment.mockResolvedValueOnce(
record(API_KEY_FAILURE_LIMIT.maxFailures + 1, true),
);
expect(small.trackedSources()).toBe(2);
expect(small.isBlocked('a')).toBe(false);
expect(small.isBlocked('b')).toBe(true);
expect(small.isBlocked('c')).toBe(true);
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
blocked: true,
limitReached: false,
});
});
it('gibt Datenbankfehler weiter, statt still durchzulassen', async () => {
increment.mockRejectedValueOnce(new Error('db down'));
await expect(limiter.recordFailure('203.0.113.7')).rejects.toThrow(
'db down',
);
});
});
@@ -1,94 +1,57 @@
export interface FailedAttemptLimiterOptions {
/** Ab so vielen Fehlversuchen im Fenster ist die Quelle gesperrt. */
maxFailures: number;
/** Länge des Fensters in Millisekunden, gezählt ab dem ersten Fehlversuch. */
windowMs: number;
/** Obergrenze gemerkter Quellen - schützt den Speicher bei verteilten Angriffen. */
maxTrackedSources: number;
clock?: () => number;
}
import { Injectable } from '@nestjs/common';
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
interface Window {
failures: number;
startedAt: number;
/**
* Unknown, revoked or expired keys per client network before that network
* gets 429 for further invalid keys - the same budget the ERP export
* contract names (docs/erp-inventar-export.md). It slows down guessing and a
* misconfigured client; a valid key is never refused by it. Otherwise
* anyone able to send requests from (or forge) a shared address - the
* cluster egress, a proxy - could lock ERPNext or the shop out, and a key
* holds 256 random bits, so letting it through costs nothing.
*/
export const API_KEY_FAILURE_LIMIT = {
maxFailures: 20,
windowMs: 15 * 60 * 1000,
} as const;
/**
* Throttler name and key prefix of these counters in "ThrottlerHit", so
* they never mix with the request rate limits stored in the same table.
*/
export const API_KEY_FAILURE_THROTTLER = 'api-key-failures';
export interface FailedAttemptVerdict {
/** The source is over the limit: refuse the request with 429. */
blocked: boolean;
/** This failure is the one that reached the limit (once per window). */
limitReached: boolean;
}
/**
* Zählt fehlgeschlagene Anmeldungen je Quelle (Client-IP) in einem festen
* Zeitfenster und sperrt die Quelle, sobald die Obergrenze erreicht ist.
*
* Bewusst im Speicher des Prozesses, wie der globale Throttler: ApiCore läuft
* mit einem Backend-Pod. Nach einem Neustart beginnt die Zählung von vorn -
* das ist für eine Bremse gegen Durchprobieren ausreichend.
* Counts invalid API keys per source (client network) in the shared,
* database-backed rate-limit storage, so the limit holds across all api
* pods. One upsert per failure, every time comparison on the database
* clock (PrismaThrottlerStorage). The window starts with the first failure;
* the failure after the limit blocks the source for one window length.
* A database error propagates: the request fails instead of passing.
*/
@Injectable()
export class FailedAttemptLimiter {
private readonly windows = new Map<string, Window>();
private readonly clock: () => number;
constructor(private readonly storage: PrismaThrottlerStorage) {}
constructor(private readonly options: FailedAttemptLimiterOptions) {
this.clock = options.clock ?? Date.now;
}
isBlocked(source: string): boolean {
const window = this.activeWindow(source);
return window !== undefined && window.failures >= this.options.maxFailures;
}
/** Zählt einen Fehlversuch; true genau dann, wenn er die Sperre auslöst. */
recordFailure(source: string): boolean {
const now = this.clock();
const current = this.activeWindow(source);
if (!current) {
this.makeRoom(now);
}
const failures = (current?.failures ?? 0) + 1;
this.windows.set(source, {
failures,
startedAt: current?.startedAt ?? now,
});
return failures === this.options.maxFailures;
}
trackedSources(): number {
return this.windows.size;
}
private activeWindow(source: string): Window | undefined {
const window = this.windows.get(source);
if (window && this.clock() - window.startedAt > this.options.windowMs) {
this.windows.delete(source);
return undefined;
}
return window;
}
private makeRoom(now: number): void {
if (this.windows.size < this.options.maxTrackedSources) return;
for (const [source, window] of this.windows) {
if (now - window.startedAt > this.options.windowMs) {
this.windows.delete(source);
}
}
// Immer noch voll: lieber eine Zählung verlieren als unbegrenzt wachsen.
// Zuerst den ältesten nicht gesperrten Eintrag verdrängen - sonst könnte
// ein Angreifer mit vielen Adressen seine eigene Sperre hinausschieben.
// Nur wenn alle gesperrt sind, geht der älteste gesperrte (die Map behält
// die Einfügereihenfolge).
if (this.windows.size >= this.options.maxTrackedSources) {
const victim =
this.oldestWhere(
(window) => window.failures < this.options.maxFailures,
) ?? this.oldestWhere(() => true);
if (victim !== undefined) this.windows.delete(victim);
}
}
private oldestWhere(
matches: (window: Window) => boolean,
): string | undefined {
for (const [source, window] of this.windows) {
if (matches(window)) return source;
}
return undefined;
async recordFailure(source: string): Promise<FailedAttemptVerdict> {
const { maxFailures, windowMs } = API_KEY_FAILURE_LIMIT;
const record = await this.storage.increment(
`${API_KEY_FAILURE_THROTTLER}:${source}`,
windowMs,
maxFailures,
windowMs,
API_KEY_FAILURE_THROTTLER,
);
return {
blocked: record.isBlocked,
limitReached: !record.isBlocked && record.totalHits === maxFailures,
};
}
}
@@ -15,12 +15,16 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateApiaryDto } from './dto/create-apiary.dto';
import { UpdateApiaryDto } from './dto/update-apiary.dto';
import { ApiariesService } from './apiaries.service';
import { ApiaryFieldService } from './apiary-field.service';
@ApiTags('apiaries')
@ApiBearerAuth()
@Controller('apiaries')
export class ApiariesController {
constructor(private readonly apiariesService: ApiariesService) {}
constructor(
private readonly apiariesService: ApiariesService,
private readonly apiaryFieldService: ApiaryFieldService,
) {}
@RequirePermission('apiaries:read')
@Get()
@@ -31,6 +35,24 @@ export class ApiariesController {
return this.apiariesService.findAll({ status, location });
}
@RequirePermission('colonies:read')
@Get(':id/colony-overview')
colonyOverview(
@Param('id') id: string,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.apiaryFieldService.colonyOverview(id, actor.id);
}
@RequirePermission('colonies:read')
@Get(':id/offline-pack')
offlinePack(
@Param('id') id: string,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.apiaryFieldService.offlinePack(id, actor.id);
}
@RequirePermission('apiaries:read')
@Get(':id')
findOne(@Param('id') id: string) {
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { ApiariesController } from './apiaries.controller';
import { ApiariesService } from './apiaries.service';
import { ApiaryFieldService } from './apiary-field.service';
@Module({
controllers: [ApiariesController],
providers: [ApiariesService],
providers: [ApiariesService, ApiaryFieldService],
exports: [ApiariesService],
})
export class ApiariesModule {}
@@ -0,0 +1,206 @@
import { NotFoundException } from '@nestjs/common';
import { ApiaryFieldService } from './apiary-field.service';
import { PrismaService } from '../prisma/prisma.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
describe('ApiaryFieldService', () => {
let service: ApiaryFieldService;
let prisma: {
apiary: { findUnique: jest.Mock };
colony: { findMany: jest.Mock };
inspection: { groupBy: jest.Mock; findMany: jest.Mock };
notification: { groupBy: jest.Mock };
queen: { findMany: jest.Mock };
task: { findMany: jest.Mock };
};
let permissionResolver: { getPermissions: jest.Mock };
const colonyRow = (id: string, code: string, queenId: string | null) => ({
id,
code,
apiaryId: 'apiary-1',
status: 'ACTIVE',
colonyType: 'PRODUCTION_COLONY',
hiveType: null,
strength: 5,
broodStatus: null,
feedStatus: null,
healthStatus: null,
currentQueenId: queenId,
notes: null,
});
const grant = (...keys: string[]) =>
permissionResolver.getPermissions.mockResolvedValue(new Set(keys));
beforeEach(() => {
prisma = {
apiary: {
findUnique: jest.fn().mockResolvedValue({
id: 'apiary-1',
name: 'Heimstand',
location: 'Garten',
status: 'ACTIVE',
}),
},
colony: {
findMany: jest
.fn()
.mockResolvedValue([
colonyRow('c10', 'V-10', 'q1'),
colonyRow('c2', 'V-2', null),
]),
},
inspection: {
groupBy: jest.fn().mockResolvedValue([]),
findMany: jest.fn().mockResolvedValue([]),
},
notification: { groupBy: jest.fn().mockResolvedValue([]) },
queen: { findMany: jest.fn().mockResolvedValue([]) },
task: { findMany: jest.fn().mockResolvedValue([]) },
};
permissionResolver = { getPermissions: jest.fn() };
service = new ApiaryFieldService(
prisma as unknown as PrismaService,
permissionResolver as unknown as PermissionResolverService,
);
});
it('throws NotFoundException for an unknown apiary', async () => {
grant('colonies:read');
prisma.apiary.findUnique.mockResolvedValue(null);
await expect(service.colonyOverview('x', 'u1')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('sorts living colonies by code with numeric collation', async () => {
grant('colonies:read');
const overview = await service.colonyOverview('apiary-1', 'u1');
expect(overview.rows.map((row) => row.code)).toEqual(['V-2', 'V-10']);
expect(prisma.colony.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ apiaryId: 'apiary-1' }) as unknown,
}),
);
});
it('leaves out inspections, queens and tasks without their read permission', async () => {
grant('colonies:read');
const pack = await service.offlinePack('apiary-1', 'u1');
expect(pack.inspections).toBeNull();
expect(pack.queens).toBeNull();
expect(pack.tasks).toBeNull();
expect(pack.overview.inspectionsVisible).toBe(false);
expect(pack.overview.rows.every((r) => r.lastInspectionDate === null)).toBe(
true,
);
expect(prisma.inspection.groupBy).not.toHaveBeenCalled();
expect(prisma.inspection.findMany).not.toHaveBeenCalled();
expect(prisma.queen.findMany).not.toHaveBeenCalled();
expect(prisma.task.findMany).not.toHaveBeenCalled();
});
it('fills the permitted sections and the last inspection date', async () => {
grant('colonies:read', 'inspections:read', 'queens:read', 'tasks:read');
const date = new Date('2026-05-01T10:00:00.000Z');
prisma.inspection.groupBy.mockResolvedValue([
{ colonyId: 'c2', _max: { date } },
]);
prisma.queen.findMany.mockResolvedValue([{ id: 'q1', code: 'K-1' }]);
prisma.task.findMany.mockResolvedValue([{ id: 't1', title: 'Futter' }]);
const pack = await service.offlinePack('apiary-1', 'u1');
expect(pack.overview.inspectionsVisible).toBe(true);
expect(pack.overview.rows[0].lastInspectionDate).toBe(date.toISOString());
expect(pack.overview.rows[1].lastInspectionDate).toBeNull();
expect(pack.queens).toEqual([{ id: 'q1', code: 'K-1' }]);
expect(prisma.queen.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: { in: ['q1'] } } }),
);
expect(pack.tasks).toEqual([{ id: 't1', title: 'Futter' }]);
expect(prisma.task.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
status: { in: ['OPEN', 'IN_PROGRESS'] },
OR: [
{ relatedType: 'APIARY', relatedId: 'apiary-1' },
{ relatedType: 'COLONY', relatedId: { in: ['c2', 'c10'] } },
],
},
}),
);
});
it('keeps at most three inspections per colony, newest first', async () => {
grant('colonies:read', 'inspections:read');
prisma.inspection.findMany.mockResolvedValue([{ id: 'i1' }]);
const pack = await service.offlinePack('apiary-1', 'u1');
expect(prisma.inspection.findMany).toHaveBeenCalledTimes(2);
expect(prisma.inspection.findMany).toHaveBeenCalledWith({
where: { colonyId: 'c2' },
take: 3,
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }],
omit: { userId: true },
});
expect(pack.inspections).toHaveLength(2);
});
it('never returns coordinates, address, apiary notes or inspection userId', async () => {
grant('colonies:read', 'inspections:read');
const pack = await service.offlinePack('apiary-1', 'u1');
expect(Object.keys(pack.apiary).sort()).toEqual([
'id',
'location',
'name',
'status',
]);
const apiarySelect = (
prisma.apiary.findUnique.mock.calls[0] as [{ select: object }]
)[0].select;
expect(apiarySelect).not.toHaveProperty('latitude');
expect(apiarySelect).not.toHaveProperty('longitude');
expect(apiarySelect).not.toHaveProperty('address');
expect(apiarySelect).not.toHaveProperty('notes');
const inspectionArgs = (
prisma.inspection.findMany.mock.calls[0] as [{ omit: object }]
)[0];
expect(inspectionArgs.omit).toEqual({ userId: true });
});
it('counts only unread WARNUNG and KRITISCH colony notifications visible to the caller', async () => {
grant('colonies:read');
prisma.notification.groupBy.mockResolvedValue([
{ relatedId: 'c2', priority: 'WARNUNG', _count: { _all: 2 } },
{ relatedId: 'c2', priority: 'KRITISCH', _count: { _all: 1 } },
]);
const overview = await service.colonyOverview('apiary-1', 'u1');
expect(prisma.notification.groupBy).toHaveBeenCalledWith(
expect.objectContaining({
by: ['relatedId', 'priority'],
where: {
relatedType: 'COLONY',
relatedId: { in: ['c2', 'c10'] },
read: false,
priority: { in: ['WARNUNG', 'KRITISCH'] },
OR: [{ userId: 'u1' }, { userId: null }],
},
}),
);
expect(overview.rows[0]).toMatchObject({
code: 'V-2',
openWarnings: 2,
openCritical: 1,
});
expect(overview.rows[1]).toMatchObject({
openWarnings: 0,
openCritical: 0,
});
});
});
@@ -0,0 +1,230 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { PrismaService } from '../prisma/prisma.service';
import type {
ColonyOverview,
ColonyOverviewRow,
OfflinePackV1,
} from './apiary-field.types';
const INSPECTIONS_PER_COLONY = 3;
type PackColony = OfflinePackV1['colonies'][number];
interface FieldData {
apiary: OfflinePackV1['apiary'];
colonies: PackColony[];
permissions: ReadonlySet<string>;
overview: ColonyOverview;
}
/**
* Read models for work at the apiary: the colony traffic-light overview and
* the offline pack the PWA caches before the beekeeper loses reception.
* Sections beyond the colonies are only included for callers who may read them.
*/
@Injectable()
export class ApiaryFieldService {
constructor(
private readonly prisma: PrismaService,
private readonly permissionResolver: PermissionResolverService,
) {}
async colonyOverview(
apiaryId: string,
userId: string,
): Promise<ColonyOverview> {
return (await this.load(apiaryId, userId)).overview;
}
async offlinePack(apiaryId: string, userId: string): Promise<OfflinePackV1> {
const { apiary, colonies, permissions, overview } = await this.load(
apiaryId,
userId,
);
const [queens, inspections, tasks] = 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,
]);
return {
version: 1,
generatedAt: new Date().toISOString(),
apiary,
overview,
colonies,
queens,
inspections,
tasks,
};
}
private async load(apiaryId: string, userId: string): Promise<FieldData> {
// Explicit select: coordinates, address and notes of an apiary are
// theft-sensitive and must never reach the field payloads.
const apiary = await this.prisma.apiary.findUnique({
where: { id: apiaryId },
select: { id: true, name: true, location: true, status: true },
});
if (!apiary) {
throw new NotFoundException('Apiary not found');
}
const permissions = await this.permissionResolver.getPermissions(userId);
const found = await this.prisma.colony.findMany({
where: { ...LIVING_COLONY_WHERE, apiaryId },
select: {
id: true,
code: true,
apiaryId: true,
status: true,
colonyType: true,
hiveType: true,
strength: true,
broodStatus: true,
feedStatus: true,
healthStatus: true,
currentQueenId: true,
notes: true,
},
});
const colonies = [...found].sort((a, b) =>
a.code.localeCompare(b.code, 'de', { numeric: true }),
);
const inspectionsVisible = permissions.has('inspections:read');
const [lastInspections, warnings] = await Promise.all([
inspectionsVisible ? this.lastInspectionDates(colonies) : null,
this.warningCounts(colonies, userId),
]);
const rows = colonies.map((colony): ColonyOverviewRow => ({
id: colony.id,
code: colony.code,
status: colony.status,
strength: colony.strength,
broodStatus: colony.broodStatus,
feedStatus: colony.feedStatus,
healthStatus: colony.healthStatus,
lastInspectionDate: lastInspections?.get(colony.id) ?? null,
openWarnings: warnings.get(colony.id)?.WARNUNG ?? 0,
openCritical: warnings.get(colony.id)?.KRITISCH ?? 0,
}));
return {
apiary,
colonies,
permissions,
overview: { apiaryId, inspectionsVisible, rows },
};
}
private async lastInspectionDates(
colonies: PackColony[],
): Promise<Map<string, string>> {
const groups = await this.prisma.inspection.groupBy({
by: ['colonyId'],
where: { colonyId: { in: colonies.map((colony) => colony.id) } },
_max: { date: true },
});
const dates = new Map<string, string>();
for (const group of groups) {
if (group._max.date) {
dates.set(group.colonyId, group._max.date.toISOString());
}
}
return dates;
}
private async warningCounts(
colonies: PackColony[],
userId: string,
): Promise<Map<string, { WARNUNG?: number; KRITISCH?: number }>> {
const groups = await this.prisma.notification.groupBy({
by: ['relatedId', 'priority'],
where: {
relatedType: 'COLONY',
relatedId: { in: colonies.map((colony) => colony.id) },
read: false,
priority: { in: ['WARNUNG', 'KRITISCH'] },
OR: [{ userId }, { userId: null }],
},
_count: { _all: true },
});
const counts = new Map<string, { WARNUNG?: number; KRITISCH?: number }>();
for (const group of groups) {
if (
group.relatedId &&
(group.priority === 'WARNUNG' || group.priority === 'KRITISCH')
) {
counts.set(group.relatedId, {
...counts.get(group.relatedId),
[group.priority]: group._count._all,
});
}
}
return counts;
}
private loadQueens(colonies: PackColony[]): Promise<OfflinePackV1['queens']> {
const queenIds = colonies.flatMap((colony) =>
colony.currentQueenId ? [colony.currentQueenId] : [],
);
return this.prisma.queen.findMany({
where: { id: { in: queenIds } },
select: {
id: true,
code: true,
hatchYear: true,
breed: true,
markingColor: true,
markingNumber: true,
status: true,
},
});
}
private async loadInspections(
colonies: PackColony[],
): Promise<OfflinePackV1['inspections']> {
const perColony = await Promise.all(
colonies.map((colony) =>
this.prisma.inspection.findMany({
where: { colonyId: colony.id },
take: INSPECTIONS_PER_COLONY,
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }],
omit: { userId: true },
}),
),
);
return perColony.flat();
}
private loadTasks(
apiaryId: string,
colonies: PackColony[],
): Promise<OfflinePackV1['tasks']> {
return this.prisma.task.findMany({
where: {
status: { in: ['OPEN', 'IN_PROGRESS'] },
OR: [
{ relatedType: 'APIARY', relatedId: apiaryId },
{
relatedType: 'COLONY',
relatedId: { in: colonies.map((colony) => colony.id) },
},
],
},
select: {
id: true,
title: true,
description: true,
dueDate: true,
priority: true,
status: true,
relatedType: true,
relatedId: true,
},
orderBy: [{ dueDate: 'asc' }, { createdAt: 'asc' }],
});
}
}
@@ -0,0 +1,91 @@
import type {
ApiaryStatus,
Colony,
ColonyCondition,
ColonyStatus,
Inspection,
Queen,
Task,
} from '../../generated/prisma';
/** One living colony on the apiary page, reduced to what the traffic light needs. */
export interface ColonyOverviewRow {
id: string;
code: string;
status: ColonyStatus;
strength: number | null;
broodStatus: ColonyCondition | null;
feedStatus: ColonyCondition | null;
healthStatus: ColonyCondition | null;
/** ISO timestamp; always null when the caller may not read inspections. */
lastInspectionDate: string | null;
/** Unread WARNUNG notifications visible to the caller. */
openWarnings: number;
/** Unread KRITISCH notifications visible to the caller. */
openCritical: number;
}
export interface ColonyOverview {
apiaryId: string;
inspectionsVisible: boolean;
rows: ColonyOverviewRow[];
}
/**
* Everything the offline apiary mode needs. It deliberately carries no
* coordinates, address or apiary notes (apiary locations are theft-sensitive)
* and no inspection userId. Sections the caller has no read permission for
* are null.
*/
export interface OfflinePackV1 {
version: 1;
generatedAt: string;
apiary: {
id: string;
name: string;
location: string | null;
status: ApiaryStatus;
};
overview: ColonyOverview;
colonies: Pick<
Colony,
| 'id'
| 'code'
| 'apiaryId'
| 'status'
| 'colonyType'
| 'hiveType'
| 'strength'
| 'broodStatus'
| 'feedStatus'
| 'healthStatus'
| 'currentQueenId'
| 'notes'
>[];
queens:
| Pick<
Queen,
| 'id'
| 'code'
| 'hatchYear'
| 'breed'
| 'markingColor'
| 'markingNumber'
| 'status'
>[]
| null;
inspections: Omit<Inspection, 'userId'>[] | null;
tasks:
| Pick<
Task,
| 'id'
| 'title'
| 'description'
| 'dueDate'
| 'priority'
| 'status'
| 'relatedType'
| 'relatedId'
>[]
| null;
}
@@ -1,5 +1,11 @@
import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger';
import { IsLatitude, IsLongitude, IsOptional, IsString } from 'class-validator';
import {
IsLatitude,
IsLongitude,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
export class CreateApiaryDto {
@ApiProperty()
@@ -35,4 +41,13 @@ export class CreateApiaryDto {
@IsOptional()
@IsString()
notes?: string;
@ApiPropertyOptional({
description:
'Ort für die öffentliche Herkunftsseite, z. B. "Nordhessen". Nie die genaue Adresse.',
})
@IsOptional()
@IsString()
@MaxLength(100)
publicLocationLabel?: string;
}
+2 -2
View File
@@ -19,7 +19,7 @@ import { noStoreMiddleware } from './common/middleware/no-store.middleware';
* Production runs client -> Cloudflare -> Traefik -> backend, so the hop
* count is not stable; TRUST_PROXY is an Express list there ("loopback,
* 10.42.0.0/16, <Cloudflare IPv4+IPv6 ranges>", see
* deploy/k8s/backend/configmap.example.yaml). A plain number ("1") is a hop
* deploy/k8s/base/backend/configmap.example.yaml). A plain number ("1") is a hop
* count and stays the default when the variable is unset.
*/
export function trustProxySetting(): number | string {
@@ -58,7 +58,7 @@ export function configureApp(app: NestExpressApplication): void {
// proxy, so rate limits, API-key failure limits and IP allowlists act
// on a shared address instead of the client.
bootstrapLogger.warn(
'TRUST_PROXY is not set in production - client IPs for rate limits and API-key allowlists are likely the proxy address; set it as in deploy/k8s/backend/configmap.example.yaml',
'TRUST_PROXY is not set in production - client IPs for rate limits and API-key allowlists are likely the proxy address; set it as in deploy/k8s/base/backend/configmap.example.yaml',
);
}
// No ETags: they let caches and proxies revalidate (and keep) personal
+14 -5
View File
@@ -3,10 +3,12 @@ import { ConfigModule } from '@nestjs/config';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ThrottlerModule } from '@nestjs/throttler';
import { ClientIpThrottlerGuard } from './common/throttler/client-ip-throttler.guard';
import { ScheduleModule } from '@nestjs/schedule';
import { PrismaThrottlerStorage } from './common/throttler/prisma-throttler.storage';
import { ThrottlerStorageModule } from './common/throttler/throttler-storage.module';
import { PrismaModule } from './prisma/prisma.module';
import { HealthModule } from './health/health.module';
import { AuditModule } from './audit/audit.module';
import { DomainEventsModule } from './domain-events/domain-events.module';
import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module';
import { RolesModule } from './roles/roles.module';
@@ -51,20 +53,27 @@ import { BackupModule } from './backup/backup.module';
import { ShopSyncModule } from './shop-sync/shop-sync.module';
import { ErpExportModule } from './erp-export/erp-export.module';
import { ApiKeysModule } from './api-keys/api-keys.module';
import { SystemModule } from './system/system.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { PermissionsGuard } from './auth/guards/permissions.guard';
import { PermissionsModule } from './auth/permissions/permissions.module';
import { IdempotencyInterceptor } from './common/idempotency/idempotency.interceptor';
import { IdempotencyCleanupService } from './common/idempotency/idempotency-cleanup.service';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ScheduleModule.forRoot(),
ThrottlerModule.forRoot([{ name: 'default', ttl: 60000, limit: 100 }]),
ThrottlerModule.forRootAsync({
imports: [ThrottlerStorageModule],
inject: [PrismaThrottlerStorage],
useFactory: (storage: PrismaThrottlerStorage) => ({
throttlers: [{ name: 'default', ttl: 60000, limit: 100 }],
storage,
}),
}),
PrismaModule,
PermissionsModule,
AuditModule,
DomainEventsModule,
HealthModule,
UsersModule,
AuthModule,
@@ -110,6 +119,7 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
ShopSyncModule,
ErpExportModule,
ApiKeysModule,
SystemModule,
],
controllers: [],
providers: [
@@ -119,7 +129,6 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard },
{ provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
IdempotencyCleanupService,
],
})
export class AppModule {}
@@ -43,6 +43,18 @@ describe('AuditLogService', () => {
expect(createArg.data.userId).toBe('user-1');
});
it('writes through the transaction client when one is given', async () => {
const tx = { auditLog: { create: jest.fn() } };
await service.record(
{ entityType: 'Inspection', entityId: 'i1', action: 'CREATE' },
tx as never,
);
expect(tx.auditLog.create).toHaveBeenCalledTimes(1);
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
it('findAll filters by entity type and id', async () => {
await service.findAll({ entityType: 'Colony', entityId: 'colony-1' });
+13 -3
View File
@@ -1,8 +1,9 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { DbClient } from '../prisma/db-client';
import { AuditAction, AuditLog, Prisma } from '../../generated/prisma';
interface RecordAuditLogInput {
export interface RecordAuditLogInput {
entityType: string;
entityId: string;
action: AuditAction;
@@ -20,8 +21,17 @@ export interface AuditLogFilter {
export class AuditLogService {
constructor(private readonly prisma: PrismaService) {}
async record(input: RecordAuditLogInput): Promise<void> {
await this.prisma.auditLog.create({
/**
* With `tx` the entry commits or rolls back together with the change it
* describes - no audited change without its entry, and no entry for a
* change that never happened.
*/
async record(
input: RecordAuditLogInput,
tx?: Prisma.TransactionClient,
): Promise<void> {
const db: DbClient = tx ?? this.prisma;
await db.auditLog.create({
data: {
entityType: input.entityType,
entityId: input.entityId,
@@ -1,37 +1,114 @@
import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util';
import { Logger } from '@nestjs/common';
import { KEY_ID_PATTERN } from '../../common/crypto/key-ring';
import {
decryptMfaSecret,
encryptMfaSecret,
mfaSecretNeedsRekey,
} from './mfa-crypto.util';
// Written by the code before the key ring (AES key sha256(JWT_ACCESS_SECRET),
// no key id) with JWT_ACCESS_SECRET = 'x' * 40.
const LEGACY_SECRET =
'zJD2lAy1b/wslLyQ.hn8scDeaxIADXlRFtcGIyg==.3+5qbYM52lKKN/VXpInUOQ==';
const PLAINTEXT = 'JBSWY3DPEHPK3PXP';
describe('mfa-crypto.util', () => {
const originalSecret = process.env.JWT_ACCESS_SECRET;
const originalEnv = { ...process.env };
beforeEach(() => {
process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret';
process.env.JWT_ACCESS_SECRET = 'x'.repeat(40);
process.env.MFA_ENCRYPTION_KEY = 'm'.repeat(40);
delete process.env.MFA_ENCRYPTION_KEY_PREVIOUS;
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
process.env.JWT_ACCESS_SECRET = originalSecret;
process.env = { ...originalEnv };
jest.restoreAllMocks();
});
it('round-trips a TOTP secret through encrypt/decrypt', () => {
const plaintext = 'JBSWY3DPEHPK3PXP';
const ciphertext = encryptMfaSecret(plaintext);
expect(ciphertext).not.toContain(plaintext);
expect(decryptMfaSecret(ciphertext)).toBe(plaintext);
it('round-trips a TOTP secret and prefixes the key id', () => {
const ciphertext = encryptMfaSecret(PLAINTEXT);
expect(ciphertext).toMatch(KEY_ID_PATTERN);
expect(ciphertext).not.toContain(PLAINTEXT);
expect(decryptMfaSecret(ciphertext)).toBe(PLAINTEXT);
});
it('produces a different ciphertext every time (random IV)', () => {
const plaintext = 'JBSWY3DPEHPK3PXP';
expect(encryptMfaSecret(plaintext)).not.toBe(encryptMfaSecret(plaintext));
expect(encryptMfaSecret(PLAINTEXT)).not.toBe(encryptMfaSecret(PLAINTEXT));
});
it('no longer depends on JWT_ACCESS_SECRET', () => {
const ciphertext = encryptMfaSecret(PLAINTEXT);
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
expect(decryptMfaSecret(ciphertext)).toBe(PLAINTEXT);
});
it('fails to decrypt with the wrong key', () => {
const ciphertext = encryptMfaSecret('JBSWY3DPEHPK3PXP');
process.env.JWT_ACCESS_SECRET = 'a-different-secret';
expect(() => decryptMfaSecret(ciphertext)).toThrow();
const ciphertext = encryptMfaSecret(PLAINTEXT);
process.env.MFA_ENCRYPTION_KEY = 'n'.repeat(40);
expect(() => decryptMfaSecret(ciphertext)).toThrow('unknown key id');
});
it('opens a secret sealed with MFA_ENCRYPTION_KEY_PREVIOUS and flags it for rekey', () => {
const ciphertext = encryptMfaSecret(PLAINTEXT);
process.env.MFA_ENCRYPTION_KEY_PREVIOUS = process.env.MFA_ENCRYPTION_KEY;
process.env.MFA_ENCRYPTION_KEY = 'n'.repeat(40);
expect(decryptMfaSecret(ciphertext)).toBe(PLAINTEXT);
expect(mfaSecretNeedsRekey(ciphertext)).toBe(true);
});
it('does not flag a secret sealed with the current key', () => {
expect(mfaSecretNeedsRekey(encryptMfaSecret(PLAINTEXT))).toBe(false);
});
it('still opens a legacy MFA secret encrypted with the JWT-derived key', () => {
expect(decryptMfaSecret(LEGACY_SECRET)).toBe(PLAINTEXT);
expect(mfaSecretNeedsRekey(LEGACY_SECRET)).toBe(true);
});
it('fails closed on a legacy secret once JWT_ACCESS_SECRET changed', () => {
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
expect(() => decryptMfaSecret(LEGACY_SECRET)).toThrow(
'ciphertext failed authentication',
);
});
it('never retries a k…: value that fails authentication with the legacy key', () => {
const warn = jest.spyOn(Logger.prototype, 'warn');
const currentKeyId = encryptMfaSecret(PLAINTEXT).split(':')[0];
// A valid legacy ciphertext behind the current key id: only a retry with
// sha256(JWT_ACCESS_SECRET) could open it.
const disguised = `${currentKeyId}:${LEGACY_SECRET}`;
expect(() => decryptMfaSecret(disguised)).toThrow(
'ciphertext failed authentication',
);
expect(() => decryptMfaSecret(`k00000000:${LEGACY_SECRET}`)).toThrow(
'unknown key id',
);
expect(warn).not.toHaveBeenCalled();
});
it('fails on a tampered ciphertext', () => {
const [keyId, body] = encryptMfaSecret(PLAINTEXT).split(':');
const [iv, tag, data] = body.split('.');
const bytes = Buffer.from(data, 'base64');
bytes[0] ^= 0xff;
expect(() =>
decryptMfaSecret(`${keyId}:${iv}.${tag}.${bytes.toString('base64')}`),
).toThrow('ciphertext failed authentication');
});
it('rejects a malformed ciphertext', () => {
expect(() => decryptMfaSecret('not-a-valid-ciphertext')).toThrow(
'Invalid MFA secret ciphertext',
'malformed ciphertext',
);
});
});
+21 -43
View File
@@ -1,53 +1,31 @@
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
} from 'node:crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_BYTES = 12;
function getKey(): Buffer {
const secret = process.env.JWT_ACCESS_SECRET;
if (!secret) {
throw new Error('JWT_ACCESS_SECRET is not set');
}
return createHash('sha256').update(secret).digest();
}
KEY_ID_PATTERN,
isSealedWithCurrentKey,
loadKeyRing,
openLegacyCiphertext,
openWithRing,
sealWithRing,
} from '../../common/crypto/key-ring';
/**
* TOTP secrets must be reversible (unlike passwords/refresh tokens, which
* are one-way hashed) so the server can compute the expected code on every
* login. AES-256-GCM at rest, keyed off JWT_ACCESS_SECRET, keeps the secret
* out of the DB in plaintext without introducing a new secret to manage.
* login. They are sealed with the MFA key ring (MFA_ENCRYPTION_KEY, see
* common/crypto/key-ring.ts). Secrets stored before the key ring have no key
* id and were encrypted with sha256(JWT_ACCESS_SECRET); they stay readable
* until the rekey job reseals them.
*/
export function encryptMfaSecret(plaintext: string): string {
const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv(ALGORITHM, getKey(), iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
return [iv, authTag, encrypted]
.map((buffer) => buffer.toString('base64'))
.join('.');
return sealWithRing(loadKeyRing('mfa'), plaintext);
}
export function decryptMfaSecret(ciphertext: string): string {
const [ivB64, authTagB64, dataB64] = ciphertext.split('.');
if (!ivB64 || !authTagB64 || !dataB64) {
throw new Error('Invalid MFA secret ciphertext');
}
const decipher = createDecipheriv(
ALGORITHM,
getKey(),
Buffer.from(ivB64, 'base64'),
);
decipher.setAuthTag(Buffer.from(authTagB64, 'base64'));
return Buffer.concat([
decipher.update(Buffer.from(dataB64, 'base64')),
decipher.final(),
]).toString('utf8');
export function decryptMfaSecret(stored: string): string {
return KEY_ID_PATTERN.test(stored)
? openWithRing(loadKeyRing('mfa'), stored).plaintext
: openLegacyCiphertext('mfa', stored);
}
/** True for secrets the rekey job should reseal with the current key. */
export function mfaSecretNeedsRekey(stored: string): boolean {
return !isSealedWithCurrentKey(loadKeyRing('mfa'), stored);
}
@@ -361,6 +361,15 @@ export const PERMISSION_CATALOG = [
[['manage', 'Anlegen, ansehen und widerrufen']],
3400,
),
...group(
'System',
'system',
[
['read', 'Hintergrundjobs und Warteschlangen ansehen'],
['manage', 'Fehlgeschlagene Ereignisse erneut anstoßen'],
],
3500,
),
// Like shop-sync: a narrow read-only allowlist for one machine consumer
// (ERPNext's nightly inventory mirror), separate from products:read etc.,
// which return descriptions and full records.
@@ -368,7 +377,7 @@ export const PERMISSION_CATALOG = [
'ERP-Anbindung',
'erp-inventory',
[['read', 'Inventar-Spiegel für ERPNext lesen']],
3500,
3600,
),
] as const satisfies readonly PermissionCatalogEntry[];
@@ -0,0 +1,253 @@
import { Prisma } from '../../generated/prisma';
import type {
AnyDomainEventEnvelope,
DomainEventEnvelope,
DomainEventType,
} from '../domain-events/domain-event-types';
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
import type { StockService } from '../stock/stock.service';
import { AutomationEventHandlers } from './automation.handlers';
import type { AutomationService } from './automation.service';
type RecordModel =
'inspection' | 'varroaMeasurement' | 'treatment' | 'feeding' | 'colony';
const RECORD_MODELS: readonly RecordModel[] = [
'inspection',
'varroaMeasurement',
'treatment',
'feeding',
'colony',
];
function envelope<T extends DomainEventType>(
type: T,
aggregateId: string,
payload: DomainEventEnvelope<T>['payload'],
): AnyDomainEventEnvelope<T> {
return {
id: 'e1',
type,
aggregateType: 'Inspection',
aggregateId,
payload,
actorUserId: 'u1',
correlationId: 'corr-1',
occurredAt: new Date('2026-10-13T08:00:00.000Z'),
attempts: 0,
} as unknown as AnyDomainEventEnvelope<T>;
}
describe('AutomationEventHandlers', () => {
let tx: Record<RecordModel, { findUnique: jest.Mock }>;
let automation: Record<
| 'evaluateInspectionIn'
| 'evaluateVarroaMeasurementIn'
| 'evaluateTreatmentIn'
| 'evaluateFeedingIn'
| 'evaluateColonyHealthIn'
| 'evaluateRawMaterialLowIn',
jest.Mock
>;
let stock: { remainingByRawMaterialKind: jest.Mock };
let handlers: AutomationEventHandlers;
const asTx = () => tx as unknown as Prisma.TransactionClient;
beforeEach(() => {
tx = Object.fromEntries(
RECORD_MODELS.map((model) => [
model,
{ findUnique: jest.fn().mockResolvedValue(null) },
]),
) as Record<RecordModel, { findUnique: jest.Mock }>;
automation = {
evaluateInspectionIn: jest.fn().mockResolvedValue(undefined),
evaluateVarroaMeasurementIn: jest.fn().mockResolvedValue(undefined),
evaluateTreatmentIn: jest.fn().mockResolvedValue(undefined),
evaluateFeedingIn: jest.fn().mockResolvedValue(undefined),
evaluateColonyHealthIn: jest.fn().mockResolvedValue(undefined),
evaluateRawMaterialLowIn: jest.fn().mockResolvedValue(undefined),
};
stock = { remainingByRawMaterialKind: jest.fn() };
handlers = new AutomationEventHandlers(
automation as unknown as AutomationService,
stock as unknown as StockService,
);
});
it('registers the stable handler ids and event types', () => {
const registered = (method: keyof AutomationEventHandlers) =>
domainEventHandlerMetadata(
Reflect.get(AutomationEventHandlers.prototype, method),
);
expect(registered('inspectionRules')).toEqual([
{
types: ['InspectionRecorded'],
handlerId: 'automation.inspection-rules',
},
]);
expect(registered('varroaThreshold')).toEqual([
{
types: ['VarroaMeasured', 'VarroaCorrected'],
handlerId: 'automation.varroa-threshold',
},
]);
expect(registered('treatmentFollowUp')).toEqual([
{
types: ['TreatmentRecorded'],
handlerId: 'automation.treatment-follow-up',
},
]);
expect(registered('feedingFollowUp')).toEqual([
{ types: ['FeedingRecorded'], handlerId: 'automation.feeding-follow-up' },
]);
expect(registered('colonyHealth')).toEqual([
{ types: ['ColonyStatusChanged'], handlerId: 'automation.colony-health' },
]);
expect(registered('rawMaterialLow')).toEqual([
{ types: ['StockChanged'], handlerId: 'automation.raw-material-low' },
]);
});
describe('record handlers', () => {
const cases = [
[
'inspectionRules',
'InspectionRecorded',
'inspection',
'evaluateInspectionIn',
],
[
'varroaThreshold',
'VarroaMeasured',
'varroaMeasurement',
'evaluateVarroaMeasurementIn',
],
[
'varroaThreshold',
'VarroaCorrected',
'varroaMeasurement',
'evaluateVarroaMeasurementIn',
],
[
'treatmentFollowUp',
'TreatmentRecorded',
'treatment',
'evaluateTreatmentIn',
],
['feedingFollowUp', 'FeedingRecorded', 'feeding', 'evaluateFeedingIn'],
] as const;
const run = (
method: (typeof cases)[number][0],
type: (typeof cases)[number][1],
) =>
handlers[method](
envelope(type, 'record-1', { colonyId: 'colony-1' }) as never,
asTx(),
);
it.each(cases)(
'handlers load the record via tx and evaluate it: %s on %s',
async (method, type, model, core) => {
const record = { id: 'record-1', colonyId: 'colony-1' };
tx[model].findUnique.mockResolvedValueOnce(record);
await run(method, type);
expect(tx[model].findUnique).toHaveBeenCalledWith({
where: { id: 'record-1' },
});
expect(automation[core]).toHaveBeenCalledWith(tx, record);
},
);
it.each(cases)(
'handlers load the record via tx and skip a missing one: %s on %s',
async (method, type, model, core) => {
await run(method, type);
expect(tx[model].findUnique).toHaveBeenCalledTimes(1);
expect(automation[core]).not.toHaveBeenCalled();
},
);
it('lets an evaluation failure propagate, so the delivery is retried', async () => {
tx.inspection.findUnique.mockResolvedValueOnce({ id: 'record-1' });
automation.evaluateInspectionIn.mockRejectedValue(
new Error('notifications down'),
);
await expect(
run('inspectionRules', 'InspectionRecorded'),
).rejects.toThrow('notifications down');
});
});
describe('colony-health', () => {
const from = { status: 'ACTIVE', healthStatus: 'AVERAGE' } as const;
const to = { status: 'ACTIVE', healthStatus: 'CRITICAL' } as const;
const event = () =>
envelope('ColonyStatusChanged', 'colony-1', { from, to });
it('hands the transition of the event to evaluateColonyHealthIn', async () => {
tx.colony.findUnique.mockResolvedValueOnce({ id: 'colony-1' });
await handlers.colonyHealth(event(), asTx());
expect(tx.colony.findUnique).toHaveBeenCalledWith({
where: { id: 'colony-1' },
select: { id: true },
});
// The rule decides on the snapshots of the event, not on the colony
// as it is now: a later change must not hide the transition.
expect(automation.evaluateColonyHealthIn).toHaveBeenCalledWith(
tx,
'colony-1',
from,
to,
);
});
it('skips a colony that no longer exists', async () => {
await handlers.colonyHealth(event(), asTx());
expect(automation.evaluateColonyHealthIn).not.toHaveBeenCalled();
});
});
describe('raw-material-low', () => {
const stockChanged = (rawMaterialKinds: ('BEESWAX' | 'PROPOLIS')[]) =>
envelope('StockChanged', 'movement-1', {
bottlingBatchIds: [],
productIds: [],
rawMaterialKinds,
});
it('evaluates every touched kind once with what is left of it', async () => {
stock.remainingByRawMaterialKind.mockImplementation((kind: string) =>
Promise.resolve(kind === 'BEESWAX' ? 400 : 75),
);
await handlers.rawMaterialLow(
stockChanged(['BEESWAX', 'PROPOLIS', 'BEESWAX']),
asTx(),
);
expect(stock.remainingByRawMaterialKind).toHaveBeenCalledTimes(2);
expect(automation.evaluateRawMaterialLowIn.mock.calls).toEqual([
[tx, 'BEESWAX', 400],
[tx, 'PROPOLIS', 75],
]);
});
it('does nothing for a change without raw material', async () => {
await handlers.rawMaterialLow(stockChanged([]), asTx());
expect(stock.remainingByRawMaterialKind).not.toHaveBeenCalled();
expect(automation.evaluateRawMaterialLowIn).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,118 @@
import { Injectable } from '@nestjs/common';
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
import { Prisma } from '../../generated/prisma';
import { StockService } from '../stock/stock.service';
import { AutomationService } from './automation.service';
type Tx = Prisma.TransactionClient;
/**
* Evaluates the automation rules on domain events. Pure DB writer: it reads
* the record the event is about through the delivery transaction and lets
* AutomationService write task, warning and audit entries in it. A record
* deleted before the event was dispatched is skipped. Errors propagate, so
* the delivery is rolled back as a whole and retried.
*/
@Injectable()
export class AutomationEventHandlers {
constructor(
private readonly automation: AutomationService,
private readonly stock: StockService,
) {}
@OnDomainEvent('InspectionRecorded', 'automation.inspection-rules')
async inspectionRules(
event: AnyDomainEventEnvelope<'InspectionRecorded'>,
tx: Tx,
): Promise<void> {
const inspection = await tx.inspection.findUnique({
where: { id: event.aggregateId },
});
if (inspection === null) {
return;
}
await this.automation.evaluateInspectionIn(tx, inspection);
}
// A correction can lift a count over the threshold, so it is evaluated
// like a new measurement; the open-task check keeps it to one task.
@OnDomainEvent(
['VarroaMeasured', 'VarroaCorrected'],
'automation.varroa-threshold',
)
async varroaThreshold(
event: AnyDomainEventEnvelope<'VarroaMeasured' | 'VarroaCorrected'>,
tx: Tx,
): Promise<void> {
const measurement = await tx.varroaMeasurement.findUnique({
where: { id: event.aggregateId },
});
if (measurement === null) {
return;
}
await this.automation.evaluateVarroaMeasurementIn(tx, measurement);
}
@OnDomainEvent('TreatmentRecorded', 'automation.treatment-follow-up')
async treatmentFollowUp(
event: AnyDomainEventEnvelope<'TreatmentRecorded'>,
tx: Tx,
): Promise<void> {
const treatment = await tx.treatment.findUnique({
where: { id: event.aggregateId },
});
if (treatment === null) {
return;
}
await this.automation.evaluateTreatmentIn(tx, treatment);
}
@OnDomainEvent('FeedingRecorded', 'automation.feeding-follow-up')
async feedingFollowUp(
event: AnyDomainEventEnvelope<'FeedingRecorded'>,
tx: Tx,
): Promise<void> {
const feeding = await tx.feeding.findUnique({
where: { id: event.aggregateId },
});
if (feeding === null) {
return;
}
await this.automation.evaluateFeedingIn(tx, feeding);
}
// Decides on the snapshots the event carries, not on the colony as it is
// now: a later edit must not hide the transition into CRITICAL.
@OnDomainEvent('ColonyStatusChanged', 'automation.colony-health')
async colonyHealth(
event: AnyDomainEventEnvelope<'ColonyStatusChanged'>,
tx: Tx,
): Promise<void> {
const colony = await tx.colony.findUnique({
where: { id: event.aggregateId },
select: { id: true },
});
if (colony === null) {
return;
}
await this.automation.evaluateColonyHealthIn(
tx,
colony.id,
event.payload.from,
event.payload.to,
);
}
// Per kind the change touched, what is left of it across all its lots.
@OnDomainEvent('StockChanged', 'automation.raw-material-low')
async rawMaterialLow(
event: AnyDomainEventEnvelope<'StockChanged'>,
tx: Tx,
): Promise<void> {
for (const kind of new Set(event.payload.rawMaterialKinds)) {
const remaining = await this.stock.remainingByRawMaterialKind(kind);
await this.automation.evaluateRawMaterialLowIn(tx, kind, remaining);
}
}
}
@@ -1,12 +1,20 @@
import { Module } from '@nestjs/common';
import { NotificationsModule } from '../notifications/notifications.module';
import { StockModule } from '../stock/stock.module';
import { TasksModule } from '../tasks/tasks.module';
import { AutomationController } from './automation.controller';
import { AutomationEventHandlers } from './automation.handlers';
import { AutomationService } from './automation.service';
/**
* Automation rules and the handlers that evaluate them on domain events.
* Rule tasks go through TasksService (audit entry, TaskCreated - and with
* it calendar entry and task.created webhook), so no WebhooksService here.
*/
@Module({
imports: [NotificationsModule],
imports: [TasksModule, NotificationsModule, StockModule],
controllers: [AutomationController],
providers: [AutomationService],
providers: [AutomationService, AutomationEventHandlers],
exports: [AutomationService],
})
export class AutomationModule {}
File diff suppressed because it is too large. Load diff
@@ -1,13 +1,14 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { assertTransactionClient, DbClient } from '../prisma/db-client';
import { runAfterCommit } from '../common/after-commit';
import { AuditLogService } from '../audit/audit-log.service';
import { NotificationsService } from '../notifications/notifications.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { TasksService } from '../tasks/tasks.service';
import type { ColonyStateSnapshot } from '../domain-events/domain-event-types';
import {
AutomationRule,
AutomationTriggerType,
Colony,
Inspection,
Feeding,
Prisma,
@@ -20,6 +21,9 @@ import {
} from '../../generated/prisma';
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
import { formatGeneratedNumber } from './number-format';
import { varroaThresholdOf } from './varroa-threshold';
type Tx = Prisma.TransactionClient;
/** The rules that fill in a batch/lot number instead of creating tasks. */
export type NumberFormatTrigger =
@@ -171,29 +175,33 @@ export class AutomationService {
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly notifications: NotificationsService,
private readonly webhooks: WebhooksService,
private readonly tasks: TasksService,
) {}
private async ensureDefaults(): Promise<void> {
for (const triggerType of Object.keys(
DEFAULT_RULES,
) as AutomationTriggerType[]) {
const defaults = DEFAULT_RULES[triggerType];
await this.prisma.automationRule.upsert({
where: { triggerType },
update: {},
create: {
/**
* Creates the rules that do not exist yet, on the given client - inside a
* handler or legacy transaction too. One INSERT … ON CONFLICT DO NOTHING:
* existing rules keep their settings, and their rows are not locked
* (an upsert would hold all of them until the evaluation commits).
*/
private async ensureDefaults(db: DbClient): Promise<void> {
const triggerTypes = Object.keys(DEFAULT_RULES) as AutomationTriggerType[];
await db.automationRule.createMany({
data: triggerTypes.map((triggerType) => {
const defaults = DEFAULT_RULES[triggerType];
return {
triggerType,
active: defaults.active ?? true,
config: defaults.config as Prisma.InputJsonValue,
actionTemplate: defaults.actionTemplate,
},
});
}
};
}),
skipDuplicates: true,
});
}
async findAll(): Promise<AutomationRuleWithSequences[]> {
await this.ensureDefaults();
await this.ensureDefaults(this.prisma);
return this.prisma.automationRule.findMany({
orderBy: { triggerType: 'asc' },
include: {
@@ -244,141 +252,140 @@ export class AutomationService {
return rule;
}
/**
* Every evaluate*() runs after the triggering record is committed and is
* called by the domain services without a guard - so it never throws (see
* runAfterCommit). A failed evaluation is logged, the request succeeds.
*/
private evaluateAfterCommit(
entityType: string,
entityId: string,
evaluate: () => Promise<void>,
): Promise<void> {
return runAfterCommit(
this.logger,
{ action: 'Automation rule evaluation', entityType, entityId },
evaluate,
);
}
// -------------------------------------------------------------------------
// Rule evaluation
//
// One core per trigger, on the caller's transaction: the domain event
// handlers (AutomationEventHandlers) call it with their delivery
// transaction, the legacy wrappers below with a transaction of their own.
// -------------------------------------------------------------------------
async evaluateInspection(inspection: Inspection): Promise<void> {
await this.evaluateAfterCommit('Inspection', inspection.id, async () => {
await this.ensureDefaults();
const rules = await this.prisma.automationRule.findMany({
where: {
triggerType: { in: ['QUEENLESS_NO_EGGS', 'FEED_STATUS_CRITICAL'] },
active: true,
},
});
const ruleByType = new Map(rules.map((rule) => [rule.triggerType, rule]));
const queenlessRule = ruleByType.get('QUEENLESS_NO_EGGS');
if (
queenlessRule &&
inspection.queenSeen === false &&
inspection.eggsPresent === false
) {
await this.triggerAction(
queenlessRule,
'COLONY',
inspection.colonyId,
inspection.id,
);
}
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') {
await this.triggerAction(
feedRule,
'COLONY',
inspection.colonyId,
inspection.id,
);
}
async evaluateInspectionIn(tx: Tx, inspection: Inspection): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateInspectionIn');
await this.ensureDefaults(tx);
const rules = await tx.automationRule.findMany({
where: {
triggerType: { in: ['QUEENLESS_NO_EGGS', 'FEED_STATUS_CRITICAL'] },
active: true,
},
});
const ruleByType = new Map(rules.map((rule) => [rule.triggerType, rule]));
const queenlessRule = ruleByType.get('QUEENLESS_NO_EGGS');
if (
queenlessRule &&
inspection.queenSeen === false &&
inspection.eggsPresent === false
) {
await this.triggerActionIn(
tx,
queenlessRule,
'COLONY',
inspection.colonyId,
inspection.id,
);
}
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') {
await this.triggerActionIn(
tx,
feedRule,
'COLONY',
inspection.colonyId,
inspection.id,
);
}
}
async evaluateVarroaMeasurement(
async evaluateVarroaMeasurementIn(
tx: Tx,
measurement: VarroaMeasurement,
): Promise<void> {
await this.evaluateAfterCommit(
'VarroaMeasurement',
measurement.id,
async () => {
await this.ensureDefaults();
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'VARROA_THRESHOLD' },
});
if (!rule || !rule.active) {
return;
}
// The threshold is a natural mite fall rate (mites per day). Only
// that method yields a value in this unit - a mite count from a
// sugar roll or wash is per bee sample and would be compared against
// the wrong scale, as would a raw board count without the number of
// days it covered.
if (measurement.method !== 'NATURAL_MITE_FALL') {
return;
}
const config = rule.config as { thresholdMitesPerDay?: number } | null;
const threshold = config?.thresholdMitesPerDay ?? 10;
const value = measurement.calculatedValue;
if (value !== null && value >= threshold) {
await this.triggerAction(rule, 'COLONY', measurement.colonyId);
}
},
assertTransactionClient(
tx,
'AutomationService.evaluateVarroaMeasurementIn',
);
await this.ensureDefaults(tx);
const rule = await tx.automationRule.findUnique({
where: { triggerType: 'VARROA_THRESHOLD' },
});
// null for an inactive rule; the one source of the default value.
const threshold = varroaThresholdOf(rule);
if (!rule || threshold === null) {
return;
}
// The threshold is a natural mite fall rate (mites per day). Only that
// method yields a value in this unit - a mite count from a sugar roll or
// wash is per bee sample and would be compared against the wrong scale,
// as would a raw board count without the number of days it covered.
if (measurement.method !== 'NATURAL_MITE_FALL') {
return;
}
const value = measurement.calculatedValue;
if (value !== null && value >= threshold) {
await this.triggerActionIn(tx, rule, 'COLONY', measurement.colonyId);
}
}
async evaluateSwarmControlCheck(check: SwarmControlCheck): Promise<void> {
await this.evaluateAfterCommit('SwarmControlCheck', check.id, async () => {
await this.ensureDefaults();
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'SWARM_MOOD_HIGH' },
});
if (!rule || !rule.active) {
return;
}
if (check.swarmMood === 'HIGH') {
await this.triggerAction(rule, 'COLONY', check.colonyId);
}
async evaluateSwarmControlCheckIn(
tx: Tx,
check: SwarmControlCheck,
): Promise<void> {
assertTransactionClient(
tx,
'AutomationService.evaluateSwarmControlCheckIn',
);
await this.ensureDefaults(tx);
const rule = await tx.automationRule.findUnique({
where: { triggerType: 'SWARM_MOOD_HIGH' },
});
if (!rule || !rule.active) {
return;
}
if (check.swarmMood === 'HIGH') {
await this.triggerActionIn(tx, rule, 'COLONY', check.colonyId);
}
}
/** Fires only on the transition into CRITICAL, not on every edit while already critical. */
async evaluateColonyHealth(before: Colony, after: Colony): Promise<void> {
await this.evaluateAfterCommit('Colony', after.id, async () => {
await this.ensureDefaults();
if (
after.healthStatus !== 'CRITICAL' ||
before.healthStatus === 'CRITICAL'
) {
return;
}
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'COLONY_HEALTH_CRITICAL' },
});
if (!rule || !rule.active) {
return;
}
await this.triggerAction(rule, 'COLONY', after.id);
async evaluateColonyHealthIn(
tx: Tx,
colonyId: string,
from: ColonyStateSnapshot,
to: ColonyStateSnapshot,
): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateColonyHealthIn');
// ColonyStatusChanged comes with every status or health edit: decide on
// the snapshots first, so most of them cost no statement at all.
if (to.healthStatus !== 'CRITICAL' || from.healthStatus === 'CRITICAL') {
return;
}
await this.ensureDefaults(tx);
const rule = await tx.automationRule.findUnique({
where: { triggerType: 'COLONY_HEALTH_CRITICAL' },
});
if (!rule || !rule.active) {
return;
}
await this.triggerActionIn(tx, rule, 'COLONY', colonyId);
}
async evaluateStockItem(item: StockItem): Promise<void> {
await this.evaluateAfterCommit('StockItem', item.id, async () => {
await this.ensureDefaults();
if (item.minThreshold === null || item.quantity >= item.minThreshold) {
return;
}
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'STOCK_ITEM_LOW' },
});
if (!rule || !rule.active) {
return;
}
await this.triggerAction(rule, 'STORAGE', item.id);
async evaluateStockItemIn(tx: Tx, item: StockItem): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateStockItemIn');
// Every movement of the item comes here: only one below its minimum
// needs the rule.
if (item.minThreshold === null || item.quantity >= item.minThreshold) {
return;
}
await this.ensureDefaults(tx);
const rule = await tx.automationRule.findUnique({
where: { triggerType: 'STOCK_ITEM_LOW' },
});
if (!rule || !rule.active) {
return;
}
await this.triggerActionIn(tx, rule, 'STORAGE', item.id);
}
/**
@@ -388,73 +395,112 @@ export class AutomationService {
* the remedy - so it hangs on GENERAL with the kind as relatedId, which
* keeps one open task per kind.
*/
async evaluateRawMaterialLow(
async evaluateRawMaterialLowIn(
tx: Tx,
kind: RawMaterialKind,
remainingGrams: number,
): Promise<void> {
await this.evaluateAfterCommit('RawMaterialKind', kind, async () => {
await this.ensureDefaults();
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'RAW_MATERIAL_LOW' },
});
if (!rule || !rule.active) {
return;
}
const config = rule.config as {
minGrams?: Partial<Record<RawMaterialKind, unknown>>;
} | null;
const minGrams = config?.minGrams?.[kind];
if (typeof minGrams !== 'number' || remainingGrams >= minGrams) {
return;
}
await this.triggerAction(rule, 'GENERAL', kind, undefined, {
description: `${RAW_MATERIAL_KIND_LABELS[kind]}: noch ${remainingGrams} g auf Lager, Mindestmenge ${minGrams} g.`,
});
assertTransactionClient(tx, 'AutomationService.evaluateRawMaterialLowIn');
await this.ensureDefaults(tx);
const rule = await tx.automationRule.findUnique({
where: { triggerType: 'RAW_MATERIAL_LOW' },
});
if (!rule || !rule.active) {
return;
}
const config = rule.config as {
minGrams?: Partial<Record<RawMaterialKind, unknown>>;
} | null;
const minGrams = config?.minGrams?.[kind];
if (typeof minGrams !== 'number' || remainingGrams >= minGrams) {
return;
}
await this.triggerActionIn(tx, rule, 'GENERAL', kind, undefined, {
description: `${RAW_MATERIAL_KIND_LABELS[kind]}: noch ${remainingGrams} g auf Lager, Mindestmenge ${minGrams} g.`,
});
}
async evaluateTreatment(treatment: Treatment): Promise<void> {
await this.evaluateAfterCommit('Treatment', treatment.id, () =>
this.evaluateFollowUp(
'TREATMENT_FOLLOW_UP',
treatment.colonyId,
treatment.endDate ?? treatment.date,
`Nach Behandlung mit ${treatment.product} am ${formatDay(treatment.endDate ?? treatment.date)}.`,
),
async evaluateTreatmentIn(tx: Tx, treatment: Treatment): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateTreatmentIn');
const measureDate = treatment.endDate ?? treatment.date;
await this.evaluateFollowUpIn(
tx,
'TREATMENT_FOLLOW_UP',
treatment.colonyId,
measureDate,
`Nach Behandlung mit ${treatment.product} am ${formatDay(measureDate)}.`,
);
}
async evaluateFeeding(feeding: Feeding): Promise<void> {
await this.evaluateAfterCommit('Feeding', feeding.id, () =>
this.evaluateFollowUp(
'FEEDING_FOLLOW_UP',
feeding.colonyId,
feeding.date,
`Nach Fütterung (${feeding.amount} ${feeding.unit}) am ${formatDay(feeding.date)}.`,
),
async evaluateFeedingIn(tx: Tx, feeding: Feeding): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateFeedingIn');
await this.evaluateFollowUpIn(
tx,
'FEEDING_FOLLOW_UP',
feeding.colonyId,
feeding.date,
`Nach Fütterung (${feeding.amount} ${feeding.unit}) am ${formatDay(feeding.date)}.`,
);
}
/** A check counted from the measure's own date, not from today. */
private async evaluateFollowUp(
/**
* A check counted from the measure's own date, not from today. Its public
* callers check the transaction client, so an error names them.
*/
private async evaluateFollowUpIn(
tx: Tx,
triggerType: 'TREATMENT_FOLLOW_UP' | 'FEEDING_FOLLOW_UP',
colonyId: string,
measureDate: Date,
description: string,
): Promise<void> {
await this.ensureDefaults();
const rule = await this.prisma.automationRule.findUnique({
await this.ensureDefaults(tx);
const rule = await tx.automationRule.findUnique({
where: { triggerType },
});
if (!rule || !rule.active) {
return;
}
await this.triggerAction(rule, 'COLONY', colonyId, undefined, {
await this.triggerActionIn(tx, rule, 'COLONY', colonyId, undefined, {
baseDate: measureDate,
description,
});
}
// -------------------------------------------------------------------------
// Legacy paths
//
// Only SWARM_MOOD_HIGH and STOCK_ITEM_LOW remain: the swarm control and
// stock modules record no event for them yet. Called after the write is
// committed, without a guard - so they never throw (see runAfterCommit): a
// failed evaluation is logged, the request succeeds. Each runs the same
// core as the handlers, in one transaction of its own.
// -------------------------------------------------------------------------
private evaluateAfterCommit(
entityType: string,
entityId: string,
evaluate: (tx: Tx) => Promise<void>,
): Promise<void> {
return runAfterCommit(
this.logger,
{ action: 'Automation rule evaluation', entityType, entityId },
() => this.prisma.$transaction((tx) => evaluate(tx)),
);
}
async evaluateSwarmControlCheck(check: SwarmControlCheck): Promise<void> {
await this.evaluateAfterCommit('SwarmControlCheck', check.id, (tx) =>
this.evaluateSwarmControlCheckIn(tx, check),
);
}
async evaluateStockItem(item: StockItem): Promise<void> {
await this.evaluateAfterCommit('StockItem', item.id, (tx) =>
this.evaluateStockItemIn(tx, item),
);
}
// Returns null (never throws) when the rule is inactive or has no format
// configured, so the caller can fall back to requiring manual entry.
//
@@ -466,7 +512,7 @@ export class AutomationService {
triggerType: NumberFormatTrigger,
options: { scope?: string; vars?: Record<string, string> } = {},
): Promise<string | null> {
await this.ensureDefaults();
await this.ensureDefaults(this.prisma);
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType },
});
@@ -547,75 +593,76 @@ export class AutomationService {
}
/**
* Leaves a suggestion task (never a record of work done) plus a warning.
* Leaves a suggestion task (never a record of work done) plus a warning,
* all on the caller's transaction: task, its audit entry and TaskCreated
* (TasksService.createInTx - the task.created webhook and the calendar
* entry follow from that event), the warning and the rule's audit entry.
* A failing write rolls back all of it; a handler delivery is retried.
*
* At most one open task per rule and object: while the reorder task for a
* stock item or the check task for a colony is still open, further triggers
* (the next removal below minimum, a second high varroa count) add nothing.
* Repeating the same alarm would only bury the one task that matters.
*
* Check and create run in one transaction under a lock on the rule's row:
* two evaluations at the same moment (parallel requests, several pods)
* would otherwise both see "no open task" and both create one. The lock
* is per rule and held for two short statements; webhook, warning and
* audit entry follow after the commit.
* The check runs under a lock on the rule's row, held until the caller
* commits: two evaluations at the same moment (parallel requests, a
* handler and a legacy path, several pods) would otherwise both see
* "no open task" and both create one.
*/
private async triggerAction(
private async triggerActionIn(
tx: Tx,
rule: AutomationRule,
relatedType: TaskRelatedType,
relatedId: string,
sourceInspectionId?: string,
options: { baseDate?: Date; description?: string } = {},
): Promise<void> {
const title = rule.actionTemplate ?? 'Automatisierte Aufgabe';
const config = rule.config as { dueInDays?: unknown } | null;
const task = await this.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM "AutomationRule" WHERE id = ${rule.id} FOR UPDATE`;
const alreadyOpen = await tx.task.findFirst({
where: {
sourceAutomationTrigger: rule.triggerType,
relatedType,
relatedId,
status: { in: ['OPEN', 'IN_PROGRESS'] },
},
select: { id: true },
});
if (alreadyOpen) {
return null;
}
return tx.task.create({
data: {
title,
priority: 'HIGH',
relatedType,
relatedId,
sourceInspectionId,
sourceAutomationTrigger: rule.triggerType,
description: options.description,
dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate),
},
});
});
if (!task) {
return;
}
await this.webhooks.dispatch('task.created', task);
await this.notifications.record({
priority: 'WARNUNG',
message: title,
relatedType,
relatedId,
});
await this.auditLog.record({
entityType: 'AutomationRule',
entityId: rule.id,
action: 'CREATE',
afterData: {
taskId: task.id,
await tx.$queryRaw`SELECT id FROM "AutomationRule" WHERE id = ${rule.id} FOR UPDATE`;
const alreadyOpen = await tx.task.findFirst({
where: {
sourceAutomationTrigger: rule.triggerType,
relatedType,
relatedId,
triggerType: rule.triggerType,
status: { in: ['OPEN', 'IN_PROGRESS'] },
},
select: { id: true },
});
if (alreadyOpen) {
return;
}
const title = rule.actionTemplate ?? 'Automatisierte Aufgabe';
const config = rule.config as { dueInDays?: unknown } | null;
const task = await this.tasks.createInTx(
tx,
{
title,
priority: 'HIGH',
relatedType,
relatedId,
sourceInspectionId,
sourceAutomationTrigger: rule.triggerType,
description: options.description,
dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate),
},
null,
);
await this.notifications.record(
{ priority: 'WARNUNG', message: title, relatedType, relatedId },
tx,
);
await this.auditLog.record(
{
entityType: 'AutomationRule',
entityId: rule.id,
action: 'CREATE',
afterData: {
taskId: task.id,
relatedType,
relatedId,
triggerType: rule.triggerType,
},
},
tx,
);
}
}
@@ -0,0 +1,41 @@
import {
DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY,
varroaThresholdOf,
} from './varroa-threshold';
describe('varroaThresholdOf', () => {
it('uses the default when no rule exists yet (default rule is active)', () => {
expect(varroaThresholdOf(null)).toBe(
DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY,
);
expect(DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY).toBe(10);
});
it('returns null for an inactive rule', () => {
expect(
varroaThresholdOf({
active: false,
config: { thresholdMitesPerDay: 7 },
}),
).toBeNull();
});
it('returns the configured threshold of an active rule', () => {
expect(
varroaThresholdOf({ active: true, config: { thresholdMitesPerDay: 7 } }),
).toBe(7);
});
it.each([
['a string', { thresholdMitesPerDay: 'x' }],
['zero', { thresholdMitesPerDay: 0 }],
['a negative number', { thresholdMitesPerDay: -1 }],
['NaN', { thresholdMitesPerDay: Number.NaN }],
['a missing key', {}],
['a null config', null],
])('falls back to the default for %s', (_label, config) => {
expect(varroaThresholdOf({ active: true, config })).toBe(
DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY,
);
});
});
@@ -0,0 +1,30 @@
import type { AutomationRule } from '../../generated/prisma';
/** Default natural mite fall (mites per day) that triggers the varroa rule. */
export const DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY = 10;
/**
* Effective varroa threshold of the VARROA_THRESHOLD rule.
* - no rule row yet: the default rule applies (ensureDefaults creates it active)
* - inactive rule: null (no threshold in force)
* - active rule: the configured value, or the default if it is not a
* positive finite number (a hand-edited config must never disable the check).
*/
export function varroaThresholdOf(
rule: Pick<AutomationRule, 'active' | 'config'> | null,
): number | null {
if (rule === null) {
return DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY;
}
if (!rule.active) {
return null;
}
const config = rule.config;
if (typeof config !== 'object' || config === null || Array.isArray(config)) {
return DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY;
}
const value = (config as Record<string, unknown>).thresholdMitesPerDay;
return typeof value === 'number' && Number.isFinite(value) && value > 0
? value
: DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY;
}
@@ -1,5 +1,41 @@
import { BadRequestException } from '@nestjs/common';
import { isSealedBackup, openBackup, sealBackup } from './backup-crypto';
import { BadRequestException, Logger } from '@nestjs/common';
import {
isSealedBackup,
openBackup,
sealBackup,
type SealedBackup,
} from './backup-crypto';
// Sealed by the code before the key ring, JWT_ACCESS_SECRET = 'x' * 40:
// LEGACY_JWT_BACKUP without BACKUP_ENCRYPTION_KEY (key derived from the JWT
// secret), KEY_BACKUP with BACKUP_ENCRYPTION_KEY = 'k' * 40.
const FIXTURE_BUNDLE = {
version: 3,
exportedAt: '2026-10-01T09:00:00.000Z',
data: { user: [{ id: 'u1' }] },
};
const LEGACY_JWT_BACKUP: SealedBackup = {
format: 'apicore-backup',
version: 3,
keyId: '6ac8404ff5ae',
exportedAt: '2026-10-01T09:00:00.000Z',
algorithm: 'A256GCM+gzip',
iv: 'vD+47m3e5IFJAexz',
tag: 'LOgrmURu9vAMalQmlduDpw==',
ciphertext:
'yvuAGhVALAxXkqZM8FpeiPnGHD9llp/SVtdQ6S58oS4ezussfP7Lqj8ZDuNbxabwjUP4QBJvVdEyhKCFZ9w5YRbCRjDozizJyE6YwjqiCsAXWPyVZ2lZ6Fo8/G+/0HsoQg==',
};
const KEY_BACKUP: SealedBackup = {
format: 'apicore-backup',
version: 3,
keyId: '14b11a29dc33',
exportedAt: '2026-10-01T09:00:00.000Z',
algorithm: 'A256GCM+gzip',
iv: 'NKe1zNJfRPyBqeYL',
tag: 'zU/oVHyV4bj5+wwF0lvGiA==',
ciphertext:
'ArjmmWq18F3OkPQYfx6x9XcjWdqP1SZC9zarUCRfV7bo6KRYx3pi5sNMRro6mg0eqlr4+C4jRKn3y+110kaWEFrY6J/ashF76pWSl1gzfiewkpxdqoyjkXRxrd4AnPrnfw==',
};
describe('backup-crypto', () => {
const originalEnv = { ...process.env };
@@ -12,10 +48,13 @@ describe('backup-crypto', () => {
beforeEach(() => {
process.env.JWT_ACCESS_SECRET = 'x'.repeat(40);
delete process.env.BACKUP_ENCRYPTION_KEY;
delete process.env.BACKUP_ENCRYPTION_KEY_PREVIOUS;
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
process.env = { ...originalEnv };
jest.restoreAllMocks();
});
it('round-trips a bundle and hides its contents', () => {
@@ -45,7 +84,17 @@ describe('backup-crypto', () => {
).toThrow(BadRequestException);
});
it('rejects a file with a truncated auth tag', () => {
const sealed = sealBackup(bundle, bundle.exportedAt);
const tag = Buffer.from(sealed.tag, 'base64').subarray(0, 4);
expect(() =>
openBackup({ ...sealed, tag: tag.toString('base64') }),
).toThrow(BadRequestException);
});
it('rejects a file sealed with another key', () => {
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
const sealed = sealBackup(bundle, bundle.exportedAt);
process.env.BACKUP_ENCRYPTION_KEY = 'y'.repeat(40);
@@ -64,4 +113,80 @@ describe('backup-crypto', () => {
process.env.BACKUP_ENCRYPTION_KEY = 'short';
expect(() => sealBackup(bundle, bundle.exportedAt)).toThrow();
});
it('opens a backup sealed with BACKUP_ENCRYPTION_KEY_PREVIOUS', () => {
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
const sealed = sealBackup(bundle, bundle.exportedAt);
process.env.BACKUP_ENCRYPTION_KEY = 'y'.repeat(40);
process.env.BACKUP_ENCRYPTION_KEY_PREVIOUS = 'k'.repeat(40);
expect(openBackup(sealed)).toEqual(bundle);
expect(sealBackup(bundle, bundle.exportedAt).keyId).not.toBe(sealed.keyId);
});
it('still opens files sealed before the key ring (file format v3 unchanged)', () => {
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
expect(openBackup(KEY_BACKUP)).toEqual(FIXTURE_BUNDLE);
expect(openBackup(LEGACY_JWT_BACKUP)).toEqual(FIXTURE_BUNDLE);
});
it('only reads with the JWT-derived key, never seals with it once BACKUP_ENCRYPTION_KEY is set', () => {
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
expect(sealBackup(bundle, bundle.exportedAt).keyId).toBe(KEY_BACKUP.keyId);
});
it('fails closed on a JWT-derived backup once JWT_ACCESS_SECRET changed', () => {
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
expect(() => openBackup(LEGACY_JWT_BACKUP)).toThrow(BadRequestException);
});
it('refuses to seal in production without BACKUP_ENCRYPTION_KEY', () => {
process.env.NODE_ENV = 'production';
expect(() => sealBackup(bundle, bundle.exportedAt)).toThrow(
'BACKUP_ENCRYPTION_KEY is not set',
);
});
it('warns once that a development key stands in for BACKUP_ENCRYPTION_KEY', () => {
jest.isolateModules(() => {
// A fresh module copy, so earlier tests cannot have used up the warning.
const nest =
jest.requireActual<typeof import('@nestjs/common')>('@nestjs/common');
const fresh =
jest.requireActual<typeof import('./backup-crypto')>('./backup-crypto');
const warn = jest
.spyOn(nest.Logger.prototype, 'warn')
.mockImplementation(() => undefined);
const sealed = fresh.sealBackup(bundle, bundle.exportedAt);
fresh.sealBackup(bundle, bundle.exportedAt);
fresh.openBackup(sealed);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(
'BACKUP_ENCRYPTION_KEY not set – using development key derived from JWT_ACCESS_SECRET; never in production',
);
});
});
it('does not warn when only reading a legacy file next to a configured key', () => {
jest.isolateModules(() => {
const nest =
jest.requireActual<typeof import('@nestjs/common')>('@nestjs/common');
const fresh =
jest.requireActual<typeof import('./backup-crypto')>('./backup-crypto');
const warn = jest
.spyOn(nest.Logger.prototype, 'warn')
.mockImplementation(() => undefined);
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
expect(fresh.openBackup(LEGACY_JWT_BACKUP)).toEqual(FIXTURE_BUNDLE);
expect(warn).not.toHaveBeenCalled();
});
});
});
+67 -54
View File
@@ -1,6 +1,17 @@
import { BadRequestException } from '@nestjs/common';
import * as crypto from 'node:crypto';
import * as zlib from 'node:zlib';
import {
ENCRYPTION_KEY_VARIABLES,
devFallbackSecret,
findRingKey,
gcmOpen,
gcmSeal,
readRingSecrets,
requireJwtAccessSecret,
type KeyRing,
type RingKey,
} from '../common/crypto/key-ring';
/**
* A backup holds everything - password hashes, encrypted MFA secrets,
@@ -9,17 +20,17 @@ import * as zlib from 'node:zlib';
* keeps it unreadable outside ApiCore AND makes any change to it (or a
* hand-made file) fail authentication before a single row is touched.
*
* Key: BACKUP_ENCRYPTION_KEY when set (needed to restore into another
* deployment), otherwise derived from JWT_ACCESS_SECRET - so a deployment
* can always restore its own backups. Either way the AES key comes from
* HKDF with a backup-specific label, never the raw secret.
* Keys follow the key ring (common/crypto/key-ring.ts): BACKUP_ENCRYPTION_KEY
* seals, BACKUP_ENCRYPTION_KEY_PREVIOUS still opens, and files sealed before
* the key was mandatory - with a key derived from JWT_ACCESS_SECRET - are
* still read (never written, except outside production without
* BACKUP_ENCRYPTION_KEY). The file names its key in `keyId`. Either way the
* AES key comes from HKDF with a backup-specific label, never the raw
* secret; derivation and file format v3 are unchanged, so older files open.
*/
export const SEALED_BACKUP_FORMAT = 'apicore-backup';
export const SEALED_BACKUP_VERSION = 3;
const ALGORITHM = 'aes-256-gcm';
const IV_BYTES = 12;
const MIN_KEY_LENGTH = 32;
// Decompressed size cap: a small file must not unpack into gigabytes.
const MAX_PLAINTEXT_BYTES = 512 * 1024 * 1024;
@@ -35,25 +46,9 @@ export interface SealedBackup {
ciphertext: string;
}
function rootSecret(): { secret: string; source: 'backup' | 'jwt' } {
const backupKey = process.env.BACKUP_ENCRYPTION_KEY;
if (backupKey) {
if (backupKey.length < MIN_KEY_LENGTH) {
throw new Error(
`BACKUP_ENCRYPTION_KEY must be at least ${MIN_KEY_LENGTH} characters`,
);
}
return { secret: backupKey, source: 'backup' };
}
const jwtSecret = process.env.JWT_ACCESS_SECRET;
if (!jwtSecret) {
throw new Error('JWT_ACCESS_SECRET is not set');
}
return { secret: jwtSecret, source: 'jwt' };
}
type KeySource = 'backup' | 'jwt';
function deriveKey(): { key: Buffer; keyId: string } {
const { secret, source } = rootSecret();
function deriveBackupKey(secret: string, source: KeySource): RingKey {
const key = Buffer.from(
crypto.hkdfSync(
'sha256',
@@ -63,12 +58,35 @@ function deriveKey(): { key: Buffer; keyId: string } {
32,
),
);
const keyId = crypto
.createHash('sha256')
.update(key)
.digest('hex')
.slice(0, 12);
return { key, keyId };
const id = crypto.createHash('sha256').update(key).digest('hex').slice(0, 12);
return { id, key };
}
function legacyBackupKey(): RingKey {
return deriveBackupKey(requireJwtAccessSecret(), 'jwt');
}
function loadBackupRing(): KeyRing {
const variable = ENCRYPTION_KEY_VARIABLES.backup;
const secrets = readRingSecrets(variable);
return {
current: secrets.current
? deriveBackupKey(secrets.current, 'backup')
: deriveBackupKey(devFallbackSecret(variable), 'jwt'),
previous: secrets.previous
? deriveBackupKey(secrets.previous, 'backup')
: null,
};
}
/** The ring's keys first; the JWT-derived key of older files only as a last resort for reading. */
function keyForReading(keyId: string): RingKey | null {
const ringKey = findRingKey(loadBackupRing(), keyId);
if (ringKey) {
return ringKey;
}
const legacy = legacyBackupKey();
return legacy.id === keyId ? legacy : null;
}
function associatedData(
@@ -80,24 +98,21 @@ function associatedData(
}
export function sealBackup(bundle: unknown, exportedAt: string): SealedBackup {
const { key, keyId } = deriveKey();
const iv = crypto.randomBytes(IV_BYTES);
const { current } = loadBackupRing();
const header = {
format: SEALED_BACKUP_FORMAT,
version: SEALED_BACKUP_VERSION,
keyId,
keyId: current.id,
exportedAt,
} as const;
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
cipher.setAAD(associatedData(header));
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(bundle)));
const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]);
const sealed = gcmSeal(current.key, compressed, associatedData(header));
return {
...header,
algorithm: 'A256GCM+gzip',
iv: iv.toString('base64'),
tag: cipher.getAuthTag().toString('base64'),
ciphertext: ciphertext.toString('base64'),
iv: sealed.iv.toString('base64'),
tag: sealed.tag.toString('base64'),
ciphertext: sealed.ciphertext.toString('base64'),
};
}
@@ -112,32 +127,30 @@ export function isSealedBackup(value: unknown): value is SealedBackup {
);
}
/** The bundle inside a sealed backup; throws if the file was not made with this key or was changed. */
/** The bundle inside a sealed backup; throws if no known key made the file or it was changed. */
export function openBackup(sealed: SealedBackup): unknown {
if (sealed.version !== SEALED_BACKUP_VERSION) {
throw new BadRequestException(
`Backup-Version ${String(sealed.version)} wird nicht unterstützt`,
);
}
const { key, keyId } = deriveKey();
if (sealed.keyId !== keyId) {
const ringKey = keyForReading(sealed.keyId);
if (!ringKey) {
throw new BadRequestException(
'Backup wurde mit einem anderen Schlüssel erstellt (BACKUP_ENCRYPTION_KEY prüfen)',
'Backup wurde mit einem anderen Schlüssel erstellt (BACKUP_ENCRYPTION_KEY bzw. BACKUP_ENCRYPTION_KEY_PREVIOUS prüfen)',
);
}
let compressed: Buffer;
try {
const decipher = crypto.createDecipheriv(
ALGORITHM,
key,
Buffer.from(sealed.iv, 'base64'),
compressed = gcmOpen(
ringKey.key,
{
iv: Buffer.from(sealed.iv, 'base64'),
tag: Buffer.from(sealed.tag, 'base64'),
ciphertext: Buffer.from(sealed.ciphertext, 'base64'),
},
associatedData(sealed),
);
decipher.setAAD(associatedData(sealed));
decipher.setAuthTag(Buffer.from(sealed.tag, 'base64'));
compressed = Buffer.concat([
decipher.update(Buffer.from(sealed.ciphertext, 'base64')),
decipher.final(),
]);
} catch {
throw new BadRequestException(
'Backup-Datei ist beschädigt oder wurde verändert',
@@ -0,0 +1,82 @@
import { runInNewContext } from 'node:vm';
import { BACKUP_SCHEDULE_TIME_ZONE, isBackupDue } from './backup-due';
describe('isBackupDue', () => {
it('reads the schedule in Europe/Berlin by default', () => {
expect(BACKUP_SCHEDULE_TIME_ZONE).toBe('Europe/Berlin');
});
it('is due when there is no backup yet', () => {
expect(
isBackupDue('0 3 * * *', null, new Date('2026-10-13T00:30:00Z')),
).toBe(true);
});
it('is not due again on the same day for "0 3 * * *"', () =>
expect(
isBackupDue(
'0 3 * * *',
new Date('2026-10-12T01:00:00Z'),
new Date('2026-10-13T00:30:00Z'),
),
).toBe(false));
it('is due after the next 03:00 Europe/Berlin', () =>
expect(
isBackupDue(
'0 3 * * *',
new Date('2026-10-12T01:00:00Z'),
new Date('2026-10-13T01:05:00Z'),
),
).toBe(true));
it('is due exactly at the next scheduled time', () =>
expect(
isBackupDue(
'0 3 * * *',
new Date('2026-10-12T01:00:00Z'),
new Date('2026-10-13T01:00:00Z'),
),
).toBe(true));
it('counts a backup written after the scheduled time (e.g. "Jetzt sichern") as covering it', () =>
expect(
isBackupDue(
'0 3 * * *',
new Date('2026-10-13T01:10:00Z'),
new Date('2026-10-13T02:00:00Z'),
),
).toBe(false));
it('stays due after a missed run until a backup exists', () =>
expect(
isBackupDue(
'0 3 * * *',
new Date('2026-10-10T01:00:00Z'),
new Date('2026-10-13T05:00:00Z'),
),
).toBe(true));
it('uses the given time zone', () => {
const lastBackupAt = new Date('2026-10-12T03:00:00Z');
const now = new Date('2026-10-13T02:00:00Z');
// Next 03:00 UTC is 03:00Z, next 03:00 Berlin was 01:00Z.
expect(isBackupDue('0 3 * * *', lastBackupAt, now, 'UTC')).toBe(false);
expect(isBackupDue('0 3 * * *', lastBackupAt, now)).toBe(true);
});
// fs.Stats.mtime is created by Node's core; under Jest it is not an
// instance of the test's Date (cron checks `instanceof Date`).
it('accepts a Date from another realm', () => {
const foreign = runInNewContext('new Date("2026-10-12T01:00:00Z")') as Date;
expect(
isBackupDue('0 3 * * *', foreign, new Date('2026-10-13T00:30:00Z')),
).toBe(false);
});
it('rejects an invalid schedule even without a backup', () => {
expect(() => isBackupDue('every night', null, new Date())).toThrow();
});
});
+34
View File
@@ -0,0 +1,34 @@
import { CronTime } from 'cron';
/**
* The time zone a backup schedule is read in - the same as the CronJobs'
* `timeZone`, so "0 3 * * *" means 03:00 in Germany, summer and winter.
*/
export const BACKUP_SCHEDULE_TIME_ZONE = 'Europe/Berlin';
/**
* Whether the hourly app-backup check has to write a backup: true while no
* backup exists, otherwise once the first scheduled time after the newest
* backup has come. A backup written after a scheduled time (a manual
* "Jetzt sichern", or a late run) covers that time; a missed or failed run
* stays due until a backup exists. Throws on an invalid schedule, also
* without a backup, so a broken schedule fails the run instead of being
* skipped silently.
*/
export function isBackupDue(
cronSchedule: string,
lastBackupAt: Date | null,
now: Date,
timeZone: string = BACKUP_SCHEDULE_TIME_ZONE,
): boolean {
const schedule = new CronTime(cronSchedule, timeZone);
if (lastBackupAt === null) {
return true;
}
// A copy: fs.Stats.mtime comes from another realm than cron's Date check.
const next = schedule.getNextDateFrom(
new Date(lastBackupAt.getTime()),
timeZone,
);
return next.toMillis() <= now.getTime();
}
@@ -1,9 +1,12 @@
import { Logger } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { CronJob } from 'cron';
import * as path from 'node:path';
import { Prisma } from '../../generated/prisma';
import { BackupSchedulerService } from './backup-scheduler.service';
import { JobRun } from '../../generated/prisma';
import { PrismaService } from '../prisma/prisma.service';
import { JobRunService } from '../system/job-run.service';
import {
APP_BACKUP_LOCK_TIMEOUT_MS,
BackupSchedulerService,
} from './backup-scheduler.service';
import { BackupService } from './backup.service';
import { BackupSettingsService } from './backup-settings.service';
@@ -17,12 +20,30 @@ jest.mock('node:fs/promises', () => ({
import * as fs from 'node:fs/promises';
type RawCall = [readonly string[], ...unknown[]];
const SEALED = {
format: 'apicore-backup',
exportedAt: '2026-10-13T01:05:00.000Z',
ciphertext: 'x'.repeat(2000),
};
describe('BackupSchedulerService', () => {
const originalEnv = { ...process.env };
const directory = path.resolve('/data/backups');
let service: BackupSchedulerService;
let backupService: { exportSealed: jest.Mock };
let settings: { get: jest.Mock };
let tx: {
$executeRaw: jest.Mock<Promise<number>, RawCall>;
$queryRaw: jest.Mock<Promise<{ now: Date }[]>, RawCall>;
};
let prisma: { $transaction: jest.Mock };
let jobRuns: { recent: jest.Mock };
let error: jest.SpyInstance;
let warn: jest.SpyInstance;
const sqlText = (call: RawCall): string => call[0].join('?');
beforeEach(() => {
jest.clearAllMocks();
@@ -31,33 +52,43 @@ describe('BackupSchedulerService', () => {
delete process.env.BACKUP_RETENTION_COUNT;
delete process.env.BACKUP_CRON_SCHEDULE;
error = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => {});
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
backupService = {
exportSealed: jest.fn().mockResolvedValue({
format: 'apicore-backup',
exportedAt: '2026-10-12T03:00:00.000Z',
ciphertext: 'x'.repeat(2000),
}),
};
const settings = {
backupService = { exportSealed: jest.fn().mockResolvedValue(SEALED) };
settings = {
get: jest.fn().mockResolvedValue({
storagePath: null,
cronSchedule: null,
retentionCount: null,
}),
};
tx = {
$executeRaw: jest.fn<Promise<number>, RawCall>().mockResolvedValue(1),
$queryRaw: jest
.fn<Promise<{ now: Date }[]>, RawCall>()
.mockResolvedValue([{ now: new Date('2026-10-13T01:05:00Z') }]),
};
prisma = {
$transaction: jest.fn((work: (client: typeof tx) => Promise<unknown>) =>
work(tx),
),
};
jobRuns = { recent: jest.fn().mockResolvedValue([]) };
service = new BackupSchedulerService(
backupService as unknown as BackupService,
settings as unknown as BackupSettingsService,
{} as SchedulerRegistry,
prisma as unknown as PrismaService,
jobRuns as unknown as JobRunService,
);
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
(fs.readdir as jest.Mock).mockResolvedValue([]);
(fs.unlink as jest.Mock).mockResolvedValue(undefined);
});
afterEach(() => {
error.mockRestore();
warn.mockRestore();
process.env = { ...originalEnv };
});
@@ -72,6 +103,19 @@ describe('BackupSchedulerService', () => {
});
}
function jobRun(overrides: Partial<JobRun> = {}): JobRun {
return {
id: 'run-1',
job: 'app-backup',
status: 'SUCCEEDED',
startedAt: new Date('2026-10-13T01:00:02.000Z'),
finishedAt: new Date('2026-10-13T01:00:09.000Z'),
result: { written: true },
error: null,
...overrides,
};
}
describe('getStatus', () => {
it('reports the import limit and the size of the newest backup', async () => {
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
@@ -129,6 +173,49 @@ describe('BackupSchedulerService', () => {
}),
);
});
it('reports the newest app-backup job run', async () => {
jobRuns.recent.mockResolvedValue([
jobRun({
status: 'FAILED',
finishedAt: new Date('2026-10-13T01:00:05.000Z'),
result: null,
error: 'EACCES: permission denied',
}),
]);
const status = await service.getStatus();
expect(jobRuns.recent).toHaveBeenCalledWith(1, 'app-backup');
expect(status.lastJobRun).toEqual({
status: 'FAILED',
startedAt: '2026-10-13T01:00:02.000Z',
finishedAt: '2026-10-13T01:00:05.000Z',
error: 'EACCES: permission denied',
});
});
it('reports a running job run without an end and the run also without a path', async () => {
delete process.env.BACKUP_STORAGE_PATH;
jobRuns.recent.mockResolvedValue([
jobRun({ status: 'RUNNING', finishedAt: null, result: null }),
]);
const status = await service.getStatus();
expect(status.lastJobRun).toEqual({
status: 'RUNNING',
startedAt: '2026-10-13T01:00:02.000Z',
finishedAt: null,
error: null,
});
});
it('reports no job run before the first one', async () => {
const status = await service.getStatus();
expect(status.lastJobRun).toBeNull();
});
});
describe('runNow', () => {
@@ -151,48 +238,188 @@ describe('BackupSchedulerService', () => {
expect(fs.writeFile).toHaveBeenCalledTimes(1);
expect(error).not.toHaveBeenCalled();
});
// A human asked for it: no due check, no wait for the schedule lock.
it('always writes, without the schedule lock', async () => {
onDisk([
{
name: 'apicore-backup-new.json',
size: 900,
mtime: new Date().toISOString(),
},
]);
await service.runNow();
expect(fs.writeFile).toHaveBeenCalledTimes(1);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
});
// The export reads every table through Prisma; a Prisma message quotes
// query values, so a failed scheduled run logs name, code and model only.
describe('scheduled run', () => {
it('logs a failed scheduled backup without the Prisma message', async () => {
const registry = {
doesExist: jest.fn().mockReturnValue(false),
addCronJob: jest.fn(),
deleteCronJob: jest.fn(),
};
const scheduled = new BackupSchedulerService(
backupService as unknown as BackupService,
describe('runIfDue', () => {
const lastNight = '2026-10-12T01:00:00.000Z';
it('writes nothing without a path', async () => {
delete process.env.BACKUP_STORAGE_PATH;
const result = await service.runIfDue(new Date('2026-10-13T01:05:00Z'));
expect(result).toEqual({ written: false, reason: 'no-path' });
expect(backupService.exportSealed).not.toHaveBeenCalled();
expect(fs.writeFile).not.toHaveBeenCalled();
});
it('writes nothing while the newest backup covers the last scheduled time', async () => {
onDisk([{ name: 'apicore-backup-a.json', size: 900, mtime: lastNight }]);
const result = await service.runIfDue(new Date('2026-10-13T00:30:00Z'));
expect(result).toEqual({ written: false, reason: 'not-due' });
expect(backupService.exportSealed).not.toHaveBeenCalled();
expect(fs.writeFile).not.toHaveBeenCalled();
expect(fs.unlink).not.toHaveBeenCalled();
});
it('writes and prunes once the next scheduled time has passed', async () => {
process.env.BACKUP_RETENTION_COUNT = '1';
onDisk([
{ name: 'apicore-backup-a.json', size: 900, mtime: lastNight },
{
get: jest.fn().mockResolvedValue({
storagePath: null,
cronSchedule: null,
retentionCount: null,
}),
} as unknown as BackupSettingsService,
registry as unknown as SchedulerRegistry,
);
backupService.exportSealed.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', {
code: 'P2010',
clientVersion: '7.9.1',
meta: { modelName: 'Task' },
}),
);
await scheduled.reload();
const [[, job]] = registry.addCronJob.mock.calls as [[string, CronJob]];
await job.stop();
name: 'apicore-backup-b.json',
size: 800,
mtime: '2026-10-11T01:00:00.000Z',
},
]);
await job.fireOnTick();
await new Promise((resolve) => setImmediate(resolve));
const result = await service.runIfDue(new Date('2026-10-13T01:05:00Z'));
const failure = error.mock.calls.find(([message]) =>
String(message).startsWith('Scheduled backup failed'),
) as [string, unknown];
expect(failure[0]).toContain('P2010');
expect(failure[0]).not.toContain('Mustermann');
expect(failure[1]).toBeUndefined();
expect(fs.writeFile).toHaveBeenCalledTimes(1);
expect(result).toEqual({
written: true,
fileName: 'apicore-backup-2026-10-13T01-05-00-000Z.json',
sizeBytes: Buffer.byteLength(JSON.stringify(SEALED)),
pruned: 1,
});
expect(fs.unlink).toHaveBeenCalledWith(
path.join(directory, 'apicore-backup-b.json'),
);
});
it('writes the first backup into an empty directory', async () => {
const result = await service.runIfDue(new Date('2026-10-13T00:30:00Z'));
expect(result).toEqual(expect.objectContaining({ written: true }));
expect(fs.writeFile).toHaveBeenCalledTimes(1);
});
it('reads the schedule in Europe/Berlin', async () => {
process.env.BACKUP_CRON_SCHEDULE = '0 3 * * *';
onDisk([
{
name: 'apicore-backup-a.json',
size: 900,
mtime: '2026-10-12T03:00:00.000Z',
},
]);
// 03:00 Berlin = 01:00Z has passed, 03:00 UTC has not.
const result = await service.runIfDue(new Date('2026-10-13T02:00:00Z'));
expect(result.written).toBe(true);
});
it('does not count a file it could not delete as pruned', async () => {
process.env.BACKUP_RETENTION_COUNT = '1';
onDisk([
{ name: 'apicore-backup-a.json', size: 900, mtime: lastNight },
{
name: 'apicore-backup-b.json',
size: 800,
mtime: '2026-10-11T01:00:00.000Z',
},
{
name: 'apicore-backup-c.json',
size: 700,
mtime: '2026-10-10T01:00:00.000Z',
},
]);
(fs.unlink as jest.Mock)
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('EBUSY'));
const result = await service.runIfDue(new Date('2026-10-13T01:05:00Z'));
expect(result).toEqual(expect.objectContaining({ pruned: 1 }));
expect(warn).toHaveBeenCalledTimes(1);
});
// Two job pods (a CronJob run and a manual `kubectl create job`) must
// not both find the backup due: the check and the write happen under
// one database lock, so the second one sees the first one's file.
it('checks and writes only while holding the schedule lock', async () => {
await service.runIfDue(new Date('2026-10-13T01:05:00Z'));
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), {
timeout: APP_BACKUP_LOCK_TIMEOUT_MS,
maxWait: expect.any(Number) as number,
});
expect(tx.$executeRaw).toHaveBeenCalledTimes(1);
const [lockCall] = tx.$executeRaw.mock.calls;
expect(sqlText(lockCall)).toContain('pg_advisory_xact_lock');
expect(lockCall.slice(1)).toEqual(['app-backup:schedule']);
const lockedAt = tx.$executeRaw.mock.invocationCallOrder[0];
expect(
(fs.readdir as jest.Mock).mock.invocationCallOrder[0],
).toBeGreaterThan(lockedAt);
expect(
(fs.writeFile as jest.Mock).mock.invocationCallOrder[0],
).toBeGreaterThan(lockedAt);
});
it('takes the time from the database clock when none is given', async () => {
onDisk([{ name: 'apicore-backup-a.json', size: 900, mtime: lastNight }]);
tx.$queryRaw.mockResolvedValue([
{ now: new Date('2026-10-13T00:30:00Z') },
]);
// The pod's own clock (2026-10-14) would say due; the database's says no.
jest.useFakeTimers({ now: new Date('2026-10-14T12:00:00Z') });
try {
await expect(service.runIfDue()).resolves.toEqual({
written: false,
reason: 'not-due',
});
} finally {
jest.useRealTimers();
}
expect(sqlText(tx.$queryRaw.mock.calls[0])).toContain('now()');
});
it('gives up the lock as soon as the backup is not due', async () => {
onDisk([{ name: 'apicore-backup-a.json', size: 900, mtime: lastNight }]);
await service.runIfDue(new Date('2026-10-13T00:30:00Z'));
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(fs.mkdir).not.toHaveBeenCalled();
});
it('fails the run when the export fails', async () => {
backupService.exportSealed.mockRejectedValue(new Error('export broke'));
await expect(
service.runIfDue(new Date('2026-10-13T01:05:00Z')),
).rejects.toThrow('export broke');
expect(fs.writeFile).not.toHaveBeenCalled();
});
it('fails the run on an invalid schedule instead of skipping silently', async () => {
process.env.BACKUP_CRON_SCHEDULE = 'every night';
await expect(
service.runIfDue(new Date('2026-10-13T01:05:00Z')),
).rejects.toThrow();
expect(fs.writeFile).not.toHaveBeenCalled();
});
});
});
@@ -1,32 +1,51 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { CronJob } from 'cron';
import { Injectable, Logger } from '@nestjs/common';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { JobRunStatus, Prisma } from '../../generated/prisma';
import { PrismaService } from '../prisma/prisma.service';
import { JobRunService } from '../system/job-run.service';
import { BackupService } from './backup.service';
import { BackupSettingsService } from './backup-settings.service';
import { loggableError } from '../common/loggable-error';
import {
assertBackupCron,
resolveGuiBackupPath,
} from './backup-schedule-rules';
import { isBackupDue } from './backup-due';
import { resolveGuiBackupPath } from './backup-schedule-rules';
import {
backupMaxImportBytes,
exceedsBackupImportLimit,
formatMegabytes,
} from './backup-import-limit';
const SCHEDULED_JOB_NAME = 'automatic-backup';
const DEFAULT_CRON_SCHEDULE = '0 3 * * *';
const DEFAULT_RETENTION_COUNT = 14;
const BACKUP_FILE_PREFIX = 'apicore-backup-';
const BACKUP_FILE_SUFFIX = '.json';
// Guards against two replicas (deployment runs with replicas: 2, no shared
// DB table for scheduling state) both firing the same cron tick: if the most
// recent backup file on this shared/mounted path is younger than this, a
// concurrent replica already handled the tick, so this one skips.
const DUPLICATE_RUN_GUARD_MS = 2 * 60 * 1000;
/**
* Advisory lock (pg_advisory_xact_lock on its hash) around the due check
* and the write of a scheduled backup.
*/
const APP_BACKUP_LOCK = 'app-backup:schedule';
/**
* How long a scheduled backup may hold the lock: the activeDeadlineSeconds
* of the app-backup CronJob (3600 s), after which the pod is gone anyway.
*/
export const APP_BACKUP_LOCK_TIMEOUT_MS = 60 * 60 * 1000;
/** How long to wait for a free pooled connection to take the lock on. */
const APP_BACKUP_LOCK_MAX_WAIT_MS = 10_000;
/** What one run of the app-backup job did (JobRun.result). */
export type AppBackupResult =
| { written: true; fileName: string; sizeBytes: number; pruned: number }
| { written: false; reason: 'no-path' | 'not-due' };
/** The newest app-backup job run, as GET /backup/schedule shows it. */
export interface BackupJobRunStatus {
status: JobRunStatus;
startedAt: string;
finishedAt: string | null;
error: string | null;
}
export interface BackupScheduleStatus {
enabled: boolean;
@@ -48,6 +67,9 @@ export interface BackupScheduleStatus {
cronSchedule: boolean;
retentionCount: boolean;
};
// The newest run of the hourly app-backup job (CronJob), null before the
// first one. lastRunAt above is the newest backup file instead.
lastJobRun: BackupJobRunStatus | null;
}
interface EffectiveBackupSettings {
@@ -57,20 +79,23 @@ interface EffectiveBackupSettings {
envOverrides: BackupScheduleStatus['envOverrides'];
}
/**
* Automatic backups. The schedule is not a timer in this process: the
* app-backup CronJob runs every hour and calls runIfDue(), which reads path,
* schedule and retention afresh (env before GUI), so a GUI change applies
* from the next hourly check on. runNow() is the "Jetzt sichern" button.
*/
@Injectable()
export class BackupSchedulerService implements OnModuleInit {
export class BackupSchedulerService {
private readonly logger = new Logger(BackupSchedulerService.name);
constructor(
private readonly backupService: BackupService,
private readonly backupSettingsService: BackupSettingsService,
private readonly schedulerRegistry: SchedulerRegistry,
private readonly prisma: PrismaService,
private readonly jobRuns: JobRunService,
) {}
async onModuleInit(): Promise<void> {
await this.reload();
}
private async effectiveSettings(): Promise<EffectiveBackupSettings> {
const envPath = process.env.BACKUP_STORAGE_PATH;
const hasEnvPath = Boolean(envPath && envPath.trim().length > 0);
@@ -106,55 +131,10 @@ export class BackupSchedulerService implements OnModuleInit {
};
}
// Re-registers (or tears down) the cron job to match the current effective
// settings. Called at boot and again after a GUI settings update, so an
// admin's schedule/path/retention change takes effect immediately without
// a pod restart.
async reload(): Promise<void> {
const settings = await this.effectiveSettings();
if (!settings.envOverrides.cronSchedule) {
try {
assertBackupCron(settings.cronSchedule);
} catch {
// Keep whatever is running instead of silently ending backups.
this.logger.error(
`Ungültiger Backup-Zeitplan "${settings.cronSchedule}" - bisheriger Zeitplan bleibt aktiv`,
);
return;
}
}
if (this.schedulerRegistry.doesExist('cron', SCHEDULED_JOB_NAME)) {
this.schedulerRegistry.deleteCronJob(SCHEDULED_JOB_NAME);
}
if (!settings.path) {
this.logger.log(
'Kein Backup-Pfad konfiguriert (weder Env noch GUI) - automatische Backups deaktiviert',
);
return;
}
const directory = settings.path;
const job = CronJob.from({
cronTime: settings.cronSchedule,
onTick: () => {
// Prisma errors by name/code/model only (see loggableError).
this.runScheduled(directory).catch((error: unknown) => {
const { message, stack } = loggableError(error);
this.logger.error(`Scheduled backup failed: ${message}`, stack);
});
},
start: true,
});
this.schedulerRegistry.addCronJob(SCHEDULED_JOB_NAME, job);
this.logger.log(
`Automatische Backups aktiv: schedule="${settings.cronSchedule}" path="${directory}" retention=${settings.retentionCount}`,
);
}
async getStatus(): Promise<BackupScheduleStatus> {
const settings = await this.effectiveSettings();
const importLimitBytes = backupMaxImportBytes();
const lastJobRun = await this.lastJobRun();
if (!settings.path) {
return {
enabled: false,
@@ -166,6 +146,7 @@ export class BackupSchedulerService implements OnModuleInit {
lastBackupSizeBytes: null,
lastBackupExceedsImportLimit: false,
envOverrides: settings.envOverrides,
lastJobRun,
};
}
@@ -185,11 +166,25 @@ export class BackupSchedulerService implements OnModuleInit {
lastBackupSizeBytes !== null &&
exceedsBackupImportLimit(lastBackupSizeBytes),
envOverrides: settings.envOverrides,
lastJobRun,
};
}
// Manual trigger (e.g. "Jetzt sichern" button) - always runs, no
// duplicate-tick guard, since a human explicitly asked for it.
private async lastJobRun(): Promise<BackupJobRunStatus | null> {
const [run] = await this.jobRuns.recent(1, 'app-backup');
if (!run) {
return null;
}
return {
status: run.status,
startedAt: run.startedAt.toISOString(),
finishedAt: run.finishedAt?.toISOString() ?? null,
error: run.error,
};
}
// Manual trigger ("Jetzt sichern") - always writes: no due check and no
// schedule lock, since a human explicitly asked for it.
async runNow(): Promise<{ fileName: string }> {
const settings = await this.effectiveSettings();
if (!settings.path) {
@@ -197,32 +192,61 @@ export class BackupSchedulerService implements OnModuleInit {
'Kein Backup-Pfad konfiguriert - weder per Umgebungsvariable noch über die GUI',
);
}
const filePath = await this.writeBackup(settings.path);
const { fileName } = await this.writeBackup(settings.path);
await this.pruneOldBackups(settings.path, settings.retentionCount);
// Only the name: the server's directory layout stays on the server.
return { fileName: path.basename(filePath) };
return { fileName };
}
private async runScheduled(directory: string): Promise<void> {
/**
* One hourly check of the app-backup job: writes a backup if one is due
* (isBackupDue against the newest backup file, schedule in
* Europe/Berlin) and prunes beyond the retention. Check and write run
* under one database advisory lock, so a second job pod that checks at
* the same time waits and then finds the first pod's file - two pods
* never both write. Failures are thrown and fail the job run.
*/
async runIfDue(now?: Date): Promise<AppBackupResult> {
const settings = await this.effectiveSettings();
const files = await this.listBackupFiles(directory);
const mostRecent = files[0];
if (
mostRecent &&
Date.now() - mostRecent.mtime.getTime() < DUPLICATE_RUN_GUARD_MS
) {
this.logger.log(
'Skipping scheduled backup - another replica already wrote one for this tick',
);
return;
if (!settings.path) {
return { written: false, reason: 'no-path' };
}
const filePath = await this.writeBackup(directory);
this.logger.log(`Scheduled backup written to ${filePath}`);
await this.pruneOldBackups(directory, settings.retentionCount);
const directory = settings.path;
return this.prisma.$transaction(
async (tx): Promise<AppBackupResult> => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${APP_BACKUP_LOCK}, 0))`;
const [newest] = await this.listBackupFiles(directory);
// The database clock, like every other time comparison of the
// platform: all job pods see the same time.
const checkedAt = now ?? (await this.databaseNow(tx));
if (
!isBackupDue(settings.cronSchedule, newest?.mtime ?? null, checkedAt)
) {
return { written: false, reason: 'not-due' };
}
const { fileName, sizeBytes } = await this.writeBackup(directory);
this.logger.log(`Scheduled backup ${fileName} written`);
const pruned = await this.pruneOldBackups(
directory,
settings.retentionCount,
);
return { written: true, fileName, sizeBytes, pruned };
},
{
timeout: APP_BACKUP_LOCK_TIMEOUT_MS,
maxWait: APP_BACKUP_LOCK_MAX_WAIT_MS,
},
);
}
private async writeBackup(directory: string): Promise<string> {
private async databaseNow(tx: Prisma.TransactionClient): Promise<Date> {
const [row] = await tx.$queryRaw<{ now: Date }[]>`SELECT now() AS now`;
return row.now;
}
private async writeBackup(
directory: string,
): Promise<{ fileName: string; sizeBytes: number }> {
const sealed = await this.backupService.exportSealed();
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
const timestamp = sealed.exportedAt.replace(/[:.]/g, '-');
@@ -241,26 +265,30 @@ export class BackupSchedulerService implements OnModuleInit {
`Backup ${path.basename(filePath)} ist ${formatMegabytes(sizeBytes)} groß und überschreitet das Import-Limit von ${formatMegabytes(backupMaxImportBytes())} - eine Wiederherstellung würde scheitern (BACKUP_MAX_IMPORT_BYTES erhöhen)`,
);
}
return filePath;
return { fileName: path.basename(filePath), sizeBytes };
}
/** Deletes all but the newest `retentionCount` backups; returns how many. */
private async pruneOldBackups(
directory: string,
retentionCount: number,
): Promise<void> {
): Promise<number> {
const files = await this.listBackupFiles(directory);
const toDelete = files.slice(retentionCount);
await Promise.all(
const deleted = await Promise.all(
toDelete.map((file) =>
fs
.unlink(file.filePath)
.catch((error) =>
fs.unlink(file.filePath).then(
() => true,
(error: unknown) => {
this.logger.warn(
`Could not delete old backup ${file.filePath}: ${String(error)}`,
),
),
);
return false;
},
),
),
);
return deleted.filter(Boolean).length;
}
private async listBackupFiles(
@@ -11,7 +11,8 @@ describe('BackupController', () => {
const originalLimit = process.env.BACKUP_MAX_IMPORT_BYTES;
let controller: BackupController;
let backupService: { importFile: jest.Mock; exportSealed: jest.Mock };
let scheduler: { reload: jest.Mock };
let scheduler: Record<string, never>;
let settings: { update: jest.Mock };
let reauth: { verify: jest.Mock };
const actor = { id: 'admin-1' } as AuthenticatedUser;
const file = {
@@ -28,12 +29,13 @@ describe('BackupController', () => {
ciphertext: 'x'.repeat(2000),
}),
};
scheduler = { reload: jest.fn().mockResolvedValue(undefined) };
scheduler = {};
settings = { update: jest.fn() };
reauth = { verify: jest.fn().mockResolvedValue(undefined) };
controller = new BackupController(
backupService as unknown as BackupService,
scheduler as unknown as BackupSchedulerService,
{} as BackupSettingsService,
settings as unknown as BackupSettingsService,
reauth as unknown as ReauthService,
);
});
@@ -46,23 +48,29 @@ describe('BackupController', () => {
}
});
it('re-registers the backup schedule after an import, since it may restore other backup settings', async () => {
await controller.import(file, { password: 'secret' }, actor);
expect(backupService.importFile).toHaveBeenCalledTimes(1);
expect(scheduler.reload).toHaveBeenCalledTimes(1);
expect(scheduler.reload.mock.invocationCallOrder[0]).toBeGreaterThan(
backupService.importFile.mock.invocationCallOrder[0],
);
});
it('leaves the schedule alone when the import fails', async () => {
backupService.importFile.mockRejectedValue(new Error('broken file'));
// The app-backup job reads the settings on every run, so nothing in the
// API has to be told about a restore or a settings change. The scheduler
// double has no methods: any call on it fails these tests.
it('imports without touching the schedule', async () => {
await expect(
controller.import(file, { password: 'secret' }, actor),
).rejects.toThrow('broken file');
expect(scheduler.reload).not.toHaveBeenCalled();
).resolves.toEqual({ imported: {}, skipped: [] });
expect(backupService.importFile).toHaveBeenCalledTimes(1);
});
it('saves settings without touching the schedule', async () => {
const saved = { id: 'singleton', cronSchedule: '0 4 * * *' };
settings.update.mockResolvedValue(saved);
await expect(
controller.updateSettings({ cronSchedule: '0 4 * * *' }, actor),
).resolves.toBe(saved);
expect(settings.update).toHaveBeenCalledWith(
{ cronSchedule: '0 4 * * *' },
'admin-1',
);
});
describe('export', () => {
@@ -65,13 +65,12 @@ export class BackupController {
@RequirePermission('backup:configure')
@Patch('settings')
async updateSettings(
updateSettings(
@Body() dto: UpdateBackupSettingsDto,
@CurrentUser() actor: AuthenticatedUser,
): Promise<BackupSettings> {
const settings = await this.backupSettingsService.update(dto, actor.id);
await this.backupSchedulerService.reload();
return settings;
// Read afresh by the next hourly app-backup check.
return this.backupSettingsService.update(dto, actor.id);
}
@RequirePermission('backup:export')
@@ -129,9 +128,7 @@ export class BackupController {
} catch {
throw new BadRequestException('Datei ist kein gültiges JSON');
}
const summary = await this.backupService.importFile(parsed, actor.id);
// The backup may have restored the schedule's settings too.
await this.backupSchedulerService.reload();
return summary;
// Restored backup settings apply from the next hourly app-backup check.
return this.backupService.importFile(parsed, actor.id);
}
}
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { MulterModule } from '@nestjs/platform-express';
import { ReauthModule } from '../auth/reauth/reauth.module';
import { SystemModule } from '../system/system.module';
import { BackupController } from './backup.controller';
import { BackupService } from './backup.service';
import { BackupSchedulerService } from './backup-scheduler.service';
@@ -10,6 +11,8 @@ import { backupMaxImportBytes } from './backup-import-limit';
@Module({
imports: [
ReauthModule,
// JobRunService: GET /backup/schedule shows the newest app-backup run.
SystemModule,
// Upload limit of POST /backup/import. A factory rather than a constant
// in the decorator: it runs once the app starts, after ConfigModule has
// loaded .env, so the limit is the same one exports are checked against.
@@ -19,5 +22,7 @@ import { backupMaxImportBytes } from './backup-import-limit';
],
controllers: [BackupController],
providers: [BackupService, BackupSchedulerService, BackupSettingsService],
// For the app-backup job (JobsModule, dist/cli.js).
exports: [BackupSchedulerService],
})
export class BackupModule {}
@@ -216,6 +216,27 @@ describe('BackupService', () => {
);
expect(documents.find((d) => d.id === 'doc-2')?.contentBase64).toBeNull();
});
it('exports and restores Inspection.broodFrames', async () => {
const row = {
id: 'inspection-1',
colonyId: 'colony-1',
userId: 'known-user',
broodFrames: 5,
};
prisma.inspection.findMany.mockResolvedValue([row]);
const bundle = await service.exportAll();
const exported = bundle.data.inspection as { broodFrames: number }[];
expect(exported[0]?.broodFrames).toBe(5);
await service.importAll(bundle, 'admin-1', TRUSTED);
const [[call]] = tx.inspection.upsert.mock.calls as [
[{ create: { broodFrames: number } }],
];
expect(call.create.broodFrames).toBe(5);
});
});
describe('importAll', () => {
@@ -127,6 +127,14 @@ export const MODELS_EXCLUDED_FROM_BACKUP: Readonly<
'Machine credentials stay out on purpose (ARCHITECTURE.md assumption 14): a new key is issued after a restore. BatchMovement/ShopStockRequest keep apiKeyId only as a plain historical reference.',
IdempotencyRecord:
'Volatile cache of first responses to retried create requests (ARCHITECTURE.md assumption 11) - a restored entry could only replay a stale response.',
DomainEvent:
'Operational outbox - side effects of restored data must never be replayed after a restore.',
DomainEventDelivery:
'Per-handler progress of DomainEvent rows, excluded with them.',
WebhookDelivery:
'Delivery log and retry queue - restoring it would resend old events to receivers.',
JobRun: "Run history of this deployment's CronJobs, meaningless elsewhere.",
ThrottlerHit: 'Rate-limit counters (UNLOGGED), valid for seconds to minutes.',
};
const DOCUMENT_ENTITY_TYPES = new Set<string>(
@@ -6,7 +6,7 @@ import {
import { BottlingService } from './bottling.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { AutomationService } from '../automation/automation.service';
import { StockService } from '../stock/stock.service';
@@ -22,6 +22,32 @@ function applyAmountChange(
return current + (change.increment ?? 0) - (change.decrement ?? 0);
}
/**
* What one bottling write records in a single recordAll - so both share a
* correlation id: its honey batch event and StockChanged for its jars.
*/
function bottlingEvents(honeyEvent: { type: string; payload: unknown }) {
return [
{
...honeyEvent,
aggregateType: 'BottlingBatch',
aggregateId: 'b1',
actorUserId: 'user-1',
},
{
type: 'StockChanged',
aggregateType: 'BottlingBatch',
aggregateId: 'b1',
payload: {
bottlingBatchIds: ['b1'],
productIds: [],
rawMaterialKinds: [],
},
actorUserId: 'user-1',
},
];
}
describe('BottlingService', () => {
let service: BottlingService;
let prisma: {
@@ -36,28 +62,44 @@ describe('BottlingService', () => {
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
let events: { recordAll: jest.Mock };
let tx: {
bottlingBatch: { create: jest.Mock; update: jest.Mock; delete: jest.Mock };
$queryRaw: jest.Mock;
product: { findUnique: jest.Mock };
bottlingBatch: {
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
delete: jest.Mock;
};
honeyBatch: {
findUnique: jest.Mock;
update: jest.Mock;
updateMany: jest.Mock;
};
};
let webhooks: { dispatch: jest.Mock };
let stock: {
remainingByBottling: jest.Mock;
notify: jest.Mock;
stockChangedEvent: jest.Mock;
hasMovements: jest.Mock;
};
// Honey batch rows as the transaction sees them - the tests assert the
// resulting amounts instead of the exact sequence of UPDATE statements.
let honeyBatches: Map<string, { amount: number | null; status?: string }>;
// The order of lock and reads inside the transaction.
let steps: string[];
beforeEach(() => {
honeyBatches = new Map();
steps = [];
tx = {
$queryRaw: jest.fn(() => {
steps.push('lock');
return Promise.resolve([]);
}),
product: { findUnique: jest.fn() },
bottlingBatch: {
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
@@ -123,22 +165,45 @@ describe('BottlingService', () => {
),
};
auditLog = { record: jest.fn() };
webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
events = { recordAll: jest.fn() };
const automation = { generateNumber: jest.fn().mockResolvedValue(null) };
stock = {
remainingByBottling: jest.fn().mockResolvedValue(new Map()),
notify: jest.fn().mockResolvedValue(undefined),
// As StockService builds it - covered by its own spec.
stockChangedEvent: jest.fn(
(
payload: unknown,
actorUserId: string | null,
aggregate: { type: string; id: string },
) => ({
type: 'StockChanged',
aggregateType: aggregate.type,
aggregateId: aggregate.id,
payload,
actorUserId,
}),
),
hasMovements: jest.fn().mockResolvedValue(false),
};
service = new BottlingService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
webhooks as unknown as WebhooksService,
events as unknown as DomainEventsService,
automation as unknown as AutomationService,
stock as unknown as StockService,
);
});
/** The bottling row as the transaction reads it after locking. */
function lockedRows(...rows: Record<string, unknown>[]) {
for (const row of rows) {
tx.bottlingBatch.findUnique.mockImplementationOnce(() => {
steps.push('read');
return Promise.resolve(row);
});
}
}
it('throws NotFoundException when the honey batch does not exist', async () => {
prisma.honeyBatch.findUnique.mockResolvedValueOnce(null);
await expect(
@@ -185,6 +250,7 @@ describe('BottlingService', () => {
honeyBatches.set('h1', { amount: 25 });
tx.bottlingBatch.create.mockResolvedValueOnce({
id: 'b1',
honeyBatchId: 'h1',
totalAmount: 25,
});
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({
@@ -211,6 +277,42 @@ describe('BottlingService', () => {
entityType: 'BottlingBatch',
action: 'CREATE',
}),
tx,
);
});
it('bottling create records BatchBottled and StockChanged together', async () => {
prisma.honeyBatch.findUnique.mockResolvedValueOnce({
id: 'h1',
amount: 25,
});
honeyBatches.set('h1', { amount: 25 });
tx.bottlingBatch.create.mockResolvedValueOnce({
id: 'b1',
honeyBatchId: 'h1',
totalAmount: 5,
});
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' });
await service.create(
{
honeyBatchId: 'h1',
date: '2026-01-01',
jarSize: 500,
jarCount: 10,
bestBeforeDate: '2027-01-01',
lotNumber: 'L-1',
},
'user-1',
);
expect(events.recordAll).toHaveBeenCalledTimes(1);
expect(events.recordAll).toHaveBeenCalledWith(
tx,
bottlingEvents({
type: 'BatchBottled',
payload: { honeyBatchId: 'h1' },
}),
);
});
@@ -238,9 +340,10 @@ describe('BottlingService', () => {
).rejects.toBeInstanceOf(BadRequestException);
expect(tx.bottlingBatch.create).not.toHaveBeenCalled();
expect(honeyBatches.get('h1')?.amount).toBe(3);
expect(events.recordAll).not.toHaveBeenCalled();
});
it('update writes an audit log entry with before and after data', async () => {
it('update writes an audit log entry with before and after data in its transaction', async () => {
const before = {
id: 'b1',
lotNumber: 'L-1',
@@ -251,18 +354,12 @@ describe('BottlingService', () => {
totalAmount: 5,
};
const updated = { ...before, lotNumber: 'L-2' };
prisma.bottlingBatch.findUnique
.mockResolvedValueOnce(before)
.mockResolvedValueOnce(updated);
lockedRows(before, updated);
const result = await service.update('b1', { lotNumber: 'L-2' }, 'user-1');
// Without movements the remaining stock equals the jar count.
expect(result).toEqual({ ...updated, remainingCount: 10 });
expect(stock.notify).toHaveBeenCalledWith({
bottlingBatchIds: ['b1'],
productIds: [],
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'BottlingBatch',
@@ -272,16 +369,125 @@ describe('BottlingService', () => {
beforeData: { ...before, remainingCount: 10 },
afterData: { ...updated, remainingCount: 10 },
}),
tx,
);
});
it('update takes "before" from the row locked in its transaction, never from a read made earlier', async () => {
// A read outside the transaction would see this stale copy.
prisma.bottlingBatch.findUnique.mockResolvedValue({
id: 'b1',
lotNumber: 'STALE',
honeyBatchId: 'h-stale',
honeyBatch: { id: 'h-stale', amount: 0 },
jarSize: 250,
jarCount: 1,
totalAmount: 0.25,
});
const locked = {
id: 'b1',
lotNumber: 'L-1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 10 },
jarSize: 500,
jarCount: 10,
totalAmount: 5,
};
lockedRows(locked, { ...locked, lotNumber: 'L-2' });
await service.update('b1', { lotNumber: 'L-2' }, 'user-1');
expect(steps.slice(0, 2)).toEqual(['lock', 'read']);
expect(prisma.bottlingBatch.findUnique).not.toHaveBeenCalled();
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
beforeData: { ...locked, remainingCount: 10 },
}),
tx,
);
// Nothing moved: the stale honey batch is never touched.
expect(tx.honeyBatch.updateMany).not.toHaveBeenCalled();
});
it('update records BottlingCorrected and StockChanged in its transaction', async () => {
const before = {
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 10 },
jarSize: 500,
jarCount: 10,
totalAmount: 5,
};
lockedRows(before, { ...before, lotNumber: 'L-2' });
await service.update('b1', { lotNumber: 'L-2' }, 'user-1');
expect(events.recordAll).toHaveBeenCalledWith(
tx,
bottlingEvents({
type: 'BottlingCorrected',
payload: { honeyBatchId: 'h1', removed: false },
}),
);
});
it('rejects updating to a product that does not exist', async () => {
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' });
prisma.product.findUnique.mockResolvedValueOnce(null);
lockedRows({
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 10 },
jarSize: 500,
jarCount: 10,
totalAmount: 5,
});
tx.product.findUnique.mockResolvedValueOnce(null);
await expect(
service.update('b1', { productId: 'missing' }, 'user-1'),
).rejects.toThrow(new NotFoundException('Product not found'));
expect(tx.bottlingBatch.update).not.toHaveBeenCalled();
expect(events.recordAll).not.toHaveBeenCalled();
});
it('update reports an unknown bottling before it checks the product', async () => {
lockedRows(null as unknown as Record<string, unknown>);
tx.product.findUnique.mockResolvedValue(null);
prisma.product.findUnique.mockResolvedValue(null);
await expect(
service.update('missing', { productId: 'also-missing' }, 'user-1'),
).rejects.toThrow(new NotFoundException('Bottling batch not found'));
expect(tx.product.findUnique).not.toHaveBeenCalled();
expect(prisma.product.findUnique).not.toHaveBeenCalled();
});
it('update refuses an article that keeps stock in production batches', async () => {
lockedRows({
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 10 },
jarSize: 500,
jarCount: 10,
totalAmount: 5,
});
tx.product.findUnique.mockResolvedValueOnce({
id: 'candle',
kind: 'MANUFACTURED',
});
await expect(
service.update('b1', { productId: 'candle' }, 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
expect(tx.bottlingBatch.update).not.toHaveBeenCalled();
});
it('update throws NotFoundException for an unknown bottling batch', async () => {
lockedRows(null as unknown as Record<string, unknown>);
await expect(
service.update('missing', { lotNumber: 'L-2' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
expect(events.recordAll).not.toHaveBeenCalled();
});
it('clears the product assignment when productId is null', async () => {
@@ -294,9 +500,7 @@ describe('BottlingService', () => {
totalAmount: 5,
productId: 'p1',
};
prisma.bottlingBatch.findUnique
.mockResolvedValueOnce(before)
.mockResolvedValueOnce({ ...before, productId: null });
lockedRows(before, { ...before, productId: null });
await service.update('b1', { productId: null }, 'user-1');
@@ -317,9 +521,7 @@ describe('BottlingService', () => {
jarCount: 20,
totalAmount: 10,
};
prisma.bottlingBatch.findUnique
.mockResolvedValueOnce(before)
.mockResolvedValueOnce({ ...before, jarCount: 10, totalAmount: 5 });
lockedRows(before, { ...before, jarCount: 10, totalAmount: 5 });
honeyBatches.set('h1', { amount: 0 });
await service.update('b1', { jarCount: 10 }, 'user-1');
@@ -333,7 +535,7 @@ describe('BottlingService', () => {
});
it('rejects a corrected amount that exceeds the honey batch capacity', async () => {
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({
lockedRows({
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 0 },
@@ -345,6 +547,8 @@ describe('BottlingService', () => {
await expect(
service.update('b1', { jarCount: 40 }, 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
expect(tx.bottlingBatch.update).not.toHaveBeenCalled();
expect(events.recordAll).not.toHaveBeenCalled();
});
it('moves the amount between honey batches when the honey batch changes', async () => {
@@ -356,10 +560,11 @@ describe('BottlingService', () => {
jarCount: 10,
totalAmount: 5,
};
prisma.bottlingBatch.findUnique
.mockResolvedValueOnce(before)
.mockResolvedValueOnce({ ...before, honeyBatchId: 'h2' });
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h2', amount: 8 });
lockedRows(before, {
...before,
honeyBatchId: 'h2',
honeyBatch: { id: 'h2', amount: 3 },
});
honeyBatches.set('h1', { amount: 2 });
honeyBatches.set('h2', { amount: 8 });
@@ -367,21 +572,33 @@ describe('BottlingService', () => {
expect(honeyBatches.get('h1')?.amount).toBe(7);
expect(honeyBatches.get('h2')).toEqual({ amount: 3, status: 'BOTTLED' });
// Receivers hear about the honey batch the bottling now draws on.
expect(events.recordAll).toHaveBeenCalledWith(
tx,
expect.arrayContaining([
expect.objectContaining({
type: 'BottlingCorrected',
payload: { honeyBatchId: 'h2', removed: false },
}),
]),
);
});
it('remove gives the amount back and records a DELETE audit entry', async () => {
it('remove gives the amount back and records a DELETE audit entry in its transaction', async () => {
const before = {
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 2 },
jarCount: 10,
totalAmount: 5,
};
prisma.bottlingBatch.findUnique.mockResolvedValueOnce(before);
lockedRows(before);
honeyBatches.set('h1', { amount: 2 });
const result = await service.remove('b1', 'user-1');
expect(result).toEqual({ id: 'b1' });
expect(steps.slice(0, 2)).toEqual(['lock', 'read']);
expect(tx.bottlingBatch.delete).toHaveBeenCalledWith({
where: { id: 'b1' },
});
@@ -391,17 +608,21 @@ describe('BottlingService', () => {
entityType: 'BottlingBatch',
entityId: 'b1',
action: 'DELETE',
beforeData: before,
beforeData: { ...before, remainingCount: 10 },
}),
tx,
);
expect(webhooks.dispatch).toHaveBeenCalledWith(
'batch.updated',
before.honeyBatch,
expect(events.recordAll).toHaveBeenCalledWith(
tx,
bottlingEvents({
type: 'BottlingCorrected',
payload: { honeyBatchId: 'h1', removed: true },
}),
);
});
it('remove refuses a bottling whose jars were already sold or withdrawn', async () => {
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({
lockedRows({
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 2 },
@@ -412,7 +633,13 @@ describe('BottlingService', () => {
await expect(service.remove('b1', 'user-1')).rejects.toBeInstanceOf(
ConflictException,
);
// Checked under the row lock, through the transaction.
expect(stock.hasMovements).toHaveBeenCalledWith(
{ kind: 'bottling', id: 'b1' },
tx,
);
expect(tx.bottlingBatch.delete).not.toHaveBeenCalled();
expect(events.recordAll).not.toHaveBeenCalled();
});
it('refuses to bottle onto an article that keeps stock in production batches', async () => {
@@ -442,7 +669,7 @@ describe('BottlingService', () => {
});
it('remove throws NotFoundException for an unknown bottling batch', async () => {
prisma.bottlingBatch.findUnique.mockResolvedValueOnce(null);
lockedRows(null as unknown as Record<string, unknown>);
await expect(service.remove('missing', 'user-1')).rejects.toBeInstanceOf(
NotFoundException,
+155 -89
View File
@@ -5,8 +5,11 @@ import {
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { DbClient } from '../prisma/db-client';
import { lockRowForUpdate } from '../prisma/row-lock';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import type { AnyNewDomainEvent } from '../domain-events/domain-event-types';
import { AutomationService } from '../automation/automation.service';
import { StockService } from '../stock/stock.service';
import { BottlingBatch, Prisma } from '../../generated/prisma';
@@ -99,12 +102,18 @@ async function normalizeAmount(tx: Tx, honeyBatchId: string): Promise<void> {
}
}
/**
* Bottlings of a honey batch. Every write records its audit entry and its
* events in its own transaction: BatchBottled or BottlingCorrected (the
* honey batch's amount changed - batch.updated webhook) and StockChanged
* (the bottling's jars - stock.changed webhook).
*/
@Injectable()
export class BottlingService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
private readonly events: DomainEventsService,
private readonly automation: AutomationService,
private readonly stock: StockService,
) {}
@@ -124,15 +133,17 @@ export class BottlingService {
}));
}
async findById(id: string) {
const bottlingBatch = await this.prisma.bottlingBatch.findUnique({
/** With `tx` it reads what the caller's transaction sees. */
async findById(id: string, tx?: Tx) {
const db: DbClient = tx ?? this.prisma;
const bottlingBatch = await db.bottlingBatch.findUnique({
where: { id },
include: bottlingBatchInclude,
});
if (!bottlingBatch) {
throw new NotFoundException('Bottling batch not found');
}
const remaining = await this.stock.remainingByBottling([id]);
const remaining = await this.stock.remainingByBottling([id], tx);
return {
...bottlingBatch,
remainingCount: remaining.get(id) ?? bottlingBatch.jarCount,
@@ -140,8 +151,12 @@ export class BottlingService {
}
/** Honey is bottled; other articles keep their stock in production batches. */
private async assertBottledProduct(productId: string): Promise<void> {
const product = await this.prisma.product.findUnique({
private async assertBottledProduct(
productId: string,
tx?: Tx,
): Promise<void> {
const db: DbClient = tx ?? this.prisma;
const product = await db.product.findUnique({
where: { id: productId },
});
if (!product) {
@@ -186,7 +201,7 @@ export class BottlingService {
// time (double tap, two devices) must not both pass the check and
// overwrite each other's deduction.
await takeFromHoneyBatch(tx, dto.honeyBatchId, totalAmount);
return tx.bottlingBatch.create({
const created = await tx.bottlingBatch.create({
data: {
honeyBatchId: dto.honeyBatchId,
productId: dto.productId,
@@ -199,24 +214,28 @@ export class BottlingService {
userId: actingUserId,
},
});
await this.auditLog.record(
{
entityType: 'BottlingBatch',
entityId: created.id,
action: 'CREATE',
userId: actingUserId,
afterData: created,
},
tx,
);
await this.recordEvents(tx, {
type: 'BatchBottled',
aggregateType: 'BottlingBatch',
aggregateId: created.id,
payload: { honeyBatchId: created.honeyBatchId },
actorUserId: actingUserId,
});
return created;
},
);
await this.auditLog.record({
entityType: 'BottlingBatch',
entityId: bottlingBatch.id,
action: 'CREATE',
userId: actingUserId,
afterData: bottlingBatch,
});
const created = await this.findById(bottlingBatch.id);
await this.webhooks.dispatch('batch.updated', created.honeyBatch);
await this.stock.notify({
bottlingBatchIds: [created.id],
productIds: [],
});
return created;
return this.findById(bottlingBatch.id);
}
/**
@@ -225,43 +244,48 @@ export class BottlingService {
* of the affected honey batch(es): the previously bottled amount is given
* back before the corrected amount is taken again, so a correction never
* double-charges the source batch.
*
* Everything that depends on the current bottling - defaults, amount
* check, audit "before" - is decided on the row locked in the
* transaction, so a concurrent correction cannot slip in between. An
* unknown bottling is reported (404) before anything about the request.
*/
async update(id: string, dto: UpdateBottlingBatchDto, actingUserId: string) {
const before = await this.findById(id);
if (dto.productId) {
await this.assertBottledProduct(dto.productId);
}
const honeyBatchId = dto.honeyBatchId ?? before.honeyBatchId;
const movedToOtherHoneyBatch = honeyBatchId !== before.honeyBatchId;
const jarSize = dto.jarSize ?? before.jarSize;
const jarCount = dto.jarCount ?? before.jarCount;
const totalAmount = roundKg((jarSize * jarCount) / 1000);
const targetHoneyBatch = movedToOtherHoneyBatch
? await this.prisma.honeyBatch.findUnique({ where: { id: honeyBatchId } })
: before.honeyBatch;
if (!targetHoneyBatch) {
throw new NotFoundException('Honey batch not found');
}
// Early, friendly check against the amounts read above. The booking
// itself happens atomically inside the transaction below.
const availableOnTarget =
targetHoneyBatch.amount === null
? null
: roundKg(
targetHoneyBatch.amount +
(movedToOtherHoneyBatch ? 0 : before.totalAmount),
);
if (availableOnTarget !== null && totalAmount > availableOnTarget) {
throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE);
}
// A duplicate lotNumber surfaces as Prisma P2002 and is turned into a
// 409 with a field-specific message by AllExceptionsFilter.
await this.prisma.$transaction(async (tx) => {
return this.prisma.$transaction(async (tx) => {
await lockRowForUpdate(tx, 'BottlingBatch', id);
const before = await this.findById(id, tx);
if (dto.productId) {
await this.assertBottledProduct(dto.productId, tx);
}
const honeyBatchId = dto.honeyBatchId ?? before.honeyBatchId;
const movedToOtherHoneyBatch = honeyBatchId !== before.honeyBatchId;
const jarSize = dto.jarSize ?? before.jarSize;
const jarCount = dto.jarCount ?? before.jarCount;
const totalAmount = roundKg((jarSize * jarCount) / 1000);
const targetHoneyBatch = movedToOtherHoneyBatch
? await tx.honeyBatch.findUnique({ where: { id: honeyBatchId } })
: before.honeyBatch;
if (!targetHoneyBatch) {
throw new NotFoundException('Honey batch not found');
}
// Early, friendly check; the booking below is still conditional on
// the stored amount (takeFromHoneyBatch).
const availableOnTarget =
targetHoneyBatch.amount === null
? null
: roundKg(
targetHoneyBatch.amount +
(movedToOtherHoneyBatch ? 0 : before.totalAmount),
);
if (availableOnTarget !== null && totalAmount > availableOnTarget) {
throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE);
}
const amountChanged =
movedToOtherHoneyBatch || totalAmount !== before.totalAmount;
if (amountChanged) {
@@ -291,21 +315,30 @@ export class BottlingService {
lotNumber: dto.lotNumber,
},
});
});
const bottlingBatch = await this.findById(id);
await this.auditLog.record({
entityType: 'BottlingBatch',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: bottlingBatch,
const bottlingBatch = await this.findById(id, tx);
await this.auditLog.record(
{
entityType: 'BottlingBatch',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: bottlingBatch,
},
tx,
);
// Receivers hear about the honey batch the bottling draws on now; a
// corrected jar count changes the remaining stock as well.
await this.recordEvents(tx, {
type: 'BottlingCorrected',
aggregateType: 'BottlingBatch',
aggregateId: id,
payload: { honeyBatchId, removed: false },
actorUserId: actingUserId,
});
return bottlingBatch;
});
await this.webhooks.dispatch('batch.updated', bottlingBatch.honeyBatch);
// A corrected jar count changes the remaining stock as well.
await this.stock.notify({ bottlingBatchIds: [id], productIds: [] });
return bottlingBatch;
}
/**
@@ -318,30 +351,63 @@ export class BottlingService {
* write a fact that never happened.
*/
async remove(id: string, actingUserId: string) {
const before = await this.findById(id);
// Once jars were sold or withdrawn, the bottling is history: deleting it
// would erase where they came from. Wrong stock is fixed by an inventory
// correction instead.
if (await this.stock.hasMovements({ kind: 'bottling', id })) {
throw new ConflictException(
'Abfüllung hat bereits Bestandsbewegungen und kann nicht gelöscht werden - Bestand über "Inventur korrigieren" anpassen',
);
}
await this.prisma.$transaction(async (tx) => {
// Shop sales and withdrawals lock the same row, so the movement check
// below cannot be raced.
await lockRowForUpdate(tx, 'BottlingBatch', id);
const before = await this.findById(id, tx);
// Once jars were sold or withdrawn, the bottling is history: deleting
// it would erase where they came from. Wrong stock is fixed by an
// inventory correction instead.
if (await this.stock.hasMovements({ kind: 'bottling', id }, tx)) {
throw new ConflictException(
'Abfüllung hat bereits Bestandsbewegungen und kann nicht gelöscht werden - Bestand über "Inventur korrigieren" anpassen',
);
}
await tx.bottlingBatch.delete({ where: { id } });
await giveBackToHoneyBatch(tx, before.honeyBatchId, before.totalAmount);
await this.auditLog.record(
{
entityType: 'BottlingBatch',
entityId: id,
action: 'DELETE',
userId: actingUserId,
beforeData: before,
},
tx,
);
await this.recordEvents(tx, {
type: 'BottlingCorrected',
aggregateType: 'BottlingBatch',
aggregateId: id,
payload: { honeyBatchId: before.honeyBatchId, removed: true },
actorUserId: actingUserId,
});
});
await this.auditLog.record({
entityType: 'BottlingBatch',
entityId: id,
action: 'DELETE',
userId: actingUserId,
beforeData: before,
});
await this.webhooks.dispatch('batch.updated', before.honeyBatch);
await this.stock.notify({ bottlingBatchIds: [id], productIds: [] });
return { id };
}
/**
* The honey batch event of a bottling write and StockChanged for the
* bottling's jars, in the caller's transaction - in one recordAll, so
* both carry the correlation id of the one action they describe.
*/
private async recordEvents(
tx: Tx,
event: AnyNewDomainEvent<'BatchBottled' | 'BottlingCorrected'>,
): Promise<void> {
const stockChanged = this.stock.stockChangedEvent(
{
bottlingBatchIds: [event.aggregateId],
productIds: [],
rawMaterialKinds: [],
},
event.actorUserId ?? null,
{ type: 'BottlingBatch', id: event.aggregateId },
);
await this.events.recordAll(
tx,
stockChanged === null ? [event] : [event, stockChanged],
);
}
}
@@ -12,7 +12,10 @@ import { RequirePermission } from '../auth/decorators/require-permission.decorat
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CalendarSyncSettingsService } from './calendar-sync-settings.service';
import { CalendarSyncService } from './calendar-sync.service';
import {
CalendarSyncService,
CalendarSyncStatusReport,
} from './calendar-sync.service';
import { UpdateCalendarSyncSettingsDto } from './dto/update-calendar-sync-settings.dto';
@ApiTags('calendar-sync')
@@ -45,13 +48,27 @@ export class CalendarSyncController {
return this.calendarSyncService.getStatus();
}
// Starts a pass and answers at once (202) - the pass itself can take
// minutes. The returned status says running: true; GET status follows it.
// Queues a pass and answers at once (202): FAILED entries are reset and
// the worker is woken - the API itself never calls Graph. GET status
// reports running from the worker's live claims; until the worker has
// claimed its first entry, a due pass counts as running, so the client
// starts following it.
@RequirePermission('calendar:configure')
@Post('run-now')
@HttpCode(HttpStatus.ACCEPTED)
runNow() {
this.calendarSyncService.startRun();
return this.calendarSyncService.getStatus();
async runNow(): Promise<CalendarSyncStatusReport> {
await this.calendarSyncService.requestRun();
const status = await this.calendarSyncService.getStatus();
return { ...status, running: status.running || isPassDue(status) };
}
}
/** The woken worker syncs every PENDING entry - if syncing is set up. */
function isPassDue(status: CalendarSyncStatusReport): boolean {
return (
status.configured &&
status.enabled &&
Boolean(status.groupId) &&
status.pendingCount > 0
);
}
@@ -0,0 +1,115 @@
import { CalendarSyncService } from './calendar-sync.service';
import { CalendarSyncEventHandlers } from './calendar-sync.handlers';
import type {
AnyDomainEventEnvelope,
DomainEventType,
} from '../domain-events/domain-event-types';
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
import { Prisma } from '../../generated/prisma';
describe('CalendarSyncEventHandlers', () => {
let handlers: CalendarSyncEventHandlers;
let calendarSync: { enqueueIn: jest.Mock };
const tx = {} as Prisma.TransactionClient;
const envelope = <T extends DomainEventType>(
type: T,
aggregateId: string,
payload: Record<string, unknown>,
) => ({ type, aggregateId, payload }) as unknown as AnyDomainEventEnvelope<T>;
beforeEach(() => {
calendarSync = { enqueueIn: jest.fn().mockResolvedValue(undefined) };
handlers = new CalendarSyncEventHandlers(
calendarSync as unknown as CalendarSyncService,
);
});
it.each([
['inspection', 'INSPECTION', 'InspectionRecorded'],
['inspection', 'INSPECTION', 'InspectionCorrected'],
['treatment', 'TREATMENT', 'TreatmentRecorded'],
['treatment', 'TREATMENT', 'TreatmentCorrected'],
['feeding', 'FEEDING', 'FeedingRecorded'],
['feeding', 'FEEDING', 'FeedingCorrected'],
['harvest', 'HARVEST', 'HarvestRecorded'],
['harvest', 'HARVEST', 'HarvestCorrected'],
] as const)(
'%s handler enqueues %s on %s through tx',
async (method, sourceType, type) => {
await handlers[method](envelope(type, 'src-1', {}) as never, tx);
expect(calendarSync.enqueueIn).toHaveBeenCalledWith(
tx,
sourceType,
'src-1',
);
},
);
it.each(['TaskCreated', 'TaskUpdated', 'TaskCompleted'] as const)(
'task handler enqueues the task on %s',
async (type) => {
await handlers.task(
envelope(type, 'task-1', { dueDate: '2027-05-01' }),
tx,
);
expect(calendarSync.enqueueIn).toHaveBeenCalledWith(tx, 'TASK', 'task-1');
},
);
it('TaskCreated without a due date enqueues nothing', async () => {
await handlers.task(
envelope('TaskCreated', 'task-1', { dueDate: null }),
tx,
);
expect(calendarSync.enqueueIn).not.toHaveBeenCalled();
});
it('TaskUpdated without a due date still enqueues, so the worker removes the event', async () => {
await handlers.task(
envelope('TaskUpdated', 'task-1', { dueDate: null, status: 'OPEN' }),
tx,
);
expect(calendarSync.enqueueIn).toHaveBeenCalledWith(tx, 'TASK', 'task-1');
});
it('lets an enqueue failure propagate so the delivery is retried', async () => {
calendarSync.enqueueIn.mockRejectedValue(new Error('db down'));
await expect(
handlers.inspection(envelope('InspectionRecorded', 'i-1', {}), tx),
).rejects.toThrow('db down');
});
it('registers the stable handler ids and event types', () => {
const registered = (method: keyof CalendarSyncEventHandlers) =>
domainEventHandlerMetadata(
Object.getOwnPropertyDescriptor(
CalendarSyncEventHandlers.prototype,
method,
)?.value as object,
);
expect(registered('inspection')).toEqual([
{
types: ['InspectionRecorded', 'InspectionCorrected'],
handlerId: 'calendar-sync.inspection',
},
]);
expect(registered('treatment')[0].handlerId).toBe(
'calendar-sync.treatment',
);
expect(registered('feeding')[0].handlerId).toBe('calendar-sync.feeding');
expect(registered('harvest')[0].handlerId).toBe('calendar-sync.harvest');
expect(registered('task')).toEqual([
{
types: ['TaskCreated', 'TaskUpdated', 'TaskCompleted'],
handlerId: 'calendar-sync.task',
},
]);
});
});
@@ -0,0 +1,77 @@
import { Injectable } from '@nestjs/common';
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
import { Prisma } from '../../generated/prisma';
import { CalendarSyncService } from './calendar-sync.service';
/**
* Puts calendar-relevant records into the M365 outbox. Pure DB writer: the
* Graph call itself happens in CalendarSyncService.processOutbox (worker
* loop). Errors propagate, so a failed delivery is retried.
*/
@Injectable()
export class CalendarSyncEventHandlers {
constructor(private readonly calendarSync: CalendarSyncService) {}
@OnDomainEvent(
['InspectionRecorded', 'InspectionCorrected'],
'calendar-sync.inspection',
)
async inspection(
event: AnyDomainEventEnvelope<'InspectionRecorded' | 'InspectionCorrected'>,
tx: Prisma.TransactionClient,
): Promise<void> {
await this.calendarSync.enqueueIn(tx, 'INSPECTION', event.aggregateId);
}
@OnDomainEvent(
['TreatmentRecorded', 'TreatmentCorrected'],
'calendar-sync.treatment',
)
async treatment(
event: AnyDomainEventEnvelope<'TreatmentRecorded' | 'TreatmentCorrected'>,
tx: Prisma.TransactionClient,
): Promise<void> {
await this.calendarSync.enqueueIn(tx, 'TREATMENT', event.aggregateId);
}
@OnDomainEvent(
['FeedingRecorded', 'FeedingCorrected'],
'calendar-sync.feeding',
)
async feeding(
event: AnyDomainEventEnvelope<'FeedingRecorded' | 'FeedingCorrected'>,
tx: Prisma.TransactionClient,
): Promise<void> {
await this.calendarSync.enqueueIn(tx, 'FEEDING', event.aggregateId);
}
@OnDomainEvent(
['HarvestRecorded', 'HarvestCorrected'],
'calendar-sync.harvest',
)
async harvest(
event: AnyDomainEventEnvelope<'HarvestRecorded' | 'HarvestCorrected'>,
tx: Prisma.TransactionClient,
): Promise<void> {
await this.calendarSync.enqueueIn(tx, 'HARVEST', event.aggregateId);
}
// Updated and completed tasks are enqueued even without a due date or
// when done: the worker then removes an event created earlier.
@OnDomainEvent(
['TaskCreated', 'TaskUpdated', 'TaskCompleted'],
'calendar-sync.task',
)
async task(
event: AnyDomainEventEnvelope<
'TaskCreated' | 'TaskUpdated' | 'TaskCompleted'
>,
tx: Prisma.TransactionClient,
): Promise<void> {
if (event.type === 'TaskCreated' && event.payload.dueDate === null) {
return;
}
await this.calendarSync.enqueueIn(tx, 'TASK', event.aggregateId);
}
}
@@ -1,18 +1,20 @@
import { Global, Module } from '@nestjs/common';
import { CalendarSyncController } from './calendar-sync.controller';
import { CalendarSyncEventHandlers } from './calendar-sync.handlers';
import { CalendarSyncService } from './calendar-sync.service';
import { CalendarSyncSettingsService } from './calendar-sync-settings.service';
import { GraphClientService } from './graph-client.service';
// @Global(): CalendarSyncService.enqueue()/cancel() are called from ~6
// otherwise-unrelated domain modules (Tasks, Inspections, Treatments,
// Feedings, Harvests, Queens) - matches WebhooksModule's own @Global()
// rationale rather than having each of those six modules import this one.
// @Global(): CalendarSyncService.enqueue() is called from otherwise-unrelated
// domain modules (Inspections, Treatments, Feedings, Harvests, Queens) until
// they record domain events - matches WebhooksModule's own @Global()
// rationale rather than having each of those modules import this one.
@Global()
@Module({
controllers: [CalendarSyncController],
providers: [
CalendarSyncService,
CalendarSyncEventHandlers,
CalendarSyncSettingsService,
GraphClientService,
],
@@ -22,6 +22,7 @@ describe('CalendarSyncService', () => {
feeding: { findUnique: jest.Mock };
harvest: { findUnique: jest.Mock };
queenColonyHistory: { findUnique: jest.Mock };
$executeRaw: jest.Mock;
};
let settingsService: { get: jest.Mock };
let graphClient: {
@@ -93,6 +94,7 @@ describe('CalendarSyncService', () => {
feeding: { findUnique: jest.fn() },
harvest: { findUnique: jest.fn() },
queenColonyHistory: { findUnique: jest.fn() },
$executeRaw: jest.fn().mockResolvedValue(1),
};
settingsService = { get: jest.fn().mockResolvedValue(enabledSettings) };
graphClient = {
@@ -149,8 +151,8 @@ describe('CalendarSyncService', () => {
});
});
// enqueue()/cancel() run after the source record is committed: a failure
// must not fail that request (the client would retry and store it twice).
// enqueue() runs after the source record is committed: a failure must not
// fail that request (the client would retry and store it twice).
describe('post-commit calls', () => {
let errorSpy: jest.SpyInstance;
@@ -176,61 +178,50 @@ describe('CalendarSyncService', () => {
expect(message).toContain('INSPECTION inspection-1');
expect(message).toContain('db down');
});
it('cancel never throws and logs the failure with the record', async () => {
prisma.calendarSyncOutboxEntry.findUnique.mockRejectedValue(
new Error('db down'),
);
await expect(service.cancel('TASK', 'task-1')).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalledTimes(1);
const [message] = errorSpy.mock.calls[0] as [string];
expect(message).toContain('TASK task-1');
expect(message).toContain('db down');
});
});
describe('cancel', () => {
it('does nothing when no outbox row exists', async () => {
prisma.calendarSyncOutboxEntry.findUnique.mockResolvedValue(null);
describe('enqueueIn', () => {
it('writes through the given client and lets errors propagate (handler retry)', async () => {
const txUpsert = jest.fn().mockResolvedValue(undefined);
const tx = {
calendarSyncOutboxEntry: { upsert: txUpsert },
} as unknown as Prisma.TransactionClient;
await service.cancel('TASK', 'task-1');
await service.enqueueIn(tx, 'TASK', 'task-1');
expect(prisma.calendarSyncOutboxEntry.update).not.toHaveBeenCalled();
});
it('deletes the Graph event and clears the row when a graphEventId exists', async () => {
prisma.calendarSyncOutboxEntry.findUnique.mockResolvedValue({
id: 'entry-1',
graphEventId: 'graph-event-1',
expect(txUpsert).toHaveBeenCalledWith({
where: {
sourceType_sourceId: { sourceType: 'TASK', sourceId: 'task-1' },
},
create: { sourceType: 'TASK', sourceId: 'task-1' },
update: {
status: 'PENDING',
attempts: 0,
lastError: null,
revision: { increment: 1 },
},
});
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
await service.cancel('TASK', 'task-1');
expect(graphClient.deleteEvent).toHaveBeenCalledWith(
'group-1',
'graph-event-1',
txUpsert.mockRejectedValue(new Error('tx failed'));
await expect(service.enqueueIn(tx, 'TASK', 'task-1')).rejects.toThrow(
'tx failed',
);
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({
where: { id: 'entry-1' },
data: { status: 'SKIPPED', graphEventId: null },
});
});
it('still clears the row when the Graph delete fails', async () => {
prisma.calendarSyncOutboxEntry.findUnique.mockResolvedValue({
id: 'entry-1',
graphEventId: 'graph-event-1',
it('does nothing when sync is disabled', async () => {
settingsService.get.mockResolvedValue({
...enabledSettings,
enabled: false,
});
graphClient.deleteEvent.mockRejectedValue(new Error('network down'));
const txUpsert = jest.fn();
const tx = {
calendarSyncOutboxEntry: { upsert: txUpsert },
} as unknown as Prisma.TransactionClient;
await service.cancel('TASK', 'task-1');
await service.enqueueIn(tx, 'TASK', 'task-1');
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({
where: { id: 'entry-1' },
data: { status: 'SKIPPED', graphEventId: null },
});
expect(txUpsert).not.toHaveBeenCalled();
});
});
@@ -239,6 +230,7 @@ describe('CalendarSyncService', () => {
id: 'task-1',
title: 'Beute prüfen',
dueDate: new Date('2026-03-07'),
status: 'OPEN',
};
/** The outcome write: only lands if the row is unchanged since the claim. */
@@ -284,6 +276,44 @@ describe('CalendarSyncService', () => {
);
});
// Worker shutdown: the entry in progress is finished and settled (a cut
// off one is only taken over after the stale-claim window), but no
// further entry is claimed.
it('stops between entries once the signal aborts', async () => {
const stop = new AbortController();
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry(),
pendingEntry({ id: 'entry-2', sourceId: 'task-2' }),
]);
prisma.task.findUnique.mockResolvedValue(dueTask);
graphClient.createEvent.mockImplementation(() => {
stop.abort();
return Promise.resolve('graph-event-1');
});
await service.processOutbox(stop.signal);
expect(graphClient.createEvent).toHaveBeenCalledTimes(1);
expectSettled({
status: 'SYNCED',
graphEventId: 'graph-event-1',
lastError: null,
});
expect(updateManyCalls().map((call) => call.where.id)).not.toContain(
'entry-2',
);
});
it('reads nothing once the signal has aborted', async () => {
const stop = new AbortController();
stop.abort();
await service.processOutbox(stop.signal);
expect(settingsService.get).not.toHaveBeenCalled();
expect(prisma.calendarSyncOutboxEntry.findMany).not.toHaveBeenCalled();
});
it('claims a row with a conditional update before working on it', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry(),
@@ -401,7 +431,78 @@ describe('CalendarSyncService', () => {
await service.processOutbox();
expect(graphClient.createEvent).not.toHaveBeenCalled();
expectSettled({ status: 'SKIPPED' });
expect(graphClient.deleteEvent).not.toHaveBeenCalled();
expectSettled({ status: 'SKIPPED', graphEventId: null });
});
it('deletes the Graph event of a task that is DONE and settles the entry SKIPPED', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry({ graphEventId: 'graph-event-1' }),
]);
prisma.task.findUnique.mockResolvedValue({ ...dueTask, status: 'DONE' });
await service.processOutbox();
expect(graphClient.deleteEvent).toHaveBeenCalledWith(
'group-1',
'graph-event-1',
);
expect(graphClient.createEvent).not.toHaveBeenCalled();
expect(graphClient.updateEvent).not.toHaveBeenCalled();
expectSettled({ status: 'SKIPPED', graphEventId: null });
});
it('treats a CANCELLED task like a DONE one', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry({ graphEventId: 'graph-event-1' }),
]);
prisma.task.findUnique.mockResolvedValue({
...dueTask,
status: 'CANCELLED',
});
await service.processOutbox();
expect(graphClient.deleteEvent).toHaveBeenCalledTimes(1);
expectSettled({ status: 'SKIPPED', graphEventId: null });
});
it.each([404, 410])(
'treats %i on delete as already deleted',
async (status) => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry({ graphEventId: 'graph-event-1' }),
]);
prisma.task.findUnique.mockResolvedValue({
...dueTask,
status: 'DONE',
});
graphClient.deleteEvent.mockRejectedValue(
new GraphRequestError('deleteEvent', status, 'gone'),
);
await service.processOutbox();
expectSettled({ status: 'SKIPPED', graphEventId: null });
},
);
it('counts any other delete error as a failed attempt', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry({ graphEventId: 'graph-event-1', attempts: 1 }),
]);
prisma.task.findUnique.mockResolvedValue({ ...dueTask, status: 'DONE' });
graphClient.deleteEvent.mockRejectedValue(
new GraphRequestError('deleteEvent', 503, 'busy'),
);
await service.processOutbox();
expectSettled({
status: 'PENDING',
attempts: 2,
lastError: expect.any(String) as string,
});
});
it('only lets go of the claim when the row changed before SKIPPED could be written', async () => {
@@ -626,22 +727,42 @@ describe('CalendarSyncService', () => {
});
});
describe('runNow', () => {
it('gives FAILED entries a fresh set of attempts before processing', async () => {
await service.runNow();
// "Jetzt synchronisieren" only queues the pass: the worker runs it, so the
// API request neither talks to Graph nor waits for up to 20 entries.
describe('requestRun', () => {
it('requestRun resets FAILED entries and wakes the worker without calling Graph', async () => {
await service.requestRun();
expect(prisma.calendarSyncOutboxEntry.updateMany).toHaveBeenCalledWith({
where: { status: 'FAILED' },
data: { status: 'PENDING', attempts: 0, lastError: null },
});
const [resetOrder] =
prisma.calendarSyncOutboxEntry.updateMany.mock.invocationCallOrder;
const [readOrder] =
prisma.calendarSyncOutboxEntry.findMany.mock.invocationCallOrder;
expect(resetOrder).toBeLessThan(readOrder);
expect(prisma.$executeRaw).toHaveBeenCalled(); // pg_notify('calendar_sync')
expect(graphClient.createEvent).not.toHaveBeenCalled();
expect(graphClient.updateEvent).not.toHaveBeenCalled();
expect(prisma.calendarSyncOutboxEntry.findMany).not.toHaveBeenCalled();
});
it('leaves FAILED entries alone on a regular cron tick', async () => {
it("wakes the calendar outbox with pg_notify('calendar_sync', '') - never data in the channel", async () => {
await service.requestRun();
const [[strings, ...values]] = prisma.$executeRaw.mock.calls as [
[TemplateStringsArray, ...unknown[]],
];
expect(strings.join('$')).toBe("SELECT pg_notify($, '')");
expect(values).toEqual(['calendar_sync']);
});
it('resets before waking, so the woken worker finds the entries PENDING', async () => {
await service.requestRun();
const [resetOrder] =
prisma.calendarSyncOutboxEntry.updateMany.mock.invocationCallOrder;
const [wakeOrder] = prisma.$executeRaw.mock.invocationCallOrder;
expect(resetOrder).toBeLessThan(wakeOrder);
});
it('leaves FAILED entries alone on a regular worker tick', async () => {
await service.processOutbox();
expect(
@@ -649,127 +770,7 @@ describe('CalendarSyncService', () => {
).not.toHaveBeenCalledWith(
expect.objectContaining({ where: { status: 'FAILED' } }),
);
});
});
// "Jetzt synchronisieren" must not hold the HTTP request for a whole pass
// (up to 20 entries with several Graph calls each): it only starts it.
describe('startRun', () => {
const flush = async () => {
for (let i = 0; i < 5; i++) {
await new Promise((resolve) => setImmediate(resolve));
}
};
/** Lets the pass hang on reading the outbox until released. */
const holdOutbox = () => {
let release: () => void = () => undefined;
prisma.calendarSyncOutboxEntry.findMany.mockReturnValue(
new Promise((resolve) => {
release = () => resolve([]);
}),
);
return () => release();
};
it('returns while the pass is still running and reports it as running', async () => {
const release = holdOutbox();
service.startRun();
await flush();
expect(prisma.calendarSyncOutboxEntry.updateMany).toHaveBeenCalledWith({
where: { status: 'FAILED' },
data: { status: 'PENDING', attempts: 0, lastError: null },
});
expect((await service.getStatus()).running).toBe(true);
release();
await flush();
expect((await service.getStatus()).running).toBe(false);
});
it('starts only one pass per pod at a time', async () => {
const release = holdOutbox();
service.startRun();
service.startRun();
await flush();
expect(prisma.calendarSyncOutboxEntry.findMany).toHaveBeenCalledTimes(1);
release();
await flush();
});
it('logs a failed pass without query values and clears the indicator', async () => {
const errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
prisma.calendarSyncOutboxEntry.findMany.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', {
code: 'P2010',
clientVersion: '7.9.1',
meta: { modelName: 'CalendarSyncOutboxEntry' },
}),
);
service.startRun();
await flush();
expect(errorSpy).toHaveBeenCalledTimes(1);
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
expect(message).toContain('P2010');
expect(message).not.toContain('Mustermann');
expect(stack).toBeUndefined();
expect((await service.getStatus()).running).toBe(false);
errorSpy.mockRestore();
});
it('lets shutdown wait for a running pass', async () => {
const release = holdOutbox();
service.startRun();
let destroyed = false;
const shutdown = service.onModuleDestroy().then(() => {
destroyed = true;
});
await flush();
expect(destroyed).toBe(false);
release();
await shutdown;
expect(destroyed).toBe(true);
});
});
// The cron tick's own errors would otherwise reach the scheduler's logger
// with the full Prisma message and stack.
describe('scheduledRun (cron tick)', () => {
it('runs the regular pass', async () => {
await service.scheduledRun();
expect(prisma.calendarSyncOutboxEntry.findMany).toHaveBeenCalledTimes(1);
});
it('never throws and logs a failed tick without the Prisma message', async () => {
const errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
prisma.calendarSyncOutboxEntry.findMany.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', {
code: 'P2010',
clientVersion: '7.9.1',
meta: { modelName: 'CalendarSyncOutboxEntry' },
}),
);
await expect(service.scheduledRun()).resolves.toBeUndefined();
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
expect(message).toContain('P2010');
expect(message).not.toContain('Mustermann');
expect(stack).toBeUndefined();
errorSpy.mockRestore();
expect(prisma.$executeRaw).not.toHaveBeenCalled();
});
});
@@ -794,8 +795,8 @@ describe('CalendarSyncService', () => {
});
});
// Another pod (its cron tick or its "Jetzt synchronisieren") may be the
// one working - its live claims show that across replicas.
// The pass runs in a worker process, never in the API that answers -
// only the worker's live claims can show it, across replicas.
it('reports running while any worker holds an entry', async () => {
prisma.calendarSyncOutboxEntry.count
.mockResolvedValueOnce(0)
@@ -1,9 +1,10 @@
import { createHash } from 'node:crypto';
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Injectable, Logger } from '@nestjs/common';
import { DbClient } from '../prisma/db-client';
import { PrismaService } from '../prisma/prisma.service';
import { runAfterCommit } from '../common/after-commit';
import { loggableError } from '../common/loggable-error';
import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup';
import { CalendarSyncSettingsService } from './calendar-sync-settings.service';
import {
GraphClientService,
@@ -17,8 +18,8 @@ import {
Prisma,
} from '../../generated/prisma';
// Bounded per tick so a large backlog can't turn one cron run into a
// long-running request that overlaps the next tick.
// Bounded per tick so a large backlog can't turn one worker tick into a
// long-running pass that delays the other wake-ups.
const BATCH_SIZE = 20;
// A row that keeps failing this many times stops retrying automatically
// (status becomes FAILED) - "Jetzt synchronisieren" or a fresh enqueue
@@ -73,7 +74,7 @@ export interface CalendarSyncStatusReport {
pendingCount: number;
failedCount: number;
syncedCount: number;
/** A pass is under way - on this pod, or any pod holding an entry. */
/** A pass is under way: some worker holds an entry (a live claim). */
running: boolean;
}
@@ -82,10 +83,8 @@ function getErrorMessage(error: unknown): string {
}
@Injectable()
export class CalendarSyncService implements OnModuleDestroy {
export class CalendarSyncService {
private readonly logger = new Logger(CalendarSyncService.name);
// The pass started by "Jetzt synchronisieren" on this pod, if one runs.
private activeRun: Promise<void> | null = null;
constructor(
private readonly prisma: PrismaService,
@@ -108,8 +107,8 @@ export class CalendarSyncService implements OnModuleDestroy {
return flagBySourceType[sourceType];
}
// enqueue()/cancel() follow a committed write in the domain services and
// are called without a guard - so they never throw (see runAfterCommit).
// enqueue() follows a committed write in the domain services and is
// called without a guard - so it never throws (see runAfterCommit).
private afterCommit(
action: string,
sourceType: CalendarSyncSourceType,
@@ -123,70 +122,48 @@ export class CalendarSyncService implements OnModuleDestroy {
);
}
// Called from the ~6 domain services right after a create/update that
// should appear on the M365 calendar. Upserts rather than always
// inserting: re-enqueuing an already-synced row (e.g. a Task's due date
// changed) resets it to PENDING while keeping its graphEventId, so the
// worker updates the existing Graph event instead of creating a duplicate.
// The revision counts up so a worker syncing the row right now notices the
// newer state instead of marking it SYNCED.
// The core of every enqueue: upserts rather than always inserting, so
// re-enqueuing an already-synced row (e.g. a Task's due date changed)
// resets it to PENDING while keeping its graphEventId - the worker then
// updates the existing Graph event instead of creating a duplicate. The
// revision counts up so a worker syncing the row right now notices the
// newer state instead of marking it SYNCED. Written through `db` (the
// handler's delivery transaction) and not guarded: a failure propagates,
// so the domain event delivery is retried.
async enqueueIn(
db: DbClient,
sourceType: CalendarSyncSourceType,
sourceId: string,
): Promise<void> {
const settings = await this.settingsService.get();
if (!settings.enabled) {
return;
}
if (!this.isSourceTypeEnabled(settings, sourceType)) {
return;
}
await db.calendarSyncOutboxEntry.upsert({
where: { sourceType_sourceId: { sourceType, sourceId } },
create: { sourceType, sourceId },
update: {
status: 'PENDING',
attempts: 0,
lastError: null,
revision: { increment: 1 },
},
});
}
// Legacy path for sources that do not record a domain event yet
// (inspections, treatments, feedings and harvests until their producer
// tasks; queen assignments), called right after a committed write: never
// throws.
async enqueue(
sourceType: CalendarSyncSourceType,
sourceId: string,
): Promise<void> {
await this.afterCommit(
'Calendar sync enqueue',
sourceType,
sourceId,
async () => {
const settings = await this.settingsService.get();
if (!settings.enabled) {
return;
}
if (!this.isSourceTypeEnabled(settings, sourceType)) {
return;
}
await this.prisma.calendarSyncOutboxEntry.upsert({
where: { sourceType_sourceId: { sourceType, sourceId } },
create: { sourceType, sourceId },
update: {
status: 'PENDING',
attempts: 0,
lastError: null,
revision: { increment: 1 },
},
});
},
);
}
// Called when a source record no longer belongs on the calendar (e.g. a
// Task is completed). Best-effort: a failed Graph delete is logged, not
// thrown, since the outbox row is cleared either way and a future enqueue
// for the same (sourceType, sourceId) will simply create a fresh event.
async cancel(
sourceType: CalendarSyncSourceType,
sourceId: string,
): Promise<void> {
await this.afterCommit(
'Calendar sync cancel',
sourceType,
sourceId,
async () => {
const entry = await this.prisma.calendarSyncOutboxEntry.findUnique({
where: { sourceType_sourceId: { sourceType, sourceId } },
});
if (!entry) {
return;
}
if (entry.graphEventId) {
await this.deleteGraphEvent(entry.graphEventId);
}
await this.prisma.calendarSyncOutboxEntry.update({
where: { id: entry.id },
data: { status: 'SKIPPED', graphEventId: null },
});
},
await this.afterCommit('Calendar sync enqueue', sourceType, sourceId, () =>
this.enqueueIn(this.prisma, sourceType, sourceId),
);
}
@@ -209,20 +186,15 @@ export class CalendarSyncService implements OnModuleDestroy {
}
}
// The cron tick. An error is logged here, Prisma errors by name/code/model
// only (see loggableError) - left uncaught, the scheduler would log the
// full Prisma message and stack.
@Cron(CronExpression.EVERY_MINUTE)
async scheduledRun(): Promise<void> {
try {
await this.processOutbox();
} catch (error: unknown) {
const { message, stack } = loggableError(error);
this.logger.error(`Calendar sync tick failed: ${message}`, stack);
// One pass, run by the worker's calendar-outbox loop (worker/
// worker-runtime.service.ts): every 60 s and on a calendar_sync wake-up.
// The loop logs a failed pass via loggableError. Once `signal` aborts
// (worker shutdown), no further entry is claimed; the entry in progress
// is finished, since a cut-off one waits for the stale-claim window.
async processOutbox(signal?: AbortSignal): Promise<void> {
if (signal?.aborted) {
return;
}
}
async processOutbox(): Promise<void> {
const settings = await this.settingsService.get();
if (
!settings.enabled ||
@@ -232,15 +204,18 @@ export class CalendarSyncService implements OnModuleDestroy {
return;
}
// Several workers may run at once - one cron tick per pod, plus "Jetzt
// synchronisieren". Each entry is claimed right before it is written,
// so exactly one of them talks to Graph about it.
// Several workers may run at once - one loop per worker pod. Each entry
// is claimed right before it is written, so exactly one of them talks
// to Graph about it.
const candidates = await this.prisma.calendarSyncOutboxEntry.findMany({
where: { status: 'PENDING', ...claimableNow() },
orderBy: { updatedAt: 'asc' },
take: BATCH_SIZE,
});
for (const candidate of candidates) {
if (signal?.aborted) {
return;
}
const claimed = await this.claim(candidate);
if (claimed) {
await this.processEntry(claimed, settings.groupId);
@@ -248,41 +223,19 @@ export class CalendarSyncService implements OnModuleDestroy {
}
}
// "Jetzt synchronisieren" also gives FAILED entries a fresh set of
// attempts - besides editing the source record the documented way out of
// FAILED. A FAILED row is never claimed (settle() clears the claim with
// it), so this cannot pull a row from under a running worker. The regular
// cron tick leaves FAILED alone.
async runNow(): Promise<void> {
// "Jetzt synchronisieren": gives FAILED entries a fresh set of attempts -
// besides editing the source record the documented way out of FAILED -
// and wakes the worker, which runs the pass. The API never calls Graph
// for it, so the request returns at once. A FAILED row is never claimed
// (settle() clears the claim with it), so this cannot pull a row from
// under a running worker. The regular tick leaves FAILED alone. Reset
// first: the woken worker must find the entries PENDING.
async requestRun(): Promise<void> {
await this.prisma.calendarSyncOutboxEntry.updateMany({
where: { status: 'FAILED' },
data: { status: 'PENDING', attempts: 0, lastError: null },
});
await this.processOutbox();
}
// What the API's "Jetzt synchronisieren" calls: starts runNow() in the
// background and returns at once - a pass over up to BATCH_SIZE entries
// with several Graph calls each must not hold the HTTP request. One such
// pass per pod at a time; passes on other pods (and the cron tick) stay
// safe through the per-entry claims. getStatus().running follows it.
startRun(): void {
if (!this.activeRun) {
this.activeRun = this.runNow()
.catch((error: unknown) => {
const { message, stack } = loggableError(error);
this.logger.error(`Calendar sync run failed: ${message}`, stack);
})
.finally(() => {
this.activeRun = null;
});
}
}
// A pass cut off mid-entry would only be retaken after STALE_CLAIM_MS -
// shutdown lets it finish (each Graph request is bounded by its timeout).
async onModuleDestroy(): Promise<void> {
await this.activeRun;
await notifyWorker(this.prisma, WAKE_CHANNELS.calendarSync);
}
// Compare-and-set on the row as it was read: the update only matches while
@@ -314,7 +267,11 @@ export class CalendarSyncService implements OnModuleDestroy {
entry.sourceId,
);
if (!input) {
await this.settle(entry, { status: 'SKIPPED' });
// Nothing to show any more: an event created earlier must go.
if (entry.graphEventId) {
await this.removeGraphEvent(groupId, entry.graphEventId);
}
await this.settle(entry, { status: 'SKIPPED', graphEventId: null });
return;
}
@@ -338,6 +295,25 @@ export class CalendarSyncService implements OnModuleDestroy {
}
}
// Unlike deleteGraphEvent, a failure counts as a failed attempt (the
// caller's catch): the event must really be gone before the entry is
// SKIPPED. 404/410 mean it already is.
private async removeGraphEvent(
groupId: string,
graphEventId: string,
): Promise<void> {
try {
await this.graphClient.deleteEvent(groupId, graphEventId);
} catch (error: unknown) {
const alreadyGone =
error instanceof GraphRequestError &&
EVENT_GONE_STATUSES.includes(error.status);
if (!alreadyGone) {
throw error;
}
}
}
/** The row exactly as claimed: still ours, PENDING, nothing enqueued since. */
private unchangedSinceClaim(
entry: ClaimedEntry,
@@ -452,7 +428,12 @@ export class CalendarSyncService implements OnModuleDestroy {
const task = await this.prisma.task.findUnique({
where: { id: sourceId },
});
if (!task || !task.dueDate) {
if (
!task ||
!task.dueDate ||
task.status === 'DONE' ||
task.status === 'CANCELLED'
) {
return null;
}
return { title: task.title, date: task.dueDate };
@@ -534,7 +515,8 @@ export class CalendarSyncService implements OnModuleDestroy {
this.prisma.calendarSyncOutboxEntry.count({
where: { status: 'SYNCED' },
}),
// Live claims: some worker is writing an entry right now.
// Live claims: some worker is writing an entry right now. The pass
// runs in a worker process, so nothing local can tell.
this.prisma.calendarSyncOutboxEntry.count({
where: { claimedAt: { gte: new Date(Date.now() - STALE_CLAIM_MS) } },
}),
@@ -546,7 +528,7 @@ export class CalendarSyncService implements OnModuleDestroy {
pendingCount,
failedCount,
syncedCount,
running: this.activeRun !== null || heldCount > 0,
running: heldCount > 0,
};
}
}
+61
View File
@@ -0,0 +1,61 @@
import { bootstrapCli } from './cli';
import * as runCliModule from './jobs/run-cli';
const VALID_ENV: NodeJS.ProcessEnv = {
DATABASE_URL: 'postgresql://job:secret@db/app',
JWT_ACCESS_SECRET: 'a'.repeat(32),
};
describe('bootstrapCli', () => {
let runCli: jest.SpyInstance;
beforeEach(() => {
runCli = jest.spyOn(runCliModule, 'runCli').mockResolvedValue(0);
});
afterEach(() => jest.restoreAllMocks());
// A misconfigured CronJob pod must fail before any module connects.
it.each([
[
'a role of another entry point',
{ ...VALID_ENV, APICORE_ROLE: 'worker' },
'APICORE_ROLE=worker cannot start dist/cli.js',
],
[
'"all" in production',
{ ...VALID_ENV, NODE_ENV: 'production', APICORE_ROLE: 'all' },
'APICORE_ROLE=all is for local development and tests only',
],
[
'no role in production',
{ ...VALID_ENV, NODE_ENV: 'production' },
'APICORE_ROLE is required in production',
],
[
'a missing DATABASE_URL',
{ JWT_ACCESS_SECRET: VALID_ENV.JWT_ACCESS_SECRET },
'Missing required environment variable(s): DATABASE_URL',
],
])(
'refuses %s before running a job',
async (_case, env: NodeJS.ProcessEnv, message: string) => {
await expect(bootstrapCli(['cleanup'], env)).rejects.toThrow(message);
expect(runCli).not.toHaveBeenCalled();
},
);
it('runs the named job with its arguments and returns the exit code', async () => {
runCli.mockResolvedValue(1);
await expect(
bootstrapCli(['rekey', '--dry-run'], {
...VALID_ENV,
APICORE_ROLE: 'job',
}),
).resolves.toBe(1);
expect(runCli).toHaveBeenCalledWith(['rekey', '--dry-run']);
});
});
+26
View File
@@ -0,0 +1,26 @@
import { CliExitCode, runCli } from './jobs/run-cli';
import { resolveRole } from './runtime/role';
import { assertStartupEnvironment } from './runtime/startup-checks';
/**
* Entry point of the job role: `node dist/cli.js <job> [args]`, one run per
* CronJob pod. Role and environment are checked like in main.ts and
* worker.ts, before any module connects. Resolves with the exit code:
* 0 succeeded, 1 failed, 2 wrong call. `env` is for tests; the modules
* themselves read process.env.
*/
export async function bootstrapCli(
argv: readonly string[] = process.argv.slice(2),
env: NodeJS.ProcessEnv = process.env,
): Promise<CliExitCode> {
const role = resolveRole('cli', env);
assertStartupEnvironment(role, env);
return runCli(argv);
}
// Started as `node dist/cli.js`; importing this file starts nothing.
if (require.main === module) {
void bootstrapCli().then((code) => {
process.exitCode = code;
});
}
@@ -1,10 +1,8 @@
import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module';
import { ColoniesController } from './colonies.controller';
import { ColoniesService } from './colonies.service';
@Module({
imports: [AutomationModule],
controllers: [ColoniesController],
providers: [ColoniesService],
exports: [ColoniesService],
@@ -3,11 +3,12 @@ import { ColoniesService } from './colonies.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
describe('ColoniesService', () => {
let service: ColoniesService;
let tx: {
$queryRaw: jest.Mock;
colony: { create: jest.Mock; update: jest.Mock; findUnique: jest.Mock };
colonyApiaryHistory: { create: jest.Mock };
colonyLineage: { create: jest.Mock };
@@ -27,6 +28,7 @@ describe('ColoniesService', () => {
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
let events: { record: jest.Mock; recordAll: jest.Mock };
const parentColony = {
id: 'colony-1',
@@ -38,6 +40,7 @@ describe('ColoniesService', () => {
beforeEach(() => {
tx = {
$queryRaw: jest.fn().mockResolvedValue([]),
colony: { create: jest.fn(), update: jest.fn(), findUnique: jest.fn() },
colonyApiaryHistory: { create: jest.fn() },
colonyLineage: { create: jest.fn() },
@@ -62,19 +65,30 @@ describe('ColoniesService', () => {
callback(tx),
),
};
// The locked re-read sees what the pre-read saw unless a test says otherwise.
tx.colony.findUnique.mockImplementation(
(args: unknown) => prisma.colony.findUnique(args) as Promise<unknown>,
);
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
const automation = {
evaluateColonyHealth: jest.fn().mockResolvedValue(undefined),
events = {
record: jest.fn().mockResolvedValue(undefined),
recordAll: jest.fn().mockResolvedValue(0),
};
service = new ColoniesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
webhooks as unknown as WebhooksService,
automation as unknown as AutomationService,
events as unknown as DomainEventsService,
);
});
function transitionTypes(): string[] {
return (
events.recordAll.mock.calls as [unknown, { type: string }[]][]
).flatMap(([, recorded]) => recorded.map((event) => event.type));
}
it('findById throws NotFoundException when missing', async () => {
prisma.colony.findUnique.mockResolvedValue(null);
await expect(service.findById('missing')).rejects.toBeInstanceOf(
@@ -137,7 +151,11 @@ describe('ColoniesService', () => {
status: 'ACTIVE',
currentQueenId: 'queen-2',
});
tx.colony.update.mockResolvedValue({});
tx.colony.update.mockResolvedValue({
id: 'colony-3',
status: 'MERGED',
healthStatus: null,
});
tx.colonyLineage.create.mockResolvedValue({});
await service.merge(
@@ -242,6 +260,7 @@ describe('ColoniesService', () => {
expect(result.status).toBe('DISSOLVED');
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'DEACTIVATE' }),
tx,
);
});
@@ -320,7 +339,7 @@ describe('ColoniesService', () => {
await expect(
service.reactivate('colony-1', 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
expect(prisma.colony.update).not.toHaveBeenCalled();
expect(tx.colony.update).not.toHaveBeenCalled();
},
);
@@ -330,7 +349,7 @@ describe('ColoniesService', () => {
status: 'DISSOLVED',
deactivatedAt: new Date(),
});
prisma.colony.update.mockResolvedValue({
tx.colony.update.mockResolvedValue({
...parentColony,
status: 'ACTIVE',
deactivatedAt: null,
@@ -339,13 +358,15 @@ describe('ColoniesService', () => {
const result = await service.reactivate('colony-1', 'user-1');
expect(result.status).toBe('ACTIVE');
expect(prisma.colony.update).toHaveBeenCalledWith({
expect(tx.colony.update).toHaveBeenCalledWith({
where: { id: 'colony-1' },
data: { status: 'ACTIVE', deactivatedAt: null },
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'UPDATE' }),
tx,
);
expect(transitionTypes()).toEqual(['ColonyStatusChanged']);
});
it('writes an apiary-history entry when an edit corrects the apiary', async () => {
@@ -375,4 +396,215 @@ describe('ColoniesService', () => {
expect(tx.colonyApiaryHistory.create).not.toHaveBeenCalled();
});
it('update into CRITICAL health records ColonyStatusChanged with from/to snapshots', async () => {
prisma.colony.findUnique.mockResolvedValue({
...parentColony,
healthStatus: null,
});
tx.colony.update.mockResolvedValue({
...parentColony,
healthStatus: 'CRITICAL',
});
await service.update('colony-1', { healthStatus: 'CRITICAL' }, 'user-1');
expect(events.recordAll).toHaveBeenCalledWith(tx, [
expect.objectContaining({
type: 'ColonyStatusChanged',
aggregateType: 'Colony',
aggregateId: 'colony-1',
actorUserId: 'user-1',
payload: {
from: { status: 'ACTIVE', healthStatus: null },
to: { status: 'ACTIVE', healthStatus: 'CRITICAL' },
},
}),
]);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'UPDATE' }),
tx,
);
});
it('update records no event when status and health stay the same', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue(parentColony);
await service.update('colony-1', { notes: 'x' }, 'user-1');
expect(transitionTypes()).toEqual([]);
});
it('dissolve records ColonyStatusChanged and ColonyEnded { status: "DISSOLVED" }', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue({
...parentColony,
status: 'DISSOLVED',
});
await service.dissolve('colony-1', 'user-1');
expect(events.recordAll).toHaveBeenCalledWith(tx, [
expect.objectContaining({ type: 'ColonyStatusChanged' }),
expect.objectContaining({
type: 'ColonyEnded',
aggregateId: 'colony-1',
payload: { status: 'DISSOLVED' },
}),
]);
});
it('merge records ColonyEnded "MERGED" for every merged colony', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.findUnique.mockImplementation(
({ where }: { where: { id: string } }) =>
Promise.resolve({
id: where.id,
status: 'ACTIVE',
healthStatus: null,
currentQueenId: null,
}),
);
tx.colony.update.mockImplementation(
({ where }: { where: { id: string } }) =>
Promise.resolve({ id: where.id, status: 'MERGED', healthStatus: null }),
);
await service.merge(
'colony-1',
{ mergedColonyIds: ['colony-3', 'colony-4'] },
'user-1',
);
const ended = (
events.recordAll.mock.calls as [
unknown,
{ type: string; aggregateId: string; payload: { status?: string } }[],
][]
)
.flatMap(([, recorded]) => recorded)
.filter((event) => event.type === 'ColonyEnded');
expect(ended.map((event) => event.aggregateId)).toEqual([
'colony-3',
'colony-4',
]);
expect(ended.map((event) => event.payload.status)).toEqual([
'MERGED',
'MERGED',
]);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityId: 'colony-1', action: 'UPDATE' }),
tx,
);
});
it('writes the audit entry of create, move and split inside the transaction', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.create.mockResolvedValue({ id: 'colony-2' });
tx.colony.update.mockResolvedValue({
...parentColony,
apiaryId: 'apiary-2',
});
await service.create(
{ code: 'V-9', apiaryId: 'apiary-1', colonyType: 'PRODUCTION_COLONY' },
'user-1',
);
await service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1');
await service.split(
'colony-1',
{ code: 'V-1-A', colonyType: 'NUCLEUS_COLONY' },
'user-1',
);
const calls = auditLog.record.mock.calls as [unknown, unknown][];
expect(calls).toHaveLength(3);
expect(calls.every(([, client]) => client === tx)).toBe(true);
});
it('locks the colony row before re-reading it in update, move, dissolve and reactivate', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue(parentColony);
await service.update('colony-1', { notes: 'x' }, 'user-1');
await service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1');
await service.dissolve('colony-1', 'user-1');
prisma.colony.findUnique.mockResolvedValue({
...parentColony,
status: 'DISSOLVED',
});
await service.reactivate('colony-1', 'user-1');
expect(tx.$queryRaw).toHaveBeenCalledTimes(4);
});
it('uses the locked row, not the pre-read, as "before" for event and audit', async () => {
prisma.colony.findUnique.mockResolvedValue({
...parentColony,
healthStatus: null,
});
// Another request made the colony CRITICAL between pre-read and lock.
tx.colony.findUnique.mockResolvedValue({
...parentColony,
healthStatus: 'CRITICAL',
});
tx.colony.update.mockResolvedValue({
...parentColony,
healthStatus: 'GOOD',
});
await service.update('colony-1', { healthStatus: 'GOOD' }, 'user-1');
expect(events.recordAll).toHaveBeenCalledWith(tx, [
expect.objectContaining({
payload: {
from: { status: 'ACTIVE', healthStatus: 'CRITICAL' },
to: { status: 'ACTIVE', healthStatus: 'GOOD' },
},
}),
]);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
beforeData: { ...parentColony, healthStatus: 'CRITICAL' },
}),
tx,
);
});
it('rejects dissolving a colony that ended between pre-read and lock', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.findUnique.mockResolvedValue({
...parentColony,
status: 'SOLD',
});
await expect(service.dissolve('colony-1', 'user-1')).rejects.toBeInstanceOf(
ConflictException,
);
expect(tx.colony.update).not.toHaveBeenCalled();
});
it('locks every merged colony', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.findUnique.mockImplementation(
({ where }: { where: { id: string } }) =>
Promise.resolve({ id: where.id, status: 'ACTIVE', healthStatus: null }),
);
tx.colony.update.mockImplementation(
({ where }: { where: { id: string } }) =>
Promise.resolve({ id: where.id, status: 'MERGED', healthStatus: null }),
);
await service.merge(
'colony-1',
{ mergedColonyIds: ['colony-3', 'colony-4'] },
'user-1',
);
// One multi-row statement, not one per colony.
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
const [[query]] = tx.$queryRaw.mock.calls as [[{ sql: string }]];
expect(query.sql).toContain('ANY(?) ORDER BY id FOR UPDATE');
});
});
+133 -80
View File
@@ -7,7 +7,7 @@ import {
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { Colony, ColonyStatus } from '../../generated/prisma';
import { CreateColonyDto } from './dto/create-colony.dto';
import { UpdateColonyDto } from './dto/update-colony.dto';
@@ -15,6 +15,8 @@ import { MoveColonyDto } from './dto/move-colony.dto';
import { SplitColonyDto } from './dto/split-colony.dto';
import { MergeColonyDto } from './dto/merge-colony.dto';
import { isLivingColonyStatus } from './colony-status';
import { lockRowsForUpdate } from '../prisma/row-lock';
import { colonyTransitionEvents, lockColony } from './colony-events';
export interface ColonyFilter {
status?: ColonyStatus;
@@ -27,7 +29,7 @@ export class ColoniesService {
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
private readonly automation: AutomationService,
private readonly events: DomainEventsService,
) {}
async findAll(filter: ColonyFilter = {}): Promise<Colony[]> {
@@ -77,14 +79,22 @@ export class ColoniesService {
async create(dto: CreateColonyDto, actingUserId: string): Promise<Colony> {
await this.assertApiaryActive(dto.apiaryId);
const colony = await this.prisma.colony.create({ data: { ...dto } });
await this.auditLog.record({
entityType: 'Colony',
entityId: colony.id,
action: 'CREATE',
userId: actingUserId,
afterData: colony,
const colony = await this.prisma.$transaction(async (tx) => {
const created = await tx.colony.create({ data: { ...dto } });
await this.auditLog.record(
{
entityType: 'Colony',
entityId: created.id,
action: 'CREATE',
userId: actingUserId,
afterData: created,
},
tx,
);
return created;
});
// No domain event for a new colony in this stage - the webhook stays on
// the direct path.
await this.webhooks.dispatch('colony.created', colony);
return colony;
}
@@ -94,14 +104,16 @@ export class ColoniesService {
dto: UpdateColonyDto,
actingUserId: string,
): Promise<Colony> {
const before = await this.findById(id);
const movesApiary =
dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId;
if (movesApiary) {
await this.assertApiaryActive(dto.apiaryId!);
}
// Fail fast with 404; everything else is decided on the locked row.
await this.findById(id);
const colony = await this.prisma.$transaction(async (tx) => {
const before = await lockColony(tx, id);
const movesApiary =
dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId;
if (movesApiary) {
await this.assertApiaryActive(dto.apiaryId!);
}
const updated = await tx.colony.update({
where: { id },
data: { ...dto },
@@ -119,17 +131,23 @@ export class ColoniesService {
},
});
}
await this.events.recordAll(
tx,
colonyTransitionEvents(before, updated, actingUserId),
);
await this.auditLog.record(
{
entityType: 'Colony',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: updated,
},
tx,
);
return updated;
});
await this.auditLog.record({
entityType: 'Colony',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: colony,
});
await this.automation.evaluateColonyHealth(before, colony);
return colony;
}
@@ -138,10 +156,11 @@ export class ColoniesService {
dto: MoveColonyDto,
actingUserId: string,
): Promise<Colony> {
const before = await this.findById(id);
this.assertActive(before);
this.assertActive(await this.findById(id));
await this.assertApiaryActive(dto.apiaryId);
const colony = await this.prisma.$transaction(async (tx) => {
const before = await lockColony(tx, id);
this.assertActive(before);
const updated = await tx.colony.update({
where: { id },
data: { apiaryId: dto.apiaryId },
@@ -154,16 +173,19 @@ export class ColoniesService {
notes: dto.notes,
},
});
await this.auditLog.record(
{
entityType: 'Colony',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: updated,
},
tx,
);
return updated;
});
await this.auditLog.record({
entityType: 'Colony',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: colony,
});
return colony;
}
@@ -192,15 +214,18 @@ export class ColoniesService {
notes: dto.notes,
},
});
await this.auditLog.record(
{
entityType: 'Colony',
entityId: created.id,
action: 'CREATE',
userId: actingUserId,
afterData: { ...created, splitFromColonyId: parent.id },
},
tx,
);
return created;
});
await this.auditLog.record({
entityType: 'Colony',
entityId: child.id,
action: 'CREATE',
userId: actingUserId,
afterData: { ...child, splitFromColonyId: parent.id },
});
return child;
}
@@ -222,6 +247,9 @@ export class ColoniesService {
);
}
await this.prisma.$transaction(async (tx) => {
// One statement in id order, the same order bulk lockRowsForShare
// uses: no deadlock with a concurrent bulk write.
await lockRowsForUpdate(tx, 'Colony', dto.mergedColonyIds);
for (const mergedColonyId of dto.mergedColonyIds) {
const merged = await tx.colony.findUnique({
where: { id: mergedColonyId },
@@ -238,7 +266,7 @@ export class ColoniesService {
// The survivor keeps its own queen; merged-away queens are simply
// released, not auto-transferred (which colony's queen "wins" a
// merge is a beekeeper decision, not one to guess here).
await tx.colony.update({
const mergedAway = await tx.colony.update({
where: { id: mergedColonyId },
data: {
status: 'MERGED',
@@ -246,6 +274,10 @@ export class ColoniesService {
currentQueenId: null,
},
});
await this.events.recordAll(
tx,
colonyTransitionEvents(merged, mergedAway, actingUserId),
);
if (merged.currentQueenId) {
await tx.queenColonyHistory.updateMany({
where: {
@@ -266,24 +298,28 @@ export class ColoniesService {
},
});
}
});
await this.auditLog.record({
entityType: 'Colony',
entityId: survivor.id,
action: 'UPDATE',
userId: actingUserId,
beforeData: survivor,
afterData: { ...survivor, mergedColonyIds: dto.mergedColonyIds },
await this.auditLog.record(
{
entityType: 'Colony',
entityId: survivor.id,
action: 'UPDATE',
userId: actingUserId,
beforeData: survivor,
afterData: { ...survivor, mergedColonyIds: dto.mergedColonyIds },
},
tx,
);
});
return this.findById(survivor.id);
}
async dissolve(id: string, actingUserId: string): Promise<Colony> {
const before = await this.findById(id);
// Re-dissolving would overwrite deactivatedAt and the terminal status
// (e.g. MERGED -> DISSOLVED), losing when and how the colony really ended.
this.assertActive(before);
this.assertActive(await this.findById(id));
const colony = await this.prisma.$transaction(async (tx) => {
const before = await lockColony(tx, id);
// Re-dissolving would overwrite deactivatedAt and the terminal status
// (e.g. MERGED -> DISSOLVED), losing when and how the colony really ended.
this.assertActive(before);
const updated = await tx.colony.update({
where: { id },
data: {
@@ -302,39 +338,56 @@ export class ColoniesService {
data: { endDate: new Date() },
});
}
await this.events.recordAll(
tx,
colonyTransitionEvents(before, updated, actingUserId),
);
await this.auditLog.record(
{
entityType: 'Colony',
entityId: id,
action: 'DEACTIVATE',
userId: actingUserId,
beforeData: before,
afterData: updated,
},
tx,
);
return updated;
});
await this.auditLog.record({
entityType: 'Colony',
entityId: id,
action: 'DEACTIVATE',
userId: actingUserId,
beforeData: before,
afterData: colony,
});
return colony;
}
async reactivate(id: string, actingUserId: string): Promise<Colony> {
const before = await this.findById(id);
// Only an ended colony can be brought back. A weak or queenless one
// still exists - reactivating it would just overwrite its condition.
if (isLivingColonyStatus(before.status)) {
throw new ConflictException('Colony is already active');
}
const colony = await this.prisma.colony.update({
where: { id },
data: { status: 'ACTIVE', deactivatedAt: null },
await this.findById(id);
return this.prisma.$transaction(async (tx) => {
const before = await lockColony(tx, id);
// Only an ended colony can be brought back. A weak or queenless one
// still exists - reactivating it would just overwrite its condition.
if (isLivingColonyStatus(before.status)) {
throw new ConflictException('Colony is already active');
}
const updated = await tx.colony.update({
where: { id },
data: { status: 'ACTIVE', deactivatedAt: null },
});
await this.events.recordAll(
tx,
colonyTransitionEvents(before, updated, actingUserId),
);
await this.auditLog.record(
{
entityType: 'Colony',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: updated,
},
tx,
);
return updated;
});
await this.auditLog.record({
entityType: 'Colony',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: colony,
});
return colony;
}
async getHistory(id: string) {
@@ -0,0 +1,72 @@
import { Colony } from '../../generated/prisma';
import { colonyTransitionEvents } from './colony-events';
function colony(overrides: Partial<Colony> = {}): Colony {
return {
id: 'c1',
status: 'ACTIVE',
healthStatus: null,
...overrides,
} as Colony;
}
describe('colonyTransitionEvents', () => {
it('records nothing when neither status nor healthStatus changed', () => {
expect(
colonyTransitionEvents(colony(), colony({ notes: 'neu' }), 'u1'),
).toEqual([]);
});
it('records ColonyStatusChanged with from/to snapshots on a health change', () => {
const events = colonyTransitionEvents(
colony(),
colony({ healthStatus: 'CRITICAL' }),
'u1',
);
expect(events).toEqual([
{
type: 'ColonyStatusChanged',
aggregateType: 'Colony',
aggregateId: 'c1',
actorUserId: 'u1',
payload: {
from: { status: 'ACTIVE', healthStatus: null },
to: { status: 'ACTIVE', healthStatus: 'CRITICAL' },
},
},
]);
});
it.each(['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] as const)(
'adds ColonyEnded { status: %s } on the transition from living to ended',
(status) => {
const events = colonyTransitionEvents(
colony({ status: 'WEAK' }),
colony({ status }),
null,
);
expect(events.map((event) => event.type)).toEqual([
'ColonyStatusChanged',
'ColonyEnded',
]);
expect(events[1].payload).toEqual({ status });
},
);
it('does not add ColonyEnded between two ended statuses or on reactivation', () => {
expect(
colonyTransitionEvents(
colony({ status: 'DISSOLVED' }),
colony({ status: 'DECEASED' }),
'u1',
).map((event) => event.type),
).toEqual(['ColonyStatusChanged']);
expect(
colonyTransitionEvents(
colony({ status: 'DISSOLVED' }),
colony({ status: 'ACTIVE' }),
'u1',
).map((event) => event.type),
).toEqual(['ColonyStatusChanged']);
});
});
@@ -0,0 +1,74 @@
import { NotFoundException } from '@nestjs/common';
import { Colony, Prisma } from '../../generated/prisma';
import { lockRowForUpdate } from '../prisma/row-lock';
import {
AnyNewDomainEvent,
ColonyStateSnapshot,
} from '../domain-events/domain-event-types';
import { isLivingColonyStatus } from './colony-status';
type ColonyState = Pick<Colony, 'status' | 'healthStatus'>;
function snapshotOf(colony: ColonyState): ColonyStateSnapshot {
return { status: colony.status, healthStatus: colony.healthStatus };
}
/**
* The domain events of one colony going from `before` to `after`:
* ColonyStatusChanged as soon as status or healthStatus differs, plus
* ColonyEnded when a living colony became an ended one. Empty when nothing
* the events care about changed. The caller records them in the same
* transaction as the colony update.
*/
export function colonyTransitionEvents(
before: Pick<Colony, 'id' | 'status' | 'healthStatus'>,
after: Pick<Colony, 'id' | 'status' | 'healthStatus'>,
actorUserId: string | null,
): AnyNewDomainEvent<'ColonyStatusChanged' | 'ColonyEnded'>[] {
if (
before.status === after.status &&
before.healthStatus === after.healthStatus
) {
return [];
}
const events: AnyNewDomainEvent<'ColonyStatusChanged' | 'ColonyEnded'>[] = [
{
type: 'ColonyStatusChanged',
aggregateType: 'Colony',
aggregateId: after.id,
actorUserId,
payload: { from: snapshotOf(before), to: snapshotOf(after) },
},
];
if (
isLivingColonyStatus(before.status) &&
!isLivingColonyStatus(after.status)
) {
events.push({
type: 'ColonyEnded',
aggregateType: 'Colony',
aggregateId: after.id,
actorUserId,
payload: { status: after.status },
});
}
return events;
}
/**
* Locks the colony row and re-reads it inside the transaction. Use the
* result - not an earlier read - as "before" for checks, audit and
* colonyTransitionEvents, so a concurrent change cannot be missed or
* mislabelled.
*/
export async function lockColony(
tx: Prisma.TransactionClient,
id: string,
): Promise<Colony> {
await lockRowForUpdate(tx, 'Colony', id);
const colony = await tx.colony.findUnique({ where: { id } });
if (!colony) {
throw new NotFoundException('Colony not found');
}
return colony;
}
@@ -0,0 +1,86 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
// Side effects run through the domain-event backbone. runAfterCommit and the
// direct wrappers stay only for the legacy paths that have no event yet
// (ARCHITECTURE assumption 11). A new caller must record an event instead.
const SRC = join(__dirname, '..');
const DEFINITION = 'common/after-commit.ts';
function productionSources(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
return productionSources(path);
}
return entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts')
? [path]
: [];
});
}
function withoutComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
}
function filesCalling(call: string): string[] {
return productionSources(SRC)
.filter((file) =>
withoutComments(readFileSync(file, 'utf8')).includes(call),
)
.map((file) => relative(SRC, file).split(sep).join('/'))
.filter((file) => file !== DEFINITION)
.map((file) => `src/${file}`)
.sort();
}
describe('legacy after-commit paths', () => {
it('runAfterCommit is only used by the remaining legacy paths', () => {
expect(filesCalling('runAfterCommit(')).toEqual([
'src/automation/automation.service.ts',
'src/calendar-sync/calendar-sync.service.ts',
'src/webhooks/webhooks.service.ts',
]);
});
it('only the queen assignment still enqueues a calendar entry directly', () => {
expect(filesCalling('calendarSync.enqueue(')).toEqual([
'src/queens/queens.service.ts',
]);
});
it('only the modules without an event still dispatch webhooks directly', () => {
expect(filesCalling('webhooks.dispatch(')).toEqual([
'src/colonies/colonies.service.ts',
'src/honey-batches/honey-batches.service.ts',
'src/queens/queens.service.ts',
'src/storage/storage.service.ts',
]);
});
it('no longer defines the removed post-commit automation wrappers (only SWARM_MOOD_HIGH and STOCK_ITEM_LOW keep one)', () => {
const removed = [
'evaluateInspection',
'evaluateVarroaMeasurement',
'evaluateTreatment',
'evaluateFeeding',
'evaluateColonyHealth',
'evaluateRawMaterialLow',
].map(
(name) =>
[
name,
new RegExp(
String.raw`\b(?:public\s+|private\s+)?async\s+${name}\s*\(`,
),
] as const,
);
const definitions = productionSources(SRC).flatMap((file) => {
const source = withoutComments(readFileSync(file, 'utf8'));
return removed
.filter(([, pattern]) => pattern.test(source))
.map(([name]) => name);
});
expect(definitions).toEqual([]);
});
});
@@ -0,0 +1,111 @@
import { BadRequestException } from '@nestjs/common';
import { Prisma } from '../../../generated/prisma';
import { assertLivingColonies } from './assert-living-colonies';
describe('assertLivingColonies', () => {
let tx: { $queryRaw: jest.Mock; colony: { findMany: jest.Mock } };
beforeEach(() => {
tx = {
$queryRaw: jest.fn().mockResolvedValue([]),
colony: { findMany: jest.fn().mockResolvedValue([]) },
};
});
async function rejection(colonyIds: string[]): Promise<BadRequestException> {
const error: unknown = await assertLivingColonies(
tx as never,
colonyIds,
).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(BadRequestException);
return error as BadRequestException;
}
it('locks the colonies for share before it reads them through the transaction', async () => {
tx.colony.findMany.mockResolvedValue([
{ id: 'c2', status: 'ACTIVE', apiaryId: 'a1' },
{ id: 'c1', status: 'ACTIVE', apiaryId: 'a1' },
]);
await assertLivingColonies(tx as never, ['c2', 'c1']);
const [[query]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]];
expect(query.sql).toBe(
'SELECT id FROM "Colony" WHERE id = ANY(?) ORDER BY id FOR SHARE',
);
expect(query.values).toEqual([['c1', 'c2']]);
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
tx.colony.findMany.mock.invocationCallOrder[0],
);
expect(tx.colony.findMany).toHaveBeenCalledWith({
where: { id: { in: ['c2', 'c1'] } },
select: { id: true, status: true, apiaryId: true },
});
});
it('returns the apiary of every colony that still lives', async () => {
tx.colony.findMany.mockResolvedValue([
{ id: 'c1', status: 'ACTIVE', apiaryId: 'a1' },
{ id: 'c2', status: 'WEAK', apiaryId: 'a2' },
{ id: 'c3', status: 'CONSPICUOUS', apiaryId: 'a1' },
{ id: 'c4', status: 'QUEENLESS', apiaryId: 'a2' },
]);
const colonies = await assertLivingColonies(tx as never, [
'c1',
'c2',
'c3',
'c4',
]);
expect(colonies).toEqual(
new Map([
['c1', { apiaryId: 'a1' }],
['c2', { apiaryId: 'a2' }],
['c3', { apiaryId: 'a1' }],
['c4', { apiaryId: 'a2' }],
]),
);
});
it.each(['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'])(
'rejects a colony with the status %s',
async (status) => {
tx.colony.findMany.mockResolvedValue([
{ id: 'c1', status: 'ACTIVE', apiaryId: 'a1' },
{ id: 'c2', status, apiaryId: 'a1' },
]);
const error = await rejection(['c1', 'c2']);
expect(error.getResponse()).toEqual({
message: 'Einige Völker sind nicht (mehr) erfassbar',
invalidColonyIds: ['c2'],
});
},
);
it('names every ended and unknown colony in request order', async () => {
tx.colony.findMany.mockResolvedValue([
{ id: 'c1', status: 'SOLD', apiaryId: 'a1' },
{ id: 'c2', status: 'ACTIVE', apiaryId: 'a1' },
]);
const error = await rejection(['missing', 'c2', 'c1']);
expect(error.getResponse()).toEqual({
message: 'Einige Völker sind nicht (mehr) erfassbar',
invalidColonyIds: ['missing', 'c1'],
});
});
it('refuses the root client before it locks or reads anything', async () => {
const root = { $connect: jest.fn(), ...tx };
await expect(assertLivingColonies(root as never, ['c1'])).rejects.toThrow(
'needs the transaction client',
);
expect(tx.$queryRaw).not.toHaveBeenCalled();
expect(tx.colony.findMany).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,41 @@
import { BadRequestException } from '@nestjs/common';
import { Prisma } from '../../../generated/prisma';
import { isLivingColonyStatus } from '../../colonies/colony-status';
import { lockRowsForShare } from '../../prisma/row-lock';
export interface LivingColony {
apiaryId: string;
}
/**
* Checks, inside the bulk transaction, that every colony exists and still
* lives, and returns where each one stands. The colonies are locked FOR
* SHARE first: none can be ended or moved before the bulk commits, so the
* apiary returned is the one kept on the records.
*
* Unknown and ended colonies are named together in `invalidColonyIds` - the
* client can drop them and send the rest again; nothing is stored.
*/
export async function assertLivingColonies(
tx: Prisma.TransactionClient,
colonyIds: readonly string[],
): Promise<Map<string, LivingColony>> {
await lockRowsForShare(tx, 'Colony', colonyIds);
const colonies = await tx.colony.findMany({
where: { id: { in: [...colonyIds] } },
select: { id: true, status: true, apiaryId: true },
});
const living = new Map<string, LivingColony>(
colonies
.filter((colony) => isLivingColonyStatus(colony.status))
.map((colony) => [colony.id, { apiaryId: colony.apiaryId }]),
);
const invalidColonyIds = colonyIds.filter((id) => !living.has(id));
if (invalidColonyIds.length > 0) {
throw new BadRequestException({
message: 'Einige Völker sind nicht (mehr) erfassbar',
invalidColonyIds,
});
}
return living;
}
@@ -0,0 +1,21 @@
import { RecordedForColony } from '../../domain-events/domain-event-types';
/**
* Answer of a bulk entry: one record per colony, in request order. `bulkId`
* groups them - it is also in the payload of each record's domain event.
*/
export interface BulkCreateResult<T> {
bulkId: string;
records: T[];
}
/**
* Payload of a *Recorded event: `bulkId` only when the record comes from a
* bulk entry, so a single create's payload stays `{ colonyId }`.
*/
export function recordedForColony(
colonyId: string,
bulkId?: string,
): RecordedForColony {
return bulkId === undefined ? { colonyId } : { colonyId, bulkId };
}
@@ -0,0 +1,13 @@
/**
* Most colonies one bulk entry (POST /treatments/bulk, /feedings/bulk,
* /varroa-measurements/bulk) may cover - more than any apiary holds, and it
* keeps one request from becoming an unbounded transaction.
*/
export const BULK_MAX_COLONIES = 100;
/**
* A bulk entry writes up to BULK_MAX_COLONIES records, each with its audit
* entry and domain event, in one transaction - Prisma's 5 s default is too
* short for the upper end.
*/
export const BULK_TRANSACTION_TIMEOUT_MS = 15_000;
@@ -0,0 +1,84 @@
import { applyDecorators } from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
ArrayUnique,
IsArray,
IsNotEmpty,
IsString,
ValidateNested,
} from 'class-validator';
import { BULK_MAX_COLONIES } from './bulk-limits';
const DUPLICATE_COLONY_MESSAGE = '$property enthält ein Volk doppelt';
// The size rule every bulk list shares: at least one colony, at most
// BULK_MAX_COLONIES.
function bulkListSize(): PropertyDecorator[] {
return [
IsArray({ message: '$property muss eine Liste sein' }),
ArrayMinSize(1, { message: '$property braucht mindestens ein Volk' }),
ArrayMaxSize(BULK_MAX_COLONIES, {
message: `$property darf höchstens ${BULK_MAX_COLONIES} Völker enthalten`,
}),
];
}
// The rule for one colony id - in colonyIds (each) and in every entry - so an
// empty id is invalid input on both paths, never an "unknown colony".
function colonyIdChecks(each: boolean): PropertyDecorator[] {
return [
IsString({ each, message: '$property enthält eine ungültige Volk-ID' }),
IsNotEmpty({ each, message: '$property enthält eine leere Volk-ID' }),
];
}
/** `colonyIds` of a bulk entry with shared values: distinct, non-empty ids. */
export function IsBulkColonyIds(): PropertyDecorator {
return applyDecorators(
ApiProperty({
type: [String],
minItems: 1,
maxItems: BULK_MAX_COLONIES,
uniqueItems: true,
description: `Die Völker, für die derselbe Eintrag gilt (1 bis ${BULK_MAX_COLONIES}, jedes nur einmal)`,
}),
...bulkListSize(),
ArrayUnique({ message: DUPLICATE_COLONY_MESSAGE }),
...colonyIdChecks(true),
);
}
/** `colonyId` of one entry in `entries`: a non-empty id. */
export function IsBulkEntryColonyId(): PropertyDecorator {
return applyDecorators(
ApiProperty({ description: 'Das Volk dieses Eintrags' }),
...colonyIdChecks(false),
);
}
/**
* `entries` of a bulk entry with one value set per colony: each entry is
* validated as its class, and each colony appears once.
*/
export function IsBulkEntries(
entryType: new () => { colonyId: string },
): PropertyDecorator {
return applyDecorators(
ApiProperty({
type: [entryType],
minItems: 1,
maxItems: BULK_MAX_COLONIES,
description: `Ein Eintrag je Volk (1 bis ${BULK_MAX_COLONIES}, jedes Volk nur einmal)`,
}),
...bulkListSize(),
// class-validator skips null entries before calling the selector.
ArrayUnique((entry: { colonyId?: unknown }) => entry.colonyId, {
message: DUPLICATE_COLONY_MESSAGE,
}),
ValidateNested({ each: true }),
Type(() => entryType),
);
}
@@ -0,0 +1,137 @@
import { BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { BULK_TRANSACTION_TIMEOUT_MS } from './bulk-limits';
import { BulkEntryWork, runBulk } from './run-bulk';
describe('runBulk', () => {
let tx: {
$queryRaw: jest.Mock;
colony: { findMany: jest.Mock };
write: jest.Mock;
};
let prisma: { $transaction: jest.Mock };
// Writes of the fake transaction that survived its commit.
let committed: string[];
beforeEach(() => {
committed = [];
tx = {
$queryRaw: jest.fn().mockResolvedValue([]),
colony: {
findMany: jest.fn().mockResolvedValue([
{ id: 'colony-1', status: 'ACTIVE', apiaryId: 'apiary-1' },
{ id: 'colony-2', status: 'WEAK', apiaryId: 'apiary-2' },
{ id: 'colony-3', status: 'ACTIVE', apiaryId: 'apiary-1' },
]),
},
write: jest.fn(),
};
// Keeps the writes only when the work succeeds - a rollback otherwise.
prisma = {
$transaction: jest.fn(
async (work: (txArg: typeof tx) => Promise<unknown>) => {
const pending: string[] = [];
tx.write.mockImplementation((value: string) => {
pending.push(value);
});
const result = await work(tx);
committed.push(...pending);
return result;
},
),
};
});
const run = <E extends { colonyId: string }, T>(
entries: E[],
perEntry: BulkEntryWork<E, T>,
) => runBulk(prisma as unknown as PrismaService, entries, perEntry);
it('runs every entry in request order in one transaction with the bulk timeout', async () => {
const result = await run(
[{ colonyId: 'colony-3' }, { colonyId: 'colony-1' }],
(txArg, entry) => {
(txArg as unknown as typeof tx).write(entry.colonyId);
return Promise.resolve(`record-of-${entry.colonyId}`);
},
);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), {
timeout: BULK_TRANSACTION_TIMEOUT_MS,
});
expect(result.records).toEqual([
'record-of-colony-3',
'record-of-colony-1',
]);
expect(committed).toEqual(['colony-3', 'colony-1']);
});
it('hands every entry the transaction, its colony and the one bulkId it returns', async () => {
const perEntry = jest.fn().mockResolvedValue('record');
const { bulkId } = await run(
[{ colonyId: 'colony-1' }, { colonyId: 'colony-2' }],
perEntry,
);
expect(bulkId).toMatch(/^[0-9a-f-]{36}$/);
expect(perEntry.mock.calls).toEqual([
[tx, { colonyId: 'colony-1' }, { apiaryId: 'apiary-1' }, bulkId],
[tx, { colonyId: 'colony-2' }, { apiaryId: 'apiary-2' }, bulkId],
]);
});
it('gives each bulk its own bulkId', async () => {
const perEntry = jest.fn().mockResolvedValue('record');
const first = await run([{ colonyId: 'colony-1' }], perEntry);
const second = await run([{ colonyId: 'colony-1' }], perEntry);
expect(first.bulkId).not.toBe(second.bulkId);
});
it('checks every colony under the share lock before the first entry runs', async () => {
tx.colony.findMany.mockResolvedValue([
{ id: 'colony-1', status: 'ACTIVE', apiaryId: 'apiary-1' },
{ id: 'colony-2', status: 'DISSOLVED', apiaryId: 'apiary-1' },
]);
const perEntry = jest.fn();
const error: unknown = await run(
[{ colonyId: 'colony-1' }, { colonyId: 'colony-2' }],
perEntry,
).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(BadRequestException);
expect((error as BadRequestException).getResponse()).toEqual(
expect.objectContaining({ invalidColonyIds: ['colony-2'] }),
);
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
expect(perEntry).not.toHaveBeenCalled();
});
it('rolls back every record when one entry fails', async () => {
const perEntry = jest.fn((txArg: unknown, entry: { colonyId: string }) => {
(txArg as typeof tx).write(entry.colonyId);
return entry.colonyId === 'colony-2'
? Promise.reject(new Error('audit write failed'))
: Promise.resolve(entry.colonyId);
});
await expect(
run(
[
{ colonyId: 'colony-1' },
{ colonyId: 'colony-2' },
{ colonyId: 'colony-3' },
],
perEntry,
),
).rejects.toThrow('audit write failed');
expect(committed).toEqual([]);
// The entries after the failure are not started.
expect(perEntry).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,51 @@
import { randomUUID } from 'node:crypto';
import { Prisma } from '../../../generated/prisma';
import { PrismaService } from '../../prisma/prisma.service';
import { assertLivingColonies, LivingColony } from './assert-living-colonies';
import { BulkCreateResult } from './bulk-create';
import { BULK_TRANSACTION_TIMEOUT_MS } from './bulk-limits';
/**
* Writes the record of one entry - record, audit entry and domain event,
* all through `tx`. `colony` is the entry's colony as read under the share
* lock; `bulkId` goes into the event payload.
*/
export type BulkEntryWork<E, T> = (
tx: Prisma.TransactionClient,
entry: E,
colony: LivingColony,
bulkId: string,
) => Promise<T>;
/**
* The frame of every bulk entry, all or none: one transaction with
* BULK_TRANSACTION_TIMEOUT_MS, first assertLivingColonies for all colonies,
* then `perEntry` once per entry in request order. The first failure rolls
* back every record; the records come back in request order with a new
* bulkId.
*/
export async function runBulk<E extends { colonyId: string }, T>(
prisma: PrismaService,
entries: readonly E[],
perEntry: BulkEntryWork<E, T>,
): Promise<BulkCreateResult<T>> {
const bulkId = randomUUID();
const records = await prisma.$transaction(
async (tx) => {
const colonies = await assertLivingColonies(
tx,
entries.map((entry) => entry.colonyId),
);
const created: T[] = [];
for (const entry of entries) {
// Every requested id is in the map - assertLivingColonies threw
// otherwise.
const colony = colonies.get(entry.colonyId)!;
created.push(await perEntry(tx, entry, colony, bulkId));
}
return created;
},
{ timeout: BULK_TRANSACTION_TIMEOUT_MS },
);
return { bulkId, records };
}
@@ -0,0 +1,370 @@
import { Logger } from '@nestjs/common';
import { createHash, hkdfSync } from 'node:crypto';
import {
KEY_ID_PATTERN,
isSealedWithCurrentKey,
legacyJwtKey,
loadKeyRing,
openLegacyCiphertext,
openWithRing,
readRingSecrets,
sealWithRing,
} from './key-ring';
const JWT_SECRET = 'j'.repeat(40);
const KEY_A = 'a'.repeat(40);
const KEY_B = 'b'.repeat(40);
// Written by the code before the key ring: AES-256-GCM with
// sha256(JWT_ACCESS_SECRET) as key, no key id. JWT_ACCESS_SECRET = 'x' * 40.
const LEGACY_JWT_SECRET = 'x'.repeat(40);
const LEGACY_CIPHERTEXT =
'zJD2lAy1b/wslLyQ.hn8scDeaxIADXlRFtcGIyg==.3+5qbYM52lKKN/VXpInUOQ==';
const LEGACY_PLAINTEXT = 'JBSWY3DPEHPK3PXP';
function expectedKeyId(secret: string, purpose: string): string {
const key = Buffer.from(
hkdfSync('sha256', secret, 'apicore-key-ring', `apicore-${purpose}-v1`, 32),
);
return `k${createHash('sha256').update(key).digest('hex').slice(0, 8)}`;
}
/** Applies `change` to one base64 part of `<id>:<iv>.<tag>.<data>`. */
function tamper(
stored: string,
part: 0 | 1 | 2,
change: (bytes: Buffer) => Buffer,
): string {
const [keyId, body] = stored.split(':');
const parts = body.split('.');
parts[part] = change(Buffer.from(parts[part], 'base64')).toString('base64');
return `${keyId}:${parts.join('.')}`;
}
function flipFirstByte(bytes: Buffer): Buffer {
const copy = Buffer.from(bytes);
copy[0] ^= 0xff;
return copy;
}
describe('key-ring', () => {
beforeEach(() => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('loadKeyRing', () => {
it('derives the key per purpose with HKDF and names it by its hash', () => {
const ring = loadKeyRing('mfa', { MFA_ENCRYPTION_KEY: KEY_A });
expect(ring.current.id).toBe(expectedKeyId(KEY_A, 'mfa'));
expect(ring.current.key).toHaveLength(32);
expect(ring.previous).toBeNull();
});
it('derives different keys for mfa and webhook from the same dev fallback', () => {
const env = { JWT_ACCESS_SECRET: JWT_SECRET };
const mfa = loadKeyRing('mfa', env);
const webhook = loadKeyRing('webhook', env);
expect(mfa.current.id).toBe(expectedKeyId(JWT_SECRET, 'mfa'));
expect(webhook.current.id).toBe(expectedKeyId(JWT_SECRET, 'webhook'));
expect(mfa.current.key.equals(webhook.current.key)).toBe(false);
expect(mfa.current.key.equals(legacyJwtKey(env))).toBe(false);
});
it('loads the previous key from *_PREVIOUS', () => {
const ring = loadKeyRing('webhook', {
WEBHOOK_ENCRYPTION_KEY: KEY_B,
WEBHOOK_ENCRYPTION_KEY_PREVIOUS: KEY_A,
});
expect(ring.current.id).toBe(expectedKeyId(KEY_B, 'webhook'));
expect(ring.previous?.id).toBe(expectedKeyId(KEY_A, 'webhook'));
});
it('requires the key in production instead of falling back', () => {
expect(() =>
loadKeyRing('webhook', {
NODE_ENV: 'production',
JWT_ACCESS_SECRET: JWT_SECRET,
}),
).toThrow('WEBHOOK_ENCRYPTION_KEY is not set');
});
it('refuses keys shorter than 32 characters, the previous one too', () => {
expect(() => loadKeyRing('mfa', { MFA_ENCRYPTION_KEY: 'short' })).toThrow(
'MFA_ENCRYPTION_KEY must be at least 32 characters',
);
expect(() =>
loadKeyRing('mfa', {
MFA_ENCRYPTION_KEY: KEY_A,
MFA_ENCRYPTION_KEY_PREVIOUS: 'short',
}),
).toThrow('MFA_ENCRYPTION_KEY_PREVIOUS must be at least 32 characters');
});
it('needs JWT_ACCESS_SECRET for the dev fallback', () => {
expect(() => loadKeyRing('mfa', {})).toThrow(
'JWT_ACCESS_SECRET is not set',
);
});
});
describe('readRingSecrets', () => {
it('returns the configured secrets of one variable', () => {
expect(
readRingSecrets('BACKUP_ENCRYPTION_KEY', {
BACKUP_ENCRYPTION_KEY: KEY_A,
BACKUP_ENCRYPTION_KEY_PREVIOUS: KEY_B,
}),
).toEqual({ current: KEY_A, previous: KEY_B });
expect(readRingSecrets('BACKUP_ENCRYPTION_KEY', {})).toEqual({
current: null,
previous: null,
});
});
it('never puts the key into its errors', () => {
const shortKey = 'too-short-but-secret';
let message = '';
try {
readRingSecrets('BACKUP_ENCRYPTION_KEY', {
BACKUP_ENCRYPTION_KEY: shortKey,
});
} catch (error) {
message = (error as Error).message;
}
expect(message).toContain('BACKUP_ENCRYPTION_KEY');
expect(message).not.toContain(shortKey);
});
});
describe('sealWithRing / openWithRing', () => {
const ring = loadKeyRing('mfa', { MFA_ENCRYPTION_KEY: KEY_A });
it('seals with the current key and prefixes its id', () => {
const stored = sealWithRing(ring, 's');
expect(stored).toMatch(KEY_ID_PATTERN);
expect(stored.startsWith(`${ring.current.id}:`)).toBe(true);
});
it('round-trips a value and reports the key it was sealed with', () => {
const stored = sealWithRing(ring, LEGACY_PLAINTEXT);
expect(stored).not.toContain(LEGACY_PLAINTEXT);
expect(openWithRing(ring, stored)).toEqual({
plaintext: LEGACY_PLAINTEXT,
keyId: ring.current.id,
});
});
it('uses a fresh IV for every value', () => {
expect(sealWithRing(ring, 's')).not.toBe(sealWithRing(ring, 's'));
});
it('opens a value sealed with the previous key', () => {
const stored = sealWithRing(ring, 'secret');
const rotated = loadKeyRing('mfa', {
MFA_ENCRYPTION_KEY: KEY_B,
MFA_ENCRYPTION_KEY_PREVIOUS: KEY_A,
});
expect(openWithRing(rotated, stored)).toEqual({
plaintext: 'secret',
keyId: ring.current.id,
});
});
it('tells whether a value is sealed with the current key', () => {
const rotated = loadKeyRing('mfa', {
MFA_ENCRYPTION_KEY: KEY_B,
MFA_ENCRYPTION_KEY_PREVIOUS: KEY_A,
});
expect(isSealedWithCurrentKey(rotated, sealWithRing(ring, 's'))).toBe(
false,
);
expect(isSealedWithCurrentKey(rotated, sealWithRing(rotated, 's'))).toBe(
true,
);
expect(isSealedWithCurrentKey(rotated, LEGACY_CIPHERTEXT)).toBe(false);
});
it('refuses an unknown key id', () => {
expect(() => openWithRing(ring, 'k00000000:a.b.c')).toThrow(
'unknown key id',
);
});
it('refuses a value sealed for another purpose', () => {
const webhookRing = loadKeyRing('webhook', {
WEBHOOK_ENCRYPTION_KEY: KEY_A,
});
expect(() =>
openWithRing(webhookRing, sealWithRing(ring, 'secret')),
).toThrow('unknown key id');
});
it('fails on a tampered ciphertext (GCM)', () => {
const stored = sealWithRing(ring, 'secret');
for (const part of [0, 1, 2] as const) {
expect(() =>
openWithRing(ring, tamper(stored, part, flipFirstByte)),
).toThrow('ciphertext failed authentication');
}
});
it('refuses a truncated auth tag', () => {
const stored = sealWithRing(ring, 'secret');
const truncated = tamper(stored, 1, (tag) => tag.subarray(0, 4));
expect(() => openWithRing(ring, truncated)).toThrow(
'malformed ciphertext',
);
});
it('fails with the right key id but a different key', () => {
const stored = sealWithRing(ring, 'secret');
const forged = {
current: { id: ring.current.id, key: Buffer.alloc(32, 1) },
previous: null,
};
expect(() => openWithRing(forged, stored)).toThrow(
'ciphertext failed authentication',
);
});
it('refuses values without key id or with a malformed body', () => {
expect(() => openWithRing(ring, LEGACY_CIPHERTEXT)).toThrow(
'missing key id',
);
expect(() => openWithRing(ring, `${ring.current.id}:abc`)).toThrow(
'malformed ciphertext',
);
});
it('never puts key or plaintext into its errors', () => {
const stored = sealWithRing(ring, 'plaintext-value');
const tampered = tamper(stored, 2, flipFirstByte);
let message = '';
try {
openWithRing(ring, tampered);
} catch (error) {
message = (error as Error).message;
}
expect(message).not.toBe('');
expect(message).not.toContain('plaintext-value');
expect(message).not.toContain(KEY_A);
expect(message).not.toContain(ring.current.key.toString('base64'));
});
});
describe('development fallback', () => {
it('warns once per purpose that a development key stands in, without values', () => {
jest.isolateModules(() => {
// A fresh module copy, so earlier tests cannot have used up the warning.
const nest =
jest.requireActual<typeof import('@nestjs/common')>('@nestjs/common');
const fresh =
jest.requireActual<typeof import('./key-ring')>('./key-ring');
const warn = jest
.spyOn(nest.Logger.prototype, 'warn')
.mockImplementation(() => undefined);
const env = { JWT_ACCESS_SECRET: JWT_SECRET };
fresh.loadKeyRing('mfa', env);
fresh.loadKeyRing('mfa', env);
fresh.loadKeyRing('webhook', env);
fresh.loadKeyRing('webhook', { ...env, WEBHOOK_ENCRYPTION_KEY: KEY_A });
expect(warn).toHaveBeenCalledTimes(2);
expect(warn).toHaveBeenNthCalledWith(
1,
'MFA_ENCRYPTION_KEY not set – using development key derived from JWT_ACCESS_SECRET; never in production',
);
expect(warn).toHaveBeenNthCalledWith(
2,
'WEBHOOK_ENCRYPTION_KEY not set – using development key derived from JWT_ACCESS_SECRET; never in production',
);
});
});
it('stays silent when the key is configured', () => {
jest.isolateModules(() => {
const nest =
jest.requireActual<typeof import('@nestjs/common')>('@nestjs/common');
const fresh =
jest.requireActual<typeof import('./key-ring')>('./key-ring');
const warn = jest
.spyOn(nest.Logger.prototype, 'warn')
.mockImplementation(() => undefined);
fresh.loadKeyRing('mfa', { MFA_ENCRYPTION_KEY: KEY_A });
expect(warn).not.toHaveBeenCalled();
});
});
});
describe('legacy ciphertexts', () => {
const legacyEnv = { JWT_ACCESS_SECRET: LEGACY_JWT_SECRET };
it('takes sha256(JWT_ACCESS_SECRET) as the legacy key', () => {
expect(legacyJwtKey(legacyEnv)).toEqual(
createHash('sha256').update(LEGACY_JWT_SECRET).digest(),
);
});
it('still opens a value sealed before the key ring', () => {
expect(openLegacyCiphertext('mfa', LEGACY_CIPHERTEXT, legacyEnv)).toBe(
LEGACY_PLAINTEXT,
);
});
it('fails closed once JWT_ACCESS_SECRET changed', () => {
expect(() =>
openLegacyCiphertext('mfa', LEGACY_CIPHERTEXT, {
JWT_ACCESS_SECRET: JWT_SECRET,
}),
).toThrow('ciphertext failed authentication');
});
it('warns once per process and purpose, without values', () => {
jest.isolateModules(() => {
// A fresh module copy, so earlier tests cannot have used up the warning.
const nest =
jest.requireActual<typeof import('@nestjs/common')>('@nestjs/common');
const fresh =
jest.requireActual<typeof import('./key-ring')>('./key-ring');
const warn = jest
.spyOn(nest.Logger.prototype, 'warn')
.mockImplementation(() => undefined);
fresh.openLegacyCiphertext('mfa', LEGACY_CIPHERTEXT, legacyEnv);
fresh.openLegacyCiphertext('mfa', LEGACY_CIPHERTEXT, legacyEnv);
fresh.warnLegacyRead('webhook');
fresh.warnLegacyRead('webhook');
expect(warn).toHaveBeenCalledTimes(2);
expect(warn).toHaveBeenNthCalledWith(
1,
'legacy ciphertext read – run the rekey job (mfa)',
);
expect(warn).toHaveBeenNthCalledWith(
2,
'legacy ciphertext read – run the rekey job (webhook)',
);
});
});
});
});
@@ -0,0 +1,286 @@
import { Logger } from '@nestjs/common';
import {
createCipheriv,
createDecipheriv,
createHash,
hkdfSync,
randomBytes,
} from 'node:crypto';
/**
* Secrets the server must read back (TOTP secrets, webhook signing secrets,
* 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
* its key: `k<8 hex>:<iv>.<tag>.<data>` (base64 parts, AES-256-GCM).
*
* The AES key comes from HKDF-SHA256 with a per-purpose label, so even the
* same secret never yields the same key for two purposes. Outside
* production a missing variable falls back to JWT_ACCESS_SECRET (still per
* purpose, logged once); production refuses to start without the variables
* (runtime/startup-checks.ts).
*
* Values from before the key ring carry no key id and were encrypted with
* sha256(JWT_ACCESS_SECRET). They stay readable (never written) until the
* rekey job reseals them - and only as long as JWT_ACCESS_SECRET is unchanged.
*/
export const MIN_ENCRYPTION_KEY_LENGTH = 32;
export const KEY_ID_PATTERN = /^k[0-9a-f]{8}:/;
export const PREVIOUS_KEY_SUFFIX = '_PREVIOUS';
export const ENCRYPTION_KEY_VARIABLES = {
mfa: 'MFA_ENCRYPTION_KEY',
webhook: 'WEBHOOK_ENCRYPTION_KEY',
backup: 'BACKUP_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 interface RingKey {
id: string;
key: Buffer;
}
export interface KeyRing {
current: RingKey;
previous: RingKey | null;
}
/** The configured secrets behind one ring, before key derivation. */
export interface RingSecrets {
current: string | null;
previous: string | null;
}
/** AES-256-GCM output, in parts, so each format chooses its own encoding. */
export interface GcmSealed {
iv: Buffer;
tag: Buffer;
ciphertext: Buffer;
}
const ALGORITHM = 'aes-256-gcm';
const IV_BYTES = 12;
const AUTH_TAG_BYTES = 16;
const KEY_BYTES = 32;
const HKDF_SALT = 'apicore-key-ring';
const LEGACY_READ_WARNING = 'legacy ciphertext read – run the rekey job';
const DEV_FALLBACK_WARNING =
'not set – using development key derived from JWT_ACCESS_SECRET; never in production';
const logger = new Logger('KeyRing');
const issuedWarnings = new Set<string>();
/** Each warning once per process: they describe configuration, not events. */
function warnOnce(message: string): void {
if (issuedWarnings.has(message)) {
return;
}
issuedWarnings.add(message);
logger.warn(message);
}
function readKey(name: string, env: NodeJS.ProcessEnv): string | null {
const value = env[name];
if (!value) {
return null;
}
if (value.length < MIN_ENCRYPTION_KEY_LENGTH) {
throw new Error(
`${name} must be at least ${MIN_ENCRYPTION_KEY_LENGTH} characters - generate one with e.g. "openssl rand -hex 32"`,
);
}
return value;
}
/**
* `<variable>` and `<variable>_PREVIOUS`, each at least 32 characters when
* set. The current one is mandatory in production. Errors name the
* variable, never its value.
*/
export function readRingSecrets(
variable: EncryptionKeyVariable,
env: NodeJS.ProcessEnv = process.env,
): RingSecrets {
const current = readKey(variable, env);
const previous = readKey(`${variable}${PREVIOUS_KEY_SUFFIX}`, env);
if (!current && env.NODE_ENV === 'production') {
throw new Error(`${variable} is not set`);
}
return { current, previous };
}
export function requireJwtAccessSecret(
env: NodeJS.ProcessEnv = process.env,
): string {
const secret = env.JWT_ACCESS_SECRET;
if (!secret) {
throw new Error('JWT_ACCESS_SECRET is not set');
}
return secret;
}
/**
* The stand-in secret for an unset `variable` outside production (in
* production readRingSecrets() has already refused). Logged once per
* variable, without values.
*/
export function devFallbackSecret(
variable: EncryptionKeyVariable,
env: NodeJS.ProcessEnv = process.env,
): string {
const secret = requireJwtAccessSecret(env);
warnOnce(`${variable} ${DEV_FALLBACK_WARNING}`);
return secret;
}
function deriveRingKey(purpose: SecretPurpose, secret: string): RingKey {
const key = Buffer.from(
hkdfSync('sha256', secret, HKDF_SALT, `apicore-${purpose}-v1`, KEY_BYTES),
);
const id = `k${createHash('sha256').update(key).digest('hex').slice(0, 8)}`;
return { id, key };
}
/** The ring of one purpose; outside production a missing key falls back to JWT_ACCESS_SECRET. */
export function loadKeyRing(
purpose: SecretPurpose,
env: NodeJS.ProcessEnv = process.env,
): KeyRing {
const variable = ENCRYPTION_KEY_VARIABLES[purpose];
const secrets = readRingSecrets(variable, env);
return {
current: deriveRingKey(
purpose,
secrets.current ?? devFallbackSecret(variable, env),
),
previous: secrets.previous
? deriveRingKey(purpose, secrets.previous)
: null,
};
}
export function findRingKey(ring: KeyRing, keyId: string): RingKey | null {
if (ring.current.id === keyId) {
return ring.current;
}
return ring.previous?.id === keyId ? ring.previous : null;
}
export function gcmSeal(
key: Buffer,
plaintext: Buffer,
associatedData?: Buffer,
): GcmSealed {
const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv(ALGORITHM, key, iv, {
authTagLength: AUTH_TAG_BYTES,
});
if (associatedData) {
cipher.setAAD(associatedData);
}
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
return { iv, tag: cipher.getAuthTag(), ciphertext };
}
/**
* Fails closed: a short tag (which GCM would otherwise accept), a wrong key
* or any changed byte throws, and the error carries no input.
*/
export function gcmOpen(
key: Buffer,
sealed: GcmSealed,
associatedData?: Buffer,
): Buffer {
if (sealed.iv.length !== IV_BYTES || sealed.tag.length !== AUTH_TAG_BYTES) {
throw new Error('malformed ciphertext');
}
try {
const decipher = createDecipheriv(ALGORITHM, key, sealed.iv, {
authTagLength: AUTH_TAG_BYTES,
});
if (associatedData) {
decipher.setAAD(associatedData);
}
decipher.setAuthTag(sealed.tag);
return Buffer.concat([
decipher.update(sealed.ciphertext),
decipher.final(),
]);
} catch {
throw new Error('ciphertext failed authentication');
}
}
function sealPayload(key: Buffer, plaintext: string): string {
const sealed = gcmSeal(key, Buffer.from(plaintext, 'utf8'));
return [sealed.iv, sealed.tag, sealed.ciphertext]
.map((part) => part.toString('base64'))
.join('.');
}
function openPayload(key: Buffer, payload: string): string {
const parts = payload.split('.');
if (parts.length !== 3) {
throw new Error('malformed ciphertext');
}
const [iv, tag, ciphertext] = parts.map((part) =>
Buffer.from(part, 'base64'),
);
return gcmOpen(key, { iv, tag, ciphertext }).toString('utf8');
}
/** `${id}:${iv}.${tag}.${data}`, always with the current key. */
export function sealWithRing(ring: KeyRing, plaintext: string): string {
return `${ring.current.id}:${sealPayload(ring.current.key, plaintext)}`;
}
export function openWithRing(
ring: KeyRing,
stored: string,
): { plaintext: string; keyId: string } {
if (!KEY_ID_PATTERN.test(stored)) {
throw new Error('missing key id');
}
const separator = stored.indexOf(':');
const keyId = stored.slice(0, separator);
const ringKey = findRingKey(ring, keyId);
if (!ringKey) {
throw new Error('unknown key id');
}
return {
plaintext: openPayload(ringKey.key, stored.slice(separator + 1)),
keyId,
};
}
/** False for anything the rekey job should reseal: previous key, legacy or plain values. */
export function isSealedWithCurrentKey(ring: KeyRing, stored: string): boolean {
return stored.startsWith(`${ring.current.id}:`);
}
/** sha256(JWT_ACCESS_SECRET), the key of values from before the key ring - only ever used to read. */
export function legacyJwtKey(env: NodeJS.ProcessEnv = process.env): Buffer {
return createHash('sha256').update(requireJwtAccessSecret(env)).digest();
}
/** Logs once per process and purpose that a pre-key-ring value was read, without the value. */
export function warnLegacyRead(purpose: SecretPurpose): void {
warnOnce(`${LEGACY_READ_WARNING} (${purpose})`);
}
/** Opens a pre-key-ring `<iv>.<tag>.<data>` value sealed with legacyJwtKey(). */
export function openLegacyCiphertext(
purpose: SecretPurpose,
payload: string,
env: NodeJS.ProcessEnv = process.env,
): string {
const plaintext = openPayload(legacyJwtKey(env), payload);
warnLegacyRead(purpose);
return plaintext;
}
@@ -1,62 +1,75 @@
import { Logger } from '@nestjs/common';
import { Prisma } from '../../../generated/prisma';
import { readFileSync } from 'node:fs';
import * as path from 'node:path';
import { PrismaService } from '../../prisma/prisma.service';
import { IdempotencyCleanupService } from './idempotency-cleanup.service';
import {
CONSUMED_TOKEN_GRACE_HOURS,
IDEMPOTENCY_RETENTION_DAYS,
IdempotencyCleanupService,
} from './idempotency-cleanup.service';
type RawCall = [readonly string[], ...unknown[]];
describe('IdempotencyCleanupService', () => {
let executeRaw: jest.Mock<Promise<number>, RawCall>;
let service: IdempotencyCleanupService;
let prisma: {
idempotencyRecord: { deleteMany: jest.Mock };
consumedToken: { deleteMany: jest.Mock };
};
const statements = (): { sql: string; values: unknown[] }[] =>
executeRaw.mock.calls.map(([strings, ...values]) => ({
sql: strings.join('?').replace(/\s+/g, ' '),
values,
}));
beforeEach(() => {
prisma = {
idempotencyRecord: {
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
},
consumedToken: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }) },
};
service = new IdempotencyCleanupService(prisma as unknown as PrismaService);
});
afterEach(() => {
jest.restoreAllMocks();
executeRaw = jest
.fn<Promise<number>, RawCall>()
.mockResolvedValueOnce(3)
.mockResolvedValueOnce(4);
service = new IdempotencyCleanupService({
$executeRaw: executeRaw,
} as unknown as PrismaService);
});
it('removes records older than the retention period and expired tokens', async () => {
prisma.idempotencyRecord.deleteMany.mockResolvedValue({ count: 3 });
const now = new Date('2026-10-12T04:00:00.000Z');
await expect(service.removeExpired(now)).resolves.toBe(3);
expect(prisma.idempotencyRecord.deleteMany).toHaveBeenCalledWith({
where: { createdAt: { lt: new Date('2026-07-14T04:00:00.000Z') } },
});
expect(prisma.consumedToken.deleteMany).toHaveBeenCalledWith({
where: { expiresAt: { lt: now } },
await expect(service.removeExpired()).resolves.toEqual({
idempotencyRecords: 3,
consumedTokens: 4,
});
const [records, tokens] = statements();
expect(records.sql).toContain('DELETE FROM "IdempotencyRecord"');
expect(records.sql).toContain(
'"createdAt" < now() - (? * interval \'1 day\')',
);
expect(records.values).toEqual([IDEMPOTENCY_RETENTION_DAYS]);
expect(tokens.sql).toContain('DELETE FROM "ConsumedToken"');
expect(tokens.sql).toContain(
'"expiresAt" < now() - (? * interval \'1 hour\')',
);
expect(tokens.values).toEqual([CONSUMED_TOKEN_GRACE_HOURS]);
});
// The cron tick's own errors would otherwise reach the scheduler's logger
// with the full Prisma message and stack.
it('the daily tick never throws and logs a failure without the Prisma message', async () => {
const errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
prisma.idempotencyRecord.deleteMany.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError('key "Max Mustermann"', {
code: 'P2024',
clientVersion: '7.9.1',
meta: { modelName: 'IdempotencyRecord' },
}),
// The job pod's clock is not the clock the api pods check token expiry
// with; the database clock plus a margin keeps a used token's row until
// no api pod can accept the token any more.
it('compares only against the database clock, with a margin for tokens', async () => {
await service.removeExpired();
expect(CONSUMED_TOKEN_GRACE_HOURS).toBe(1);
for (const { sql, values } of statements()) {
expect(sql).toContain('now()');
expect(values.some((value) => value instanceof Date)).toBe(false);
}
});
// The cleanup CronJob (dist/cli.js cleanup) runs it once a day; an
// in-process timer would run it again in every api pod.
it('has no in-process schedule of its own', () => {
const source = readFileSync(
path.join(__dirname, 'idempotency-cleanup.service.ts'),
'utf8',
);
await expect(service.scheduledCleanup()).resolves.toBeUndefined();
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
expect(message).toContain('P2024');
expect(message).not.toContain('Mustermann');
expect(stack).toBeUndefined();
expect(source).not.toMatch(/@nestjs\/schedule|@Cron\(/);
expect('scheduledCleanup' in service).toBe(false);
});
});
@@ -1,46 +1,45 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '../../prisma/prisma.service';
import { loggableError } from '../loggable-error';
// Longer than any realistic offline stretch at the apiary: an entry queued
// on the phone must still find its key when it finally syncs, otherwise the
// repeat would create a duplicate after all.
export const IDEMPOTENCY_RETENTION_DAYS = 90;
// The api pods check token expiry against their own clock. A used token's
// row stays this long past its expiry (database clock), so a pod whose
// clock lags behind can never accept the token again.
export const CONSUMED_TOKEN_GRACE_HOURS = 1;
export interface ExpiredRemovalCounts {
idempotencyRecords: number;
consumedTokens: number;
}
/**
* Removes expired idempotency records and used one-time tokens. Runs once a
* day as part of the cleanup job (dist/cli.js cleanup, CronJob
* apicore-job-cleanup), not as an in-process timer in every pod. Ages are
* compared against now(), the database clock, like every cleanup step.
*/
@Injectable()
export class IdempotencyCleanupService {
private readonly logger = new Logger(IdempotencyCleanupService.name);
constructor(private readonly prisma: PrismaService) {}
// The daily tick. An error is logged here, Prisma errors by name/code/model
// only (see loggableError) - left uncaught, the scheduler would log the
// full Prisma message and stack.
@Cron(CronExpression.EVERY_DAY_AT_4AM)
async scheduledCleanup(): Promise<void> {
try {
await this.removeExpired();
} catch (error: unknown) {
const { message, stack } = loggableError(error);
this.logger.error(`Idempotency cleanup failed: ${message}`, stack);
}
}
async removeExpired(now: Date = new Date()): Promise<number> {
const cutoff = new Date(
now.getTime() - IDEMPOTENCY_RETENTION_DAYS * 24 * 60 * 60 * 1000,
);
const { count } = await this.prisma.idempotencyRecord.deleteMany({
where: { createdAt: { lt: cutoff } },
});
if (count > 0) {
this.logger.log(`Removed ${count} expired idempotency record(s)`);
async removeExpired(): Promise<ExpiredRemovalCounts> {
const idempotencyRecords = await this.prisma.$executeRaw`
DELETE FROM "IdempotencyRecord"
WHERE "createdAt" < now() - (${IDEMPOTENCY_RETENTION_DAYS} * interval '1 day')`;
if (idempotencyRecords > 0) {
this.logger.log(
`Removed ${idempotencyRecords} expired idempotency record(s)`,
);
}
// Used one-time tokens only matter until the token itself expires.
await this.prisma.consumedToken.deleteMany({
where: { expiresAt: { lt: now } },
});
return count;
const consumedTokens = await this.prisma.$executeRaw`
DELETE FROM "ConsumedToken"
WHERE "expiresAt" < now() - (${CONSUMED_TOKEN_GRACE_HOURS} * interval '1 hour')`;
return { idempotencyRecords, consumedTokens };
}
}
@@ -0,0 +1,26 @@
import type { NextFunction, Request, Response } from 'express';
import { noStoreMiddleware } from './no-store.middleware';
function run(path: string) {
const setHeader = jest.fn();
const next = jest.fn() as unknown as NextFunction;
noStoreMiddleware(
{ path } as Request,
{ setHeader } as unknown as Response,
next,
);
return { setHeader, next };
}
describe('noStoreMiddleware', () => {
it.each(['/api/v1/apiaries', '/api/v1/public/batches/WH-1'])(
'marks %s no-store',
(path) => {
const { setHeader, next } = run(path);
expect(setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store');
expect(setHeader).toHaveBeenCalledWith('Pragma', 'no-cache');
expect(next).toHaveBeenCalledTimes(1);
},
);
});
@@ -1,22 +1,17 @@
import type { NextFunction, Request, Response } from 'express';
// The public origin pages of honey lots are the only answers meant for
// everyone; they may be cached briefly.
const CACHEABLE_PREFIX = '/api/v1/public/';
/**
* Personal data, tokens and exports must not end up in a browser's
* back/forward cache or a shared proxy. Every API answer is no-store unless
* it is one of the public, anonymous ones.
* back/forward cache or a shared proxy. Every API answer starts out no-store;
* the one anonymous origin page overrides this on its 200 only (see
* PublicController).
*/
export function noStoreMiddleware(
req: Request,
_req: Request,
res: Response,
next: NextFunction,
): void {
if (!req.path.startsWith(CACHEABLE_PREFIX)) {
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Pragma', 'no-cache');
}
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Pragma', 'no-cache');
next();
}
@@ -0,0 +1,204 @@
import { Prisma } from '../../../generated/prisma';
import { DbClient } from '../../prisma/db-client';
import { PrismaService } from '../../prisma/prisma.service';
import { MAX_ATTEMPTS, retryDelayMs, STALE_CLAIM_MS } from '../retry-policy';
/**
* Claim, settle and stale release for every outbox table. One worker owns
* an entry from the claim (status PROCESSING, claimedAt = now()) until it
* settles it; settling is a compare-and-set on id + claimedAt, so a worker
* whose claim was released in the meantime changes nothing. Every time
* comparison and calculation runs in SQL against now() - the database
* clock - never against a JS Date.
*/
export type OutboxTable = 'DomainEvent' | 'WebhookDelivery';
export type OutboxOrderColumn = 'occurredAt' | 'createdAt';
export interface ClaimOptions {
limit: number;
/** Extra condition, ANDed to the claim (e.g. a correlation id filter). */
filter?: Prisma.Sql;
orderBy: OutboxOrderColumn;
}
interface OutboxTableSql {
table: Prisma.Sql;
statusType: Prisma.Sql;
settledAtColumn: Prisma.Sql;
successStatus: 'DONE' | 'DELIVERED';
}
/**
* Table, enum and column names cannot be bound as parameters. They come
* only from these closed maps - never from caller input - which is what
* makes Prisma.raw safe here.
*/
const OUTBOX_TABLES: Readonly<Record<OutboxTable, OutboxTableSql>> = {
DomainEvent: {
table: Prisma.raw('"DomainEvent"'),
statusType: Prisma.raw('"DomainEventStatus"'),
settledAtColumn: Prisma.raw('"processedAt"'),
successStatus: 'DONE',
},
WebhookDelivery: {
table: Prisma.raw('"WebhookDelivery"'),
statusType: Prisma.raw('"WebhookDeliveryStatus"'),
settledAtColumn: Prisma.raw('"deliveredAt"'),
successStatus: 'DELIVERED',
},
};
const ORDER_COLUMNS: Readonly<Record<OutboxOrderColumn, Prisma.Sql>> = {
occurredAt: Prisma.raw('"occurredAt"'),
createdAt: Prisma.raw('"createdAt"'),
};
const STALE_CLAIM_ERROR = 'claim expired (worker restarted?)';
function outboxTable(table: OutboxTable): OutboxTableSql {
if (!Object.hasOwn(OUTBOX_TABLES, table)) {
throw new Error(`Unknown outbox table "${String(table)}"`);
}
return OUTBOX_TABLES[table];
}
function orderColumn(column: OutboxOrderColumn): Prisma.Sql {
if (!Object.hasOwn(ORDER_COLUMNS, column)) {
throw new Error(`Unknown outbox order column "${String(column)}"`);
}
return ORDER_COLUMNS[column];
}
/**
* Claims up to `limit` due PENDING entries, oldest first. SKIP LOCKED lets
* several workers claim side by side without waiting for or overlapping
* each other. Returns the claimed rows (all columns) in claim order.
* Uses the root client: a claim must commit on its own.
*/
export async function claimBatch<Row>(
db: PrismaService,
table: OutboxTable,
options: ClaimOptions,
): Promise<Row[]> {
if (!Number.isInteger(options.limit) || options.limit < 1) {
throw new RangeError(
`Claim limit must be a positive integer, got ${options.limit}`,
);
}
const { table: name } = outboxTable(table);
const orderBy = orderColumn(options.orderBy);
const filter = options.filter
? Prisma.sql`AND ${options.filter}`
: Prisma.empty;
return db.$queryRaw<Row[]>(Prisma.sql`
WITH claimed AS (
UPDATE ${name} SET "status" = 'PROCESSING', "claimedAt" = now()
WHERE "id" IN (
SELECT "id" FROM ${name}
WHERE "status" = 'PENDING' AND "availableAt" <= now() ${filter}
ORDER BY ${orderBy}
LIMIT ${options.limit}
FOR UPDATE SKIP LOCKED
)
RETURNING *
)
SELECT * FROM claimed ORDER BY ${orderBy}`);
}
/** An entry a stale release put back (PENDING) or gave up on (FAILED). */
export interface ReleasedClaim {
id: string;
status: 'PENDING' | 'FAILED';
}
/**
* Puts entries whose claim is older than STALE_CLAIM_MS (their worker died)
* back to PENDING right away, with attempts + 1 - or to FAILED once that
* reaches MAX_ATTEMPTS, so an entry that kills its worker cannot loop
* forever. Returns the released entries, for follow-up work on FAILED ones.
*/
export async function releaseStaleClaimRows(
db: PrismaService,
table: OutboxTable,
): Promise<ReleasedClaim[]> {
const { table: name, statusType } = outboxTable(table);
return db.$queryRaw<ReleasedClaim[]>(Prisma.sql`
UPDATE ${name}
SET "status" = CAST(CASE WHEN "attempts" + 1 >= ${MAX_ATTEMPTS} THEN 'FAILED' ELSE 'PENDING' END AS ${statusType}),
"attempts" = "attempts" + 1,
"claimedAt" = NULL,
"availableAt" = now(),
"lastError" = ${STALE_CLAIM_ERROR}
WHERE "status" = 'PROCESSING'
AND "claimedAt" < now() - (${STALE_CLAIM_MS} * interval '1 millisecond')
RETURNING "id", "status"`);
}
/** releaseStaleClaimRows for callers that only need the count. */
export async function releaseStaleClaims(
db: PrismaService,
table: OutboxTable,
): Promise<number> {
return (await releaseStaleClaimRows(db, table)).length;
}
/**
* Settles a claimed entry as done. Returns false if the claim was lost
* (released as stale, maybe claimed again) - the current owner settles it.
* lastError stays as history of earlier failures.
*/
export async function settleSuccess(
db: DbClient,
table: OutboxTable,
id: string,
claimedAt: Date,
status: 'DONE' | 'DELIVERED',
): Promise<boolean> {
const sql = outboxTable(table);
if (status !== sql.successStatus) {
throw new Error(`${table} settles as ${sql.successStatus}, not ${status}`);
}
const count = await db.$executeRaw(Prisma.sql`
UPDATE ${sql.table}
SET "status" = CAST(${status} AS ${sql.statusType}),
"claimedAt" = NULL,
${sql.settledAtColumn} = now()
WHERE "id" = ${id} AND "status" = 'PROCESSING' AND "claimedAt" = ${claimedAt}`);
return count === 1;
}
/**
* Settles a claimed entry after a failed attempt: back to PENDING after
* retryDelayMs(attempts) by the database clock, or FAILED once attempts
* reaches MAX_ATTEMPTS. `lastError` must come from loggableError().
*
* The outcome also holds if the claim was lost: only a stale release
* changes a claimed row, and it applies the same attempts + 1 >=
* MAX_ATTEMPTS rule to the same attempt count.
*/
export async function settleFailure(
db: DbClient,
table: OutboxTable,
id: string,
claimedAt: Date,
attemptsBefore: number,
lastError: string,
): Promise<'retry' | 'failed'> {
const { table: name, statusType } = outboxTable(table);
const attempts = attemptsBefore + 1;
const outcome = attempts >= MAX_ATTEMPTS ? 'failed' : 'retry';
const nextAttempt =
outcome === 'retry'
? Prisma.sql`, "availableAt" = now() + (${retryDelayMs(attempts)} * interval '1 millisecond')`
: Prisma.empty;
await db.$executeRaw(Prisma.sql`
UPDATE ${name}
SET "status" = CAST(${outcome === 'retry' ? 'PENDING' : 'FAILED'} AS ${statusType}),
"attempts" = "attempts" + 1,
"claimedAt" = NULL,
"lastError" = ${lastError}
${nextAttempt}
WHERE "id" = ${id} AND "status" = 'PROCESSING' AND "claimedAt" = ${claimedAt}`);
return outcome;
}
@@ -0,0 +1,29 @@
import {
MAX_ATTEMPTS,
RETRY_DELAYS_MS,
retryDelayMs,
STALE_CLAIM_MS,
} from './retry-policy';
describe('retry policy', () => {
it('waits 30 s, 2 min, 10 min, 1 h and from the 5th failure on 6 h', () => {
expect([1, 2, 3, 4, 5, 6, 7].map(retryDelayMs)).toEqual([
30_000, 120_000, 600_000, 3_600_000, 21_600_000, 21_600_000, 21_600_000,
]);
});
it('gives up after 8 attempts and treats a claim older than 5 min as stale', () => {
expect(MAX_ATTEMPTS).toBe(8);
expect(STALE_CLAIM_MS).toBe(300_000);
expect(RETRY_DELAYS_MS).toEqual([
30_000, 120_000, 600_000, 3_600_000, 21_600_000,
]);
});
it.each([0, -1, 1.5, Number.NaN])(
'refuses %p failed attempts - a delay exists only after a failure',
(attempts) => {
expect(() => retryDelayMs(attempts)).toThrow(RangeError);
},
);
});
@@ -0,0 +1,23 @@
/**
* The one retry rule for every outbox (domain events, webhook deliveries):
* after the n-th failed attempt the entry waits RETRY_DELAYS_MS[n - 1],
* from the 5th failure on 6 h each; the MAX_ATTEMPTS-th failure ends in
* FAILED. A claim older than STALE_CLAIM_MS belongs to a worker that died.
* The delays are applied in SQL against now() (database clock), see
* outbox-claims.ts.
*/
export const RETRY_DELAYS_MS = [
30_000, 120_000, 600_000, 3_600_000, 21_600_000,
] as const;
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 {
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];
}
@@ -0,0 +1,108 @@
import { PrismaService } from '../../prisma/prisma.service';
import { PrismaThrottlerStorage } from './prisma-throttler.storage';
describe('PrismaThrottlerStorage', () => {
const queryRaw = jest.fn<
Promise<unknown>,
[readonly string[], ...unknown[]]
>();
const prisma = { $queryRaw: queryRaw } as unknown as PrismaService;
const row = {
totalHits: 3,
timeToExpire: 42,
isBlocked: false,
timeToBlockExpire: 0,
};
const sqlText = (): string => queryRaw.mock.calls[0][0].join('?');
const sqlValues = (): unknown[] => queryRaw.mock.calls[0].slice(1);
beforeEach(() => {
queryRaw.mockReset();
queryRaw.mockResolvedValue([row]);
});
it('uses only the database clock (no JS dates in the statement)', async () => {
await new PrismaThrottlerStorage(prisma, '').increment(
'k',
60000,
10,
60000,
'default',
);
expect(sqlValues().some((v) => v instanceof Date)).toBe(false);
expect(sqlText()).toContain('now()');
});
it('blocks on the very first hit when the limit is below 1', async () => {
await new PrismaThrottlerStorage(prisma, '').increment(
'k',
60000,
0,
60000,
'default',
);
expect(sqlText()).toMatch(/VALUES[\s\S]*?CASE\s+WHEN 1 > /);
});
it('runs exactly one statement as an upsert on the composite key', async () => {
await new PrismaThrottlerStorage(prisma, '').increment(
'k',
60000,
10,
60000,
'default',
);
expect(queryRaw).toHaveBeenCalledTimes(1);
expect(sqlText()).toContain('INSERT INTO "ThrottlerHit"');
expect(sqlText()).toContain('ON CONFLICT ("key", "throttlerName")');
expect(sqlText()).toContain('RETURNING');
});
it('prefixes the stored key with the namespace and passes the limits as parameters', async () => {
await new PrismaThrottlerStorage(prisma, 'ns-1').increment(
'abc',
60000,
10,
120000,
'default',
);
const values = sqlValues();
expect(values).toContain('ns-1:abc');
expect(values).toContain('default');
expect(values).toContain(60000);
expect(values).toContain(10);
expect(values).toContain(120000);
});
it('returns the record computed by the database', async () => {
const result = await new PrismaThrottlerStorage(prisma, '').increment(
'k',
60000,
10,
60000,
'default',
);
expect(result).toEqual(row);
});
it('propagates database errors so the request fails closed', async () => {
queryRaw.mockRejectedValue(new Error('db down'));
await expect(
new PrismaThrottlerStorage(prisma, '').increment(
'k',
60000,
10,
60000,
'default',
),
).rejects.toThrow('db down');
});
});
@@ -0,0 +1,92 @@
import { Inject, Injectable } from '@nestjs/common';
import { ThrottlerStorage } from '@nestjs/throttler';
import { PrismaService } from '../../prisma/prisma.service';
// The package does not export the record type from its root.
type ThrottlerStorageRecord = Awaited<
ReturnType<ThrottlerStorage['increment']>
>;
/**
* Key prefix that separates counter sets inside the shared table. Production
* uses '' (all pods share one set); tests give every app its own value.
*/
export const THROTTLER_NAMESPACE = Symbol('THROTTLER_NAMESPACE');
/**
* Rate-limit counters in the UNLOGGED table "ThrottlerHit", so the limits hold
* across all api pods. One upsert statement does the whole bookkeeping, and
* every time comparison uses the database clock (now()), never a JS date.
* A database error is not caught here: the guard fails and the request is
* rejected (fail closed).
*/
@Injectable()
export class PrismaThrottlerStorage implements ThrottlerStorage {
constructor(
private readonly prisma: PrismaService,
@Inject(THROTTLER_NAMESPACE) private readonly namespace: string,
) {}
async increment(
key: string,
ttl: number,
limit: number,
blockDuration: number,
throttlerName: string,
): Promise<ThrottlerStorageRecord> {
const storedKey = `${this.namespace}:${key}`;
// The columns are timestamp(3): a stored now() + ttl would be rounded UP by
// up to 1 ms and ceil() would then report ttl + 1 seconds. So the stored
// end times start from now() truncated to the millisecond (never later than
// the real clock); comparisons still use the exact now().
// ttl and blockDuration are milliseconds. In ON CONFLICT DO UPDATE every
// expression reads the OLD row: "active block" keeps the hits, an expired
// window or block starts again at 1, everything else counts up.
const rows = await this.prisma.$queryRaw<ThrottlerStorageRecord[]>`
INSERT INTO "ThrottlerHit" AS t ("key", "throttlerName", "hits", "expiresAt", "blockedUntil")
VALUES (
${storedKey}, ${throttlerName}, 1,
date_trunc('milliseconds', now()) + (${ttl}::double precision * interval '1 millisecond'),
CASE
WHEN 1 > ${limit}::int
THEN date_trunc('milliseconds', now()) + (${blockDuration}::double precision * interval '1 millisecond')
ELSE NULL
END
)
ON CONFLICT ("key", "throttlerName") DO UPDATE SET
"hits" = CASE
WHEN t."blockedUntil" > now() THEN t."hits"
WHEN t."expiresAt" <= now() OR t."blockedUntil" IS NOT NULL THEN 1
ELSE t."hits" + 1
END,
"expiresAt" = CASE
WHEN t."blockedUntil" > now() THEN t."expiresAt"
WHEN t."expiresAt" <= now() OR t."blockedUntil" IS NOT NULL
THEN date_trunc('milliseconds', now()) + (${ttl}::double precision * interval '1 millisecond')
ELSE t."expiresAt"
END,
"blockedUntil" = CASE
WHEN t."blockedUntil" > now() THEN t."blockedUntil"
WHEN t."expiresAt" <= now() OR t."blockedUntil" IS NOT NULL THEN
CASE
WHEN 1 > ${limit}::int
THEN date_trunc('milliseconds', now()) + (${blockDuration}::double precision * interval '1 millisecond')
ELSE NULL
END
WHEN t."hits" + 1 > ${limit}::int
THEN date_trunc('milliseconds', now()) + (${blockDuration}::double precision * interval '1 millisecond')
ELSE NULL
END
RETURNING
"hits"::int AS "totalHits",
GREATEST(ceil(EXTRACT(EPOCH FROM ("expiresAt" - now()))), 0)::int AS "timeToExpire",
COALESCE("blockedUntil" > now(), false) AS "isBlocked",
CASE
WHEN "blockedUntil" > now()
THEN GREATEST(ceil(EXTRACT(EPOCH FROM ("blockedUntil" - now()))), 0)::int
ELSE 0
END AS "timeToBlockExpire"
`;
return rows[0];
}
}
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import {
PrismaThrottlerStorage,
THROTTLER_NAMESPACE,
} from './prisma-throttler.storage';
@Module({
providers: [
// Production: one shared counter set for all pods.
{ provide: THROTTLER_NAMESPACE, useValue: '' },
PrismaThrottlerStorage,
],
exports: [PrismaThrottlerStorage],
})
export class ThrottlerStorageModule {}
@@ -22,6 +22,7 @@ import { UpdateQueenEvaluationDto } from '../../queens/dto/update-queen-evaluati
import { UpdateQueenDto } from '../../queens/dto/update-queen.dto';
import { UpdateRawMaterialLotDto } from '../../raw-materials/dto/update-raw-material-lot.dto';
import { UpdateRoleDto } from '../../roles/dto/update-role.dto';
import { UpdateSiteSettingsDto } from '../../site-settings/dto/update-site-settings.dto';
import { UpdateStockItemDto } from '../../storage/dto/update-stock-item.dto';
import { UpdateStorageContainerDto } from '../../storage/dto/update-storage-container.dto';
import { UpdateSwarmControlCheckDto } from '../../swarm-control/dto/update-swarm-control-check.dto';
@@ -53,6 +54,7 @@ const contracts: Record<string, NullContract> = {
'longitude',
'address',
'notes',
'publicLocationLabel',
],
},
UpdateAutomationRuleDto: {
@@ -152,6 +154,7 @@ const contracts: Record<string, NullContract> = {
'weather',
'temperature',
'colonyStrength',
'broodFrames',
'broodAmount',
'broodPattern',
'openBroodPresent',
@@ -243,6 +246,13 @@ const contracts: Record<string, NullContract> = {
notNull: ['key', 'name', 'permissionKeys'],
clearable: ['description'],
},
// The link fields clear with "" (see the DTO), not null; originCountry is
// NOT NULL with a default.
UpdateSiteSettingsDto: {
dto: UpdateSiteSettingsDto,
notNull: ['originCountry'],
clearable: [],
},
UpdateStockItemDto: {
dto: UpdateStockItemDto,
notNull: ['name', 'category', 'unit', 'quantity'],
@@ -0,0 +1,37 @@
import { withTimeout } from './with-timeout';
describe('withTimeout', () => {
afterEach(() => {
jest.useRealTimers();
});
it('resolves with the value when the promise settles in time', async () => {
await expect(withTimeout(Promise.resolve(42), 1000, 'late')).resolves.toBe(
42,
);
});
it('rejects with the original error when the promise rejects in time', async () => {
await expect(
withTimeout(Promise.reject(new Error('boom')), 1000, 'late'),
).rejects.toThrow('boom');
});
it('rejects with the given message when the promise takes too long', async () => {
jest.useFakeTimers();
const pending = withTimeout(
new Promise<never>(() => undefined),
500,
'too slow',
);
const assertion = expect(pending).rejects.toThrow('too slow');
await jest.advanceTimersByTimeAsync(500);
await assertion;
});
it('clears its timer once the promise settled', async () => {
jest.useFakeTimers();
await withTimeout(Promise.resolve(1), 500, 'late');
expect(jest.getTimerCount()).toBe(0);
});
});
@@ -0,0 +1,16 @@
/** Rejects with `message` if `promise` has not settled within `ms`. */
export async function withTimeout<T>(
promise: Promise<T>,
ms: number,
message: string,
): Promise<T> {
let timer: NodeJS.Timeout;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), ms);
});
try {
return await Promise.race([promise, timeoutPromise]);
} finally {
clearTimeout(timer!);
}
}
@@ -0,0 +1,24 @@
import { DbClient } from '../prisma/db-client';
/** LISTEN/NOTIFY channels the worker loops wait on, one per outbox. */
export const WAKE_CHANNELS = {
domainEvents: 'domain_event',
calendarSync: 'calendar_sync',
webhookDeliveries: 'webhook_delivery',
} as const;
export type WakeChannel = (typeof WAKE_CHANNELS)[keyof typeof WAKE_CHANNELS];
/**
* Wakes the worker loop behind `channel` before its next poll. The payload
* is always empty: the loop reads its work from the table, so nothing can
* leak through the channel or be slipped in as work. On a transaction
* client Postgres sends the notification only on commit - a rolled-back
* write wakes nobody. A lost notification only delays work until the poll.
*/
export async function notifyWorker(
db: DbClient,
channel: WakeChannel,
): Promise<void> {
await db.$executeRaw`SELECT pg_notify(${channel}, '')`;
}
@@ -24,6 +24,7 @@ import { isAllowedDocumentMimeType } from './document-mime-types';
import { CreateDocumentDto } from './dto/create-document.dto';
import { UpdateDocumentDto } from './dto/update-document.dto';
import { DocumentsService } from './documents.service';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
@@ -60,6 +61,7 @@ export class DocumentsController {
}
@RequirePermission('documents:upload')
@Idempotent()
@Post()
@ApiConsumes('multipart/form-data')
@UseInterceptors(
@@ -0,0 +1,10 @@
import { AsyncLocalStorage } from 'node:async_hooks';
/**
* Set by the dispatcher while a handler runs: events the handler records
* inherit the correlation id of the event being handled, so one business
* action and all its follow-up events can be traced together.
*/
export const domainEventContext = new AsyncLocalStorage<{
correlationId: string;
}>();
@@ -0,0 +1,589 @@
import { Logger } from '@nestjs/common';
import { Prisma } from '../../generated/prisma';
import { PrismaService } from '../prisma/prisma.service';
import { domainEventContext } from './domain-event-context';
import {
DOMAIN_EVENT_BATCH_SIZE,
DomainEventDispatcher,
DRAIN_MAX_ROUNDS,
HANDLER_TX_TIMEOUT_MS,
} from './domain-event-dispatcher';
import type {
DomainEventRegistry,
RegisteredDomainEventHandler,
} from './domain-event-registry';
interface ClaimedRow {
id: string;
type: string;
aggregateType: string;
aggregateId: string;
payload: unknown;
actorUserId: string | null;
correlationId: string;
occurredAt: Date;
attempts: number;
claimedAt: Date;
}
type DeliveryStatus = 'PENDING' | 'DONE' | 'FAILED';
interface UpsertArgs {
where: unknown;
create: { lastError: string };
update: { lastError: string };
}
const CLAIMED_AT = new Date('2026-10-13T08:00:01.234Z');
function row(overrides: Partial<ClaimedRow> = {}): ClaimedRow {
return {
id: 'e1',
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
payload: { dueOn: '2026-10-13' },
actorUserId: null,
correlationId: 'corr-1',
occurredAt: new Date('2026-10-13T08:00:00.000Z'),
attempts: 0,
claimedAt: CLAIMED_AT,
...overrides,
};
}
function handler(
id: string,
invoke: RegisteredDomainEventHandler['invoke'] = () => Promise.resolve(),
): RegisteredDomainEventHandler & { invoke: jest.Mock } {
return { id, invoke: jest.fn(invoke) };
}
function text(query: Prisma.Sql): string {
return query.sql.replace(/\s+/g, ' ').trim();
}
/**
* Stands in for Postgres: a queue of claim results, the delivery rows the
* lock query finds, and every statement the dispatcher sent.
*/
class FakeDb {
readonly claims: ClaimedRow[][] = [];
/** Rows the stale release returns, one list per call. */
readonly releases: { id: string; status: 'PENDING' | 'FAILED' }[][] = [];
readonly deliveries = new Map<string, DeliveryStatus>();
readonly tx = {
domainEventDelivery: {
createMany: jest.fn().mockResolvedValue({ count: 1 }),
},
$queryRaw: jest.fn((query: Prisma.Sql) => {
const handlerId = query.values[1] as string;
return Promise.resolve([
{
id: `d-${handlerId}`,
status: this.deliveries.get(handlerId) ?? 'PENDING',
},
]);
}),
$executeRaw: jest.fn().mockResolvedValue(1),
};
readonly prisma = {
$queryRaw: jest.fn((query: Prisma.Sql) =>
Promise.resolve(
(query.sql.includes('SKIP LOCKED')
? this.claims.shift()
: this.releases.shift()) ?? [],
),
),
$executeRaw: jest.fn().mockResolvedValue(1),
// Called as (fn, { timeout }); the options only matter to Prisma.
$transaction: jest.fn((fn: (tx: unknown) => Promise<unknown>) =>
fn(this.tx),
),
domainEventDelivery: {
upsert: jest.fn().mockResolvedValue({}),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
};
claimQuery(call = 0): Prisma.Sql {
return (this.prisma.$queryRaw.mock.calls as unknown as [Prisma.Sql][])[
call
][0];
}
/** Statements on the root client: settle and stale release. */
rootStatements(): Prisma.Sql[] {
return (this.prisma.$executeRaw.mock.calls as [Prisma.Sql][]).map(
([query]) => query,
);
}
/** Statements inside handler transactions after the lock: delivery DONE. */
deliveryDoneStatements(): Prisma.Sql[] {
return (this.tx.$executeRaw.mock.calls as [Prisma.Sql][]).map(
([query]) => query,
);
}
upsertArgs(call = 0): UpsertArgs {
return (
this.prisma.domainEventDelivery.upsert.mock.calls as [UpsertArgs][]
)[call][0];
}
}
describe('DomainEventDispatcher', () => {
let db: FakeDb;
let handlers: RegisteredDomainEventHandler[];
let dispatcher: DomainEventDispatcher;
let logWarn: jest.SpyInstance;
beforeEach(() => {
db = new FakeDb();
handlers = [];
const registry = {
handlersFor: jest.fn(() => handlers),
} as unknown as DomainEventRegistry;
dispatcher = new DomainEventDispatcher(
db.prisma as unknown as PrismaService,
registry,
);
logWarn = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
});
afterEach(() => jest.restoreAllMocks());
it('pins batch size, handler transaction timeout and drain rounds', () => {
expect(DOMAIN_EVENT_BATCH_SIZE).toBe(50);
expect(HANDLER_TX_TIMEOUT_MS).toBe(10_000);
expect(DRAIN_MAX_ROUNDS).toBe(20);
});
it('claims at most 50 pending events, oldest first, skipping locked rows, by the database clock', async () => {
await dispatcher.runOnce();
const claim = db.claimQuery();
expect(text(claim)).toContain(
`UPDATE "DomainEvent" SET "status" = 'PROCESSING', "claimedAt" = now()`,
);
expect(text(claim)).toContain(
`WHERE "status" = 'PENDING' AND "availableAt" <= now()`,
);
expect(text(claim)).toContain(
'ORDER BY "occurredAt" LIMIT ? FOR UPDATE SKIP LOCKED',
);
expect(claim.values).toEqual([50]);
});
it('restricts the claim to the given correlation ids', async () => {
await dispatcher.runOnce({ correlationIds: ['c1', 'c2'] });
const claim = db.claimQuery();
expect(text(claim)).toContain(
`"availableAt" <= now() AND "correlationId" = ANY(?) ORDER BY`,
);
expect(claim.values).toEqual([['c1', 'c2'], 50]);
});
it('marks an event DONE when every handler succeeded', async () => {
handlers = [handler('a.first'), handler('b.second')];
db.claims.push([row()]);
await expect(dispatcher.runOnce()).resolves.toBe(1);
expect(handlers[0].invoke).toHaveBeenCalledTimes(1);
expect(handlers[1].invoke).toHaveBeenCalledTimes(1);
expect(db.deliveryDoneStatements().map((query) => query.values)).toEqual([
['d-a.first'],
['d-b.second'],
]);
const [settle] = db.rootStatements();
expect(text(settle)).toContain('"processedAt" = now()');
expect(text(settle)).toContain(
`WHERE "id" = ? AND "status" = 'PROCESSING' AND "claimedAt" = ?`,
);
expect(settle.values).toEqual(['DONE', 'e1', CLAIMED_AT]);
});
it('marks an event without handlers DONE', async () => {
db.claims.push([row({ type: 'VarroaMeasured' })]);
await dispatcher.runOnce();
expect(db.prisma.$transaction).not.toHaveBeenCalled();
expect(db.rootStatements()[0].values).toEqual(['DONE', 'e1', CLAIMED_AT]);
});
it('marks the delivery DONE with attempts + 1 in the handler transaction, by the database clock', async () => {
handlers = [handler('a.first')];
db.claims.push([row()]);
await dispatcher.runOnce();
const [done] = db.deliveryDoneStatements();
expect(text(done)).toBe(
`UPDATE "DomainEventDelivery" SET "status" = 'DONE', "attempts" = "attempts" + 1, "completedAt" = now(), "updatedAt" = now() WHERE "id" = ?`,
);
});
it('retries only the failed handler and schedules the event 30 s ahead after the first failure', async () => {
const ok = handler('a.ok');
const flaky = handler('b.flaky');
flaky.invoke.mockRejectedValueOnce(new Error('boom'));
handlers = [ok, flaky];
db.claims.push([row({ attempts: 0 })]);
await dispatcher.runOnce();
expect(db.deliveryDoneStatements().map((query) => query.values)).toEqual([
['d-a.ok'],
]);
expect(db.prisma.domainEventDelivery.upsert).toHaveBeenCalledWith({
where: { eventId_handler: { eventId: 'e1', handler: 'b.flaky' } },
create: {
eventId: 'e1',
handler: 'b.flaky',
attempts: 1,
lastError: 'boom',
},
update: { attempts: { increment: 1 }, lastError: 'boom' },
});
const [settle] = db.rootStatements();
expect(text(settle)).toContain(
`"availableAt" = now() + (? * interval '1 millisecond')`,
);
expect(text(settle)).toContain('"attempts" = "attempts" + 1');
expect(settle.values).toEqual([
'PENDING',
'b.flaky: boom',
30_000,
'e1',
CLAIMED_AT,
]);
expect(db.prisma.domainEventDelivery.updateMany).not.toHaveBeenCalled();
// The next run: a.ok committed its delivery, so only b.flaky runs again.
db.deliveries.set('a.ok', 'DONE');
db.claims.push([row({ attempts: 1 })]);
await dispatcher.runOnce();
expect(ok.invoke).toHaveBeenCalledTimes(1);
expect(flaky.invoke).toHaveBeenCalledTimes(2);
expect(db.rootStatements()[1].values).toEqual(['DONE', 'e1', CLAIMED_AT]);
});
it('marks the event and its open deliveries FAILED after the 8th failed attempt', async () => {
handlers = [handler('a.broken', () => Promise.reject(new Error('boom')))];
db.claims.push([row({ attempts: 7 })]);
await dispatcher.runOnce();
const [settle] = db.rootStatements();
expect(settle.values).toEqual([
'FAILED',
'a.broken: boom',
'e1',
CLAIMED_AT,
]);
expect(text(settle)).not.toContain('interval');
expect(db.prisma.domainEventDelivery.updateMany).toHaveBeenCalledWith({
where: { eventId: { in: ['e1'] }, status: 'PENDING' },
data: { status: 'FAILED' },
});
});
it('keeps the first failure as lastError when several handlers fail', async () => {
handlers = [
handler('a.first', () => Promise.reject(new Error('one'))),
handler('b.second', () => Promise.reject(new Error('two'))),
];
db.claims.push([row()]);
await dispatcher.runOnce();
expect(db.prisma.domainEventDelivery.upsert).toHaveBeenCalledTimes(2);
expect(db.rootStatements()[0].values).toContain('a.first: one');
});
it('skips a handler whose delivery is already DONE', async () => {
handlers = [handler('a.done'), handler('b.open')];
db.deliveries.set('a.done', 'DONE');
db.claims.push([row()]);
await dispatcher.runOnce();
expect(handlers[0].invoke).not.toHaveBeenCalled();
expect(handlers[1].invoke).toHaveBeenCalledTimes(1);
expect(db.deliveryDoneStatements().map((query) => query.values)).toEqual([
['d-b.open'],
]);
expect(db.rootStatements()[0].values).toEqual(['DONE', 'e1', CLAIMED_AT]);
});
it('runs the handler again when its delivery FAILED before (manual retry)', async () => {
handlers = [handler('a.failed-before')];
db.deliveries.set('a.failed-before', 'FAILED');
db.claims.push([row()]);
await dispatcher.runOnce();
expect(handlers[0].invoke).toHaveBeenCalledTimes(1);
});
it('runs the handler inside the delivery transaction with the event correlation id', async () => {
const seen: unknown[] = [];
handlers = [
handler('a.first', (event, tx) => {
seen.push(event, tx, domainEventContext.getStore());
return Promise.resolve();
}),
];
db.claims.push([row({ correlationId: 'corr-42' })]);
await dispatcher.runOnce();
expect(db.prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), {
timeout: HANDLER_TX_TIMEOUT_MS,
});
expect(seen[1]).toBe(db.tx);
expect(seen[2]).toEqual({ correlationId: 'corr-42' });
expect(seen[0]).toEqual({
id: 'e1',
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
payload: { dueOn: '2026-10-13' },
actorUserId: null,
correlationId: 'corr-42',
occurredAt: new Date('2026-10-13T08:00:00.000Z'),
attempts: 0,
});
expect(domainEventContext.getStore()).toBeUndefined();
// Delivery row first (idempotent), then the row lock, then the handler.
expect(db.tx.domainEventDelivery.createMany).toHaveBeenCalledWith({
data: [{ eventId: 'e1', handler: 'a.first' }],
skipDuplicates: true,
});
const [lock] = db.tx.$queryRaw.mock.calls[0];
expect(text(lock)).toBe(
'SELECT "id", "status" FROM "DomainEventDelivery" WHERE "eventId" = ? AND "handler" = ? FOR UPDATE',
);
expect(lock.values).toEqual(['e1', 'a.first']);
expect(
db.tx.domainEventDelivery.createMany.mock.invocationCallOrder[0],
).toBeLessThan(db.tx.$queryRaw.mock.invocationCallOrder[0]);
expect(db.tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
(handlers[0].invoke as jest.Mock).mock.invocationCallOrder[0],
);
});
it('advances lastTickAt after every dispatched event, so a long batch never looks stale', async () => {
const start = new Date('2026-10-13T08:00:00.000Z');
jest.useFakeTimers({
now: start,
doNotFake: [
'hrtime',
'nextTick',
'performance',
'queueMicrotask',
'setImmediate',
'clearImmediate',
'setInterval',
'clearInterval',
'setTimeout',
'clearTimeout',
],
});
const seen: (Date | null)[] = [];
// Every event takes 100 s, e.g. waiting on locks.
handlers = [
handler('a.slow', () => {
seen.push(dispatcher.lastTickAt);
jest.setSystemTime(Date.now() + 100_000);
return Promise.resolve();
}),
];
db.claims.push([row({ id: 'e1' }), row({ id: 'e2' }), row({ id: 'e3' })]);
try {
await dispatcher.runOnce();
} finally {
jest.useRealTimers();
}
expect(seen.map((tick) => tick?.toISOString())).toEqual([
'2026-10-13T08:00:00.000Z',
'2026-10-13T08:01:40.000Z',
'2026-10-13T08:03:20.000Z',
]);
expect(dispatcher.lastTickAt?.toISOString()).toBe(
'2026-10-13T08:05:00.000Z',
);
});
it('updates lastTickAt even when nothing was claimed', async () => {
expect(dispatcher.lastTickAt).toBeNull();
await expect(dispatcher.runOnce()).resolves.toBe(0);
expect(dispatcher.lastTickAt).toBeInstanceOf(Date);
});
it('stores lastError without the Prisma message', async () => {
handlers = [
handler('a.db', () =>
Promise.reject(
new Prisma.PrismaClientKnownRequestError(
"Can't reach database server at secret-host:5432",
{ code: 'P1001', clientVersion: '7.9.1' },
),
),
),
];
db.claims.push([row()]);
await dispatcher.runOnce();
const upsert = db.upsertArgs();
expect(upsert.create.lastError).toBe('PrismaClientKnownRequestError P1001');
expect(upsert.update.lastError).toBe('PrismaClientKnownRequestError P1001');
const settleValues = db.rootStatements()[0].values;
expect(settleValues).toContain('a.db: PrismaClientKnownRequestError P1001');
expect(JSON.stringify(settleValues)).not.toContain('secret-host');
});
it('keeps dispatching the batch when one event cannot be settled', async () => {
db.claims.push([row({ id: 'e1' }), row({ id: 'e2' })]);
db.prisma.$executeRaw.mockRejectedValueOnce(new Error('connection lost'));
await expect(dispatcher.runOnce()).resolves.toBe(2);
expect(db.rootStatements().map((query) => query.values[1])).toEqual([
'e1',
'e2',
]);
});
it('only logs when the claim was lost before settling (stale release)', async () => {
db.claims.push([row()]);
db.prisma.$executeRaw.mockResolvedValueOnce(0);
await expect(dispatcher.runOnce()).resolves.toBe(1);
expect(logWarn).toHaveBeenCalledWith(expect.stringContaining('e1'));
});
describe('drain', () => {
it('repeats rounds until a round claims nothing and returns the total', async () => {
db.claims.push(
[row({ id: 'e1' }), row({ id: 'e2' })],
[row({ id: 'e3' })],
);
await expect(dispatcher.drain({ correlationIds: ['c1'] })).resolves.toBe(
3,
);
expect(db.prisma.$queryRaw).toHaveBeenCalledTimes(3);
expect(db.claimQuery(2).values).toEqual([['c1'], 50]);
});
it('stops after maxRounds even if work remains', async () => {
for (let i = 0; i < 5; i++) {
db.claims.push([row({ id: `e${i}` })]);
}
await expect(dispatcher.drain(undefined, 2)).resolves.toBe(2);
expect(db.prisma.$queryRaw).toHaveBeenCalledTimes(2);
});
it('makes at most 20 rounds by default', async () => {
for (let i = 0; i < 25; i++) {
db.claims.push([row({ id: `e${i}` })]);
}
await expect(dispatcher.drain()).resolves.toBe(DRAIN_MAX_ROUNDS);
});
// Worker shutdown: the claimed batch is finished (its events would
// otherwise sit PROCESSING until the stale-claim release), but no
// further batch is claimed.
it('finishes the claimed batch but claims no further one once the signal aborts', async () => {
const stop = new AbortController();
handlers = [
handler('a.stopper', () => {
stop.abort();
return Promise.resolve();
}),
];
db.claims.push(
[row({ id: 'e1' }), row({ id: 'e2' })],
[row({ id: 'e3' })],
);
await expect(
dispatcher.drain(undefined, DRAIN_MAX_ROUNDS, stop.signal),
).resolves.toBe(2);
expect(handlers[0].invoke).toHaveBeenCalledTimes(2);
expect(db.prisma.$queryRaw).toHaveBeenCalledTimes(1);
});
it('claims nothing once the signal has aborted', async () => {
const stop = new AbortController();
stop.abort();
db.claims.push([row()]);
await expect(
dispatcher.drain(undefined, DRAIN_MAX_ROUNDS, stop.signal),
).resolves.toBe(0);
expect(db.prisma.$queryRaw).not.toHaveBeenCalled();
});
});
it('releases stale claims with attempts + 1, FAILED from the 8th attempt on', async () => {
db.releases.push([
{ id: 'e1', status: 'PENDING' },
{ id: 'e2', status: 'PENDING' },
{ id: 'e3', status: 'PENDING' },
]);
await expect(dispatcher.releaseStaleClaims()).resolves.toBe(3);
const [release] = db.prisma.$queryRaw.mock.calls[0];
expect(text(release)).toBe(
`UPDATE "DomainEvent" SET "status" = CAST(CASE WHEN "attempts" + 1 >= ? THEN 'FAILED' ELSE 'PENDING' END AS "DomainEventStatus"), "attempts" = "attempts" + 1, "claimedAt" = NULL, "availableAt" = now(), "lastError" = ? WHERE "status" = 'PROCESSING' AND "claimedAt" < now() - (? * interval '1 millisecond') RETURNING "id", "status"`,
);
expect(release.values).toEqual([
8,
'claim expired (worker restarted?)',
300_000,
]);
expect(db.prisma.domainEventDelivery.updateMany).not.toHaveBeenCalled();
});
it('marks the open deliveries FAILED when a stale release ends an event in FAILED', async () => {
db.releases.push([
{ id: 'e1', status: 'FAILED' },
{ id: 'e2', status: 'PENDING' },
{ id: 'e3', status: 'FAILED' },
]);
await expect(dispatcher.releaseStaleClaims()).resolves.toBe(3);
expect(db.prisma.domainEventDelivery.updateMany).toHaveBeenCalledTimes(1);
expect(db.prisma.domainEventDelivery.updateMany).toHaveBeenCalledWith({
where: { eventId: { in: ['e1', 'e3'] }, status: 'PENDING' },
data: { status: 'FAILED' },
});
});
});
@@ -0,0 +1,269 @@
import { Injectable, Logger } from '@nestjs/common';
import { DomainEventDeliveryStatus, Prisma } from '../../generated/prisma';
import { loggableError } from '../common/loggable-error';
import {
claimBatch,
releaseStaleClaimRows,
settleFailure,
settleSuccess,
} from '../common/outbox/outbox-claims';
import { PrismaService } from '../prisma/prisma.service';
import { domainEventContext } from './domain-event-context';
import {
DomainEventRegistry,
RegisteredDomainEventHandler,
} from './domain-event-registry';
import { AnyDomainEventEnvelope, DomainEventType } from './domain-event-types';
export const DOMAIN_EVENT_BATCH_SIZE = 50;
export const HANDLER_TX_TIMEOUT_MS = 10_000;
export const DRAIN_MAX_ROUNDS = 20;
export interface DispatchFilter {
/** Only events of these correlations (e2e tests: no foreign events). */
correlationIds?: readonly string[];
}
/** A claimed DomainEvent row, as RETURNING * yields it. */
interface ClaimedDomainEvent {
id: string;
type: string;
aggregateType: string;
aggregateId: string;
payload: Prisma.JsonValue;
actorUserId: string | null;
correlationId: string;
occurredAt: Date;
attempts: number;
claimedAt: Date;
}
/**
* Delivers claimed domain events to their registered handlers, exactly once
* per (event, handler): each handler runs in its own transaction that first
* locks its DomainEventDelivery row and marks it DONE on commit. A handler
* whose delivery is DONE is skipped, so a retry, a second worker or a
* stale-released event never repeats an effect that was committed.
*/
@Injectable()
export class DomainEventDispatcher {
private readonly logger = new Logger(DomainEventDispatcher.name);
private tickAt: Date | null = null;
constructor(
private readonly prisma: PrismaService,
private readonly registry: DomainEventRegistry,
) {}
/**
* When the dispatcher last made progress - a claim (empty or not) or a
* dispatched event - for the worker health. Advancing per event keeps a
* long batch (lock waits, slow handlers) from looking stale.
*/
get lastTickAt(): Date | null {
return this.tickAt;
}
/** One batch: claims up to 50 events and returns how many it claimed. */
async runOnce(filter?: DispatchFilter): Promise<number> {
const events = await claimBatch<ClaimedDomainEvent>(
this.prisma,
'DomainEvent',
{
limit: DOMAIN_EVENT_BATCH_SIZE,
filter: claimFilter(filter),
orderBy: 'occurredAt',
},
);
this.tickAt = new Date();
for (const event of events) {
await this.dispatchSafely(event);
this.tickAt = new Date();
}
return events.length;
}
/**
* Runs batches until one claims nothing - follow-up events that handlers
* record are picked up in the next round - but at most `maxRounds`, so
* one tick never blocks the worker for long. Returns the claimed total.
* Once `signal` aborts (worker shutdown), no further batch is claimed;
* the batch already claimed is finished, since its events would
* otherwise wait for the stale-claim release.
*/
async drain(
filter?: DispatchFilter,
maxRounds = DRAIN_MAX_ROUNDS,
signal?: AbortSignal,
): Promise<number> {
let total = 0;
for (let round = 0; round < maxRounds && !signal?.aborted; round++) {
const claimed = await this.runOnce(filter);
if (claimed === 0) {
break;
}
total += claimed;
}
return total;
}
/**
* Releases stale claims; an event that thereby reaches FAILED gets its
* open deliveries FAILED as well, like a failed settle.
*/
async releaseStaleClaims(): Promise<number> {
const released = await releaseStaleClaimRows(this.prisma, 'DomainEvent');
const failed = released
.filter((claim) => claim.status === 'FAILED')
.map((claim) => claim.id);
if (failed.length > 0) {
await this.failOpenDeliveries(failed);
this.logger.error(
`Domain events FAILED, an expired claim used up their attempts: ${failed.join(', ')}`,
);
}
return released.length;
}
private async failOpenDeliveries(eventIds: readonly string[]): Promise<void> {
await this.prisma.domainEventDelivery.updateMany({
where: { eventId: { in: [...eventIds] }, status: 'PENDING' },
data: { status: 'FAILED' },
});
}
/** One event must not stop the batch; an unsettled event goes stale. */
private async dispatchSafely(event: ClaimedDomainEvent): Promise<void> {
try {
await this.dispatch(event);
} catch (error) {
this.logger.error(
`Domain event ${event.id} (${event.type}) not settled: ${loggableError(error).message}`,
);
}
}
private async dispatch(event: ClaimedDomainEvent): Promise<void> {
const envelope = toEnvelope(event);
const failures: string[] = [];
for (const handler of this.registry.handlersFor(
event.type as DomainEventType,
)) {
const failure = await this.deliver(envelope, handler);
if (failure !== null) {
failures.push(`${handler.id}: ${failure}`);
}
}
if (failures.length === 0) {
const settled = await settleSuccess(
this.prisma,
'DomainEvent',
event.id,
event.claimedAt,
'DONE',
);
if (!settled) {
this.logger.warn(
`Domain event ${event.id} (${event.type}): claim lost before settling, left to its current owner`,
);
}
return;
}
const outcome = await settleFailure(
this.prisma,
'DomainEvent',
event.id,
event.claimedAt,
event.attempts,
failures[0],
);
if (outcome === 'failed') {
await this.failOpenDeliveries([event.id]);
this.logger.error(
`Domain event ${event.id} (${event.type}) FAILED after ${event.attempts + 1} attempts: ${failures.join('; ')}`,
);
return;
}
this.logger.warn(
`Domain event ${event.id} (${event.type}) attempt ${event.attempts + 1} failed, retrying: ${failures.join('; ')}`,
);
}
/**
* Runs one handler in its delivery transaction. Returns null on success
* or skip, else the loggable error message (already recorded on the
* delivery row, outside the rolled-back transaction).
*/
private async deliver(
event: AnyDomainEventEnvelope,
handler: RegisteredDomainEventHandler,
): Promise<string | null> {
try {
await this.prisma.$transaction(
async (tx) => {
await tx.domainEventDelivery.createMany({
data: [{ eventId: event.id, handler: handler.id }],
skipDuplicates: true,
});
const [delivery] = await tx.$queryRaw<
{ id: string; status: DomainEventDeliveryStatus }[]
>(
Prisma.sql`SELECT "id", "status" FROM "DomainEventDelivery" WHERE "eventId" = ${event.id} AND "handler" = ${handler.id} FOR UPDATE`,
);
if (delivery.status === 'DONE') {
return;
}
await domainEventContext.run(
{ correlationId: event.correlationId },
() => handler.invoke(event, tx),
);
await tx.$executeRaw(
Prisma.sql`UPDATE "DomainEventDelivery" SET "status" = 'DONE', "attempts" = "attempts" + 1, "completedAt" = now(), "updatedAt" = now() WHERE "id" = ${delivery.id}`,
);
},
{ timeout: HANDLER_TX_TIMEOUT_MS },
);
return null;
} catch (error) {
const message = loggableError(error).message;
await this.prisma.domainEventDelivery.upsert({
where: { eventId_handler: { eventId: event.id, handler: handler.id } },
create: {
eventId: event.id,
handler: handler.id,
attempts: 1,
lastError: message,
},
update: { attempts: { increment: 1 }, lastError: message },
});
return message;
}
}
}
function claimFilter(filter?: DispatchFilter): Prisma.Sql | undefined {
if (filter?.correlationIds === undefined) {
return undefined;
}
return Prisma.sql`"correlationId" = ANY(${[...filter.correlationIds]})`;
}
/**
* The row as the handler sees it. The producer paired type and payload
* (DomainEventsService only accepts AnyNewDomainEvent), so the cast holds.
*/
function toEnvelope(event: ClaimedDomainEvent): AnyDomainEventEnvelope {
return {
id: event.id,
type: event.type,
aggregateType: event.aggregateType,
aggregateId: event.aggregateId,
payload: event.payload,
actorUserId: event.actorUserId,
correlationId: event.correlationId,
occurredAt: event.occurredAt,
attempts: event.attempts,
} as AnyDomainEventEnvelope;
}
@@ -0,0 +1,456 @@
import { Injectable, Provider, Scope } from '@nestjs/common';
import { DiscoveryService, MetadataScanner } from '@nestjs/core';
import { Test, TestingModule } from '@nestjs/testing';
import { Prisma } from '../../generated/prisma';
import { DomainEventRegistry } from './domain-event-registry';
import { DomainEventsModule } from './domain-events.module';
import type {
AnyDomainEventEnvelope,
DomainEventEnvelope,
DomainEventHandlerFn,
DomainEventType,
} from './domain-event-types';
import { HANDLER_ID_PATTERN, OnDomainEvent } from './on-domain-event.decorator';
interface FakeWrapper {
instance: unknown;
isAlias?: boolean;
isStatic?: boolean;
isTransient?: boolean;
}
function registryWith(...wrappers: FakeWrapper[]): DomainEventRegistry {
const discovery = {
getProviders: () =>
wrappers.map(
({
instance,
isAlias = false,
isStatic = true,
isTransient = false,
}) => ({
instance,
isAlias,
isTransient,
isDependencyTreeStatic: () => isStatic,
}),
),
};
return new DomainEventRegistry(
discovery as unknown as DiscoveryService,
new MetadataScanner(),
);
}
function registryFor(...instances: unknown[]): DomainEventRegistry {
return registryWith(...instances.map((instance) => ({ instance })));
}
function envelope<T extends DomainEventType>(
type: T,
payload: DomainEventEnvelope<T>['payload'],
): DomainEventEnvelope<T> {
return {
id: 'e1',
type,
aggregateType: 'Inspection',
aggregateId: 'i1',
payload,
actorUserId: null,
correlationId: 'corr-1',
occurredAt: new Date('2026-10-13T08:00:00Z'),
attempts: 0,
};
}
class CalendarHandlers {
readonly seen: string[] = [];
@OnDomainEvent(['InspectionRecorded', 'TreatmentRecorded'], 'calendar.sync')
sync(event: DomainEventEnvelope): Promise<void> {
this.seen.push(event.type);
return Promise.resolve();
}
notAHandler(): void {}
}
class AutomationHandlers {
@OnDomainEvent('InspectionRecorded', 'automation.queenless')
@OnDomainEvent('InspectionCorrected', 'automation.queenless-corrected')
evaluate(): Promise<void> {
return Promise.resolve();
}
}
describe('DomainEventRegistry', () => {
it('finds handlers by type, also when one method serves several types', async () => {
const calendar = new CalendarHandlers();
const registry = registryFor(calendar, new AutomationHandlers());
registry.onModuleInit();
expect(
registry.handlersFor('InspectionRecorded').map((handler) => handler.id),
).toEqual(['automation.queenless', 'calendar.sync']);
expect(
registry.handlersFor('TreatmentRecorded').map((handler) => handler.id),
).toEqual(['calendar.sync']);
expect(registry.handlersFor('VarroaMeasured')).toEqual([]);
const [handler] = registry.handlersFor('TreatmentRecorded');
await handler.invoke(
envelope('TreatmentRecorded', { colonyId: 'c1' }),
{} as Prisma.TransactionClient,
);
expect(calendar.seen).toEqual(['TreatmentRecorded']);
});
it('registers stacked decorators on one method separately', () => {
const registry = registryFor(new AutomationHandlers());
registry.onModuleInit();
expect(
registry.handlersFor('InspectionCorrected').map((handler) => handler.id),
).toEqual(['automation.queenless-corrected']);
expect(registry.handlerIds()).toEqual([
'automation.queenless',
'automation.queenless-corrected',
]);
});
it('passes event and transaction client to the handler bound to its provider', async () => {
const received: unknown[] = [];
class Recorder {
readonly name = 'recorder';
@OnDomainEvent('TaskDue', 'tasks.reminder')
handle(event: DomainEventEnvelope, tx: Prisma.TransactionClient) {
received.push(this.name, event.id, tx);
return Promise.resolve();
}
}
const registry = registryFor(new Recorder());
registry.onModuleInit();
const tx = { marker: true } as unknown as Prisma.TransactionClient;
await registry
.handlersFor('TaskDue')[0]
.invoke(envelope('TaskDue', { dueOn: '2026-10-13' }), tx);
expect(received).toEqual(['recorder', 'e1', tx]);
});
it('lists every handler id once', () => {
const registry = registryFor(
new CalendarHandlers(),
new AutomationHandlers(),
);
registry.onModuleInit();
expect(registry.handlerIds()).toEqual([
'automation.queenless',
'automation.queenless-corrected',
'calendar.sync',
]);
});
it('ignores providers without an instance or without handlers', () => {
const registry = registryFor(undefined, null, 'value', { plain: true });
registry.onModuleInit();
expect(registry.handlerIds()).toEqual([]);
});
it('refuses two handlers with the same id for one type', () => {
class OtherCalendarHandlers {
@OnDomainEvent('InspectionRecorded', 'calendar.sync')
again(): Promise<void> {
return Promise.resolve();
}
}
const registry = registryFor(
new CalendarHandlers(),
new OtherCalendarHandlers(),
);
expect(() => registry.onModuleInit()).toThrow(
'Duplicate domain event handler',
);
});
it('refuses the same provider registered twice', () => {
const registry = registryFor(
new CalendarHandlers(),
new CalendarHandlers(),
);
expect(() => registry.onModuleInit()).toThrow(
'Duplicate domain event handler',
);
});
it.each([
'calendar',
'Calendar.sync',
'calendar.sync.extra',
'calendar.',
'calendar sync',
'.sync',
])(
'rejects a handler id that is not "<consumer>.<name>": %p',
(handlerId) => {
class BadId {
@OnDomainEvent('TaskDue', handlerId)
handle(): Promise<void> {
return Promise.resolve();
}
}
const registry = registryFor(new BadId());
expect(HANDLER_ID_PATTERN.test(handlerId)).toBe(false);
expect(() => registry.onModuleInit()).toThrow(
'Invalid domain event handler id',
);
},
);
it('rejects an event type outside the catalog', () => {
class UnknownType {
@OnDomainEvent('ColonyCreated' as DomainEventType, 'colonies.welcome')
handle(): Promise<void> {
return Promise.resolve();
}
}
expect(() => registryFor(new UnknownType()).onModuleInit()).toThrow(
'Unknown domain event type',
);
});
it('rejects a handler without any event type', () => {
class NoType {
@OnDomainEvent([], 'colonies.nothing')
handle(): Promise<void> {
return Promise.resolve();
}
}
expect(() => registryFor(new NoType()).onModuleInit()).toThrow(
'serves no domain event type',
);
});
it('skips alias providers - the aliased provider is registered once', () => {
const calendar = new CalendarHandlers();
const registry = registryWith(
{ instance: calendar },
{ instance: calendar, isAlias: true },
);
registry.onModuleInit();
expect(
registry.handlersFor('InspectionRecorded').map((handler) => handler.id),
).toEqual(['calendar.sync']);
});
it('refuses a handler on a request-scoped provider (dependency tree not static)', () => {
const registry = registryWith({
instance: new CalendarHandlers(),
isStatic: false,
});
expect(() => registry.onModuleInit()).toThrow(
'must be on a static (singleton) provider',
);
});
it('refuses a handler on a transient provider even with a static dependency tree', () => {
// Nest reports isDependencyTreeStatic() === true here, but the
// discovered instance is an unconstructed Object.create(prototype).
const registry = registryWith({
instance: new CalendarHandlers(),
isStatic: true,
isTransient: true,
});
expect(() => registry.onModuleInit()).toThrow(
'must be on a static (singleton) provider',
);
});
it('ignores a transient provider without handlers', () => {
class TransientService {
run(): void {}
}
const registry = registryWith({
instance: new TransientService(),
isTransient: true,
});
expect(() => registry.onModuleInit()).not.toThrow();
});
it('ignores a non-static provider without handlers', () => {
class RequestScopedService {
run(): void {}
}
const registry = registryWith({
instance: new RequestScopedService(),
isStatic: false,
});
expect(() => registry.onModuleInit()).not.toThrow();
expect(registry.handlerIds()).toEqual([]);
});
describe('order', () => {
class ManyHandlers {
@OnDomainEvent('TaskDue', 'calendar1.a')
@OnDomainEvent('TaskDue', 'calendar.sync')
@OnDomainEvent('TaskDue', 'calendar-sync.a')
@OnDomainEvent('TaskDue', 'calendar.a')
handle(): Promise<void> {
return Promise.resolve();
}
}
const CODE_POINT_ORDER = [
'calendar-sync.a',
'calendar.a',
'calendar.sync',
'calendar1.a',
];
afterEach(() => jest.restoreAllMocks());
it('sorts by code point in handlersFor and handlerIds, whatever the locale collation says', () => {
// A collation that reverses everything: the order must not change.
jest
.spyOn(String.prototype, 'localeCompare')
.mockImplementation(function (this: string, other: string) {
if (this > other) return -1;
return this < other ? 1 : 0;
});
const registry = registryFor(new ManyHandlers());
registry.onModuleInit();
expect(
registry.handlersFor('TaskDue').map((handler) => handler.id),
).toEqual(CODE_POINT_ORDER);
expect(registry.handlerIds()).toEqual(CODE_POINT_ORDER);
});
});
});
describe('handler envelope typing', () => {
const harvest = envelope('HarvestRecorded', {
apiaryId: 'a1',
colonyIds: ['c1', 'c2'],
});
const inspection = envelope('InspectionRecorded', { colonyId: 'c3' });
it('ties every event type to its payload, so a multi-type handler narrows by type', async () => {
const seen: string[] = [];
// Compiles only because the envelope is a distributive union.
const handler: DomainEventHandlerFn<
'InspectionRecorded' | 'HarvestRecorded'
> = (event) => {
seen.push(
...(event.type === 'HarvestRecorded'
? event.payload.colonyIds
: [event.payload.colonyId]),
);
return Promise.resolve();
};
await handler(harvest, {} as Prisma.TransactionClient);
await handler(inspection, {} as Prisma.TransactionClient);
expect(seen).toEqual(['c1', 'c2', 'c3']);
});
it('refuses an envelope whose payload belongs to another type', () => {
const misfit = { ...harvest, type: 'TaskDue' } as const;
// @ts-expect-error - TaskDue carries { dueOn }, not a harvest payload
const wrong: AnyDomainEventEnvelope = misfit;
expect(wrong.type).toBe('TaskDue');
});
});
describe('DomainEventRegistry with Nest DI', () => {
@Injectable()
class Clock {
today(): string {
return '2026-10-13';
}
}
@Injectable()
class SingletonHandlers {
readonly seen: string[] = [];
constructor(private readonly clock: Clock) {}
@OnDomainEvent('TaskDue', 'di.singleton')
handle(): Promise<void> {
this.seen.push(this.clock.today());
return Promise.resolve();
}
}
@Injectable({ scope: Scope.TRANSIENT })
class TransientHandlers {
constructor(readonly clock: Clock) {}
@OnDomainEvent('TaskDue', 'di.transient')
handle(): Promise<void> {
return Promise.resolve();
}
}
@Injectable({ scope: Scope.REQUEST })
class RequestHandlers {
@OnDomainEvent('TaskDue', 'di.request')
handle(): Promise<void> {
return Promise.resolve();
}
}
async function compile(...providers: Provider[]): Promise<TestingModule> {
return Test.createTestingModule({
imports: [DomainEventsModule],
providers: [Clock, ...providers],
}).compile();
}
it('registers a singleton handler once, also behind an alias, bound to the DI instance', async () => {
const moduleRef = await compile(SingletonHandlers, {
provide: 'HANDLERS_ALIAS',
useExisting: SingletonHandlers,
});
await moduleRef.init();
const registry = moduleRef.get(DomainEventRegistry);
expect(
registry.handlersFor('TaskDue').map((handler) => handler.id),
).toEqual(['di.singleton']);
await registry
.handlersFor('TaskDue')[0]
.invoke(
envelope('TaskDue', { dueOn: '2026-10-13' }),
{} as Prisma.TransactionClient,
);
expect(moduleRef.get(SingletonHandlers).seen).toEqual(['2026-10-13']);
await moduleRef.close();
});
it.each([
['transient', TransientHandlers],
['request-scoped', RequestHandlers],
])('stops the boot for a handler on a %s provider', async (_scope, type) => {
const moduleRef = await compile(type);
// No close(): it re-throws the failed init, and nothing holds resources.
await expect(moduleRef.init()).rejects.toThrow(
'must be on a static (singleton) provider',
);
});
});
@@ -0,0 +1,174 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { DiscoveryService, MetadataScanner } from '@nestjs/core';
import {
DOMAIN_EVENT_TYPES,
DomainEventHandlerFn,
DomainEventType,
} from './domain-event-types';
import {
domainEventHandlerMetadata,
DomainEventHandlerMetadata,
HANDLER_ID_PATTERN,
} from './on-domain-event.decorator';
export interface RegisteredDomainEventHandler {
id: string;
invoke: DomainEventHandlerFn;
}
interface DiscoveredHandler extends RegisteredDomainEventHandler {
types: readonly DomainEventType[];
/** "<Class>.<method>" for boot error messages. */
source: string;
}
const NO_HANDLERS: readonly RegisteredDomainEventHandler[] = Object.freeze([]);
/**
* Collects every @OnDomainEvent method of every provider at boot. A wrong
* declaration - invalid id, unknown type, the same id twice for one type -
* stops the boot: a handler that silently never runs, or runs under
* another handler's delivery record, would lose or swallow effects.
*/
@Injectable()
export class DomainEventRegistry implements OnModuleInit {
private handlers = new Map<
DomainEventType,
readonly RegisteredDomainEventHandler[]
>();
constructor(
private readonly discovery: DiscoveryService,
private readonly scanner: MetadataScanner,
) {}
onModuleInit(): void {
this.handlers = groupByType(this.discover());
}
/** Sorted by handler id (compareIds), so handlers run in a stable order. */
handlersFor(type: DomainEventType): readonly RegisteredDomainEventHandler[] {
return this.handlers.get(type) ?? NO_HANDLERS;
}
handlerIds(): string[] {
const ids = new Set<string>();
for (const handlers of this.handlers.values()) {
handlers.forEach((handler) => ids.add(handler.id));
}
return [...ids].sort(compareIds);
}
/**
* Aliases are skipped - they point at a provider that is scanned anyway.
* A handler must sit on a static (singleton) provider: the worker calls
* it outside any request. For a request-scoped provider (dependency tree
* not static) and for a transient one (Nest reports its tree as static)
* the discovered instance is only an unconstructed Object.create of the
* prototype, without dependencies - the handler would fail at dispatch.
*/
private discover(): DiscoveredHandler[] {
return this.discovery.getProviders().flatMap((wrapper) => {
const instance: unknown = wrapper.instance;
if (wrapper.isAlias || !instance || typeof instance !== 'object') {
return [];
}
const handlers = this.handlersOf(instance);
const isSingleton =
!wrapper.isTransient && wrapper.isDependencyTreeStatic();
if (handlers.length > 0 && !isSingleton) {
const [first] = handlers;
throw new Error(
`Domain event handler "${first.id}" on ${first.source} must be on a static (singleton) provider: the worker calls handlers outside any request`,
);
}
return handlers;
});
}
private handlersOf(instance: object): DiscoveredHandler[] {
const prototype = Object.getPrototypeOf(instance) as object | null;
return this.scanner.getAllMethodNames(prototype).flatMap((methodName) => {
const method: unknown = (prototype as Record<string, unknown>)[
methodName
];
if (typeof method !== 'function') {
return [];
}
const source = `${instance.constructor.name}.${methodName}`;
return domainEventHandlerMetadata(method).map((entry) => {
assertValid(entry, source);
return {
id: entry.handlerId,
types: entry.types,
source,
invoke: (method as DomainEventHandlerFn).bind(
instance,
) as DomainEventHandlerFn,
};
});
});
}
}
function assertValid(entry: DomainEventHandlerMetadata, source: string): void {
if (!HANDLER_ID_PATTERN.test(entry.handlerId)) {
throw new Error(
`Invalid domain event handler id "${entry.handlerId}" on ${source}: expected "<consumer>.<name>" in lowercase letters, digits and hyphens`,
);
}
if (entry.types.length === 0) {
throw new Error(
`Domain event handler "${entry.handlerId}" on ${source} serves no domain event type`,
);
}
const unknown = entry.types.find(
(type) => !DOMAIN_EVENT_TYPES.includes(type),
);
if (unknown !== undefined) {
throw new Error(
`Unknown domain event type "${unknown}" for handler "${entry.handlerId}" on ${source}`,
);
}
}
function groupByType(
handlers: readonly DiscoveredHandler[],
): Map<DomainEventType, readonly RegisteredDomainEventHandler[]> {
const byType = new Map<DomainEventType, DiscoveredHandler[]>();
for (const handler of handlers) {
for (const type of handler.types) {
const existing = byType.get(type) ?? [];
const duplicate = existing.find((other) => other.id === handler.id);
if (duplicate) {
throw new Error(
`Duplicate domain event handler "${handler.id}" for ${type} (${duplicate.source} and ${handler.source})`,
);
}
byType.set(type, [...existing, handler]);
}
}
return new Map(
[...byType].map(([type, list]) => [
type,
Object.freeze(
[...list]
.sort((a, b) => compareIds(a.id, b.id))
.map(({ id, invoke }) => ({ id, invoke })),
),
]),
);
}
/**
* Ordinal comparison by UTF-16 code unit - for handler ids, which
* HANDLER_ID_PATTERN restricts to ASCII, that is code-point order. Unlike
* localeCompare it does not depend on the runtime's locale or ICU data, so
* every pod runs handlers in the same order.
*/
function compareIds(a: string, b: string): number {
if (a < b) {
return -1;
}
return a > b ? 1 : 0;
}
@@ -0,0 +1,190 @@
import {
ColonyCondition,
ColonyStatus,
Prisma,
RawMaterialKind,
TaskRelatedType,
TaskStatus,
} from '../../generated/prisma';
export interface ColonyStateSnapshot {
status: ColonyStatus;
healthStatus: ColonyCondition | null;
}
/**
* A record that a bulk entry (POST …/bulk) may have written: `bulkId` groups
* the records of one bulk entry, a single create leaves it out.
*/
export interface RecordedForColony {
colonyId: string;
bulkId?: string;
}
/**
* The complete event catalog. A payload carries only ids and the values a
* handler needs - never personal data or secrets; dates are 'YYYY-MM-DD'
* strings. Handlers read anything else from the database.
*/
export interface DomainEventPayloads {
InspectionRecorded: { colonyId: string };
InspectionCorrected: { colonyId: string };
VarroaMeasured: RecordedForColony;
VarroaCorrected: { colonyId: string };
TreatmentRecorded: RecordedForColony;
TreatmentCorrected: { colonyId: string };
FeedingRecorded: RecordedForColony;
FeedingCorrected: { colonyId: string };
HarvestRecorded: { apiaryId: string; colonyIds: string[] };
HarvestCorrected: { apiaryId: string; colonyIds: string[] };
ColonyStatusChanged: { from: ColonyStateSnapshot; to: ColonyStateSnapshot };
ColonyEnded: { status: ColonyStatus };
BatchBottled: { honeyBatchId: string };
BottlingCorrected: { honeyBatchId: string; removed: boolean };
StockChanged: {
bottlingBatchIds: string[];
productIds: string[];
rawMaterialKinds: RawMaterialKind[];
};
TaskCreated: {
relatedType: TaskRelatedType;
relatedId: string | null;
dueDate: string | null;
};
TaskUpdated: { status: TaskStatus; dueDate: string | null };
TaskCompleted: { relatedType: TaskRelatedType; relatedId: string | null };
WithdrawalEnded: { colonyId: string; endedOn: string };
TaskDue: { dueOn: string };
QueenAged: { hatchYear: number; colonyId: string | null };
VarroaMeasurementDue: { dueOn: string; lastMeasuredOn: string | null };
InspectionOverdue: { dueOn: string; lastInspectedOn: string | null };
}
export type DomainEventType = keyof DomainEventPayloads;
// A Record over the union: the compiler refuses a missing or unknown type.
const CATALOG: Record<DomainEventType, true> = {
InspectionRecorded: true,
InspectionCorrected: true,
VarroaMeasured: true,
VarroaCorrected: true,
TreatmentRecorded: true,
TreatmentCorrected: true,
FeedingRecorded: true,
FeedingCorrected: true,
HarvestRecorded: true,
HarvestCorrected: true,
ColonyStatusChanged: true,
ColonyEnded: true,
BatchBottled: true,
BottlingCorrected: true,
StockChanged: true,
TaskCreated: true,
TaskUpdated: true,
TaskCompleted: true,
WithdrawalEnded: true,
TaskDue: true,
QueenAged: true,
VarroaMeasurementDue: true,
InspectionOverdue: true,
};
export const DOMAIN_EVENT_TYPES: readonly DomainEventType[] = Object.freeze(
Object.keys(CATALOG) as DomainEventType[],
);
export type AggregateType =
| 'Inspection'
| 'VarroaMeasurement'
| 'Treatment'
| 'Feeding'
| 'Harvest'
| 'Colony'
| 'BottlingBatch'
| 'ProductionBatch'
| 'BatchMovement'
| 'ShopStockRequest'
| 'Task'
| 'Queen';
export interface NewDomainEvent<T extends DomainEventType = DomainEventType> {
type: T;
aggregateType: AggregateType;
aggregateId: string;
payload: DomainEventPayloads[T];
/** The acting user; null or absent for the system or a job. */
actorUserId?: string | null;
/** Time-based events only, see dailyDedupeKey. */
dedupeKey?: string;
}
/**
* One NewDomainEvent per type in T, so `type` and `payload` stay paired: a
* TaskDue with an InspectionRecorded payload does not compile.
* NewDomainEvent<A | B> alone would accept either payload with either type.
*/
export type AnyNewDomainEvent<T extends DomainEventType = DomainEventType> = {
[K in T]: NewDomainEvent<K>;
}[T];
/** What a handler receives: the stored event. */
export interface DomainEventEnvelope<
T extends DomainEventType = DomainEventType,
> {
id: string;
type: T;
aggregateType: AggregateType;
aggregateId: string;
payload: DomainEventPayloads[T];
actorUserId: string | null;
correlationId: string;
occurredAt: Date;
attempts: number;
}
/**
* One DomainEventEnvelope per type in T, paired like AnyNewDomainEvent: a
* handler for several types narrows the payload by checking `event.type`.
*/
export type AnyDomainEventEnvelope<
T extends DomainEventType = DomainEventType,
> = {
[K in T]: DomainEventEnvelope<K>;
}[T];
/**
* A handler writes only through `tx` (its delivery transaction) and makes
* no network calls; reading through `tx` or PrismaService is fine.
*/
export type DomainEventHandlerFn<T extends DomainEventType = DomainEventType> =
(
event: AnyDomainEventEnvelope<T>,
tx: Prisma.TransactionClient,
) => Promise<void>;
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
/**
* The UTC day of `date` as 'YYYY-MM-DD' - the form payloads carry dates in
* and the day dailyDedupeKey expects.
*/
export function utcDay(date: Date): string {
return date.toISOString().slice(0, 10);
}
/**
* Unique key of a time-based event: at most one per type, aggregate and
* (UTC) day of the business event, however often the job runs. A full
* timestamp instead of the day would create a new event on every run, so
* it is refused.
*/
export function dailyDedupeKey(
type: DomainEventType,
aggregateId: string,
day: string,
): string {
if (!DAY_PATTERN.test(day)) {
throw new Error('Daily dedupe key needs a YYYY-MM-DD day');
}
return `${type}:${aggregateId}:${day}`;
}
@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { DiscoveryModule } from '@nestjs/core';
import { PrismaModule } from '../prisma/prisma.module';
import { DomainEventDispatcher } from './domain-event-dispatcher';
import { DomainEventRegistry } from './domain-event-registry';
import { DomainEventsService } from './domain-events.service';
@Global()
@Module({
imports: [DiscoveryModule, PrismaModule],
providers: [DomainEventsService, DomainEventRegistry, DomainEventDispatcher],
exports: [DomainEventsService, DomainEventRegistry, DomainEventDispatcher],
})
export class DomainEventsModule {}
@@ -0,0 +1,281 @@
import { DomainEventsService } from './domain-events.service';
import { domainEventContext } from './domain-event-context';
import {
AnyNewDomainEvent,
DOMAIN_EVENT_TYPES,
dailyDedupeKey,
NewDomainEvent,
utcDay,
} from './domain-event-types';
type CreateManyArg = {
data: {
type: string;
aggregateId: string;
actorUserId: string | null;
correlationId: string;
dedupeKey: string | null;
payload: unknown;
}[];
skipDuplicates: boolean;
};
describe('DomainEventsService', () => {
let service: DomainEventsService;
let tx: {
domainEvent: { createMany: jest.Mock };
$executeRaw: jest.Mock;
};
const event: NewDomainEvent<'InspectionRecorded'> = {
type: 'InspectionRecorded',
aggregateType: 'Inspection',
aggregateId: 'i1',
payload: { colonyId: 'c1' },
};
const daily: NewDomainEvent<'TaskDue'> = {
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
payload: { dueOn: '2026-10-13' },
dedupeKey: dailyDedupeKey('TaskDue', 't1', '2026-10-13'),
};
function createManyArg(call = 0): CreateManyArg {
const calls = tx.domainEvent.createMany.mock.calls as [CreateManyArg][];
return calls[call][0];
}
beforeEach(() => {
tx = {
domainEvent: {
createMany: jest
.fn()
.mockImplementation(({ data }: CreateManyArg) =>
Promise.resolve({ count: data.length }),
),
},
$executeRaw: jest.fn().mockResolvedValue(1),
};
service = new DomainEventsService();
});
it('records the event and the wake-up on the same transaction client', async () => {
await service.record(tx as never, {
type: 'InspectionRecorded',
aggregateType: 'Inspection',
aggregateId: 'i1',
payload: { colonyId: 'c1' },
actorUserId: 'u1',
});
expect(tx.domainEvent.createMany).toHaveBeenCalledWith({
data: [
expect.objectContaining({
type: 'InspectionRecorded',
aggregateId: 'i1',
actorUserId: 'u1',
correlationId: expect.any(String) as unknown,
}),
],
skipDuplicates: true,
});
expect(tx.$executeRaw).toHaveBeenCalledTimes(1);
});
it("wakes the dispatcher with pg_notify('domain_event', '') - never data in the channel", async () => {
await service.record(tx as never, event);
const [[strings, ...values]] = tx.$executeRaw.mock.calls as [
[TemplateStringsArray, ...unknown[]],
];
expect(strings.join('$')).toBe("SELECT pg_notify($, '')");
expect(values).toEqual(['domain_event']);
});
it('refuses the root PrismaService (must run inside the business transaction)', async () => {
const prismaServiceMock = {
...tx,
$connect: jest.fn(),
$transaction: jest.fn(),
};
await expect(
service.record(prismaServiceMock as never, event),
).rejects.toThrow(
'DomainEventsService.record needs the transaction client',
);
expect(tx.domainEvent.createMany).not.toHaveBeenCalled();
});
it('names recordAll when recordAll gets the root client', async () => {
const prismaServiceMock = { ...tx, $connect: jest.fn() };
await expect(
service.recordAll(prismaServiceMock as never, [event]),
).rejects.toThrow(
'DomainEventsService.recordAll needs the transaction client',
);
expect(tx.domainEvent.createMany).not.toHaveBeenCalled();
});
it('records through a transaction client that can open nested transactions (Prisma 7)', async () => {
const nestingTx = { ...tx, $transaction: jest.fn() };
await service.record(nestingTx as never, event);
expect(tx.domainEvent.createMany).toHaveBeenCalledTimes(1);
});
it('inherits the correlation id of the event being handled', async () => {
await domainEventContext.run({ correlationId: 'corr-1' }, () =>
service.record(tx as never, event),
);
expect(createManyArg().data[0].correlationId).toBe('corr-1');
});
it('starts a new correlation id per call outside a handler, shared by all events of the call', async () => {
await service.recordAll(tx as never, [event, daily]);
await service.record(tx as never, event);
const [first, second] = createManyArg(0).data;
expect(first.correlationId).toBe(second.correlationId);
expect(createManyArg(1).data[0].correlationId).not.toBe(
first.correlationId,
);
});
it('stores missing actor and dedupe key as null and keeps the payload as given', async () => {
await service.recordAll(tx as never, [event, daily]);
const [plain, withKey] = createManyArg().data;
expect(plain.actorUserId).toBeNull();
expect(plain.dedupeKey).toBeNull();
expect(plain.payload).toEqual({ colonyId: 'c1' });
expect(withKey.dedupeKey).toBe('TaskDue:t1:2026-10-13');
});
it('wakes the worker only when something was inserted', async () => {
tx.domainEvent.createMany.mockResolvedValue({ count: 0 });
await expect(service.recordAll(tx as never, [daily])).resolves.toBe(0);
expect(tx.$executeRaw).not.toHaveBeenCalled();
});
it('returns the number of inserted events', async () => {
tx.domainEvent.createMany.mockResolvedValue({ count: 1 });
await expect(service.recordAll(tx as never, [event, daily])).resolves.toBe(
1,
);
expect(tx.$executeRaw).toHaveBeenCalledTimes(1);
});
it('does nothing for an empty list', async () => {
await expect(service.recordAll(tx as never, [])).resolves.toBe(0);
expect(tx.domainEvent.createMany).not.toHaveBeenCalled();
expect(tx.$executeRaw).not.toHaveBeenCalled();
});
});
describe('domain event catalog', () => {
it('lists every event type exactly once', () => {
expect(DOMAIN_EVENT_TYPES).toHaveLength(23);
expect(new Set(DOMAIN_EVENT_TYPES).size).toBe(DOMAIN_EVENT_TYPES.length);
expect(DOMAIN_EVENT_TYPES).toEqual(
expect.arrayContaining([
'InspectionRecorded',
'StockChanged',
'TaskDue',
'InspectionOverdue',
]),
);
});
it('builds the daily dedupe key as "<type>:<aggregateId>:<YYYY-MM-DD>"', () => {
expect(dailyDedupeKey('QueenAged', 'q1', '2026-10-13')).toBe(
'QueenAged:q1:2026-10-13',
);
});
it.each(['2026-10-13T00:00:00.000Z', '13.10.2026', '2026-1-3', ''])(
'refuses %p as day - a timestamp would make every run a new event',
(day) => {
expect(() => dailyDedupeKey('TaskDue', 't1', day)).toThrow(
'Daily dedupe key needs a YYYY-MM-DD day',
);
},
);
it.each([
['2027-05-01T00:00:00.000Z', '2027-05-01'],
// Late evening in Berlin is still the same UTC day ...
['2026-10-13T23:30:00+02:00', '2026-10-13'],
// ... and shortly after midnight in Berlin the UTC day before.
['2026-10-14T01:30:00+02:00', '2026-10-13'],
])('utcDay(%s) is %s', (instant, day) => {
expect(utcDay(new Date(instant))).toBe(day);
});
it('utcDay yields a day dailyDedupeKey accepts', () => {
const day = utcDay(new Date('2026-10-13T08:15:00.000Z'));
expect(dailyDedupeKey('TaskDue', 't1', day)).toBe('TaskDue:t1:2026-10-13');
});
});
// The @ts-expect-error lines are checked by `npm run typecheck`: if the
// type stopped refusing the mismatch, the unused directive fails it.
describe('domain event typing', () => {
const tx = {
domainEvent: { createMany: jest.fn().mockResolvedValue({ count: 0 }) },
};
const service = new DomainEventsService();
it('ties every event type to its payload in recordAll', async () => {
const recorded = service.recordAll(tx as never, [
{
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
payload: { dueOn: '2026-10-13' },
},
{
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
// @ts-expect-error - TaskDue carries { dueOn }, not { colonyId }
payload: { colonyId: 'c1' },
},
]);
await expect(recorded).resolves.toBe(0);
});
it('refuses a stored event value whose payload belongs to another type', () => {
const misfit = {
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
payload: { colonyId: 'c1' },
} as const;
// @ts-expect-error - TaskDue carries { dueOn }, not { colonyId }
const wrong: AnyNewDomainEvent = misfit;
expect(wrong.type).toBe('TaskDue');
});
it('keeps record<T> checking the payload against the literal type', async () => {
await service.record(tx as never, {
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
// @ts-expect-error - TaskDue carries { dueOn }, not { colonyId }
payload: { colonyId: 'c1' },
});
expect(tx.domainEvent.createMany).toHaveBeenCalled();
});
});
@@ -0,0 +1,66 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { Prisma } from '../../generated/prisma';
import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup';
import { assertTransactionClient } from '../prisma/db-client';
import { domainEventContext } from './domain-event-context';
import {
AnyNewDomainEvent,
DomainEventType,
NewDomainEvent,
} from './domain-event-types';
/**
* Writes domain events into the outbox. Always inside the business
* transaction: no commit, no event - and no commit without its event.
*/
@Injectable()
export class DomainEventsService {
async record<T extends DomainEventType>(
tx: Prisma.TransactionClient,
event: NewDomainEvent<T>,
): Promise<void> {
assertTransactionClient(tx, 'DomainEventsService.record');
await this.insert(tx, [event]);
}
/**
* Returns how many events were inserted - an event whose dedupeKey
* already exists is skipped. The worker is woken only if something new
* was written; Postgres delivers the wake-up on commit.
*/
async recordAll(
tx: Prisma.TransactionClient,
events: readonly AnyNewDomainEvent[],
): Promise<number> {
assertTransactionClient(tx, 'DomainEventsService.recordAll');
return this.insert(tx, events);
}
private async insert(
tx: Prisma.TransactionClient,
events: readonly NewDomainEvent[],
): Promise<number> {
if (events.length === 0) {
return 0;
}
const correlationId =
domainEventContext.getStore()?.correlationId ?? randomUUID();
const { count } = await tx.domainEvent.createMany({
data: events.map((event) => ({
type: event.type,
aggregateType: event.aggregateType,
aggregateId: event.aggregateId,
payload: event.payload as Prisma.InputJsonValue,
actorUserId: event.actorUserId ?? null,
correlationId,
dedupeKey: event.dedupeKey ?? null,
})),
skipDuplicates: true,
});
if (count > 0) {
await notifyWorker(tx, WAKE_CHANNELS.domainEvents);
}
return count;
}
}
Loaded 100 of 564 files, more files were not shown because too many files have changed in this diff. Show more