Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7097fcd97c | ||
|
|
2d5c740f7f | ||
|
|
cdd30cf3ee | ||
|
|
25d95b0473 | ||
|
|
53fab102f2 | ||
|
|
c01a38835a |
No files matched your search
@@ -54,7 +54,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
|
||||||
type=sha,format=short
|
type=sha,format=short
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
|
||||||
type=sha,format=short
|
type=sha,format=short
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -51,10 +51,20 @@ app/
|
|||||||
│ ├── honey-batches/[id]/
|
│ ├── honey-batches/[id]/
|
||||||
│ ├── storage/
|
│ ├── storage/
|
||||||
│ ├── search/
|
│ ├── 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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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`).
|
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. 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.
|
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) 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.
|
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.
|
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).
|
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.
|
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.
|
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.
|
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`.
|
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.
|
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
|
## 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
|
## 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.
|
**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:
|
**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`.
|
- **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:** `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.
|
- **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.
|
- **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)
|
## 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 |
|
| 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) |
|
| 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 |
|
| 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 |
|
| 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: Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, 5 s Timeout, Signatur über Zeitstempel+ID+Body |
|
| 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 | `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 |
|
| 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 (100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über `TRUST_PROXY` |
|
| 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 |
|
| Daten | Backups verschlüsselt und signiert, Dateien 0600; CSV-Export ohne Formel-Injection; Logs ohne Query-Strings |
|
||||||
|
|
||||||
**Bekannte Restpunkte (bewusst nicht umgesetzt):**
|
**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.
|
- 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).
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
# A stale build info makes `nest build` emit only changed files into the
|
||||||
|
# freshly deleted dist (tsconfig.json: incremental).
|
||||||
|
*.tsbuildinfo
|
||||||
generated
|
generated
|
||||||
coverage
|
coverage
|
||||||
.devdb
|
.devdb
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
DATABASE_URL="postgresql://user:password@localhost:5432/apicore?schema=public"
|
DATABASE_URL="postgresql://user:password@localhost:5432/apicore?schema=public"
|
||||||
|
|
||||||
# Mindestens 32 Zeichen (server-seitig erzwungen, siehe main.ts) - signiert
|
# Mindestens 32 Zeichen (server-seitig erzwungen, siehe
|
||||||
# jedes Access-/MFA-/SSO-Token UND verschluesselt gespeicherte TOTP-Secrets.
|
# src/runtime/startup-checks.ts) - signiert jedes Access-/MFA-/SSO-Token.
|
||||||
|
# TOTP- und Webhook-Secrets aus der Zeit vor den eigenen Schluesseln (unten)
|
||||||
|
# sind noch damit verschluesselt: erst nach dem rekey-Job rotieren.
|
||||||
# Erzeugen z.B. mit: openssl rand -hex 32
|
# Erzeugen z.B. mit: openssl rand -hex 32
|
||||||
JWT_ACCESS_SECRET="change-me-to-a-random-value-at-least-32-characters-long"
|
JWT_ACCESS_SECRET="change-me-to-a-random-value-at-least-32-characters-long"
|
||||||
JWT_REFRESH_SECRET="change-me-refresh-secret"
|
JWT_REFRESH_SECRET="change-me-refresh-secret"
|
||||||
@@ -22,7 +24,7 @@ FRONTEND_ORIGIN="http://localhost:3000"
|
|||||||
# trusted (client IP for rate limits and API-key IP allowlists). "1" for the
|
# trusted (client IP for rate limits and API-key IP allowlists). "1" for the
|
||||||
# single K8s ingress; a list such as "loopback, 10.0.0.0/8" also works.
|
# single K8s ingress; a list such as "loopback, 10.0.0.0/8" also works.
|
||||||
# Behind Cloudflare + Traefik use "loopback, <pod CIDR>, <Cloudflare ranges>"
|
# Behind Cloudflare + Traefik use "loopback, <pod CIDR>, <Cloudflare ranges>"
|
||||||
# (see deploy/k8s/backend/configmap.example.yaml); "1" would yield the
|
# (see deploy/k8s/base/backend/configmap.example.yaml); "1" would yield the
|
||||||
# Cloudflare edge IP that many clients share.
|
# Cloudflare edge IP that many clients share.
|
||||||
TRUST_PROXY=1
|
TRUST_PROXY=1
|
||||||
|
|
||||||
@@ -54,11 +56,25 @@ BACKUP_CRON_SCHEDULE="0 3 * * *"
|
|||||||
# Wie viele der neuesten Backup-Dateien behalten werden, aeltere werden nach
|
# Wie viele der neuesten Backup-Dateien behalten werden, aeltere werden nach
|
||||||
# jedem Lauf geloescht (Standard: 14).
|
# jedem Lauf geloescht (Standard: 14).
|
||||||
BACKUP_RETENTION_COUNT=14
|
BACKUP_RETENTION_COUNT=14
|
||||||
# Backups are encrypted (AES-256-GCM). Without this key they are sealed with a
|
|
||||||
# key derived from JWT_ACCESS_SECRET and restore only into this deployment.
|
# Encryption keys, one per purpose (AES-256-GCM, key derived per purpose with
|
||||||
# Set the same value on another deployment to restore there. At least 32
|
# HKDF): TOTP secrets, webhook signing secrets, backup files. Required in
|
||||||
# characters, e.g. "openssl rand -hex 32". Keep it outside the backup storage.
|
# production: at least 32 characters each, all three different from each
|
||||||
|
# other and from JWT_ACCESS_SECRET/JWT_REFRESH_SECRET, e.g.
|
||||||
|
# "openssl rand -hex 32". Outside production a missing key falls back to one
|
||||||
|
# derived from JWT_ACCESS_SECRET.
|
||||||
|
# Rotation: move the old value to <NAME>_PREVIOUS (still opens), set a new
|
||||||
|
# <NAME>, run the rekey job, then drop <NAME>_PREVIOUS. Backup files are not
|
||||||
|
# resealed: keep BACKUP_ENCRYPTION_KEY_PREVIOUS while older files must restore.
|
||||||
|
# Set the same BACKUP_ENCRYPTION_KEY on another deployment to restore its
|
||||||
|
# backups there (MFA_/WEBHOOK_ENCRYPTION_KEY too, to keep MFA and webhook
|
||||||
|
# secrets). Keep the backup key outside the backup storage.
|
||||||
|
# MFA_ENCRYPTION_KEY=""
|
||||||
|
# WEBHOOK_ENCRYPTION_KEY=""
|
||||||
# BACKUP_ENCRYPTION_KEY=""
|
# BACKUP_ENCRYPTION_KEY=""
|
||||||
|
# MFA_ENCRYPTION_KEY_PREVIOUS=""
|
||||||
|
# WEBHOOK_ENCRYPTION_KEY_PREVIOUS=""
|
||||||
|
# BACKUP_ENCRYPTION_KEY_PREVIOUS=""
|
||||||
|
|
||||||
# Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert
|
# Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert
|
||||||
# (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start).
|
# (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start).
|
||||||
@@ -84,3 +100,11 @@ OIDC_REDIRECT_URI="http://localhost:3001/api/v1/auth/sso/callback"
|
|||||||
# gesamte API-Schema ungeschuetzt (kein JwtAuthGuard) preisgeben wuerde.
|
# gesamte API-Schema ungeschuetzt (kein JwtAuthGuard) preisgeben wuerde.
|
||||||
# Nur fuer lokale Entwicklung/Debugging explizit aktivieren.
|
# Nur fuer lokale Entwicklung/Debugging explizit aktivieren.
|
||||||
ENABLE_SWAGGER=false
|
ENABLE_SWAGGER=false
|
||||||
|
|
||||||
|
# Laufrolle des Prozesses: api (nur HTTP), worker, job (CLI) oder all (API +
|
||||||
|
# Worker in einem Prozess, nur fuer lokale Entwicklung und Tests). Ohne Wert
|
||||||
|
# gilt der Standard des Einstiegspunkts (main: all, worker: worker, cli: job).
|
||||||
|
# In Produktion Pflicht und "all" ist dort verboten.
|
||||||
|
# APICORE_ROLE=api
|
||||||
|
# Port des Health-Endpunkts des Worker-Prozesses.
|
||||||
|
# WORKER_HEALTH_PORT=3002
|
||||||
+24
-10
@@ -1,6 +1,10 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
FROM node:22-alpine AS builder
|
# Base-Image per Digest gepinnt (node:22-alpine, Multi-Arch-Index vom
|
||||||
|
# 2026-09-23): ein Rebuild zieht nie ungeprueft ein neues Image. Ein Update
|
||||||
|
# ist ein bewusster Commit mit neuem Digest, ermittelt per
|
||||||
|
# docker buildx imagetools inspect node:22-alpine
|
||||||
|
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
@@ -14,16 +18,16 @@ RUN npx prisma generate
|
|||||||
RUN npx eslint "{src,test}/**/*.ts" && npm run typecheck && npm test -- --ci
|
RUN npx eslint "{src,test}/**/*.ts" && npm run typecheck && npm test -- --ci
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:22-alpine AS runtime
|
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS runtime
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY --from=builder /app/generated ./generated
|
COPY --from=builder /app/generated ./generated
|
||||||
# schema.prisma, migrations/ und prisma.config.ts: noetig fuer
|
# schema.prisma, migrations/ und prisma.config.ts: noetig fuer den
|
||||||
# `npm run migrate:deploy`, das der Container beim Start selbst ausfuehrt
|
# initContainer `migrate` (siehe Kommentar ueber CMD unten).
|
||||||
# (siehe CMD unten). seed.ts selbst wird NICHT zur Laufzeit ausgefuehrt -
|
# seed.ts selbst wird NICHT zur Laufzeit ausgefuehrt -
|
||||||
# `npm run build` kompiliert es vorab zu dist/seed.js (siehe
|
# `npm run build` kompiliert es vorab zu dist/seed.js (siehe
|
||||||
# tsconfig.seed.json), das schon in ./dist enthalten ist. Frueher lief
|
# tsconfig.seed.json), das schon in ./dist enthalten ist. Frueher lief
|
||||||
# `ts-node prisma/seed.ts` direkt im Container: der TypeScript-Compiler
|
# `ts-node prisma/seed.ts` direkt im Container: der TypeScript-Compiler
|
||||||
@@ -31,9 +35,19 @@ COPY --from=builder /app/generated ./generated
|
|||||||
# fuehrte zu sporadischen OOM-Crashes beim Start.
|
# fuehrte zu sporadischen OOM-Crashes beim Start.
|
||||||
COPY --from=builder /app/prisma ./prisma
|
COPY --from=builder /app/prisma ./prisma
|
||||||
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
|
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
|
||||||
EXPOSE 3001
|
# 3001: HTTP der api-Rolle. 3002: /health der worker-Rolle.
|
||||||
|
EXPOSE 3001 3002
|
||||||
USER node
|
USER node
|
||||||
# migrate deploy ist idempotent und nutzt einen DB-seitigen Advisory-Lock,
|
# Migration und Seed laufen nicht mehr hier, sondern als initContainer im
|
||||||
# seed ist idempotent per Unique-Constraint auf User.email (siehe seed.ts) -
|
# Pod (deploy/k8s/components/backend-roles): `migrate` in api und worker
|
||||||
# beides unproblematisch bei mehreren gleichzeitig startenden Pod-Replicas.
|
# (`node node_modules/prisma/build/index.js migrate deploy`, idempotent mit
|
||||||
CMD ["sh", "-c", "npm run migrate:deploy && npm run seed && node dist/main.js"]
|
# DB-seitigem Advisory-Lock), `seed` nur in der api (`node dist/seed.js`,
|
||||||
|
# idempotent per Unique-Constraint auf User.email). Scheitert die Migration,
|
||||||
|
# startet der neue Pod nicht und der alte laeuft weiter. Der Hauptprozess
|
||||||
|
# startet ohne Prisma-CLI; worker und Jobs ueberschreiben das Kommando
|
||||||
|
# (dist/worker.js, dist/cli.js <job>), die Rolle waehlt APICORE_ROLE.
|
||||||
|
# Dieses Image darf darum nur zusammen mit components/backend-roles live
|
||||||
|
# gehen (Runbook Schritt 3). Fehlt APICORE_ROLE, bricht der Start in
|
||||||
|
# Produktion absichtlich ab. APICORE_ROLE nicht von Hand in ein Deployment
|
||||||
|
# ohne die initContainer setzen: dann liefe keine Migration.
|
||||||
|
CMD ["node", "dist/main.js"]
|
||||||
Generated
+21
-35
@@ -15,7 +15,6 @@
|
|||||||
"@nestjs/jwt": "^11.0.2",
|
"@nestjs/jwt": "^11.0.2",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/schedule": "^12.0.1",
|
|
||||||
"@nestjs/swagger": "^11.4.7",
|
"@nestjs/swagger": "^11.4.7",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@prisma/adapter-pg": "^7.9.1",
|
"@prisma/adapter-pg": "^7.9.1",
|
||||||
@@ -24,12 +23,14 @@
|
|||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cron": "^4.4.0",
|
||||||
"helmet": "^8.3.0",
|
"helmet": "^8.3.0",
|
||||||
"openid-client": "^6.8.4",
|
"openid-client": "^6.8.4",
|
||||||
"otplib": "^13.5.0",
|
"otplib": "^13.5.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"pdfkit": "^0.19.1",
|
"pdfkit": "^0.19.1",
|
||||||
|
"pg": "^8.23.1",
|
||||||
"prisma": "^7.9.1",
|
"prisma": "^7.9.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/pdfkit": "^0.17.6",
|
"@types/pdfkit": "^0.17.6",
|
||||||
|
"@types/pg": "^8.23.1",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/supertest": "^7.0.0",
|
"@types/supertest": "^7.0.0",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
@@ -2478,22 +2480,6 @@
|
|||||||
"@nestjs/core": "^11.0.0"
|
"@nestjs/core": "^11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nestjs/schedule": {
|
|
||||||
"version": "12.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-12.0.1.tgz",
|
|
||||||
"integrity": "sha512-v0z/li9L8oxpvuIDAGm5uOLaZ8KzsfZRzgWgppvs5vClneSinZP5IFl3C1F1DCeTCkNLiGN9g/oLUvzg8x++RQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"cron": "4.4.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.19.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@nestjs/common": "^11.0.0 || ^12.0.0",
|
|
||||||
"@nestjs/core": "^11.0.0 || ^12.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nestjs/schematics": {
|
"node_modules/@nestjs/schematics": {
|
||||||
"version": "11.1.0",
|
"version": "11.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz",
|
||||||
@@ -3601,9 +3587,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/luxon": {
|
"node_modules/@types/luxon": {
|
||||||
"version": "3.7.5",
|
"version": "3.7.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.6.tgz",
|
||||||
"integrity": "sha512-jJ41Q4z6ZVO260MNDdHfW7+7a5iMiX8Mr6ZJHcmgrvhZha6dz5704o/lF2kKl6URjH6ivEL97w9xS/MgpJEphg==",
|
"integrity": "sha512-6KSjliQAXK8ZLgFQO4B7iE4Rpf/B3rItzCFuXezBY/XIAGxOdLd7vRNK9/StRPwiS/yJsVbB7HKpW46B8tcEuQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/methods": {
|
"node_modules/@types/methods": {
|
||||||
@@ -10087,14 +10073,14 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pg": {
|
"node_modules/pg": {
|
||||||
"version": "8.23.0",
|
"version": "8.23.1",
|
||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
|
||||||
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
|
"integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.14.0",
|
"pg-connection-string": "^2.14.1",
|
||||||
"pg-pool": "^3.14.0",
|
"pg-pool": "^3.14.0",
|
||||||
"pg-protocol": "^1.16.0",
|
"pg-protocol": "^1.16.1",
|
||||||
"pg-types": "2.2.0",
|
"pg-types": "2.2.0",
|
||||||
"pgpass": "1.0.5"
|
"pgpass": "1.0.5"
|
||||||
},
|
},
|
||||||
@@ -10102,7 +10088,7 @@
|
|||||||
"node": ">= 16.0.0"
|
"node": ">= 16.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"pg-cloudflare": "^1.4.0"
|
"pg-cloudflare": "^1.4.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"pg-native": ">=3.0.1"
|
"pg-native": ">=3.0.1"
|
||||||
@@ -10114,16 +10100,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pg-cloudflare": {
|
"node_modules/pg-cloudflare": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
|
||||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
"integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/pg-connection-string": {
|
"node_modules/pg-connection-string": {
|
||||||
"version": "2.14.0",
|
"version": "2.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
|
||||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
"integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pg-int8": {
|
"node_modules/pg-int8": {
|
||||||
@@ -10145,9 +10131,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pg-protocol": {
|
"node_modules/pg-protocol": {
|
||||||
"version": "1.16.0",
|
"version": "1.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
|
||||||
"integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
|
"integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pg-types": {
|
"node_modules/pg-types": {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "node dist/main",
|
"start:prod": "node dist/main",
|
||||||
|
"worker": "node dist/worker.js",
|
||||||
|
"job": "node dist/cli.js",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
@@ -29,7 +31,6 @@
|
|||||||
"@nestjs/jwt": "^11.0.2",
|
"@nestjs/jwt": "^11.0.2",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/schedule": "^12.0.1",
|
|
||||||
"@nestjs/swagger": "^11.4.7",
|
"@nestjs/swagger": "^11.4.7",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@prisma/adapter-pg": "^7.9.1",
|
"@prisma/adapter-pg": "^7.9.1",
|
||||||
@@ -38,12 +39,14 @@
|
|||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cron": "^4.4.0",
|
||||||
"helmet": "^8.3.0",
|
"helmet": "^8.3.0",
|
||||||
"openid-client": "^6.8.4",
|
"openid-client": "^6.8.4",
|
||||||
"otplib": "^13.5.0",
|
"otplib": "^13.5.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"pdfkit": "^0.19.1",
|
"pdfkit": "^0.19.1",
|
||||||
|
"pg": "^8.23.1",
|
||||||
"prisma": "^7.9.1",
|
"prisma": "^7.9.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
@@ -63,6 +66,7 @@
|
|||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/pdfkit": "^0.17.6",
|
"@types/pdfkit": "^0.17.6",
|
||||||
|
"@types/pg": "^8.23.1",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/supertest": "^7.0.0",
|
"@types/supertest": "^7.0.0",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
@@ -92,7 +96,7 @@
|
|||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose|@nestjs/schedule|cron)/)"
|
"node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose|cron)/)"
|
||||||
],
|
],
|
||||||
"collectCoverageFrom": [
|
"collectCoverageFrom": [
|
||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
-- Platform backbone: domain-event outbox (DomainEvent + per-handler
|
||||||
|
-- DomainEventDelivery), webhook delivery queue, cron job run history and
|
||||||
|
-- throttler counters, plus two small columns (Apiary.publicLocationLabel,
|
||||||
|
-- SiteSettings.originCountry).
|
||||||
|
-- ThrottlerHit is UNLOGGED on purpose: no WAL, emptied after a crash - only
|
||||||
|
-- rate-limit counters get lost. DomainEvent.dedupeKey is a nullable UNIQUE
|
||||||
|
-- (not a partial index) so the schema and the database cannot drift apart.
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "DomainEventStatus" AS ENUM ('PENDING', 'PROCESSING', 'DONE', 'FAILED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "DomainEventDeliveryStatus" AS ENUM ('PENDING', 'DONE', 'FAILED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "WebhookDeliveryStatus" AS ENUM ('PENDING', 'PROCESSING', 'DELIVERED', 'FAILED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "JobRunStatus" AS ENUM ('RUNNING', 'SUCCEEDED', 'FAILED');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Apiary" ADD COLUMN "publicLocationLabel" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "SiteSettings" ADD COLUMN "originCountry" TEXT NOT NULL DEFAULT 'Deutschland';
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DomainEvent" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"aggregateType" TEXT NOT NULL,
|
||||||
|
"aggregateId" TEXT NOT NULL,
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
"actorUserId" TEXT,
|
||||||
|
"correlationId" TEXT NOT NULL,
|
||||||
|
"dedupeKey" TEXT,
|
||||||
|
"occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"status" "DomainEventStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"claimedAt" TIMESTAMP(3),
|
||||||
|
"processedAt" TIMESTAMP(3),
|
||||||
|
"lastError" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "DomainEvent_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DomainEventDelivery" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"eventId" TEXT NOT NULL,
|
||||||
|
"handler" TEXT NOT NULL,
|
||||||
|
"status" "DomainEventDeliveryStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"lastError" TEXT,
|
||||||
|
"completedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "DomainEventDelivery_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "WebhookDelivery" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"subscriptionId" TEXT NOT NULL,
|
||||||
|
"event" TEXT NOT NULL,
|
||||||
|
"body" TEXT NOT NULL,
|
||||||
|
"status" "WebhookDeliveryStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"claimedAt" TIMESTAMP(3),
|
||||||
|
"lastStatusCode" INTEGER,
|
||||||
|
"lastError" TEXT,
|
||||||
|
"deliveredAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "WebhookDelivery_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "JobRun" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"job" TEXT NOT NULL,
|
||||||
|
"status" "JobRunStatus" NOT NULL DEFAULT 'RUNNING',
|
||||||
|
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"finishedAt" TIMESTAMP(3),
|
||||||
|
"result" JSONB,
|
||||||
|
"error" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "JobRun_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE UNLOGGED TABLE "ThrottlerHit" (
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"throttlerName" TEXT NOT NULL,
|
||||||
|
"hits" INTEGER NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"blockedUntil" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "ThrottlerHit_pkey" PRIMARY KEY ("key","throttlerName")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "DomainEvent_dedupeKey_key" ON "DomainEvent"("dedupeKey");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DomainEvent_status_availableAt_idx" ON "DomainEvent"("status", "availableAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DomainEvent_correlationId_idx" ON "DomainEvent"("correlationId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DomainEvent_aggregateType_aggregateId_idx" ON "DomainEvent"("aggregateType", "aggregateId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "DomainEventDelivery_eventId_handler_key" ON "DomainEventDelivery"("eventId", "handler");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WebhookDelivery_status_availableAt_idx" ON "WebhookDelivery"("status", "availableAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WebhookDelivery_subscriptionId_createdAt_idx" ON "WebhookDelivery"("subscriptionId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "JobRun_job_startedAt_idx" ON "JobRun"("job", "startedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "JobRun_status_idx" ON "JobRun"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ThrottlerHit_expiresAt_idx" ON "ThrottlerHit"("expiresAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DomainEventDelivery" ADD CONSTRAINT "DomainEventDelivery_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "DomainEvent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "WebhookDelivery" ADD CONSTRAINT "WebhookDelivery_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "WebhookSubscription"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "Inspection" ADD COLUMN "broodFrames" INTEGER;
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
-- ERP-Export /bewegungen sortiert und blättert nach (createdAt, id).
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "BatchMovement_createdAt_id_idx" ON "BatchMovement"("createdAt", "id");
|
||||||
@@ -230,6 +230,8 @@ model Apiary {
|
|||||||
latitude Float?
|
latitude Float?
|
||||||
longitude Float?
|
longitude Float?
|
||||||
address String?
|
address String?
|
||||||
|
// Coarse, public-safe place name for storefront/shop output (never the exact address).
|
||||||
|
publicLocationLabel String?
|
||||||
status ApiaryStatus @default(ACTIVE)
|
status ApiaryStatus @default(ACTIVE)
|
||||||
notes String?
|
notes String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -454,6 +456,7 @@ model Inspection {
|
|||||||
weather String?
|
weather String?
|
||||||
temperature Float?
|
temperature Float?
|
||||||
colonyStrength Int?
|
colonyStrength Int?
|
||||||
|
broodFrames Int?
|
||||||
broodAmount String?
|
broodAmount String?
|
||||||
broodPattern Int?
|
broodPattern Int?
|
||||||
openBroodPresent Boolean?
|
openBroodPresent Boolean?
|
||||||
@@ -541,7 +544,7 @@ enum TaskTemplateRecurrenceType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wiederkehrende Aufgaben-Vorlagen – erzeugen automatisch eine neue Task,
|
// Wiederkehrende Aufgaben-Vorlagen – erzeugen automatisch eine neue Task,
|
||||||
// sobald nextDueDate erreicht ist (siehe TasksService.generateDueTasks).
|
// sobald nextDueDate erreicht ist (siehe TasksService.generateFromTemplates).
|
||||||
model TaskTemplate {
|
model TaskTemplate {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
title String
|
title String
|
||||||
@@ -1207,6 +1210,8 @@ model BatchMovement {
|
|||||||
@@index([productionBatchId])
|
@@index([productionBatchId])
|
||||||
@@index([rawMaterialLotId])
|
@@index([rawMaterialLotId])
|
||||||
@@index([shopRequestId])
|
@@index([shopRequestId])
|
||||||
|
// ERP-Export /bewegungen: sortiert und blättert nach (createdAt, id).
|
||||||
|
@@index([createdAt, id])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ShopStockRequestKind {
|
enum ShopStockRequestKind {
|
||||||
@@ -1300,6 +1305,8 @@ model WebhookSubscription {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
deliveries WebhookDelivery[]
|
||||||
|
|
||||||
@@index([active])
|
@@index([active])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1403,6 +1410,8 @@ model SiteSettings {
|
|||||||
// part of any public page.
|
// part of any public page.
|
||||||
holderName String?
|
holderName String?
|
||||||
holderAddress String?
|
holderAddress String?
|
||||||
|
// Country of origin printed on labels and shop output.
|
||||||
|
originCountry String @default("Deutschland")
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1521,3 +1530,119 @@ model CalendarSyncOutboxEntry {
|
|||||||
@@unique([sourceType, sourceId])
|
@@unique([sourceType, sourceId])
|
||||||
@@index([status])
|
@@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])
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { WorkerRuntimeModule } from './worker/worker-runtime.module';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role "all": the API plus the worker loops in one process, for local
|
||||||
|
* development and tests. Production refuses it (runtime/role.ts) and runs
|
||||||
|
* api and worker as separate pods. No worker health server here - the
|
||||||
|
* API's /health endpoints serve the process.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [AppModule, WorkerRuntimeModule],
|
||||||
|
})
|
||||||
|
export class AllInOneModule {}
|
||||||
@@ -19,6 +19,24 @@ export const API_KEY_SCOPES = [
|
|||||||
|
|
||||||
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
|
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scopes that book (change data). Every other scope only reads. A new key
|
||||||
|
* never mixes both kinds: a leaked read key cannot book, a leaked booking
|
||||||
|
* key cannot read (ARCHITECTURE.md, Annahme 14).
|
||||||
|
*/
|
||||||
|
export const API_KEY_WRITE_SCOPES = [
|
||||||
|
'shop-sync:write',
|
||||||
|
] as const satisfies readonly ApiKeyScope[];
|
||||||
|
|
||||||
export function isApiKeyScope(value: string): value is ApiKeyScope {
|
export function isApiKeyScope(value: string): value is ApiKeyScope {
|
||||||
return (API_KEY_SCOPES as readonly string[]).includes(value);
|
return (API_KEY_SCOPES as readonly string[]).includes(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isWriteScope(scope: string): boolean {
|
||||||
|
return (API_KEY_WRITE_SCOPES as readonly string[]).includes(scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if the scopes hold at least one reading and one booking right. */
|
||||||
|
export function combinesReadAndWrite(scopes: readonly string[]): boolean {
|
||||||
|
return scopes.some(isWriteScope) && scopes.some((s) => !isWriteScope(s));
|
||||||
|
}
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { ThrottlerStorageModule } from '../common/throttler/throttler-storage.module';
|
||||||
import { ApiKeysController } from './api-keys.controller';
|
import { ApiKeysController } from './api-keys.controller';
|
||||||
import { ApiKeysService } from './api-keys.service';
|
import { ApiKeysService } from './api-keys.service';
|
||||||
|
import { FailedAttemptLimiter } from './failed-attempt-limiter';
|
||||||
|
|
||||||
// Global: the app-wide auth guards need ApiKeysService to recognise keys.
|
// Global: the app-wide auth guards need ApiKeysService to recognise keys.
|
||||||
|
// ThrottlerStorageModule: the failure counters share the rate-limit table,
|
||||||
|
// so the limit holds across all api pods.
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [ThrottlerStorageModule],
|
||||||
controllers: [ApiKeysController],
|
controllers: [ApiKeysController],
|
||||||
providers: [ApiKeysService],
|
providers: [ApiKeysService, FailedAttemptLimiter],
|
||||||
exports: [ApiKeysService],
|
exports: [ApiKeysService],
|
||||||
})
|
})
|
||||||
export class ApiKeysModule {}
|
export class ApiKeysModule {}
|
||||||
@@ -1,13 +1,19 @@
|
|||||||
import { ForbiddenException, HttpException, Logger } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
HttpException,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
|
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
|
||||||
|
import { ApiKeysService, extractApiKeyToken } from './api-keys.service';
|
||||||
import {
|
import {
|
||||||
API_KEY_FAILURE_LIMIT,
|
API_KEY_FAILURE_LIMIT,
|
||||||
ApiKeysService,
|
FailedAttemptLimiter,
|
||||||
extractApiKeyToken,
|
} from './failed-attempt-limiter';
|
||||||
} from './api-keys.service';
|
|
||||||
|
|
||||||
// Synthetischer Schlüssel im echten Format - kein echter Zugang.
|
// Synthetischer Schlüssel im echten Format - kein echter Zugang.
|
||||||
const TOKEN = `ack_abcd1234_${'A'.repeat(43)}`;
|
const TOKEN = `ack_abcd1234_${'A'.repeat(43)}`;
|
||||||
@@ -31,6 +37,38 @@ function storedKey(overrides: Record<string, unknown> = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stand-in for the shared counter table with the same counting rule as
|
||||||
|
* PrismaThrottlerStorage within one window: count up to the limit, the hit
|
||||||
|
* after it blocks, a blocked source keeps its count. Time and the database
|
||||||
|
* itself are covered by the e2e tests.
|
||||||
|
*/
|
||||||
|
function inMemoryStorage(): PrismaThrottlerStorage {
|
||||||
|
const rows = new Map<string, { hits: number; blocked: boolean }>();
|
||||||
|
return {
|
||||||
|
increment: (
|
||||||
|
key: string,
|
||||||
|
ttl: number,
|
||||||
|
limit: number,
|
||||||
|
blockDuration: number,
|
||||||
|
throttlerName: string,
|
||||||
|
) => {
|
||||||
|
const id = `${throttlerName}|${key}`;
|
||||||
|
const row = rows.get(id) ?? { hits: 0, blocked: false };
|
||||||
|
const next = row.blocked
|
||||||
|
? row
|
||||||
|
: { hits: row.hits + 1, blocked: row.hits + 1 > limit };
|
||||||
|
rows.set(id, next);
|
||||||
|
return Promise.resolve({
|
||||||
|
totalHits: next.hits,
|
||||||
|
timeToExpire: ttl / 1000,
|
||||||
|
isBlocked: next.blocked,
|
||||||
|
timeToBlockExpire: next.blocked ? blockDuration / 1000 : 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
} as unknown as PrismaThrottlerStorage;
|
||||||
|
}
|
||||||
|
|
||||||
describe('ApiKeysService.authenticate', () => {
|
describe('ApiKeysService.authenticate', () => {
|
||||||
let prisma: { apiKey: { findUnique: jest.Mock; update: jest.Mock } };
|
let prisma: { apiKey: { findUnique: jest.Mock; update: jest.Mock } };
|
||||||
let service: ApiKeysService;
|
let service: ApiKeysService;
|
||||||
@@ -47,6 +85,7 @@ describe('ApiKeysService.authenticate', () => {
|
|||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
{ record: jest.fn() } as unknown as AuditLogService,
|
{ record: jest.fn() } as unknown as AuditLogService,
|
||||||
{ getPermissions: jest.fn() } as unknown as PermissionResolverService,
|
{ getPermissions: jest.fn() } as unknown as PermissionResolverService,
|
||||||
|
new FailedAttemptLimiter(inMemoryStorage()),
|
||||||
);
|
);
|
||||||
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
||||||
});
|
});
|
||||||
@@ -166,6 +205,18 @@ describe('ApiKeysService.authenticate', () => {
|
|||||||
).resolves.toBe(null);
|
).resolves.toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('meldet das Erreichen der Grenze einmal im Log', async () => {
|
||||||
|
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
|
||||||
|
await expect(
|
||||||
|
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
|
||||||
|
).rejects.toMatchObject({ status: 429 });
|
||||||
|
|
||||||
|
const limitMessages = warn.mock.calls.filter(([message]) =>
|
||||||
|
String(message).includes('get 429'),
|
||||||
|
);
|
||||||
|
expect(limitMessages).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('weist einen gültigen Schlüssel nie wegen der Sperre ab - sonst könnte jeder ERPNext oder den Shop aussperren', async () => {
|
it('weist einen gültigen Schlüssel nie wegen der Sperre ab - sonst könnte jeder ERPNext oder den Shop aussperren', async () => {
|
||||||
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
|
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
|
||||||
await expect(
|
await expect(
|
||||||
@@ -210,6 +261,101 @@ describe('ApiKeysService.authenticate', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ApiKeysService.create', () => {
|
||||||
|
let service: ApiKeysService;
|
||||||
|
let prisma: { apiKey: { create: jest.Mock } };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma = {
|
||||||
|
apiKey: {
|
||||||
|
create: jest.fn((args: { data: Record<string, unknown> }) =>
|
||||||
|
Promise.resolve({ id: 'key-1', keyHash: 'h', ...args.data }),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const permissionResolver = {
|
||||||
|
getPermissions: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
new Set(['shop-sync:read', 'shop-sync:write', 'erp-inventory:read']),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
service = new ApiKeysService(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
{ record: jest.fn() } as unknown as AuditLogService,
|
||||||
|
permissionResolver as unknown as PermissionResolverService,
|
||||||
|
new FailedAttemptLimiter(inMemoryStorage()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a new key with both shop scopes', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create(
|
||||||
|
{
|
||||||
|
name: 'Shop',
|
||||||
|
scopes: ['shop-sync:read', 'shop-sync:write'],
|
||||||
|
},
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
).rejects.toThrow(
|
||||||
|
new BadRequestException(
|
||||||
|
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(prisma.apiKey.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses the read-only ERP scope together with booking', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create(
|
||||||
|
{
|
||||||
|
name: 'ERPNext',
|
||||||
|
scopes: ['erp-inventory:read', 'shop-sync:write'],
|
||||||
|
},
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
).rejects.toThrow(
|
||||||
|
new BadRequestException(
|
||||||
|
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(prisma.apiKey.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a new key with more than one scope, even if all of them read', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create(
|
||||||
|
{ name: 'Alles', scopes: ['shop-sync:read', 'erp-inventory:read'] },
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
).rejects.toThrow(
|
||||||
|
new BadRequestException(
|
||||||
|
'Je Schlüssel nur eine Berechtigung - bitte getrennte Schlüssel anlegen',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(prisma.apiKey.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats the same scope given twice as one', async () => {
|
||||||
|
const created = await service.create(
|
||||||
|
{ name: 'ERPNext', scopes: ['erp-inventory:read', 'erp-inventory:read'] },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
expect(created.apiKey.scopes).toEqual(['erp-inventory:read']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['shop-sync:read', 'shop-sync:write', 'erp-inventory:read'])(
|
||||||
|
'still creates a key with only %s',
|
||||||
|
async (scope) => {
|
||||||
|
const created = await service.create(
|
||||||
|
{ name: 'Shop', scopes: [scope] },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
expect(created.apiKey.scopes).toEqual([scope]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
describe('extractApiKeyToken', () => {
|
describe('extractApiKeyToken', () => {
|
||||||
it('erkennt nur Bearer-Schlüssel im ApiCore-Format', () => {
|
it('erkennt nur Bearer-Schlüssel im ApiCore-Format', () => {
|
||||||
expect(extractApiKeyToken(`Bearer ${TOKEN}`)).toBe(TOKEN);
|
expect(extractApiKeyToken(`Bearer ${TOKEN}`)).toBe(TOKEN);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
@@ -11,11 +12,18 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { ApiKey } from '../../generated/prisma';
|
import { ApiKey } from '../../generated/prisma';
|
||||||
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
||||||
import { ApiKeyScope, isApiKeyScope } from './api-key-scopes';
|
import {
|
||||||
|
ApiKeyScope,
|
||||||
|
combinesReadAndWrite,
|
||||||
|
isApiKeyScope,
|
||||||
|
} from './api-key-scopes';
|
||||||
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
|
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
|
||||||
import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
|
import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
|
||||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
import { FailedAttemptLimiter } from './failed-attempt-limiter';
|
import {
|
||||||
|
API_KEY_FAILURE_LIMIT,
|
||||||
|
FailedAttemptLimiter,
|
||||||
|
} from './failed-attempt-limiter';
|
||||||
import { rateLimitSubject } from '../common/throttler/rate-limit-subject';
|
import { rateLimitSubject } from '../common/throttler/rate-limit-subject';
|
||||||
|
|
||||||
function normalizeAllowlist(entries: readonly string[] | undefined): string[] {
|
function normalizeAllowlist(entries: readonly string[] | undefined): string[] {
|
||||||
@@ -38,21 +46,6 @@ const LAST_USED_RESOLUTION_MS = 5 * 60 * 1000;
|
|||||||
// revealing its secret part.
|
// revealing its secret part.
|
||||||
const LOGGABLE_PREFIX_LENGTH = 12;
|
const LOGGABLE_PREFIX_LENGTH = 12;
|
||||||
|
|
||||||
/**
|
|
||||||
* Unknown, revoked or expired keys per client network before that network
|
|
||||||
* gets 429 for further invalid keys for the rest of the window - the same
|
|
||||||
* budget as the shop's ERP export. It slows down guessing and a
|
|
||||||
* misconfigured client; a valid key is never refused by it. Otherwise
|
|
||||||
* anyone able to send requests from (or forge) a shared address - the
|
|
||||||
* cluster egress, a proxy - could lock ERPNext or the shop out, and a key
|
|
||||||
* holds 256 random bits, so letting it through costs nothing.
|
|
||||||
*/
|
|
||||||
export const API_KEY_FAILURE_LIMIT = {
|
|
||||||
maxFailures: 20,
|
|
||||||
windowMs: 15 * 60 * 1000,
|
|
||||||
maxTrackedSources: 10_000,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type RejectReason = 'unknown' | 'revoked' | 'expired';
|
type RejectReason = 'unknown' | 'revoked' | 'expired';
|
||||||
|
|
||||||
/** What a request authenticated by an API key may do - it is not a user. */
|
/** What a request authenticated by an API key may do - it is not a user. */
|
||||||
@@ -91,12 +84,13 @@ function toSummary(apiKey: ApiKey): ApiKeySummary {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApiKeysService {
|
export class ApiKeysService {
|
||||||
private readonly logger = new Logger(ApiKeysService.name);
|
private readonly logger = new Logger(ApiKeysService.name);
|
||||||
private readonly failures = new FailedAttemptLimiter(API_KEY_FAILURE_LIMIT);
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
private readonly permissionResolver: PermissionResolverService,
|
private readonly permissionResolver: PermissionResolverService,
|
||||||
|
// Counters shared by all api pods (ThrottlerHit), not process memory.
|
||||||
|
private readonly failures: FailedAttemptLimiter,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(): Promise<ApiKeySummary[]> {
|
async findAll(): Promise<ApiKeySummary[]> {
|
||||||
@@ -110,6 +104,19 @@ export class ApiKeysService {
|
|||||||
dto: CreateApiKeyDto,
|
dto: CreateApiKeyDto,
|
||||||
actingUserId: string,
|
actingUserId: string,
|
||||||
): Promise<CreatedApiKey> {
|
): Promise<CreatedApiKey> {
|
||||||
|
// One scope per new key: a leaked read key cannot book, a leaked booking
|
||||||
|
// key cannot read, and each connected system gets its own key.
|
||||||
|
// Existing multi-scope keys stay valid (ARCHITECTURE.md, Annahme 14).
|
||||||
|
if (combinesReadAndWrite(dto.scopes)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (new Set(dto.scopes).size > 1) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Je Schlüssel nur eine Berechtigung - bitte getrennte Schlüssel anlegen',
|
||||||
|
);
|
||||||
|
}
|
||||||
// Nobody hands a machine more than they may do themselves.
|
// Nobody hands a machine more than they may do themselves.
|
||||||
const callerPermissions =
|
const callerPermissions =
|
||||||
await this.permissionResolver.getPermissions(actingUserId);
|
await this.permissionResolver.getPermissions(actingUserId);
|
||||||
@@ -224,20 +231,21 @@ export class ApiKeysService {
|
|||||||
if (reason === null && apiKey) {
|
if (reason === null && apiKey) {
|
||||||
return this.accept(apiKey, clientIp, now);
|
return this.accept(apiKey, clientIp, now);
|
||||||
}
|
}
|
||||||
this.recordRejection(token, reason ?? 'unknown', clientIp);
|
await this.recordRejection(token, reason ?? 'unknown', clientIp);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Counts an invalid key; 429 once the client network is over the limit. */
|
/** Counts an invalid key; 429 once the client network is over the limit. */
|
||||||
private recordRejection(
|
private async recordRejection(
|
||||||
token: string,
|
token: string,
|
||||||
reason: RejectReason,
|
reason: RejectReason,
|
||||||
clientIp: string | undefined,
|
clientIp: string | undefined,
|
||||||
): void {
|
): Promise<void> {
|
||||||
// Counted per client network like the throttler (IPv6 /64), so rotating
|
// Counted per client network like the throttler (IPv6 /64), so rotating
|
||||||
// addresses inside one's own prefix does not reset the budget.
|
// addresses inside one's own prefix does not reset the budget.
|
||||||
const subject = clientIp ? rateLimitSubject(clientIp) : 'unknown';
|
const subject = clientIp ? rateLimitSubject(clientIp) : 'unknown';
|
||||||
if (this.failures.isBlocked(subject)) {
|
const verdict = await this.failures.recordFailure(subject);
|
||||||
|
if (verdict.blocked) {
|
||||||
// Same body shape as the built-in 4xx exceptions (JSON object, not a
|
// Same body shape as the built-in 4xx exceptions (JSON object, not a
|
||||||
// bare string), so clients can read `message` uniformly.
|
// bare string), so clients can read `message` uniformly.
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
@@ -254,9 +262,9 @@ export class ApiKeysService {
|
|||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`API key rejected (${reason}): ${token.slice(0, LOGGABLE_PREFIX_LENGTH)} from ${clientIp ?? 'unknown'}`,
|
`API key rejected (${reason}): ${token.slice(0, LOGGABLE_PREFIX_LENGTH)} from ${clientIp ?? 'unknown'}`,
|
||||||
);
|
);
|
||||||
if (this.failures.recordFailure(subject)) {
|
if (verdict.limitReached) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Invalid API keys from ${subject} refused for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes after ${API_KEY_FAILURE_LIMIT.maxFailures} failures`,
|
`Invalid API keys from ${subject} reached ${API_KEY_FAILURE_LIMIT.maxFailures} failures - further invalid keys get 429 for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,112 +1,91 @@
|
|||||||
import { FailedAttemptLimiter } from './failed-attempt-limiter';
|
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
|
||||||
|
import {
|
||||||
|
API_KEY_FAILURE_LIMIT,
|
||||||
|
API_KEY_FAILURE_THROTTLER,
|
||||||
|
FailedAttemptLimiter,
|
||||||
|
} from './failed-attempt-limiter';
|
||||||
|
|
||||||
describe('FailedAttemptLimiter', () => {
|
describe('FailedAttemptLimiter', () => {
|
||||||
let now: number;
|
const increment = jest.fn();
|
||||||
let limiter: FailedAttemptLimiter;
|
const limiter = new FailedAttemptLimiter({
|
||||||
|
increment,
|
||||||
|
} as unknown as PrismaThrottlerStorage);
|
||||||
|
|
||||||
|
function record(totalHits: number, isBlocked: boolean) {
|
||||||
|
return {
|
||||||
|
totalHits,
|
||||||
|
timeToExpire: 600,
|
||||||
|
isBlocked,
|
||||||
|
timeToBlockExpire: isBlocked ? 900 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
now = 1_000_000;
|
increment.mockReset();
|
||||||
limiter = new FailedAttemptLimiter({
|
increment.mockResolvedValue(record(1, false));
|
||||||
maxFailures: 3,
|
});
|
||||||
windowMs: 60_000,
|
|
||||||
maxTrackedSources: 100,
|
it('zählt in der gemeinsamen Zähler-Tabelle, getrennt vom Anfrage-Limit', async () => {
|
||||||
clock: () => now,
|
await limiter.recordFailure('203.0.113.7');
|
||||||
|
|
||||||
|
// Ein Aufruf = eine Upsert-Anweisung mit DB-Uhr (PrismaThrottlerStorage),
|
||||||
|
// unter eigenem Throttler-Namen und eigenem Schlüssel-Präfix.
|
||||||
|
expect(increment).toHaveBeenCalledTimes(1);
|
||||||
|
expect(increment).toHaveBeenCalledWith(
|
||||||
|
`${API_KEY_FAILURE_THROTTLER}:203.0.113.7`,
|
||||||
|
API_KEY_FAILURE_LIMIT.windowMs,
|
||||||
|
API_KEY_FAILURE_LIMIT.maxFailures,
|
||||||
|
API_KEY_FAILURE_LIMIT.windowMs,
|
||||||
|
API_KEY_FAILURE_THROTTLER,
|
||||||
|
);
|
||||||
|
expect(API_KEY_FAILURE_THROTTLER).toBe('api-key-failures');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('behält die vereinbarten Grenzen: 20 Fehlversuche in 15 Minuten', () => {
|
||||||
|
expect(API_KEY_FAILURE_LIMIT).toEqual({
|
||||||
|
maxFailures: 20,
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sperrt eine Quelle erst, wenn die Obergrenze erreicht ist', () => {
|
it('sperrt nicht unterhalb der Grenze', async () => {
|
||||||
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
|
increment.mockResolvedValueOnce(
|
||||||
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
|
record(API_KEY_FAILURE_LIMIT.maxFailures - 1, false),
|
||||||
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
|
);
|
||||||
|
|
||||||
// Der Fehlversuch, mit dem die Grenze erreicht wird, meldet die Sperre.
|
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
|
||||||
expect(limiter.recordFailure('203.0.113.7')).toBe(true);
|
blocked: false,
|
||||||
expect(limiter.isBlocked('203.0.113.7')).toBe(true);
|
limitReached: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('meldet die Sperre nur einmal, nicht bei jedem weiteren Versuch', () => {
|
it('meldet genau den Fehlversuch, mit dem die Grenze erreicht ist', async () => {
|
||||||
limiter.recordFailure('203.0.113.7');
|
increment.mockResolvedValueOnce(
|
||||||
limiter.recordFailure('203.0.113.7');
|
record(API_KEY_FAILURE_LIMIT.maxFailures, false),
|
||||||
expect(limiter.recordFailure('203.0.113.7')).toBe(true);
|
);
|
||||||
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
|
|
||||||
|
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
|
||||||
|
blocked: false,
|
||||||
|
limitReached: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('trennt Quellen voneinander', () => {
|
it('sperrt, sobald die Datenbank die Quelle als gesperrt meldet', async () => {
|
||||||
for (let i = 0; i < 3; i++) limiter.recordFailure('203.0.113.7');
|
increment.mockResolvedValueOnce(
|
||||||
expect(limiter.isBlocked('203.0.113.7')).toBe(true);
|
record(API_KEY_FAILURE_LIMIT.maxFailures + 1, true),
|
||||||
expect(limiter.isBlocked('198.51.100.1')).toBe(false);
|
);
|
||||||
|
|
||||||
|
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
|
||||||
|
blocked: true,
|
||||||
|
limitReached: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gibt die Quelle nach Ablauf des Fensters wieder frei', () => {
|
it('gibt Datenbankfehler weiter, statt still durchzulassen', async () => {
|
||||||
for (let i = 0; i < 3; i++) limiter.recordFailure('203.0.113.7');
|
increment.mockRejectedValueOnce(new Error('db down'));
|
||||||
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', () => {
|
await expect(limiter.recordFailure('203.0.113.7')).rejects.toThrow(
|
||||||
const small = new FailedAttemptLimiter({
|
'db down',
|
||||||
maxFailures: 1,
|
);
|
||||||
windowMs: 60_000,
|
|
||||||
maxTrackedSources: 2,
|
|
||||||
clock: () => now,
|
|
||||||
});
|
|
||||||
small.recordFailure('a');
|
|
||||||
small.recordFailure('b');
|
|
||||||
now += 60_001;
|
|
||||||
// Beim dritten Eintrag wird aufgeräumt: a und b sind abgelaufen.
|
|
||||||
small.recordFailure('c');
|
|
||||||
expect(small.trackedSources()).toBe(1);
|
|
||||||
expect(small.isBlocked('c')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('verdrängt bei voller Liste den ältesten Eintrag, auch wenn er noch läuft', () => {
|
|
||||||
const small = new FailedAttemptLimiter({
|
|
||||||
maxFailures: 5,
|
|
||||||
windowMs: 60_000,
|
|
||||||
maxTrackedSources: 2,
|
|
||||||
clock: () => now,
|
|
||||||
});
|
|
||||||
small.recordFailure('a');
|
|
||||||
small.recordFailure('b');
|
|
||||||
small.recordFailure('c');
|
|
||||||
expect(small.trackedSources()).toBe(2);
|
|
||||||
});
|
|
||||||
it('verdrängt bei voller Liste zuerst nicht gesperrte Quellen', () => {
|
|
||||||
const small = new FailedAttemptLimiter({
|
|
||||||
maxFailures: 2,
|
|
||||||
windowMs: 60_000,
|
|
||||||
maxTrackedSources: 2,
|
|
||||||
clock: () => now,
|
|
||||||
});
|
|
||||||
small.recordFailure('angreifer');
|
|
||||||
small.recordFailure('angreifer'); // gesperrt, ältester Eintrag
|
|
||||||
small.recordFailure('harmlos'); // ein Fehlversuch, nicht gesperrt
|
|
||||||
small.recordFailure('neu');
|
|
||||||
|
|
||||||
expect(small.trackedSources()).toBe(2);
|
|
||||||
// Die Sperre bleibt, obwohl sie der älteste Eintrag ist.
|
|
||||||
expect(small.isBlocked('angreifer')).toBe(true);
|
|
||||||
// Verdrängt wurde der nicht gesperrte Eintrag.
|
|
||||||
expect(small.recordFailure('harmlos')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('verdrängt gesperrte Quellen erst, wenn alle gesperrt sind (älteste zuerst)', () => {
|
|
||||||
const small = new FailedAttemptLimiter({
|
|
||||||
maxFailures: 1,
|
|
||||||
windowMs: 60_000,
|
|
||||||
maxTrackedSources: 2,
|
|
||||||
clock: () => now,
|
|
||||||
});
|
|
||||||
small.recordFailure('a');
|
|
||||||
small.recordFailure('b');
|
|
||||||
small.recordFailure('c');
|
|
||||||
|
|
||||||
expect(small.trackedSources()).toBe(2);
|
|
||||||
expect(small.isBlocked('a')).toBe(false);
|
|
||||||
expect(small.isBlocked('b')).toBe(true);
|
|
||||||
expect(small.isBlocked('c')).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,94 +1,57 @@
|
|||||||
export interface FailedAttemptLimiterOptions {
|
import { Injectable } from '@nestjs/common';
|
||||||
/** Ab so vielen Fehlversuchen im Fenster ist die Quelle gesperrt. */
|
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
|
||||||
maxFailures: number;
|
|
||||||
/** Länge des Fensters in Millisekunden, gezählt ab dem ersten Fehlversuch. */
|
|
||||||
windowMs: number;
|
|
||||||
/** Obergrenze gemerkter Quellen - schützt den Speicher bei verteilten Angriffen. */
|
|
||||||
maxTrackedSources: number;
|
|
||||||
clock?: () => number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Window {
|
/**
|
||||||
failures: number;
|
* Unknown, revoked or expired keys per client network before that network
|
||||||
startedAt: number;
|
* gets 429 for further invalid keys - the same budget the ERP export
|
||||||
|
* contract names (docs/erp-inventar-export.md). It slows down guessing and a
|
||||||
|
* misconfigured client; a valid key is never refused by it. Otherwise
|
||||||
|
* anyone able to send requests from (or forge) a shared address - the
|
||||||
|
* cluster egress, a proxy - could lock ERPNext or the shop out, and a key
|
||||||
|
* holds 256 random bits, so letting it through costs nothing.
|
||||||
|
*/
|
||||||
|
export const API_KEY_FAILURE_LIMIT = {
|
||||||
|
maxFailures: 20,
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throttler name and key prefix of these counters in "ThrottlerHit", so
|
||||||
|
* they never mix with the request rate limits stored in the same table.
|
||||||
|
*/
|
||||||
|
export const API_KEY_FAILURE_THROTTLER = 'api-key-failures';
|
||||||
|
|
||||||
|
export interface FailedAttemptVerdict {
|
||||||
|
/** The source is over the limit: refuse the request with 429. */
|
||||||
|
blocked: boolean;
|
||||||
|
/** This failure is the one that reached the limit (once per window). */
|
||||||
|
limitReached: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zählt fehlgeschlagene Anmeldungen je Quelle (Client-IP) in einem festen
|
* Counts invalid API keys per source (client network) in the shared,
|
||||||
* Zeitfenster und sperrt die Quelle, sobald die Obergrenze erreicht ist.
|
* database-backed rate-limit storage, so the limit holds across all api
|
||||||
*
|
* pods. One upsert per failure, every time comparison on the database
|
||||||
* Bewusst im Speicher des Prozesses, wie der globale Throttler: ApiCore läuft
|
* clock (PrismaThrottlerStorage). The window starts with the first failure;
|
||||||
* mit einem Backend-Pod. Nach einem Neustart beginnt die Zählung von vorn -
|
* the failure after the limit blocks the source for one window length.
|
||||||
* das ist für eine Bremse gegen Durchprobieren ausreichend.
|
* A database error propagates: the request fails instead of passing.
|
||||||
*/
|
*/
|
||||||
|
@Injectable()
|
||||||
export class FailedAttemptLimiter {
|
export class FailedAttemptLimiter {
|
||||||
private readonly windows = new Map<string, Window>();
|
constructor(private readonly storage: PrismaThrottlerStorage) {}
|
||||||
private readonly clock: () => number;
|
|
||||||
|
|
||||||
constructor(private readonly options: FailedAttemptLimiterOptions) {
|
async recordFailure(source: string): Promise<FailedAttemptVerdict> {
|
||||||
this.clock = options.clock ?? Date.now;
|
const { maxFailures, windowMs } = API_KEY_FAILURE_LIMIT;
|
||||||
}
|
const record = await this.storage.increment(
|
||||||
|
`${API_KEY_FAILURE_THROTTLER}:${source}`,
|
||||||
isBlocked(source: string): boolean {
|
windowMs,
|
||||||
const window = this.activeWindow(source);
|
maxFailures,
|
||||||
return window !== undefined && window.failures >= this.options.maxFailures;
|
windowMs,
|
||||||
}
|
API_KEY_FAILURE_THROTTLER,
|
||||||
|
);
|
||||||
/** Zählt einen Fehlversuch; true genau dann, wenn er die Sperre auslöst. */
|
return {
|
||||||
recordFailure(source: string): boolean {
|
blocked: record.isBlocked,
|
||||||
const now = this.clock();
|
limitReached: !record.isBlocked && record.totalHits === maxFailures,
|
||||||
const current = this.activeWindow(source);
|
};
|
||||||
if (!current) {
|
|
||||||
this.makeRoom(now);
|
|
||||||
}
|
|
||||||
const failures = (current?.failures ?? 0) + 1;
|
|
||||||
this.windows.set(source, {
|
|
||||||
failures,
|
|
||||||
startedAt: current?.startedAt ?? now,
|
|
||||||
});
|
|
||||||
return failures === this.options.maxFailures;
|
|
||||||
}
|
|
||||||
|
|
||||||
trackedSources(): number {
|
|
||||||
return this.windows.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
private activeWindow(source: string): Window | undefined {
|
|
||||||
const window = this.windows.get(source);
|
|
||||||
if (window && this.clock() - window.startedAt > this.options.windowMs) {
|
|
||||||
this.windows.delete(source);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return window;
|
|
||||||
}
|
|
||||||
|
|
||||||
private makeRoom(now: number): void {
|
|
||||||
if (this.windows.size < this.options.maxTrackedSources) return;
|
|
||||||
for (const [source, window] of this.windows) {
|
|
||||||
if (now - window.startedAt > this.options.windowMs) {
|
|
||||||
this.windows.delete(source);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Immer noch voll: lieber eine Zählung verlieren als unbegrenzt wachsen.
|
|
||||||
// Zuerst den ältesten nicht gesperrten Eintrag verdrängen - sonst könnte
|
|
||||||
// ein Angreifer mit vielen Adressen seine eigene Sperre hinausschieben.
|
|
||||||
// Nur wenn alle gesperrt sind, geht der älteste gesperrte (die Map behält
|
|
||||||
// die Einfügereihenfolge).
|
|
||||||
if (this.windows.size >= this.options.maxTrackedSources) {
|
|
||||||
const victim =
|
|
||||||
this.oldestWhere(
|
|
||||||
(window) => window.failures < this.options.maxFailures,
|
|
||||||
) ?? this.oldestWhere(() => true);
|
|
||||||
if (victim !== undefined) this.windows.delete(victim);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private oldestWhere(
|
|
||||||
matches: (window: Window) => boolean,
|
|
||||||
): string | undefined {
|
|
||||||
for (const [source, window] of this.windows) {
|
|
||||||
if (matches(window)) return source;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,12 +15,16 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
|||||||
import { CreateApiaryDto } from './dto/create-apiary.dto';
|
import { CreateApiaryDto } from './dto/create-apiary.dto';
|
||||||
import { UpdateApiaryDto } from './dto/update-apiary.dto';
|
import { UpdateApiaryDto } from './dto/update-apiary.dto';
|
||||||
import { ApiariesService } from './apiaries.service';
|
import { ApiariesService } from './apiaries.service';
|
||||||
|
import { ApiaryFieldService } from './apiary-field.service';
|
||||||
|
|
||||||
@ApiTags('apiaries')
|
@ApiTags('apiaries')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('apiaries')
|
@Controller('apiaries')
|
||||||
export class ApiariesController {
|
export class ApiariesController {
|
||||||
constructor(private readonly apiariesService: ApiariesService) {}
|
constructor(
|
||||||
|
private readonly apiariesService: ApiariesService,
|
||||||
|
private readonly apiaryFieldService: ApiaryFieldService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@RequirePermission('apiaries:read')
|
@RequirePermission('apiaries:read')
|
||||||
@Get()
|
@Get()
|
||||||
@@ -31,6 +35,24 @@ export class ApiariesController {
|
|||||||
return this.apiariesService.findAll({ status, location });
|
return this.apiariesService.findAll({ status, location });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequirePermission('colonies:read')
|
||||||
|
@Get(':id/colony-overview')
|
||||||
|
colonyOverview(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.apiaryFieldService.colonyOverview(id, actor.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('colonies:read')
|
||||||
|
@Get(':id/offline-pack')
|
||||||
|
offlinePack(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.apiaryFieldService.offlinePack(id, actor.id);
|
||||||
|
}
|
||||||
|
|
||||||
@RequirePermission('apiaries:read')
|
@RequirePermission('apiaries:read')
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ApiariesController } from './apiaries.controller';
|
import { ApiariesController } from './apiaries.controller';
|
||||||
import { ApiariesService } from './apiaries.service';
|
import { ApiariesService } from './apiaries.service';
|
||||||
|
import { ApiaryFieldService } from './apiary-field.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [ApiariesController],
|
controllers: [ApiariesController],
|
||||||
providers: [ApiariesService],
|
providers: [ApiariesService, ApiaryFieldService],
|
||||||
exports: [ApiariesService],
|
exports: [ApiariesService],
|
||||||
})
|
})
|
||||||
export class ApiariesModule {}
|
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 { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsLatitude, IsLongitude, IsOptional, IsString } from 'class-validator';
|
import {
|
||||||
|
IsLatitude,
|
||||||
|
IsLongitude,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class CreateApiaryDto {
|
export class CreateApiaryDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -35,4 +41,13 @@ export class CreateApiaryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Ort für die öffentliche Herkunftsseite, z. B. "Nordhessen". Nie die genaue Adresse.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
publicLocationLabel?: string;
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,7 @@ import { noStoreMiddleware } from './common/middleware/no-store.middleware';
|
|||||||
* Production runs client -> Cloudflare -> Traefik -> backend, so the hop
|
* Production runs client -> Cloudflare -> Traefik -> backend, so the hop
|
||||||
* count is not stable; TRUST_PROXY is an Express list there ("loopback,
|
* count is not stable; TRUST_PROXY is an Express list there ("loopback,
|
||||||
* 10.42.0.0/16, <Cloudflare IPv4+IPv6 ranges>", see
|
* 10.42.0.0/16, <Cloudflare IPv4+IPv6 ranges>", see
|
||||||
* deploy/k8s/backend/configmap.example.yaml). A plain number ("1") is a hop
|
* deploy/k8s/base/backend/configmap.example.yaml). A plain number ("1") is a hop
|
||||||
* count and stays the default when the variable is unset.
|
* count and stays the default when the variable is unset.
|
||||||
*/
|
*/
|
||||||
export function trustProxySetting(): number | string {
|
export function trustProxySetting(): number | string {
|
||||||
@@ -58,7 +58,7 @@ export function configureApp(app: NestExpressApplication): void {
|
|||||||
// proxy, so rate limits, API-key failure limits and IP allowlists act
|
// proxy, so rate limits, API-key failure limits and IP allowlists act
|
||||||
// on a shared address instead of the client.
|
// on a shared address instead of the client.
|
||||||
bootstrapLogger.warn(
|
bootstrapLogger.warn(
|
||||||
'TRUST_PROXY is not set in production - client IPs for rate limits and API-key allowlists are likely the proxy address; set it as in deploy/k8s/backend/configmap.example.yaml',
|
'TRUST_PROXY is not set in production - client IPs for rate limits and API-key allowlists are likely the proxy address; set it as in deploy/k8s/base/backend/configmap.example.yaml',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// No ETags: they let caches and proxies revalidate (and keep) personal
|
// No ETags: they let caches and proxies revalidate (and keep) personal
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { ConfigModule } from '@nestjs/config';
|
|||||||
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
import { ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerModule } from '@nestjs/throttler';
|
||||||
import { ClientIpThrottlerGuard } from './common/throttler/client-ip-throttler.guard';
|
import { ClientIpThrottlerGuard } from './common/throttler/client-ip-throttler.guard';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { PrismaThrottlerStorage } from './common/throttler/prisma-throttler.storage';
|
||||||
|
import { ThrottlerStorageModule } from './common/throttler/throttler-storage.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { AuditModule } from './audit/audit.module';
|
import { AuditModule } from './audit/audit.module';
|
||||||
|
import { DomainEventsModule } from './domain-events/domain-events.module';
|
||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { RolesModule } from './roles/roles.module';
|
import { RolesModule } from './roles/roles.module';
|
||||||
@@ -51,20 +53,27 @@ import { BackupModule } from './backup/backup.module';
|
|||||||
import { ShopSyncModule } from './shop-sync/shop-sync.module';
|
import { ShopSyncModule } from './shop-sync/shop-sync.module';
|
||||||
import { ErpExportModule } from './erp-export/erp-export.module';
|
import { ErpExportModule } from './erp-export/erp-export.module';
|
||||||
import { ApiKeysModule } from './api-keys/api-keys.module';
|
import { ApiKeysModule } from './api-keys/api-keys.module';
|
||||||
|
import { SystemModule } from './system/system.module';
|
||||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||||
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
||||||
import { PermissionsModule } from './auth/permissions/permissions.module';
|
import { PermissionsModule } from './auth/permissions/permissions.module';
|
||||||
import { IdempotencyInterceptor } from './common/idempotency/idempotency.interceptor';
|
import { IdempotencyInterceptor } from './common/idempotency/idempotency.interceptor';
|
||||||
import { IdempotencyCleanupService } from './common/idempotency/idempotency-cleanup.service';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
ScheduleModule.forRoot(),
|
ThrottlerModule.forRootAsync({
|
||||||
ThrottlerModule.forRoot([{ name: 'default', ttl: 60000, limit: 100 }]),
|
imports: [ThrottlerStorageModule],
|
||||||
|
inject: [PrismaThrottlerStorage],
|
||||||
|
useFactory: (storage: PrismaThrottlerStorage) => ({
|
||||||
|
throttlers: [{ name: 'default', ttl: 60000, limit: 100 }],
|
||||||
|
storage,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PermissionsModule,
|
PermissionsModule,
|
||||||
AuditModule,
|
AuditModule,
|
||||||
|
DomainEventsModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
@@ -110,6 +119,7 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
|
|||||||
ShopSyncModule,
|
ShopSyncModule,
|
||||||
ErpExportModule,
|
ErpExportModule,
|
||||||
ApiKeysModule,
|
ApiKeysModule,
|
||||||
|
SystemModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -119,7 +129,6 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
|
|||||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||||
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
||||||
{ provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
|
{ provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
|
||||||
IdempotencyCleanupService,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
@@ -43,6 +43,18 @@ describe('AuditLogService', () => {
|
|||||||
expect(createArg.data.userId).toBe('user-1');
|
expect(createArg.data.userId).toBe('user-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('writes through the transaction client when one is given', async () => {
|
||||||
|
const tx = { auditLog: { create: jest.fn() } };
|
||||||
|
|
||||||
|
await service.record(
|
||||||
|
{ entityType: 'Inspection', entityId: 'i1', action: 'CREATE' },
|
||||||
|
tx as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tx.auditLog.create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('findAll filters by entity type and id', async () => {
|
it('findAll filters by entity type and id', async () => {
|
||||||
await service.findAll({ entityType: 'Colony', entityId: 'colony-1' });
|
await service.findAll({ entityType: 'Colony', entityId: 'colony-1' });
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { DbClient } from '../prisma/db-client';
|
||||||
import { AuditAction, AuditLog, Prisma } from '../../generated/prisma';
|
import { AuditAction, AuditLog, Prisma } from '../../generated/prisma';
|
||||||
|
|
||||||
interface RecordAuditLogInput {
|
export interface RecordAuditLogInput {
|
||||||
entityType: string;
|
entityType: string;
|
||||||
entityId: string;
|
entityId: string;
|
||||||
action: AuditAction;
|
action: AuditAction;
|
||||||
@@ -20,8 +21,17 @@ export interface AuditLogFilter {
|
|||||||
export class AuditLogService {
|
export class AuditLogService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async record(input: RecordAuditLogInput): Promise<void> {
|
/**
|
||||||
await this.prisma.auditLog.create({
|
* With `tx` the entry commits or rolls back together with the change it
|
||||||
|
* describes - no audited change without its entry, and no entry for a
|
||||||
|
* change that never happened.
|
||||||
|
*/
|
||||||
|
async record(
|
||||||
|
input: RecordAuditLogInput,
|
||||||
|
tx?: Prisma.TransactionClient,
|
||||||
|
): Promise<void> {
|
||||||
|
const db: DbClient = tx ?? this.prisma;
|
||||||
|
await db.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
entityType: input.entityType,
|
entityType: input.entityType,
|
||||||
entityId: input.entityId,
|
entityId: input.entityId,
|
||||||
|
|||||||
@@ -1,37 +1,114 @@
|
|||||||
import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util';
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { KEY_ID_PATTERN } from '../../common/crypto/key-ring';
|
||||||
|
import {
|
||||||
|
decryptMfaSecret,
|
||||||
|
encryptMfaSecret,
|
||||||
|
mfaSecretNeedsRekey,
|
||||||
|
} from './mfa-crypto.util';
|
||||||
|
|
||||||
|
// Written by the code before the key ring (AES key sha256(JWT_ACCESS_SECRET),
|
||||||
|
// no key id) with JWT_ACCESS_SECRET = 'x' * 40.
|
||||||
|
const LEGACY_SECRET =
|
||||||
|
'zJD2lAy1b/wslLyQ.hn8scDeaxIADXlRFtcGIyg==.3+5qbYM52lKKN/VXpInUOQ==';
|
||||||
|
const PLAINTEXT = 'JBSWY3DPEHPK3PXP';
|
||||||
|
|
||||||
describe('mfa-crypto.util', () => {
|
describe('mfa-crypto.util', () => {
|
||||||
const originalSecret = process.env.JWT_ACCESS_SECRET;
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret';
|
process.env.JWT_ACCESS_SECRET = 'x'.repeat(40);
|
||||||
|
process.env.MFA_ENCRYPTION_KEY = 'm'.repeat(40);
|
||||||
|
delete process.env.MFA_ENCRYPTION_KEY_PREVIOUS;
|
||||||
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
process.env.JWT_ACCESS_SECRET = originalSecret;
|
process.env = { ...originalEnv };
|
||||||
|
jest.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('round-trips a TOTP secret through encrypt/decrypt', () => {
|
it('round-trips a TOTP secret and prefixes the key id', () => {
|
||||||
const plaintext = 'JBSWY3DPEHPK3PXP';
|
const ciphertext = encryptMfaSecret(PLAINTEXT);
|
||||||
const ciphertext = encryptMfaSecret(plaintext);
|
|
||||||
expect(ciphertext).not.toContain(plaintext);
|
expect(ciphertext).toMatch(KEY_ID_PATTERN);
|
||||||
expect(decryptMfaSecret(ciphertext)).toBe(plaintext);
|
expect(ciphertext).not.toContain(PLAINTEXT);
|
||||||
|
expect(decryptMfaSecret(ciphertext)).toBe(PLAINTEXT);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('produces a different ciphertext every time (random IV)', () => {
|
it('produces a different ciphertext every time (random IV)', () => {
|
||||||
const plaintext = 'JBSWY3DPEHPK3PXP';
|
expect(encryptMfaSecret(PLAINTEXT)).not.toBe(encryptMfaSecret(PLAINTEXT));
|
||||||
expect(encryptMfaSecret(plaintext)).not.toBe(encryptMfaSecret(plaintext));
|
});
|
||||||
|
|
||||||
|
it('no longer depends on JWT_ACCESS_SECRET', () => {
|
||||||
|
const ciphertext = encryptMfaSecret(PLAINTEXT);
|
||||||
|
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
|
||||||
|
|
||||||
|
expect(decryptMfaSecret(ciphertext)).toBe(PLAINTEXT);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails to decrypt with the wrong key', () => {
|
it('fails to decrypt with the wrong key', () => {
|
||||||
const ciphertext = encryptMfaSecret('JBSWY3DPEHPK3PXP');
|
const ciphertext = encryptMfaSecret(PLAINTEXT);
|
||||||
process.env.JWT_ACCESS_SECRET = 'a-different-secret';
|
process.env.MFA_ENCRYPTION_KEY = 'n'.repeat(40);
|
||||||
expect(() => decryptMfaSecret(ciphertext)).toThrow();
|
|
||||||
|
expect(() => decryptMfaSecret(ciphertext)).toThrow('unknown key id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens a secret sealed with MFA_ENCRYPTION_KEY_PREVIOUS and flags it for rekey', () => {
|
||||||
|
const ciphertext = encryptMfaSecret(PLAINTEXT);
|
||||||
|
process.env.MFA_ENCRYPTION_KEY_PREVIOUS = process.env.MFA_ENCRYPTION_KEY;
|
||||||
|
process.env.MFA_ENCRYPTION_KEY = 'n'.repeat(40);
|
||||||
|
|
||||||
|
expect(decryptMfaSecret(ciphertext)).toBe(PLAINTEXT);
|
||||||
|
expect(mfaSecretNeedsRekey(ciphertext)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not flag a secret sealed with the current key', () => {
|
||||||
|
expect(mfaSecretNeedsRekey(encryptMfaSecret(PLAINTEXT))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still opens a legacy MFA secret encrypted with the JWT-derived key', () => {
|
||||||
|
expect(decryptMfaSecret(LEGACY_SECRET)).toBe(PLAINTEXT);
|
||||||
|
expect(mfaSecretNeedsRekey(LEGACY_SECRET)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed on a legacy secret once JWT_ACCESS_SECRET changed', () => {
|
||||||
|
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
|
||||||
|
|
||||||
|
expect(() => decryptMfaSecret(LEGACY_SECRET)).toThrow(
|
||||||
|
'ciphertext failed authentication',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never retries a k…: value that fails authentication with the legacy key', () => {
|
||||||
|
const warn = jest.spyOn(Logger.prototype, 'warn');
|
||||||
|
const currentKeyId = encryptMfaSecret(PLAINTEXT).split(':')[0];
|
||||||
|
// A valid legacy ciphertext behind the current key id: only a retry with
|
||||||
|
// sha256(JWT_ACCESS_SECRET) could open it.
|
||||||
|
const disguised = `${currentKeyId}:${LEGACY_SECRET}`;
|
||||||
|
|
||||||
|
expect(() => decryptMfaSecret(disguised)).toThrow(
|
||||||
|
'ciphertext failed authentication',
|
||||||
|
);
|
||||||
|
expect(() => decryptMfaSecret(`k00000000:${LEGACY_SECRET}`)).toThrow(
|
||||||
|
'unknown key id',
|
||||||
|
);
|
||||||
|
expect(warn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails on a tampered ciphertext', () => {
|
||||||
|
const [keyId, body] = encryptMfaSecret(PLAINTEXT).split(':');
|
||||||
|
const [iv, tag, data] = body.split('.');
|
||||||
|
const bytes = Buffer.from(data, 'base64');
|
||||||
|
bytes[0] ^= 0xff;
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
decryptMfaSecret(`${keyId}:${iv}.${tag}.${bytes.toString('base64')}`),
|
||||||
|
).toThrow('ciphertext failed authentication');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a malformed ciphertext', () => {
|
it('rejects a malformed ciphertext', () => {
|
||||||
expect(() => decryptMfaSecret('not-a-valid-ciphertext')).toThrow(
|
expect(() => decryptMfaSecret('not-a-valid-ciphertext')).toThrow(
|
||||||
'Invalid MFA secret ciphertext',
|
'malformed ciphertext',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,53 +1,31 @@
|
|||||||
import {
|
import {
|
||||||
createCipheriv,
|
KEY_ID_PATTERN,
|
||||||
createDecipheriv,
|
isSealedWithCurrentKey,
|
||||||
createHash,
|
loadKeyRing,
|
||||||
randomBytes,
|
openLegacyCiphertext,
|
||||||
} from 'node:crypto';
|
openWithRing,
|
||||||
|
sealWithRing,
|
||||||
const ALGORITHM = 'aes-256-gcm';
|
} from '../../common/crypto/key-ring';
|
||||||
const IV_BYTES = 12;
|
|
||||||
|
|
||||||
function getKey(): Buffer {
|
|
||||||
const secret = process.env.JWT_ACCESS_SECRET;
|
|
||||||
if (!secret) {
|
|
||||||
throw new Error('JWT_ACCESS_SECRET is not set');
|
|
||||||
}
|
|
||||||
return createHash('sha256').update(secret).digest();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TOTP secrets must be reversible (unlike passwords/refresh tokens, which
|
* TOTP secrets must be reversible (unlike passwords/refresh tokens, which
|
||||||
* are one-way hashed) so the server can compute the expected code on every
|
* are one-way hashed) so the server can compute the expected code on every
|
||||||
* login. AES-256-GCM at rest, keyed off JWT_ACCESS_SECRET, keeps the secret
|
* login. They are sealed with the MFA key ring (MFA_ENCRYPTION_KEY, see
|
||||||
* out of the DB in plaintext without introducing a new secret to manage.
|
* common/crypto/key-ring.ts). Secrets stored before the key ring have no key
|
||||||
|
* id and were encrypted with sha256(JWT_ACCESS_SECRET); they stay readable
|
||||||
|
* until the rekey job reseals them.
|
||||||
*/
|
*/
|
||||||
export function encryptMfaSecret(plaintext: string): string {
|
export function encryptMfaSecret(plaintext: string): string {
|
||||||
const iv = randomBytes(IV_BYTES);
|
return sealWithRing(loadKeyRing('mfa'), plaintext);
|
||||||
const cipher = createCipheriv(ALGORITHM, getKey(), iv);
|
|
||||||
const encrypted = Buffer.concat([
|
|
||||||
cipher.update(plaintext, 'utf8'),
|
|
||||||
cipher.final(),
|
|
||||||
]);
|
|
||||||
const authTag = cipher.getAuthTag();
|
|
||||||
return [iv, authTag, encrypted]
|
|
||||||
.map((buffer) => buffer.toString('base64'))
|
|
||||||
.join('.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decryptMfaSecret(ciphertext: string): string {
|
export function decryptMfaSecret(stored: string): string {
|
||||||
const [ivB64, authTagB64, dataB64] = ciphertext.split('.');
|
return KEY_ID_PATTERN.test(stored)
|
||||||
if (!ivB64 || !authTagB64 || !dataB64) {
|
? openWithRing(loadKeyRing('mfa'), stored).plaintext
|
||||||
throw new Error('Invalid MFA secret ciphertext');
|
: openLegacyCiphertext('mfa', stored);
|
||||||
}
|
}
|
||||||
const decipher = createDecipheriv(
|
|
||||||
ALGORITHM,
|
/** True for secrets the rekey job should reseal with the current key. */
|
||||||
getKey(),
|
export function mfaSecretNeedsRekey(stored: string): boolean {
|
||||||
Buffer.from(ivB64, 'base64'),
|
return !isSealedWithCurrentKey(loadKeyRing('mfa'), stored);
|
||||||
);
|
|
||||||
decipher.setAuthTag(Buffer.from(authTagB64, 'base64'));
|
|
||||||
return Buffer.concat([
|
|
||||||
decipher.update(Buffer.from(dataB64, 'base64')),
|
|
||||||
decipher.final(),
|
|
||||||
]).toString('utf8');
|
|
||||||
}
|
}
|
||||||
@@ -361,6 +361,15 @@ export const PERMISSION_CATALOG = [
|
|||||||
[['manage', 'Anlegen, ansehen und widerrufen']],
|
[['manage', 'Anlegen, ansehen und widerrufen']],
|
||||||
3400,
|
3400,
|
||||||
),
|
),
|
||||||
|
...group(
|
||||||
|
'System',
|
||||||
|
'system',
|
||||||
|
[
|
||||||
|
['read', 'Hintergrundjobs und Warteschlangen ansehen'],
|
||||||
|
['manage', 'Fehlgeschlagene Ereignisse erneut anstoßen'],
|
||||||
|
],
|
||||||
|
3500,
|
||||||
|
),
|
||||||
// Like shop-sync: a narrow read-only allowlist for one machine consumer
|
// Like shop-sync: a narrow read-only allowlist for one machine consumer
|
||||||
// (ERPNext's nightly inventory mirror), separate from products:read etc.,
|
// (ERPNext's nightly inventory mirror), separate from products:read etc.,
|
||||||
// which return descriptions and full records.
|
// which return descriptions and full records.
|
||||||
@@ -368,7 +377,7 @@ export const PERMISSION_CATALOG = [
|
|||||||
'ERP-Anbindung',
|
'ERP-Anbindung',
|
||||||
'erp-inventory',
|
'erp-inventory',
|
||||||
[['read', 'Inventar-Spiegel für ERPNext lesen']],
|
[['read', 'Inventar-Spiegel für ERPNext lesen']],
|
||||||
3500,
|
3600,
|
||||||
),
|
),
|
||||||
] as const satisfies readonly PermissionCatalogEntry[];
|
] 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 { Module } from '@nestjs/common';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
import { StockModule } from '../stock/stock.module';
|
||||||
|
import { TasksModule } from '../tasks/tasks.module';
|
||||||
import { AutomationController } from './automation.controller';
|
import { AutomationController } from './automation.controller';
|
||||||
|
import { AutomationEventHandlers } from './automation.handlers';
|
||||||
import { AutomationService } from './automation.service';
|
import { AutomationService } from './automation.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automation rules and the handlers that evaluate them on domain events.
|
||||||
|
* Rule tasks go through TasksService (audit entry, TaskCreated - and with
|
||||||
|
* it calendar entry and task.created webhook), so no WebhooksService here.
|
||||||
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [NotificationsModule],
|
imports: [TasksModule, NotificationsModule, StockModule],
|
||||||
controllers: [AutomationController],
|
controllers: [AutomationController],
|
||||||
providers: [AutomationService],
|
providers: [AutomationService, AutomationEventHandlers],
|
||||||
exports: [AutomationService],
|
exports: [AutomationService],
|
||||||
})
|
})
|
||||||
export class AutomationModule {}
|
export class AutomationModule {}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,13 +1,14 @@
|
|||||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { assertTransactionClient, DbClient } from '../prisma/db-client';
|
||||||
import { runAfterCommit } from '../common/after-commit';
|
import { runAfterCommit } from '../common/after-commit';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.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 {
|
import {
|
||||||
AutomationRule,
|
AutomationRule,
|
||||||
AutomationTriggerType,
|
AutomationTriggerType,
|
||||||
Colony,
|
|
||||||
Inspection,
|
Inspection,
|
||||||
Feeding,
|
Feeding,
|
||||||
Prisma,
|
Prisma,
|
||||||
@@ -20,6 +21,9 @@ import {
|
|||||||
} from '../../generated/prisma';
|
} from '../../generated/prisma';
|
||||||
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
|
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
|
||||||
import { formatGeneratedNumber } from './number-format';
|
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. */
|
/** The rules that fill in a batch/lot number instead of creating tasks. */
|
||||||
export type NumberFormatTrigger =
|
export type NumberFormatTrigger =
|
||||||
@@ -171,29 +175,33 @@ export class AutomationService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
private readonly webhooks: WebhooksService,
|
private readonly tasks: TasksService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async ensureDefaults(): Promise<void> {
|
/**
|
||||||
for (const triggerType of Object.keys(
|
* Creates the rules that do not exist yet, on the given client - inside a
|
||||||
DEFAULT_RULES,
|
* handler or legacy transaction too. One INSERT … ON CONFLICT DO NOTHING:
|
||||||
) as AutomationTriggerType[]) {
|
* 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];
|
const defaults = DEFAULT_RULES[triggerType];
|
||||||
await this.prisma.automationRule.upsert({
|
return {
|
||||||
where: { triggerType },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
triggerType,
|
triggerType,
|
||||||
active: defaults.active ?? true,
|
active: defaults.active ?? true,
|
||||||
config: defaults.config as Prisma.InputJsonValue,
|
config: defaults.config as Prisma.InputJsonValue,
|
||||||
actionTemplate: defaults.actionTemplate,
|
actionTemplate: defaults.actionTemplate,
|
||||||
},
|
};
|
||||||
|
}),
|
||||||
|
skipDuplicates: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async findAll(): Promise<AutomationRuleWithSequences[]> {
|
async findAll(): Promise<AutomationRuleWithSequences[]> {
|
||||||
await this.ensureDefaults();
|
await this.ensureDefaults(this.prisma);
|
||||||
return this.prisma.automationRule.findMany({
|
return this.prisma.automationRule.findMany({
|
||||||
orderBy: { triggerType: 'asc' },
|
orderBy: { triggerType: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
@@ -244,27 +252,18 @@ export class AutomationService {
|
|||||||
return rule;
|
return rule;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// -------------------------------------------------------------------------
|
||||||
* Every evaluate*() runs after the triggering record is committed and is
|
// Rule evaluation
|
||||||
* called by the domain services without a guard - so it never throws (see
|
//
|
||||||
* runAfterCommit). A failed evaluation is logged, the request succeeds.
|
// One core per trigger, on the caller's transaction: the domain event
|
||||||
*/
|
// handlers (AutomationEventHandlers) call it with their delivery
|
||||||
private evaluateAfterCommit(
|
// transaction, the legacy wrappers below with a transaction of their own.
|
||||||
entityType: string,
|
// -------------------------------------------------------------------------
|
||||||
entityId: string,
|
|
||||||
evaluate: () => Promise<void>,
|
|
||||||
): Promise<void> {
|
|
||||||
return runAfterCommit(
|
|
||||||
this.logger,
|
|
||||||
{ action: 'Automation rule evaluation', entityType, entityId },
|
|
||||||
evaluate,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async evaluateInspection(inspection: Inspection): Promise<void> {
|
async evaluateInspectionIn(tx: Tx, inspection: Inspection): Promise<void> {
|
||||||
await this.evaluateAfterCommit('Inspection', inspection.id, async () => {
|
assertTransactionClient(tx, 'AutomationService.evaluateInspectionIn');
|
||||||
await this.ensureDefaults();
|
await this.ensureDefaults(tx);
|
||||||
const rules = await this.prisma.automationRule.findMany({
|
const rules = await tx.automationRule.findMany({
|
||||||
where: {
|
where: {
|
||||||
triggerType: { in: ['QUEENLESS_NO_EGGS', 'FEED_STATUS_CRITICAL'] },
|
triggerType: { in: ['QUEENLESS_NO_EGGS', 'FEED_STATUS_CRITICAL'] },
|
||||||
active: true,
|
active: true,
|
||||||
@@ -278,7 +277,8 @@ export class AutomationService {
|
|||||||
inspection.queenSeen === false &&
|
inspection.queenSeen === false &&
|
||||||
inspection.eggsPresent === false
|
inspection.eggsPresent === false
|
||||||
) {
|
) {
|
||||||
await this.triggerAction(
|
await this.triggerActionIn(
|
||||||
|
tx,
|
||||||
queenlessRule,
|
queenlessRule,
|
||||||
'COLONY',
|
'COLONY',
|
||||||
inspection.colonyId,
|
inspection.colonyId,
|
||||||
@@ -288,97 +288,104 @@ export class AutomationService {
|
|||||||
|
|
||||||
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
|
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
|
||||||
if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') {
|
if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') {
|
||||||
await this.triggerAction(
|
await this.triggerActionIn(
|
||||||
|
tx,
|
||||||
feedRule,
|
feedRule,
|
||||||
'COLONY',
|
'COLONY',
|
||||||
inspection.colonyId,
|
inspection.colonyId,
|
||||||
inspection.id,
|
inspection.id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async evaluateVarroaMeasurement(
|
async evaluateVarroaMeasurementIn(
|
||||||
|
tx: Tx,
|
||||||
measurement: VarroaMeasurement,
|
measurement: VarroaMeasurement,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.evaluateAfterCommit(
|
assertTransactionClient(
|
||||||
'VarroaMeasurement',
|
tx,
|
||||||
measurement.id,
|
'AutomationService.evaluateVarroaMeasurementIn',
|
||||||
async () => {
|
);
|
||||||
await this.ensureDefaults();
|
await this.ensureDefaults(tx);
|
||||||
const rule = await this.prisma.automationRule.findUnique({
|
const rule = await tx.automationRule.findUnique({
|
||||||
where: { triggerType: 'VARROA_THRESHOLD' },
|
where: { triggerType: 'VARROA_THRESHOLD' },
|
||||||
});
|
});
|
||||||
if (!rule || !rule.active) {
|
// null for an inactive rule; the one source of the default value.
|
||||||
|
const threshold = varroaThresholdOf(rule);
|
||||||
|
if (!rule || threshold === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The threshold is a natural mite fall rate (mites per day). Only
|
// The threshold is a natural mite fall rate (mites per day). Only that
|
||||||
// that method yields a value in this unit - a mite count from a
|
// method yields a value in this unit - a mite count from a sugar roll or
|
||||||
// sugar roll or wash is per bee sample and would be compared against
|
// wash is per bee sample and would be compared against the wrong scale,
|
||||||
// the wrong scale, as would a raw board count without the number of
|
// as would a raw board count without the number of days it covered.
|
||||||
// days it covered.
|
|
||||||
if (measurement.method !== 'NATURAL_MITE_FALL') {
|
if (measurement.method !== 'NATURAL_MITE_FALL') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const config = rule.config as { thresholdMitesPerDay?: number } | null;
|
|
||||||
const threshold = config?.thresholdMitesPerDay ?? 10;
|
|
||||||
const value = measurement.calculatedValue;
|
const value = measurement.calculatedValue;
|
||||||
if (value !== null && value >= threshold) {
|
if (value !== null && value >= threshold) {
|
||||||
await this.triggerAction(rule, 'COLONY', measurement.colonyId);
|
await this.triggerActionIn(tx, rule, 'COLONY', measurement.colonyId);
|
||||||
}
|
}
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async evaluateSwarmControlCheck(check: SwarmControlCheck): Promise<void> {
|
async evaluateSwarmControlCheckIn(
|
||||||
await this.evaluateAfterCommit('SwarmControlCheck', check.id, async () => {
|
tx: Tx,
|
||||||
await this.ensureDefaults();
|
check: SwarmControlCheck,
|
||||||
const rule = await this.prisma.automationRule.findUnique({
|
): Promise<void> {
|
||||||
|
assertTransactionClient(
|
||||||
|
tx,
|
||||||
|
'AutomationService.evaluateSwarmControlCheckIn',
|
||||||
|
);
|
||||||
|
await this.ensureDefaults(tx);
|
||||||
|
const rule = await tx.automationRule.findUnique({
|
||||||
where: { triggerType: 'SWARM_MOOD_HIGH' },
|
where: { triggerType: 'SWARM_MOOD_HIGH' },
|
||||||
});
|
});
|
||||||
if (!rule || !rule.active) {
|
if (!rule || !rule.active) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (check.swarmMood === 'HIGH') {
|
if (check.swarmMood === 'HIGH') {
|
||||||
await this.triggerAction(rule, 'COLONY', check.colonyId);
|
await this.triggerActionIn(tx, rule, 'COLONY', check.colonyId);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fires only on the transition into CRITICAL, not on every edit while already critical. */
|
/** Fires only on the transition into CRITICAL, not on every edit while already critical. */
|
||||||
async evaluateColonyHealth(before: Colony, after: Colony): Promise<void> {
|
async evaluateColonyHealthIn(
|
||||||
await this.evaluateAfterCommit('Colony', after.id, async () => {
|
tx: Tx,
|
||||||
await this.ensureDefaults();
|
colonyId: string,
|
||||||
if (
|
from: ColonyStateSnapshot,
|
||||||
after.healthStatus !== 'CRITICAL' ||
|
to: ColonyStateSnapshot,
|
||||||
before.healthStatus === 'CRITICAL'
|
): 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;
|
return;
|
||||||
}
|
}
|
||||||
const rule = await this.prisma.automationRule.findUnique({
|
await this.ensureDefaults(tx);
|
||||||
|
const rule = await tx.automationRule.findUnique({
|
||||||
where: { triggerType: 'COLONY_HEALTH_CRITICAL' },
|
where: { triggerType: 'COLONY_HEALTH_CRITICAL' },
|
||||||
});
|
});
|
||||||
if (!rule || !rule.active) {
|
if (!rule || !rule.active) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.triggerAction(rule, 'COLONY', after.id);
|
await this.triggerActionIn(tx, rule, 'COLONY', colonyId);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async evaluateStockItem(item: StockItem): Promise<void> {
|
async evaluateStockItemIn(tx: Tx, item: StockItem): Promise<void> {
|
||||||
await this.evaluateAfterCommit('StockItem', item.id, async () => {
|
assertTransactionClient(tx, 'AutomationService.evaluateStockItemIn');
|
||||||
await this.ensureDefaults();
|
// Every movement of the item comes here: only one below its minimum
|
||||||
|
// needs the rule.
|
||||||
if (item.minThreshold === null || item.quantity >= item.minThreshold) {
|
if (item.minThreshold === null || item.quantity >= item.minThreshold) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rule = await this.prisma.automationRule.findUnique({
|
await this.ensureDefaults(tx);
|
||||||
|
const rule = await tx.automationRule.findUnique({
|
||||||
where: { triggerType: 'STOCK_ITEM_LOW' },
|
where: { triggerType: 'STOCK_ITEM_LOW' },
|
||||||
});
|
});
|
||||||
if (!rule || !rule.active) {
|
if (!rule || !rule.active) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.triggerAction(rule, 'STORAGE', item.id);
|
await this.triggerActionIn(tx, rule, 'STORAGE', item.id);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -388,13 +395,14 @@ export class AutomationService {
|
|||||||
* the remedy - so it hangs on GENERAL with the kind as relatedId, which
|
* the remedy - so it hangs on GENERAL with the kind as relatedId, which
|
||||||
* keeps one open task per kind.
|
* keeps one open task per kind.
|
||||||
*/
|
*/
|
||||||
async evaluateRawMaterialLow(
|
async evaluateRawMaterialLowIn(
|
||||||
|
tx: Tx,
|
||||||
kind: RawMaterialKind,
|
kind: RawMaterialKind,
|
||||||
remainingGrams: number,
|
remainingGrams: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.evaluateAfterCommit('RawMaterialKind', kind, async () => {
|
assertTransactionClient(tx, 'AutomationService.evaluateRawMaterialLowIn');
|
||||||
await this.ensureDefaults();
|
await this.ensureDefaults(tx);
|
||||||
const rule = await this.prisma.automationRule.findUnique({
|
const rule = await tx.automationRule.findUnique({
|
||||||
where: { triggerType: 'RAW_MATERIAL_LOW' },
|
where: { triggerType: 'RAW_MATERIAL_LOW' },
|
||||||
});
|
});
|
||||||
if (!rule || !rule.active) {
|
if (!rule || !rule.active) {
|
||||||
@@ -407,54 +415,92 @@ export class AutomationService {
|
|||||||
if (typeof minGrams !== 'number' || remainingGrams >= minGrams) {
|
if (typeof minGrams !== 'number' || remainingGrams >= minGrams) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.triggerAction(rule, 'GENERAL', kind, undefined, {
|
await this.triggerActionIn(tx, rule, 'GENERAL', kind, undefined, {
|
||||||
description: `${RAW_MATERIAL_KIND_LABELS[kind]}: noch ${remainingGrams} g auf Lager, Mindestmenge ${minGrams} g.`,
|
description: `${RAW_MATERIAL_KIND_LABELS[kind]}: noch ${remainingGrams} g auf Lager, Mindestmenge ${minGrams} g.`,
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async evaluateTreatment(treatment: Treatment): Promise<void> {
|
async evaluateTreatmentIn(tx: Tx, treatment: Treatment): Promise<void> {
|
||||||
await this.evaluateAfterCommit('Treatment', treatment.id, () =>
|
assertTransactionClient(tx, 'AutomationService.evaluateTreatmentIn');
|
||||||
this.evaluateFollowUp(
|
const measureDate = treatment.endDate ?? treatment.date;
|
||||||
|
await this.evaluateFollowUpIn(
|
||||||
|
tx,
|
||||||
'TREATMENT_FOLLOW_UP',
|
'TREATMENT_FOLLOW_UP',
|
||||||
treatment.colonyId,
|
treatment.colonyId,
|
||||||
treatment.endDate ?? treatment.date,
|
measureDate,
|
||||||
`Nach Behandlung mit ${treatment.product} am ${formatDay(treatment.endDate ?? treatment.date)}.`,
|
`Nach Behandlung mit ${treatment.product} am ${formatDay(measureDate)}.`,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async evaluateFeeding(feeding: Feeding): Promise<void> {
|
async evaluateFeedingIn(tx: Tx, feeding: Feeding): Promise<void> {
|
||||||
await this.evaluateAfterCommit('Feeding', feeding.id, () =>
|
assertTransactionClient(tx, 'AutomationService.evaluateFeedingIn');
|
||||||
this.evaluateFollowUp(
|
await this.evaluateFollowUpIn(
|
||||||
|
tx,
|
||||||
'FEEDING_FOLLOW_UP',
|
'FEEDING_FOLLOW_UP',
|
||||||
feeding.colonyId,
|
feeding.colonyId,
|
||||||
feeding.date,
|
feeding.date,
|
||||||
`Nach Fütterung (${feeding.amount} ${feeding.unit}) am ${formatDay(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',
|
triggerType: 'TREATMENT_FOLLOW_UP' | 'FEEDING_FOLLOW_UP',
|
||||||
colonyId: string,
|
colonyId: string,
|
||||||
measureDate: Date,
|
measureDate: Date,
|
||||||
description: string,
|
description: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.ensureDefaults();
|
await this.ensureDefaults(tx);
|
||||||
const rule = await this.prisma.automationRule.findUnique({
|
const rule = await tx.automationRule.findUnique({
|
||||||
where: { triggerType },
|
where: { triggerType },
|
||||||
});
|
});
|
||||||
if (!rule || !rule.active) {
|
if (!rule || !rule.active) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.triggerAction(rule, 'COLONY', colonyId, undefined, {
|
await this.triggerActionIn(tx, rule, 'COLONY', colonyId, undefined, {
|
||||||
baseDate: measureDate,
|
baseDate: measureDate,
|
||||||
description,
|
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
|
// Returns null (never throws) when the rule is inactive or has no format
|
||||||
// configured, so the caller can fall back to requiring manual entry.
|
// configured, so the caller can fall back to requiring manual entry.
|
||||||
//
|
//
|
||||||
@@ -466,7 +512,7 @@ export class AutomationService {
|
|||||||
triggerType: NumberFormatTrigger,
|
triggerType: NumberFormatTrigger,
|
||||||
options: { scope?: string; vars?: Record<string, string> } = {},
|
options: { scope?: string; vars?: Record<string, string> } = {},
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
await this.ensureDefaults();
|
await this.ensureDefaults(this.prisma);
|
||||||
const rule = await this.prisma.automationRule.findUnique({
|
const rule = await this.prisma.automationRule.findUnique({
|
||||||
where: { triggerType },
|
where: { triggerType },
|
||||||
});
|
});
|
||||||
@@ -547,29 +593,30 @@ 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
|
* 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
|
* 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.
|
* (the next removal below minimum, a second high varroa count) add nothing.
|
||||||
* Repeating the same alarm would only bury the one task that matters.
|
* 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:
|
* The check runs under a lock on the rule's row, held until the caller
|
||||||
* two evaluations at the same moment (parallel requests, several pods)
|
* commits: two evaluations at the same moment (parallel requests, a
|
||||||
* would otherwise both see "no open task" and both create one. The lock
|
* handler and a legacy path, several pods) would otherwise both see
|
||||||
* is per rule and held for two short statements; webhook, warning and
|
* "no open task" and both create one.
|
||||||
* audit entry follow after the commit.
|
|
||||||
*/
|
*/
|
||||||
private async triggerAction(
|
private async triggerActionIn(
|
||||||
|
tx: Tx,
|
||||||
rule: AutomationRule,
|
rule: AutomationRule,
|
||||||
relatedType: TaskRelatedType,
|
relatedType: TaskRelatedType,
|
||||||
relatedId: string,
|
relatedId: string,
|
||||||
sourceInspectionId?: string,
|
sourceInspectionId?: string,
|
||||||
options: { baseDate?: Date; description?: string } = {},
|
options: { baseDate?: Date; description?: string } = {},
|
||||||
): Promise<void> {
|
): 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`;
|
await tx.$queryRaw`SELECT id FROM "AutomationRule" WHERE id = ${rule.id} FOR UPDATE`;
|
||||||
const alreadyOpen = await tx.task.findFirst({
|
const alreadyOpen = await tx.task.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -581,10 +628,13 @@ export class AutomationService {
|
|||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (alreadyOpen) {
|
if (alreadyOpen) {
|
||||||
return null;
|
return;
|
||||||
}
|
}
|
||||||
return tx.task.create({
|
const title = rule.actionTemplate ?? 'Automatisierte Aufgabe';
|
||||||
data: {
|
const config = rule.config as { dueInDays?: unknown } | null;
|
||||||
|
const task = await this.tasks.createInTx(
|
||||||
|
tx,
|
||||||
|
{
|
||||||
title,
|
title,
|
||||||
priority: 'HIGH',
|
priority: 'HIGH',
|
||||||
relatedType,
|
relatedType,
|
||||||
@@ -594,19 +644,14 @@ export class AutomationService {
|
|||||||
description: options.description,
|
description: options.description,
|
||||||
dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate),
|
dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate),
|
||||||
},
|
},
|
||||||
});
|
null,
|
||||||
});
|
);
|
||||||
if (!task) {
|
await this.notifications.record(
|
||||||
return;
|
{ priority: 'WARNUNG', message: title, relatedType, relatedId },
|
||||||
}
|
tx,
|
||||||
await this.webhooks.dispatch('task.created', task);
|
);
|
||||||
await this.notifications.record({
|
await this.auditLog.record(
|
||||||
priority: 'WARNUNG',
|
{
|
||||||
message: title,
|
|
||||||
relatedType,
|
|
||||||
relatedId,
|
|
||||||
});
|
|
||||||
await this.auditLog.record({
|
|
||||||
entityType: 'AutomationRule',
|
entityType: 'AutomationRule',
|
||||||
entityId: rule.id,
|
entityId: rule.id,
|
||||||
action: 'CREATE',
|
action: 'CREATE',
|
||||||
@@ -616,6 +661,8 @@ export class AutomationService {
|
|||||||
relatedId,
|
relatedId,
|
||||||
triggerType: rule.triggerType,
|
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 { BadRequestException, Logger } from '@nestjs/common';
|
||||||
import { isSealedBackup, openBackup, sealBackup } from './backup-crypto';
|
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', () => {
|
describe('backup-crypto', () => {
|
||||||
const originalEnv = { ...process.env };
|
const originalEnv = { ...process.env };
|
||||||
@@ -12,10 +48,13 @@ describe('backup-crypto', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env.JWT_ACCESS_SECRET = 'x'.repeat(40);
|
process.env.JWT_ACCESS_SECRET = 'x'.repeat(40);
|
||||||
delete process.env.BACKUP_ENCRYPTION_KEY;
|
delete process.env.BACKUP_ENCRYPTION_KEY;
|
||||||
|
delete process.env.BACKUP_ENCRYPTION_KEY_PREVIOUS;
|
||||||
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
process.env = { ...originalEnv };
|
process.env = { ...originalEnv };
|
||||||
|
jest.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('round-trips a bundle and hides its contents', () => {
|
it('round-trips a bundle and hides its contents', () => {
|
||||||
@@ -45,7 +84,17 @@ describe('backup-crypto', () => {
|
|||||||
).toThrow(BadRequestException);
|
).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', () => {
|
it('rejects a file sealed with another key', () => {
|
||||||
|
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
|
||||||
const sealed = sealBackup(bundle, bundle.exportedAt);
|
const sealed = sealBackup(bundle, bundle.exportedAt);
|
||||||
process.env.BACKUP_ENCRYPTION_KEY = 'y'.repeat(40);
|
process.env.BACKUP_ENCRYPTION_KEY = 'y'.repeat(40);
|
||||||
|
|
||||||
@@ -64,4 +113,80 @@ describe('backup-crypto', () => {
|
|||||||
process.env.BACKUP_ENCRYPTION_KEY = 'short';
|
process.env.BACKUP_ENCRYPTION_KEY = 'short';
|
||||||
expect(() => sealBackup(bundle, bundle.exportedAt)).toThrow();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,17 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import * as crypto from 'node:crypto';
|
import * as crypto from 'node:crypto';
|
||||||
import * as zlib from 'node:zlib';
|
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,
|
* 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
|
* keeps it unreadable outside ApiCore AND makes any change to it (or a
|
||||||
* hand-made file) fail authentication before a single row is touched.
|
* hand-made file) fail authentication before a single row is touched.
|
||||||
*
|
*
|
||||||
* Key: BACKUP_ENCRYPTION_KEY when set (needed to restore into another
|
* Keys follow the key ring (common/crypto/key-ring.ts): BACKUP_ENCRYPTION_KEY
|
||||||
* deployment), otherwise derived from JWT_ACCESS_SECRET - so a deployment
|
* seals, BACKUP_ENCRYPTION_KEY_PREVIOUS still opens, and files sealed before
|
||||||
* can always restore its own backups. Either way the AES key comes from
|
* the key was mandatory - with a key derived from JWT_ACCESS_SECRET - are
|
||||||
* HKDF with a backup-specific label, never the raw secret.
|
* 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_FORMAT = 'apicore-backup';
|
||||||
export const SEALED_BACKUP_VERSION = 3;
|
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.
|
// Decompressed size cap: a small file must not unpack into gigabytes.
|
||||||
const MAX_PLAINTEXT_BYTES = 512 * 1024 * 1024;
|
const MAX_PLAINTEXT_BYTES = 512 * 1024 * 1024;
|
||||||
|
|
||||||
@@ -35,25 +46,9 @@ export interface SealedBackup {
|
|||||||
ciphertext: string;
|
ciphertext: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function rootSecret(): { secret: string; source: 'backup' | 'jwt' } {
|
type KeySource = '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' };
|
|
||||||
}
|
|
||||||
|
|
||||||
function deriveKey(): { key: Buffer; keyId: string } {
|
function deriveBackupKey(secret: string, source: KeySource): RingKey {
|
||||||
const { secret, source } = rootSecret();
|
|
||||||
const key = Buffer.from(
|
const key = Buffer.from(
|
||||||
crypto.hkdfSync(
|
crypto.hkdfSync(
|
||||||
'sha256',
|
'sha256',
|
||||||
@@ -63,12 +58,35 @@ function deriveKey(): { key: Buffer; keyId: string } {
|
|||||||
32,
|
32,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const keyId = crypto
|
const id = crypto.createHash('sha256').update(key).digest('hex').slice(0, 12);
|
||||||
.createHash('sha256')
|
return { id, key };
|
||||||
.update(key)
|
}
|
||||||
.digest('hex')
|
|
||||||
.slice(0, 12);
|
function legacyBackupKey(): RingKey {
|
||||||
return { key, keyId };
|
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(
|
function associatedData(
|
||||||
@@ -80,24 +98,21 @@ function associatedData(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function sealBackup(bundle: unknown, exportedAt: string): SealedBackup {
|
export function sealBackup(bundle: unknown, exportedAt: string): SealedBackup {
|
||||||
const { key, keyId } = deriveKey();
|
const { current } = loadBackupRing();
|
||||||
const iv = crypto.randomBytes(IV_BYTES);
|
|
||||||
const header = {
|
const header = {
|
||||||
format: SEALED_BACKUP_FORMAT,
|
format: SEALED_BACKUP_FORMAT,
|
||||||
version: SEALED_BACKUP_VERSION,
|
version: SEALED_BACKUP_VERSION,
|
||||||
keyId,
|
keyId: current.id,
|
||||||
exportedAt,
|
exportedAt,
|
||||||
} as const;
|
} as const;
|
||||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
||||||
cipher.setAAD(associatedData(header));
|
|
||||||
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(bundle)));
|
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 {
|
return {
|
||||||
...header,
|
...header,
|
||||||
algorithm: 'A256GCM+gzip',
|
algorithm: 'A256GCM+gzip',
|
||||||
iv: iv.toString('base64'),
|
iv: sealed.iv.toString('base64'),
|
||||||
tag: cipher.getAuthTag().toString('base64'),
|
tag: sealed.tag.toString('base64'),
|
||||||
ciphertext: ciphertext.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 {
|
export function openBackup(sealed: SealedBackup): unknown {
|
||||||
if (sealed.version !== SEALED_BACKUP_VERSION) {
|
if (sealed.version !== SEALED_BACKUP_VERSION) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Backup-Version ${String(sealed.version)} wird nicht unterstützt`,
|
`Backup-Version ${String(sealed.version)} wird nicht unterstützt`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { key, keyId } = deriveKey();
|
const ringKey = keyForReading(sealed.keyId);
|
||||||
if (sealed.keyId !== keyId) {
|
if (!ringKey) {
|
||||||
throw new BadRequestException(
|
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;
|
let compressed: Buffer;
|
||||||
try {
|
try {
|
||||||
const decipher = crypto.createDecipheriv(
|
compressed = gcmOpen(
|
||||||
ALGORITHM,
|
ringKey.key,
|
||||||
key,
|
{
|
||||||
Buffer.from(sealed.iv, 'base64'),
|
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 {
|
} catch {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Backup-Datei ist beschädigt oder wurde verändert',
|
'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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { Logger } from '@nestjs/common';
|
||||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
|
||||||
import { CronJob } from 'cron';
|
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { Prisma } from '../../generated/prisma';
|
import { JobRun } from '../../generated/prisma';
|
||||||
import { BackupSchedulerService } from './backup-scheduler.service';
|
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 { BackupService } from './backup.service';
|
||||||
import { BackupSettingsService } from './backup-settings.service';
|
import { BackupSettingsService } from './backup-settings.service';
|
||||||
|
|
||||||
@@ -17,12 +20,30 @@ jest.mock('node:fs/promises', () => ({
|
|||||||
|
|
||||||
import * as fs from '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', () => {
|
describe('BackupSchedulerService', () => {
|
||||||
const originalEnv = { ...process.env };
|
const originalEnv = { ...process.env };
|
||||||
const directory = path.resolve('/data/backups');
|
const directory = path.resolve('/data/backups');
|
||||||
let service: BackupSchedulerService;
|
let service: BackupSchedulerService;
|
||||||
let backupService: { exportSealed: jest.Mock };
|
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 error: jest.SpyInstance;
|
||||||
|
let warn: jest.SpyInstance;
|
||||||
|
|
||||||
|
const sqlText = (call: RawCall): string => call[0].join('?');
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
@@ -31,33 +52,43 @@ describe('BackupSchedulerService', () => {
|
|||||||
delete process.env.BACKUP_RETENTION_COUNT;
|
delete process.env.BACKUP_RETENTION_COUNT;
|
||||||
delete process.env.BACKUP_CRON_SCHEDULE;
|
delete process.env.BACKUP_CRON_SCHEDULE;
|
||||||
error = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => {});
|
error = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => {});
|
||||||
|
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
backupService = {
|
backupService = { exportSealed: jest.fn().mockResolvedValue(SEALED) };
|
||||||
exportSealed: jest.fn().mockResolvedValue({
|
settings = {
|
||||||
format: 'apicore-backup',
|
|
||||||
exportedAt: '2026-10-12T03:00:00.000Z',
|
|
||||||
ciphertext: 'x'.repeat(2000),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const settings = {
|
|
||||||
get: jest.fn().mockResolvedValue({
|
get: jest.fn().mockResolvedValue({
|
||||||
storagePath: null,
|
storagePath: null,
|
||||||
cronSchedule: null,
|
cronSchedule: null,
|
||||||
retentionCount: 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(
|
service = new BackupSchedulerService(
|
||||||
backupService as unknown as BackupService,
|
backupService as unknown as BackupService,
|
||||||
settings as unknown as BackupSettingsService,
|
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.mkdir as jest.Mock).mockResolvedValue(undefined);
|
||||||
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
|
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
|
||||||
(fs.readdir as jest.Mock).mockResolvedValue([]);
|
(fs.readdir as jest.Mock).mockResolvedValue([]);
|
||||||
|
(fs.unlink as jest.Mock).mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
error.mockRestore();
|
error.mockRestore();
|
||||||
|
warn.mockRestore();
|
||||||
process.env = { ...originalEnv };
|
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', () => {
|
describe('getStatus', () => {
|
||||||
it('reports the import limit and the size of the newest backup', async () => {
|
it('reports the import limit and the size of the newest backup', async () => {
|
||||||
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
|
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', () => {
|
describe('runNow', () => {
|
||||||
@@ -151,48 +238,188 @@ describe('BackupSchedulerService', () => {
|
|||||||
expect(fs.writeFile).toHaveBeenCalledTimes(1);
|
expect(fs.writeFile).toHaveBeenCalledTimes(1);
|
||||||
expect(error).not.toHaveBeenCalled();
|
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
|
describe('runIfDue', () => {
|
||||||
// query values, so a failed scheduled run logs name, code and model only.
|
const lastNight = '2026-10-12T01:00:00.000Z';
|
||||||
describe('scheduled run', () => {
|
|
||||||
it('logs a failed scheduled backup without the Prisma message', async () => {
|
it('writes nothing without a path', async () => {
|
||||||
const registry = {
|
delete process.env.BACKUP_STORAGE_PATH;
|
||||||
doesExist: jest.fn().mockReturnValue(false),
|
|
||||||
addCronJob: jest.fn(),
|
const result = await service.runIfDue(new Date('2026-10-13T01:05:00Z'));
|
||||||
deleteCronJob: jest.fn(),
|
|
||||||
};
|
expect(result).toEqual({ written: false, reason: 'no-path' });
|
||||||
const scheduled = new BackupSchedulerService(
|
expect(backupService.exportSealed).not.toHaveBeenCalled();
|
||||||
backupService as unknown as BackupService,
|
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({
|
name: 'apicore-backup-b.json',
|
||||||
storagePath: null,
|
size: 800,
|
||||||
cronSchedule: null,
|
mtime: '2026-10-11T01:00:00.000Z',
|
||||||
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();
|
|
||||||
|
|
||||||
await job.fireOnTick();
|
const result = await service.runIfDue(new Date('2026-10-13T01:05:00Z'));
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
|
||||||
|
|
||||||
const failure = error.mock.calls.find(([message]) =>
|
expect(fs.writeFile).toHaveBeenCalledTimes(1);
|
||||||
String(message).startsWith('Scheduled backup failed'),
|
expect(result).toEqual({
|
||||||
) as [string, unknown];
|
written: true,
|
||||||
expect(failure[0]).toContain('P2010');
|
fileName: 'apicore-backup-2026-10-13T01-05-00-000Z.json',
|
||||||
expect(failure[0]).not.toContain('Mustermann');
|
sizeBytes: Buffer.byteLength(JSON.stringify(SEALED)),
|
||||||
expect(failure[1]).toBeUndefined();
|
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 { Injectable, Logger } from '@nestjs/common';
|
||||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
|
||||||
import { CronJob } from 'cron';
|
|
||||||
import * as fs from 'node:fs/promises';
|
import * as fs from 'node:fs/promises';
|
||||||
import * as path from 'node:path';
|
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 { BackupService } from './backup.service';
|
||||||
import { BackupSettingsService } from './backup-settings.service';
|
import { BackupSettingsService } from './backup-settings.service';
|
||||||
import { loggableError } from '../common/loggable-error';
|
import { isBackupDue } from './backup-due';
|
||||||
import {
|
import { resolveGuiBackupPath } from './backup-schedule-rules';
|
||||||
assertBackupCron,
|
|
||||||
resolveGuiBackupPath,
|
|
||||||
} from './backup-schedule-rules';
|
|
||||||
import {
|
import {
|
||||||
backupMaxImportBytes,
|
backupMaxImportBytes,
|
||||||
exceedsBackupImportLimit,
|
exceedsBackupImportLimit,
|
||||||
formatMegabytes,
|
formatMegabytes,
|
||||||
} from './backup-import-limit';
|
} from './backup-import-limit';
|
||||||
|
|
||||||
const SCHEDULED_JOB_NAME = 'automatic-backup';
|
|
||||||
const DEFAULT_CRON_SCHEDULE = '0 3 * * *';
|
const DEFAULT_CRON_SCHEDULE = '0 3 * * *';
|
||||||
const DEFAULT_RETENTION_COUNT = 14;
|
const DEFAULT_RETENTION_COUNT = 14;
|
||||||
const BACKUP_FILE_PREFIX = 'apicore-backup-';
|
const BACKUP_FILE_PREFIX = 'apicore-backup-';
|
||||||
const BACKUP_FILE_SUFFIX = '.json';
|
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
|
* Advisory lock (pg_advisory_xact_lock on its hash) around the due check
|
||||||
// recent backup file on this shared/mounted path is younger than this, a
|
* and the write of a scheduled backup.
|
||||||
// concurrent replica already handled the tick, so this one skips.
|
*/
|
||||||
const DUPLICATE_RUN_GUARD_MS = 2 * 60 * 1000;
|
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 {
|
export interface BackupScheduleStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -48,6 +67,9 @@ export interface BackupScheduleStatus {
|
|||||||
cronSchedule: boolean;
|
cronSchedule: boolean;
|
||||||
retentionCount: 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 {
|
interface EffectiveBackupSettings {
|
||||||
@@ -57,20 +79,23 @@ interface EffectiveBackupSettings {
|
|||||||
envOverrides: BackupScheduleStatus['envOverrides'];
|
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()
|
@Injectable()
|
||||||
export class BackupSchedulerService implements OnModuleInit {
|
export class BackupSchedulerService {
|
||||||
private readonly logger = new Logger(BackupSchedulerService.name);
|
private readonly logger = new Logger(BackupSchedulerService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly backupService: BackupService,
|
private readonly backupService: BackupService,
|
||||||
private readonly backupSettingsService: BackupSettingsService,
|
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> {
|
private async effectiveSettings(): Promise<EffectiveBackupSettings> {
|
||||||
const envPath = process.env.BACKUP_STORAGE_PATH;
|
const envPath = process.env.BACKUP_STORAGE_PATH;
|
||||||
const hasEnvPath = Boolean(envPath && envPath.trim().length > 0);
|
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> {
|
async getStatus(): Promise<BackupScheduleStatus> {
|
||||||
const settings = await this.effectiveSettings();
|
const settings = await this.effectiveSettings();
|
||||||
const importLimitBytes = backupMaxImportBytes();
|
const importLimitBytes = backupMaxImportBytes();
|
||||||
|
const lastJobRun = await this.lastJobRun();
|
||||||
if (!settings.path) {
|
if (!settings.path) {
|
||||||
return {
|
return {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -166,6 +146,7 @@ export class BackupSchedulerService implements OnModuleInit {
|
|||||||
lastBackupSizeBytes: null,
|
lastBackupSizeBytes: null,
|
||||||
lastBackupExceedsImportLimit: false,
|
lastBackupExceedsImportLimit: false,
|
||||||
envOverrides: settings.envOverrides,
|
envOverrides: settings.envOverrides,
|
||||||
|
lastJobRun,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,11 +166,25 @@ export class BackupSchedulerService implements OnModuleInit {
|
|||||||
lastBackupSizeBytes !== null &&
|
lastBackupSizeBytes !== null &&
|
||||||
exceedsBackupImportLimit(lastBackupSizeBytes),
|
exceedsBackupImportLimit(lastBackupSizeBytes),
|
||||||
envOverrides: settings.envOverrides,
|
envOverrides: settings.envOverrides,
|
||||||
|
lastJobRun,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manual trigger (e.g. "Jetzt sichern" button) - always runs, no
|
private async lastJobRun(): Promise<BackupJobRunStatus | null> {
|
||||||
// duplicate-tick guard, since a human explicitly asked for it.
|
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 }> {
|
async runNow(): Promise<{ fileName: string }> {
|
||||||
const settings = await this.effectiveSettings();
|
const settings = await this.effectiveSettings();
|
||||||
if (!settings.path) {
|
if (!settings.path) {
|
||||||
@@ -197,32 +192,61 @@ export class BackupSchedulerService implements OnModuleInit {
|
|||||||
'Kein Backup-Pfad konfiguriert - weder per Umgebungsvariable noch über die GUI',
|
'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);
|
await this.pruneOldBackups(settings.path, settings.retentionCount);
|
||||||
// Only the name: the server's directory layout stays on the server.
|
// 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 settings = await this.effectiveSettings();
|
||||||
const files = await this.listBackupFiles(directory);
|
if (!settings.path) {
|
||||||
const mostRecent = files[0];
|
return { written: false, reason: 'no-path' };
|
||||||
|
}
|
||||||
|
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 (
|
if (
|
||||||
mostRecent &&
|
!isBackupDue(settings.cronSchedule, newest?.mtime ?? null, checkedAt)
|
||||||
Date.now() - mostRecent.mtime.getTime() < DUPLICATE_RUN_GUARD_MS
|
|
||||||
) {
|
) {
|
||||||
this.logger.log(
|
return { written: false, reason: 'not-due' };
|
||||||
'Skipping scheduled backup - another replica already wrote one for this tick',
|
}
|
||||||
|
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,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const filePath = await this.writeBackup(directory);
|
private async databaseNow(tx: Prisma.TransactionClient): Promise<Date> {
|
||||||
this.logger.log(`Scheduled backup written to ${filePath}`);
|
const [row] = await tx.$queryRaw<{ now: Date }[]>`SELECT now() AS now`;
|
||||||
await this.pruneOldBackups(directory, settings.retentionCount);
|
return row.now;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async writeBackup(directory: string): Promise<string> {
|
private async writeBackup(
|
||||||
|
directory: string,
|
||||||
|
): Promise<{ fileName: string; sizeBytes: number }> {
|
||||||
const sealed = await this.backupService.exportSealed();
|
const sealed = await this.backupService.exportSealed();
|
||||||
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||||
const timestamp = sealed.exportedAt.replace(/[:.]/g, '-');
|
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)`,
|
`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(
|
private async pruneOldBackups(
|
||||||
directory: string,
|
directory: string,
|
||||||
retentionCount: number,
|
retentionCount: number,
|
||||||
): Promise<void> {
|
): Promise<number> {
|
||||||
const files = await this.listBackupFiles(directory);
|
const files = await this.listBackupFiles(directory);
|
||||||
const toDelete = files.slice(retentionCount);
|
const toDelete = files.slice(retentionCount);
|
||||||
await Promise.all(
|
const deleted = await Promise.all(
|
||||||
toDelete.map((file) =>
|
toDelete.map((file) =>
|
||||||
fs
|
fs.unlink(file.filePath).then(
|
||||||
.unlink(file.filePath)
|
() => true,
|
||||||
.catch((error) =>
|
(error: unknown) => {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Could not delete old backup ${file.filePath}: ${String(error)}`,
|
`Could not delete old backup ${file.filePath}: ${String(error)}`,
|
||||||
),
|
);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
return deleted.filter(Boolean).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async listBackupFiles(
|
private async listBackupFiles(
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ describe('BackupController', () => {
|
|||||||
const originalLimit = process.env.BACKUP_MAX_IMPORT_BYTES;
|
const originalLimit = process.env.BACKUP_MAX_IMPORT_BYTES;
|
||||||
let controller: BackupController;
|
let controller: BackupController;
|
||||||
let backupService: { importFile: jest.Mock; exportSealed: jest.Mock };
|
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 };
|
let reauth: { verify: jest.Mock };
|
||||||
const actor = { id: 'admin-1' } as AuthenticatedUser;
|
const actor = { id: 'admin-1' } as AuthenticatedUser;
|
||||||
const file = {
|
const file = {
|
||||||
@@ -28,12 +29,13 @@ describe('BackupController', () => {
|
|||||||
ciphertext: 'x'.repeat(2000),
|
ciphertext: 'x'.repeat(2000),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
scheduler = { reload: jest.fn().mockResolvedValue(undefined) };
|
scheduler = {};
|
||||||
|
settings = { update: jest.fn() };
|
||||||
reauth = { verify: jest.fn().mockResolvedValue(undefined) };
|
reauth = { verify: jest.fn().mockResolvedValue(undefined) };
|
||||||
controller = new BackupController(
|
controller = new BackupController(
|
||||||
backupService as unknown as BackupService,
|
backupService as unknown as BackupService,
|
||||||
scheduler as unknown as BackupSchedulerService,
|
scheduler as unknown as BackupSchedulerService,
|
||||||
{} as BackupSettingsService,
|
settings as unknown as BackupSettingsService,
|
||||||
reauth as unknown as ReauthService,
|
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 () => {
|
// The app-backup job reads the settings on every run, so nothing in the
|
||||||
await controller.import(file, { password: 'secret' }, actor);
|
// 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.
|
||||||
expect(backupService.importFile).toHaveBeenCalledTimes(1);
|
it('imports without touching the schedule', async () => {
|
||||||
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'));
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
controller.import(file, { password: 'secret' }, actor),
|
controller.import(file, { password: 'secret' }, actor),
|
||||||
).rejects.toThrow('broken file');
|
).resolves.toEqual({ imported: {}, skipped: [] });
|
||||||
expect(scheduler.reload).not.toHaveBeenCalled();
|
|
||||||
|
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', () => {
|
describe('export', () => {
|
||||||
|
|||||||
@@ -65,13 +65,12 @@ export class BackupController {
|
|||||||
|
|
||||||
@RequirePermission('backup:configure')
|
@RequirePermission('backup:configure')
|
||||||
@Patch('settings')
|
@Patch('settings')
|
||||||
async updateSettings(
|
updateSettings(
|
||||||
@Body() dto: UpdateBackupSettingsDto,
|
@Body() dto: UpdateBackupSettingsDto,
|
||||||
@CurrentUser() actor: AuthenticatedUser,
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
): Promise<BackupSettings> {
|
): Promise<BackupSettings> {
|
||||||
const settings = await this.backupSettingsService.update(dto, actor.id);
|
// Read afresh by the next hourly app-backup check.
|
||||||
await this.backupSchedulerService.reload();
|
return this.backupSettingsService.update(dto, actor.id);
|
||||||
return settings;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequirePermission('backup:export')
|
@RequirePermission('backup:export')
|
||||||
@@ -129,9 +128,7 @@ export class BackupController {
|
|||||||
} catch {
|
} catch {
|
||||||
throw new BadRequestException('Datei ist kein gültiges JSON');
|
throw new BadRequestException('Datei ist kein gültiges JSON');
|
||||||
}
|
}
|
||||||
const summary = await this.backupService.importFile(parsed, actor.id);
|
// Restored backup settings apply from the next hourly app-backup check.
|
||||||
// The backup may have restored the schedule's settings too.
|
return this.backupService.importFile(parsed, actor.id);
|
||||||
await this.backupSchedulerService.reload();
|
|
||||||
return summary;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { MulterModule } from '@nestjs/platform-express';
|
import { MulterModule } from '@nestjs/platform-express';
|
||||||
import { ReauthModule } from '../auth/reauth/reauth.module';
|
import { ReauthModule } from '../auth/reauth/reauth.module';
|
||||||
|
import { SystemModule } from '../system/system.module';
|
||||||
import { BackupController } from './backup.controller';
|
import { BackupController } from './backup.controller';
|
||||||
import { BackupService } from './backup.service';
|
import { BackupService } from './backup.service';
|
||||||
import { BackupSchedulerService } from './backup-scheduler.service';
|
import { BackupSchedulerService } from './backup-scheduler.service';
|
||||||
@@ -10,6 +11,8 @@ import { backupMaxImportBytes } from './backup-import-limit';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ReauthModule,
|
ReauthModule,
|
||||||
|
// JobRunService: GET /backup/schedule shows the newest app-backup run.
|
||||||
|
SystemModule,
|
||||||
// Upload limit of POST /backup/import. A factory rather than a constant
|
// Upload limit of POST /backup/import. A factory rather than a constant
|
||||||
// in the decorator: it runs once the app starts, after ConfigModule has
|
// 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.
|
// 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],
|
controllers: [BackupController],
|
||||||
providers: [BackupService, BackupSchedulerService, BackupSettingsService],
|
providers: [BackupService, BackupSchedulerService, BackupSettingsService],
|
||||||
|
// For the app-backup job (JobsModule, dist/cli.js).
|
||||||
|
exports: [BackupSchedulerService],
|
||||||
})
|
})
|
||||||
export class BackupModule {}
|
export class BackupModule {}
|
||||||
@@ -216,6 +216,27 @@ describe('BackupService', () => {
|
|||||||
);
|
);
|
||||||
expect(documents.find((d) => d.id === 'doc-2')?.contentBase64).toBeNull();
|
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', () => {
|
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.',
|
'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:
|
IdempotencyRecord:
|
||||||
'Volatile cache of first responses to retried create requests (ARCHITECTURE.md assumption 11) - a restored entry could only replay a stale response.',
|
'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>(
|
const DOCUMENT_ENTITY_TYPES = new Set<string>(
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
import { BottlingService } from './bottling.service';
|
import { BottlingService } from './bottling.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||||
import { AutomationService } from '../automation/automation.service';
|
import { AutomationService } from '../automation/automation.service';
|
||||||
import { StockService } from '../stock/stock.service';
|
import { StockService } from '../stock/stock.service';
|
||||||
|
|
||||||
@@ -22,6 +22,32 @@ function applyAmountChange(
|
|||||||
return current + (change.increment ?? 0) - (change.decrement ?? 0);
|
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', () => {
|
describe('BottlingService', () => {
|
||||||
let service: BottlingService;
|
let service: BottlingService;
|
||||||
let prisma: {
|
let prisma: {
|
||||||
@@ -36,28 +62,44 @@ describe('BottlingService', () => {
|
|||||||
$transaction: jest.Mock;
|
$transaction: jest.Mock;
|
||||||
};
|
};
|
||||||
let auditLog: { record: jest.Mock };
|
let auditLog: { record: jest.Mock };
|
||||||
|
let events: { recordAll: jest.Mock };
|
||||||
let tx: {
|
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: {
|
honeyBatch: {
|
||||||
findUnique: jest.Mock;
|
findUnique: jest.Mock;
|
||||||
update: jest.Mock;
|
update: jest.Mock;
|
||||||
updateMany: jest.Mock;
|
updateMany: jest.Mock;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
let webhooks: { dispatch: jest.Mock };
|
|
||||||
let stock: {
|
let stock: {
|
||||||
remainingByBottling: jest.Mock;
|
remainingByBottling: jest.Mock;
|
||||||
notify: jest.Mock;
|
stockChangedEvent: jest.Mock;
|
||||||
hasMovements: jest.Mock;
|
hasMovements: jest.Mock;
|
||||||
};
|
};
|
||||||
// Honey batch rows as the transaction sees them - the tests assert the
|
// Honey batch rows as the transaction sees them - the tests assert the
|
||||||
// resulting amounts instead of the exact sequence of UPDATE statements.
|
// resulting amounts instead of the exact sequence of UPDATE statements.
|
||||||
let honeyBatches: Map<string, { amount: number | null; status?: string }>;
|
let honeyBatches: Map<string, { amount: number | null; status?: string }>;
|
||||||
|
// The order of lock and reads inside the transaction.
|
||||||
|
let steps: string[];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
honeyBatches = new Map();
|
honeyBatches = new Map();
|
||||||
|
steps = [];
|
||||||
tx = {
|
tx = {
|
||||||
|
$queryRaw: jest.fn(() => {
|
||||||
|
steps.push('lock');
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}),
|
||||||
|
product: { findUnique: jest.fn() },
|
||||||
bottlingBatch: {
|
bottlingBatch: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
delete: jest.fn(),
|
delete: jest.fn(),
|
||||||
@@ -123,22 +165,45 @@ describe('BottlingService', () => {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
auditLog = { record: jest.fn() };
|
auditLog = { record: jest.fn() };
|
||||||
webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
events = { recordAll: jest.fn() };
|
||||||
const automation = { generateNumber: jest.fn().mockResolvedValue(null) };
|
const automation = { generateNumber: jest.fn().mockResolvedValue(null) };
|
||||||
stock = {
|
stock = {
|
||||||
remainingByBottling: jest.fn().mockResolvedValue(new Map()),
|
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),
|
hasMovements: jest.fn().mockResolvedValue(false),
|
||||||
};
|
};
|
||||||
service = new BottlingService(
|
service = new BottlingService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
auditLog as unknown as AuditLogService,
|
auditLog as unknown as AuditLogService,
|
||||||
webhooks as unknown as WebhooksService,
|
events as unknown as DomainEventsService,
|
||||||
automation as unknown as AutomationService,
|
automation as unknown as AutomationService,
|
||||||
stock as unknown as StockService,
|
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 () => {
|
it('throws NotFoundException when the honey batch does not exist', async () => {
|
||||||
prisma.honeyBatch.findUnique.mockResolvedValueOnce(null);
|
prisma.honeyBatch.findUnique.mockResolvedValueOnce(null);
|
||||||
await expect(
|
await expect(
|
||||||
@@ -185,6 +250,7 @@ describe('BottlingService', () => {
|
|||||||
honeyBatches.set('h1', { amount: 25 });
|
honeyBatches.set('h1', { amount: 25 });
|
||||||
tx.bottlingBatch.create.mockResolvedValueOnce({
|
tx.bottlingBatch.create.mockResolvedValueOnce({
|
||||||
id: 'b1',
|
id: 'b1',
|
||||||
|
honeyBatchId: 'h1',
|
||||||
totalAmount: 25,
|
totalAmount: 25,
|
||||||
});
|
});
|
||||||
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({
|
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({
|
||||||
@@ -211,6 +277,42 @@ describe('BottlingService', () => {
|
|||||||
entityType: 'BottlingBatch',
|
entityType: 'BottlingBatch',
|
||||||
action: 'CREATE',
|
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);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
expect(tx.bottlingBatch.create).not.toHaveBeenCalled();
|
expect(tx.bottlingBatch.create).not.toHaveBeenCalled();
|
||||||
expect(honeyBatches.get('h1')?.amount).toBe(3);
|
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 = {
|
const before = {
|
||||||
id: 'b1',
|
id: 'b1',
|
||||||
lotNumber: 'L-1',
|
lotNumber: 'L-1',
|
||||||
@@ -251,18 +354,12 @@ describe('BottlingService', () => {
|
|||||||
totalAmount: 5,
|
totalAmount: 5,
|
||||||
};
|
};
|
||||||
const updated = { ...before, lotNumber: 'L-2' };
|
const updated = { ...before, lotNumber: 'L-2' };
|
||||||
prisma.bottlingBatch.findUnique
|
lockedRows(before, updated);
|
||||||
.mockResolvedValueOnce(before)
|
|
||||||
.mockResolvedValueOnce(updated);
|
|
||||||
|
|
||||||
const result = await service.update('b1', { lotNumber: 'L-2' }, 'user-1');
|
const result = await service.update('b1', { lotNumber: 'L-2' }, 'user-1');
|
||||||
|
|
||||||
// Without movements the remaining stock equals the jar count.
|
// Without movements the remaining stock equals the jar count.
|
||||||
expect(result).toEqual({ ...updated, remainingCount: 10 });
|
expect(result).toEqual({ ...updated, remainingCount: 10 });
|
||||||
expect(stock.notify).toHaveBeenCalledWith({
|
|
||||||
bottlingBatchIds: ['b1'],
|
|
||||||
productIds: [],
|
|
||||||
});
|
|
||||||
expect(auditLog.record).toHaveBeenCalledWith(
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
entityType: 'BottlingBatch',
|
entityType: 'BottlingBatch',
|
||||||
@@ -272,16 +369,125 @@ describe('BottlingService', () => {
|
|||||||
beforeData: { ...before, remainingCount: 10 },
|
beforeData: { ...before, remainingCount: 10 },
|
||||||
afterData: { ...updated, 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 () => {
|
it('rejects updating to a product that does not exist', async () => {
|
||||||
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' });
|
lockedRows({
|
||||||
prisma.product.findUnique.mockResolvedValueOnce(null);
|
id: 'b1',
|
||||||
|
honeyBatchId: 'h1',
|
||||||
|
honeyBatch: { id: 'h1', amount: 10 },
|
||||||
|
jarSize: 500,
|
||||||
|
jarCount: 10,
|
||||||
|
totalAmount: 5,
|
||||||
|
});
|
||||||
|
tx.product.findUnique.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.update('b1', { productId: 'missing' }, 'user-1'),
|
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);
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
expect(events.recordAll).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears the product assignment when productId is null', async () => {
|
it('clears the product assignment when productId is null', async () => {
|
||||||
@@ -294,9 +500,7 @@ describe('BottlingService', () => {
|
|||||||
totalAmount: 5,
|
totalAmount: 5,
|
||||||
productId: 'p1',
|
productId: 'p1',
|
||||||
};
|
};
|
||||||
prisma.bottlingBatch.findUnique
|
lockedRows(before, { ...before, productId: null });
|
||||||
.mockResolvedValueOnce(before)
|
|
||||||
.mockResolvedValueOnce({ ...before, productId: null });
|
|
||||||
|
|
||||||
await service.update('b1', { productId: null }, 'user-1');
|
await service.update('b1', { productId: null }, 'user-1');
|
||||||
|
|
||||||
@@ -317,9 +521,7 @@ describe('BottlingService', () => {
|
|||||||
jarCount: 20,
|
jarCount: 20,
|
||||||
totalAmount: 10,
|
totalAmount: 10,
|
||||||
};
|
};
|
||||||
prisma.bottlingBatch.findUnique
|
lockedRows(before, { ...before, jarCount: 10, totalAmount: 5 });
|
||||||
.mockResolvedValueOnce(before)
|
|
||||||
.mockResolvedValueOnce({ ...before, jarCount: 10, totalAmount: 5 });
|
|
||||||
honeyBatches.set('h1', { amount: 0 });
|
honeyBatches.set('h1', { amount: 0 });
|
||||||
|
|
||||||
await service.update('b1', { jarCount: 10 }, 'user-1');
|
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 () => {
|
it('rejects a corrected amount that exceeds the honey batch capacity', async () => {
|
||||||
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({
|
lockedRows({
|
||||||
id: 'b1',
|
id: 'b1',
|
||||||
honeyBatchId: 'h1',
|
honeyBatchId: 'h1',
|
||||||
honeyBatch: { id: 'h1', amount: 0 },
|
honeyBatch: { id: 'h1', amount: 0 },
|
||||||
@@ -345,6 +547,8 @@ describe('BottlingService', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
service.update('b1', { jarCount: 40 }, 'user-1'),
|
service.update('b1', { jarCount: 40 }, 'user-1'),
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).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 () => {
|
it('moves the amount between honey batches when the honey batch changes', async () => {
|
||||||
@@ -356,10 +560,11 @@ describe('BottlingService', () => {
|
|||||||
jarCount: 10,
|
jarCount: 10,
|
||||||
totalAmount: 5,
|
totalAmount: 5,
|
||||||
};
|
};
|
||||||
prisma.bottlingBatch.findUnique
|
lockedRows(before, {
|
||||||
.mockResolvedValueOnce(before)
|
...before,
|
||||||
.mockResolvedValueOnce({ ...before, honeyBatchId: 'h2' });
|
honeyBatchId: 'h2',
|
||||||
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h2', amount: 8 });
|
honeyBatch: { id: 'h2', amount: 3 },
|
||||||
|
});
|
||||||
honeyBatches.set('h1', { amount: 2 });
|
honeyBatches.set('h1', { amount: 2 });
|
||||||
honeyBatches.set('h2', { amount: 8 });
|
honeyBatches.set('h2', { amount: 8 });
|
||||||
|
|
||||||
@@ -367,21 +572,33 @@ describe('BottlingService', () => {
|
|||||||
|
|
||||||
expect(honeyBatches.get('h1')?.amount).toBe(7);
|
expect(honeyBatches.get('h1')?.amount).toBe(7);
|
||||||
expect(honeyBatches.get('h2')).toEqual({ amount: 3, status: 'BOTTLED' });
|
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 = {
|
const before = {
|
||||||
id: 'b1',
|
id: 'b1',
|
||||||
honeyBatchId: 'h1',
|
honeyBatchId: 'h1',
|
||||||
honeyBatch: { id: 'h1', amount: 2 },
|
honeyBatch: { id: 'h1', amount: 2 },
|
||||||
|
jarCount: 10,
|
||||||
totalAmount: 5,
|
totalAmount: 5,
|
||||||
};
|
};
|
||||||
prisma.bottlingBatch.findUnique.mockResolvedValueOnce(before);
|
lockedRows(before);
|
||||||
honeyBatches.set('h1', { amount: 2 });
|
honeyBatches.set('h1', { amount: 2 });
|
||||||
|
|
||||||
const result = await service.remove('b1', 'user-1');
|
const result = await service.remove('b1', 'user-1');
|
||||||
|
|
||||||
expect(result).toEqual({ id: 'b1' });
|
expect(result).toEqual({ id: 'b1' });
|
||||||
|
expect(steps.slice(0, 2)).toEqual(['lock', 'read']);
|
||||||
expect(tx.bottlingBatch.delete).toHaveBeenCalledWith({
|
expect(tx.bottlingBatch.delete).toHaveBeenCalledWith({
|
||||||
where: { id: 'b1' },
|
where: { id: 'b1' },
|
||||||
});
|
});
|
||||||
@@ -391,17 +608,21 @@ describe('BottlingService', () => {
|
|||||||
entityType: 'BottlingBatch',
|
entityType: 'BottlingBatch',
|
||||||
entityId: 'b1',
|
entityId: 'b1',
|
||||||
action: 'DELETE',
|
action: 'DELETE',
|
||||||
beforeData: before,
|
beforeData: { ...before, remainingCount: 10 },
|
||||||
}),
|
}),
|
||||||
|
tx,
|
||||||
);
|
);
|
||||||
expect(webhooks.dispatch).toHaveBeenCalledWith(
|
expect(events.recordAll).toHaveBeenCalledWith(
|
||||||
'batch.updated',
|
tx,
|
||||||
before.honeyBatch,
|
bottlingEvents({
|
||||||
|
type: 'BottlingCorrected',
|
||||||
|
payload: { honeyBatchId: 'h1', removed: true },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('remove refuses a bottling whose jars were already sold or withdrawn', async () => {
|
it('remove refuses a bottling whose jars were already sold or withdrawn', async () => {
|
||||||
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({
|
lockedRows({
|
||||||
id: 'b1',
|
id: 'b1',
|
||||||
honeyBatchId: 'h1',
|
honeyBatchId: 'h1',
|
||||||
honeyBatch: { id: 'h1', amount: 2 },
|
honeyBatch: { id: 'h1', amount: 2 },
|
||||||
@@ -412,7 +633,13 @@ describe('BottlingService', () => {
|
|||||||
await expect(service.remove('b1', 'user-1')).rejects.toBeInstanceOf(
|
await expect(service.remove('b1', 'user-1')).rejects.toBeInstanceOf(
|
||||||
ConflictException,
|
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(tx.bottlingBatch.delete).not.toHaveBeenCalled();
|
||||||
|
expect(events.recordAll).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refuses to bottle onto an article that keeps stock in production batches', async () => {
|
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 () => {
|
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(
|
await expect(service.remove('missing', 'user-1')).rejects.toBeInstanceOf(
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
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 { 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 { AutomationService } from '../automation/automation.service';
|
||||||
import { StockService } from '../stock/stock.service';
|
import { StockService } from '../stock/stock.service';
|
||||||
import { BottlingBatch, Prisma } from '../../generated/prisma';
|
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()
|
@Injectable()
|
||||||
export class BottlingService {
|
export class BottlingService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
private readonly webhooks: WebhooksService,
|
private readonly events: DomainEventsService,
|
||||||
private readonly automation: AutomationService,
|
private readonly automation: AutomationService,
|
||||||
private readonly stock: StockService,
|
private readonly stock: StockService,
|
||||||
) {}
|
) {}
|
||||||
@@ -124,15 +133,17 @@ export class BottlingService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(id: string) {
|
/** With `tx` it reads what the caller's transaction sees. */
|
||||||
const bottlingBatch = await this.prisma.bottlingBatch.findUnique({
|
async findById(id: string, tx?: Tx) {
|
||||||
|
const db: DbClient = tx ?? this.prisma;
|
||||||
|
const bottlingBatch = await db.bottlingBatch.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: bottlingBatchInclude,
|
include: bottlingBatchInclude,
|
||||||
});
|
});
|
||||||
if (!bottlingBatch) {
|
if (!bottlingBatch) {
|
||||||
throw new NotFoundException('Bottling batch not found');
|
throw new NotFoundException('Bottling batch not found');
|
||||||
}
|
}
|
||||||
const remaining = await this.stock.remainingByBottling([id]);
|
const remaining = await this.stock.remainingByBottling([id], tx);
|
||||||
return {
|
return {
|
||||||
...bottlingBatch,
|
...bottlingBatch,
|
||||||
remainingCount: remaining.get(id) ?? bottlingBatch.jarCount,
|
remainingCount: remaining.get(id) ?? bottlingBatch.jarCount,
|
||||||
@@ -140,8 +151,12 @@ export class BottlingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Honey is bottled; other articles keep their stock in production batches. */
|
/** Honey is bottled; other articles keep their stock in production batches. */
|
||||||
private async assertBottledProduct(productId: string): Promise<void> {
|
private async assertBottledProduct(
|
||||||
const product = await this.prisma.product.findUnique({
|
productId: string,
|
||||||
|
tx?: Tx,
|
||||||
|
): Promise<void> {
|
||||||
|
const db: DbClient = tx ?? this.prisma;
|
||||||
|
const product = await db.product.findUnique({
|
||||||
where: { id: productId },
|
where: { id: productId },
|
||||||
});
|
});
|
||||||
if (!product) {
|
if (!product) {
|
||||||
@@ -186,7 +201,7 @@ export class BottlingService {
|
|||||||
// time (double tap, two devices) must not both pass the check and
|
// time (double tap, two devices) must not both pass the check and
|
||||||
// overwrite each other's deduction.
|
// overwrite each other's deduction.
|
||||||
await takeFromHoneyBatch(tx, dto.honeyBatchId, totalAmount);
|
await takeFromHoneyBatch(tx, dto.honeyBatchId, totalAmount);
|
||||||
return tx.bottlingBatch.create({
|
const created = await tx.bottlingBatch.create({
|
||||||
data: {
|
data: {
|
||||||
honeyBatchId: dto.honeyBatchId,
|
honeyBatchId: dto.honeyBatchId,
|
||||||
productId: dto.productId,
|
productId: dto.productId,
|
||||||
@@ -199,24 +214,28 @@ export class BottlingService {
|
|||||||
userId: actingUserId,
|
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({
|
return this.findById(bottlingBatch.id);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -225,12 +244,20 @@ export class BottlingService {
|
|||||||
* of the affected honey batch(es): the previously bottled amount is given
|
* of the affected honey batch(es): the previously bottled amount is given
|
||||||
* back before the corrected amount is taken again, so a correction never
|
* back before the corrected amount is taken again, so a correction never
|
||||||
* double-charges the source batch.
|
* 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) {
|
async update(id: string, dto: UpdateBottlingBatchDto, actingUserId: string) {
|
||||||
const before = await this.findById(id);
|
// A duplicate lotNumber surfaces as Prisma P2002 and is turned into a
|
||||||
|
// 409 with a field-specific message by AllExceptionsFilter.
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
await lockRowForUpdate(tx, 'BottlingBatch', id);
|
||||||
|
const before = await this.findById(id, tx);
|
||||||
if (dto.productId) {
|
if (dto.productId) {
|
||||||
await this.assertBottledProduct(dto.productId);
|
await this.assertBottledProduct(dto.productId, tx);
|
||||||
}
|
}
|
||||||
|
|
||||||
const honeyBatchId = dto.honeyBatchId ?? before.honeyBatchId;
|
const honeyBatchId = dto.honeyBatchId ?? before.honeyBatchId;
|
||||||
@@ -240,14 +267,14 @@ export class BottlingService {
|
|||||||
const totalAmount = roundKg((jarSize * jarCount) / 1000);
|
const totalAmount = roundKg((jarSize * jarCount) / 1000);
|
||||||
|
|
||||||
const targetHoneyBatch = movedToOtherHoneyBatch
|
const targetHoneyBatch = movedToOtherHoneyBatch
|
||||||
? await this.prisma.honeyBatch.findUnique({ where: { id: honeyBatchId } })
|
? await tx.honeyBatch.findUnique({ where: { id: honeyBatchId } })
|
||||||
: before.honeyBatch;
|
: before.honeyBatch;
|
||||||
if (!targetHoneyBatch) {
|
if (!targetHoneyBatch) {
|
||||||
throw new NotFoundException('Honey batch not found');
|
throw new NotFoundException('Honey batch not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Early, friendly check against the amounts read above. The booking
|
// Early, friendly check; the booking below is still conditional on
|
||||||
// itself happens atomically inside the transaction below.
|
// the stored amount (takeFromHoneyBatch).
|
||||||
const availableOnTarget =
|
const availableOnTarget =
|
||||||
targetHoneyBatch.amount === null
|
targetHoneyBatch.amount === null
|
||||||
? null
|
? null
|
||||||
@@ -259,9 +286,6 @@ export class BottlingService {
|
|||||||
throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE);
|
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) => {
|
|
||||||
const amountChanged =
|
const amountChanged =
|
||||||
movedToOtherHoneyBatch || totalAmount !== before.totalAmount;
|
movedToOtherHoneyBatch || totalAmount !== before.totalAmount;
|
||||||
if (amountChanged) {
|
if (amountChanged) {
|
||||||
@@ -291,21 +315,30 @@ export class BottlingService {
|
|||||||
lotNumber: dto.lotNumber,
|
lotNumber: dto.lotNumber,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const bottlingBatch = await this.findById(id);
|
const bottlingBatch = await this.findById(id, tx);
|
||||||
await this.auditLog.record({
|
await this.auditLog.record(
|
||||||
|
{
|
||||||
entityType: 'BottlingBatch',
|
entityType: 'BottlingBatch',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
action: 'UPDATE',
|
action: 'UPDATE',
|
||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
beforeData: before,
|
beforeData: before,
|
||||||
afterData: bottlingBatch,
|
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,
|
||||||
});
|
});
|
||||||
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;
|
return bottlingBatch;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -318,30 +351,63 @@ export class BottlingService {
|
|||||||
* write a fact that never happened.
|
* write a fact that never happened.
|
||||||
*/
|
*/
|
||||||
async remove(id: string, actingUserId: string) {
|
async remove(id: string, actingUserId: string) {
|
||||||
const before = await this.findById(id);
|
await this.prisma.$transaction(async (tx) => {
|
||||||
// Once jars were sold or withdrawn, the bottling is history: deleting it
|
// Shop sales and withdrawals lock the same row, so the movement check
|
||||||
// would erase where they came from. Wrong stock is fixed by an inventory
|
// below cannot be raced.
|
||||||
// correction instead.
|
await lockRowForUpdate(tx, 'BottlingBatch', id);
|
||||||
if (await this.stock.hasMovements({ kind: 'bottling', 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(
|
throw new ConflictException(
|
||||||
'Abfüllung hat bereits Bestandsbewegungen und kann nicht gelöscht werden - Bestand über "Inventur korrigieren" anpassen',
|
'Abfüllung hat bereits Bestandsbewegungen und kann nicht gelöscht werden - Bestand über "Inventur korrigieren" anpassen',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prisma.$transaction(async (tx) => {
|
|
||||||
await tx.bottlingBatch.delete({ where: { id } });
|
await tx.bottlingBatch.delete({ where: { id } });
|
||||||
await giveBackToHoneyBatch(tx, before.honeyBatchId, before.totalAmount);
|
await giveBackToHoneyBatch(tx, before.honeyBatchId, before.totalAmount);
|
||||||
});
|
await this.auditLog.record(
|
||||||
|
{
|
||||||
await this.auditLog.record({
|
|
||||||
entityType: 'BottlingBatch',
|
entityType: 'BottlingBatch',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
action: 'DELETE',
|
action: 'DELETE',
|
||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
beforeData: before,
|
beforeData: before,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
await this.recordEvents(tx, {
|
||||||
|
type: 'BottlingCorrected',
|
||||||
|
aggregateType: 'BottlingBatch',
|
||||||
|
aggregateId: id,
|
||||||
|
payload: { honeyBatchId: before.honeyBatchId, removed: true },
|
||||||
|
actorUserId: actingUserId,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
await this.webhooks.dispatch('batch.updated', before.honeyBatch);
|
|
||||||
await this.stock.notify({ bottlingBatchIds: [id], productIds: [] });
|
|
||||||
return { id };
|
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 { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||||
import { CalendarSyncSettingsService } from './calendar-sync-settings.service';
|
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';
|
import { UpdateCalendarSyncSettingsDto } from './dto/update-calendar-sync-settings.dto';
|
||||||
|
|
||||||
@ApiTags('calendar-sync')
|
@ApiTags('calendar-sync')
|
||||||
@@ -45,13 +48,27 @@ export class CalendarSyncController {
|
|||||||
return this.calendarSyncService.getStatus();
|
return this.calendarSyncService.getStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Starts a pass and answers at once (202) - the pass itself can take
|
// Queues a pass and answers at once (202): FAILED entries are reset and
|
||||||
// minutes. The returned status says running: true; GET status follows it.
|
// 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')
|
@RequirePermission('calendar:configure')
|
||||||
@Post('run-now')
|
@Post('run-now')
|
||||||
@HttpCode(HttpStatus.ACCEPTED)
|
@HttpCode(HttpStatus.ACCEPTED)
|
||||||
runNow() {
|
async runNow(): Promise<CalendarSyncStatusReport> {
|
||||||
this.calendarSyncService.startRun();
|
await this.calendarSyncService.requestRun();
|
||||||
return this.calendarSyncService.getStatus();
|
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 { Global, Module } from '@nestjs/common';
|
||||||
import { CalendarSyncController } from './calendar-sync.controller';
|
import { CalendarSyncController } from './calendar-sync.controller';
|
||||||
|
import { CalendarSyncEventHandlers } from './calendar-sync.handlers';
|
||||||
import { CalendarSyncService } from './calendar-sync.service';
|
import { CalendarSyncService } from './calendar-sync.service';
|
||||||
import { CalendarSyncSettingsService } from './calendar-sync-settings.service';
|
import { CalendarSyncSettingsService } from './calendar-sync-settings.service';
|
||||||
import { GraphClientService } from './graph-client.service';
|
import { GraphClientService } from './graph-client.service';
|
||||||
|
|
||||||
// @Global(): CalendarSyncService.enqueue()/cancel() are called from ~6
|
// @Global(): CalendarSyncService.enqueue() is called from otherwise-unrelated
|
||||||
// otherwise-unrelated domain modules (Tasks, Inspections, Treatments,
|
// domain modules (Inspections, Treatments, Feedings, Harvests, Queens) until
|
||||||
// Feedings, Harvests, Queens) - matches WebhooksModule's own @Global()
|
// they record domain events - matches WebhooksModule's own @Global()
|
||||||
// rationale rather than having each of those six modules import this one.
|
// rationale rather than having each of those modules import this one.
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [CalendarSyncController],
|
controllers: [CalendarSyncController],
|
||||||
providers: [
|
providers: [
|
||||||
CalendarSyncService,
|
CalendarSyncService,
|
||||||
|
CalendarSyncEventHandlers,
|
||||||
CalendarSyncSettingsService,
|
CalendarSyncSettingsService,
|
||||||
GraphClientService,
|
GraphClientService,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ describe('CalendarSyncService', () => {
|
|||||||
feeding: { findUnique: jest.Mock };
|
feeding: { findUnique: jest.Mock };
|
||||||
harvest: { findUnique: jest.Mock };
|
harvest: { findUnique: jest.Mock };
|
||||||
queenColonyHistory: { findUnique: jest.Mock };
|
queenColonyHistory: { findUnique: jest.Mock };
|
||||||
|
$executeRaw: jest.Mock;
|
||||||
};
|
};
|
||||||
let settingsService: { get: jest.Mock };
|
let settingsService: { get: jest.Mock };
|
||||||
let graphClient: {
|
let graphClient: {
|
||||||
@@ -93,6 +94,7 @@ describe('CalendarSyncService', () => {
|
|||||||
feeding: { findUnique: jest.fn() },
|
feeding: { findUnique: jest.fn() },
|
||||||
harvest: { findUnique: jest.fn() },
|
harvest: { findUnique: jest.fn() },
|
||||||
queenColonyHistory: { findUnique: jest.fn() },
|
queenColonyHistory: { findUnique: jest.fn() },
|
||||||
|
$executeRaw: jest.fn().mockResolvedValue(1),
|
||||||
};
|
};
|
||||||
settingsService = { get: jest.fn().mockResolvedValue(enabledSettings) };
|
settingsService = { get: jest.fn().mockResolvedValue(enabledSettings) };
|
||||||
graphClient = {
|
graphClient = {
|
||||||
@@ -149,8 +151,8 @@ describe('CalendarSyncService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// enqueue()/cancel() run after the source record is committed: a failure
|
// enqueue() runs after the source record is committed: a failure must not
|
||||||
// must not fail that request (the client would retry and store it twice).
|
// fail that request (the client would retry and store it twice).
|
||||||
describe('post-commit calls', () => {
|
describe('post-commit calls', () => {
|
||||||
let errorSpy: jest.SpyInstance;
|
let errorSpy: jest.SpyInstance;
|
||||||
|
|
||||||
@@ -176,61 +178,50 @@ describe('CalendarSyncService', () => {
|
|||||||
expect(message).toContain('INSPECTION inspection-1');
|
expect(message).toContain('INSPECTION inspection-1');
|
||||||
expect(message).toContain('db down');
|
expect(message).toContain('db down');
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('cancel never throws and logs the failure with the record', async () => {
|
describe('enqueueIn', () => {
|
||||||
prisma.calendarSyncOutboxEntry.findUnique.mockRejectedValue(
|
it('writes through the given client and lets errors propagate (handler retry)', async () => {
|
||||||
new Error('db down'),
|
const txUpsert = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const tx = {
|
||||||
|
calendarSyncOutboxEntry: { upsert: txUpsert },
|
||||||
|
} as unknown as Prisma.TransactionClient;
|
||||||
|
|
||||||
|
await service.enqueueIn(tx, 'TASK', 'task-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();
|
||||||
|
|
||||||
|
txUpsert.mockRejectedValue(new Error('tx failed'));
|
||||||
|
await expect(service.enqueueIn(tx, 'TASK', 'task-1')).rejects.toThrow(
|
||||||
|
'tx failed',
|
||||||
);
|
);
|
||||||
|
|
||||||
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 sync is disabled', async () => {
|
||||||
it('does nothing when no outbox row exists', async () => {
|
settingsService.get.mockResolvedValue({
|
||||||
prisma.calendarSyncOutboxEntry.findUnique.mockResolvedValue(null);
|
...enabledSettings,
|
||||||
|
enabled: false,
|
||||||
await service.cancel('TASK', 'task-1');
|
|
||||||
|
|
||||||
expect(prisma.calendarSyncOutboxEntry.update).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
const txUpsert = jest.fn();
|
||||||
|
const tx = {
|
||||||
|
calendarSyncOutboxEntry: { upsert: txUpsert },
|
||||||
|
} as unknown as Prisma.TransactionClient;
|
||||||
|
|
||||||
it('deletes the Graph event and clears the row when a graphEventId exists', async () => {
|
await service.enqueueIn(tx, 'TASK', 'task-1');
|
||||||
prisma.calendarSyncOutboxEntry.findUnique.mockResolvedValue({
|
|
||||||
id: 'entry-1',
|
|
||||||
graphEventId: 'graph-event-1',
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.cancel('TASK', 'task-1');
|
expect(txUpsert).not.toHaveBeenCalled();
|
||||||
|
|
||||||
expect(graphClient.deleteEvent).toHaveBeenCalledWith(
|
|
||||||
'group-1',
|
|
||||||
'graph-event-1',
|
|
||||||
);
|
|
||||||
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',
|
|
||||||
});
|
|
||||||
graphClient.deleteEvent.mockRejectedValue(new Error('network down'));
|
|
||||||
|
|
||||||
await service.cancel('TASK', 'task-1');
|
|
||||||
|
|
||||||
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({
|
|
||||||
where: { id: 'entry-1' },
|
|
||||||
data: { status: 'SKIPPED', graphEventId: null },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -239,6 +230,7 @@ describe('CalendarSyncService', () => {
|
|||||||
id: 'task-1',
|
id: 'task-1',
|
||||||
title: 'Beute prüfen',
|
title: 'Beute prüfen',
|
||||||
dueDate: new Date('2026-03-07'),
|
dueDate: new Date('2026-03-07'),
|
||||||
|
status: 'OPEN',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The outcome write: only lands if the row is unchanged since the claim. */
|
/** 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 () => {
|
it('claims a row with a conditional update before working on it', async () => {
|
||||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||||
pendingEntry(),
|
pendingEntry(),
|
||||||
@@ -401,7 +431,78 @@ describe('CalendarSyncService', () => {
|
|||||||
await service.processOutbox();
|
await service.processOutbox();
|
||||||
|
|
||||||
expect(graphClient.createEvent).not.toHaveBeenCalled();
|
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 () => {
|
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', () => {
|
// "Jetzt synchronisieren" only queues the pass: the worker runs it, so the
|
||||||
it('gives FAILED entries a fresh set of attempts before processing', async () => {
|
// API request neither talks to Graph nor waits for up to 20 entries.
|
||||||
await service.runNow();
|
describe('requestRun', () => {
|
||||||
|
it('requestRun resets FAILED entries and wakes the worker without calling Graph', async () => {
|
||||||
|
await service.requestRun();
|
||||||
|
|
||||||
expect(prisma.calendarSyncOutboxEntry.updateMany).toHaveBeenCalledWith({
|
expect(prisma.calendarSyncOutboxEntry.updateMany).toHaveBeenCalledWith({
|
||||||
where: { status: 'FAILED' },
|
where: { status: 'FAILED' },
|
||||||
data: { status: 'PENDING', attempts: 0, lastError: null },
|
data: { status: 'PENDING', attempts: 0, lastError: null },
|
||||||
});
|
});
|
||||||
const [resetOrder] =
|
expect(prisma.$executeRaw).toHaveBeenCalled(); // pg_notify('calendar_sync')
|
||||||
prisma.calendarSyncOutboxEntry.updateMany.mock.invocationCallOrder;
|
expect(graphClient.createEvent).not.toHaveBeenCalled();
|
||||||
const [readOrder] =
|
expect(graphClient.updateEvent).not.toHaveBeenCalled();
|
||||||
prisma.calendarSyncOutboxEntry.findMany.mock.invocationCallOrder;
|
expect(prisma.calendarSyncOutboxEntry.findMany).not.toHaveBeenCalled();
|
||||||
expect(resetOrder).toBeLessThan(readOrder);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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();
|
await service.processOutbox();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
@@ -649,127 +770,7 @@ describe('CalendarSyncService', () => {
|
|||||||
).not.toHaveBeenCalledWith(
|
).not.toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ where: { status: 'FAILED' } }),
|
expect.objectContaining({ where: { status: 'FAILED' } }),
|
||||||
);
|
);
|
||||||
});
|
expect(prisma.$executeRaw).not.toHaveBeenCalled();
|
||||||
});
|
|
||||||
|
|
||||||
// "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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -794,8 +795,8 @@ describe('CalendarSyncService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Another pod (its cron tick or its "Jetzt synchronisieren") may be the
|
// The pass runs in a worker process, never in the API that answers -
|
||||||
// one working - its live claims show that across replicas.
|
// only the worker's live claims can show it, across replicas.
|
||||||
it('reports running while any worker holds an entry', async () => {
|
it('reports running while any worker holds an entry', async () => {
|
||||||
prisma.calendarSyncOutboxEntry.count
|
prisma.calendarSyncOutboxEntry.count
|
||||||
.mockResolvedValueOnce(0)
|
.mockResolvedValueOnce(0)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { DbClient } from '../prisma/db-client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { runAfterCommit } from '../common/after-commit';
|
import { runAfterCommit } from '../common/after-commit';
|
||||||
import { loggableError } from '../common/loggable-error';
|
import { loggableError } from '../common/loggable-error';
|
||||||
|
import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup';
|
||||||
import { CalendarSyncSettingsService } from './calendar-sync-settings.service';
|
import { CalendarSyncSettingsService } from './calendar-sync-settings.service';
|
||||||
import {
|
import {
|
||||||
GraphClientService,
|
GraphClientService,
|
||||||
@@ -17,8 +18,8 @@ import {
|
|||||||
Prisma,
|
Prisma,
|
||||||
} from '../../generated/prisma';
|
} from '../../generated/prisma';
|
||||||
|
|
||||||
// Bounded per tick so a large backlog can't turn one cron run into a
|
// Bounded per tick so a large backlog can't turn one worker tick into a
|
||||||
// long-running request that overlaps the next tick.
|
// long-running pass that delays the other wake-ups.
|
||||||
const BATCH_SIZE = 20;
|
const BATCH_SIZE = 20;
|
||||||
// A row that keeps failing this many times stops retrying automatically
|
// A row that keeps failing this many times stops retrying automatically
|
||||||
// (status becomes FAILED) - "Jetzt synchronisieren" or a fresh enqueue
|
// (status becomes FAILED) - "Jetzt synchronisieren" or a fresh enqueue
|
||||||
@@ -73,7 +74,7 @@ export interface CalendarSyncStatusReport {
|
|||||||
pendingCount: number;
|
pendingCount: number;
|
||||||
failedCount: number;
|
failedCount: number;
|
||||||
syncedCount: 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;
|
running: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,10 +83,8 @@ function getErrorMessage(error: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CalendarSyncService implements OnModuleDestroy {
|
export class CalendarSyncService {
|
||||||
private readonly logger = new Logger(CalendarSyncService.name);
|
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(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
@@ -108,8 +107,8 @@ export class CalendarSyncService implements OnModuleDestroy {
|
|||||||
return flagBySourceType[sourceType];
|
return flagBySourceType[sourceType];
|
||||||
}
|
}
|
||||||
|
|
||||||
// enqueue()/cancel() follow a committed write in the domain services and
|
// enqueue() follows a committed write in the domain services and is
|
||||||
// are called without a guard - so they never throw (see runAfterCommit).
|
// called without a guard - so it never throws (see runAfterCommit).
|
||||||
private afterCommit(
|
private afterCommit(
|
||||||
action: string,
|
action: string,
|
||||||
sourceType: CalendarSyncSourceType,
|
sourceType: CalendarSyncSourceType,
|
||||||
@@ -123,22 +122,19 @@ export class CalendarSyncService implements OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called from the ~6 domain services right after a create/update that
|
// The core of every enqueue: upserts rather than always inserting, so
|
||||||
// should appear on the M365 calendar. Upserts rather than always
|
// re-enqueuing an already-synced row (e.g. a Task's due date changed)
|
||||||
// inserting: re-enqueuing an already-synced row (e.g. a Task's due date
|
// resets it to PENDING while keeping its graphEventId - the worker then
|
||||||
// changed) resets it to PENDING while keeping its graphEventId, so the
|
// updates the existing Graph event instead of creating a duplicate. The
|
||||||
// worker updates the existing Graph event instead of creating a duplicate.
|
// revision counts up so a worker syncing the row right now notices the
|
||||||
// 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
|
||||||
// newer state instead of marking it SYNCED.
|
// handler's delivery transaction) and not guarded: a failure propagates,
|
||||||
async enqueue(
|
// so the domain event delivery is retried.
|
||||||
|
async enqueueIn(
|
||||||
|
db: DbClient,
|
||||||
sourceType: CalendarSyncSourceType,
|
sourceType: CalendarSyncSourceType,
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.afterCommit(
|
|
||||||
'Calendar sync enqueue',
|
|
||||||
sourceType,
|
|
||||||
sourceId,
|
|
||||||
async () => {
|
|
||||||
const settings = await this.settingsService.get();
|
const settings = await this.settingsService.get();
|
||||||
if (!settings.enabled) {
|
if (!settings.enabled) {
|
||||||
return;
|
return;
|
||||||
@@ -146,7 +142,7 @@ export class CalendarSyncService implements OnModuleDestroy {
|
|||||||
if (!this.isSourceTypeEnabled(settings, sourceType)) {
|
if (!this.isSourceTypeEnabled(settings, sourceType)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.prisma.calendarSyncOutboxEntry.upsert({
|
await db.calendarSyncOutboxEntry.upsert({
|
||||||
where: { sourceType_sourceId: { sourceType, sourceId } },
|
where: { sourceType_sourceId: { sourceType, sourceId } },
|
||||||
create: { sourceType, sourceId },
|
create: { sourceType, sourceId },
|
||||||
update: {
|
update: {
|
||||||
@@ -156,37 +152,18 @@ export class CalendarSyncService implements OnModuleDestroy {
|
|||||||
revision: { increment: 1 },
|
revision: { increment: 1 },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called when a source record no longer belongs on the calendar (e.g. a
|
// Legacy path for sources that do not record a domain event yet
|
||||||
// Task is completed). Best-effort: a failed Graph delete is logged, not
|
// (inspections, treatments, feedings and harvests until their producer
|
||||||
// thrown, since the outbox row is cleared either way and a future enqueue
|
// tasks; queen assignments), called right after a committed write: never
|
||||||
// for the same (sourceType, sourceId) will simply create a fresh event.
|
// throws.
|
||||||
async cancel(
|
async enqueue(
|
||||||
sourceType: CalendarSyncSourceType,
|
sourceType: CalendarSyncSourceType,
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.afterCommit(
|
await this.afterCommit('Calendar sync enqueue', sourceType, sourceId, () =>
|
||||||
'Calendar sync cancel',
|
this.enqueueIn(this.prisma, sourceType, sourceId),
|
||||||
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 },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,20 +186,15 @@ export class CalendarSyncService implements OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The cron tick. An error is logged here, Prisma errors by name/code/model
|
// One pass, run by the worker's calendar-outbox loop (worker/
|
||||||
// only (see loggableError) - left uncaught, the scheduler would log the
|
// worker-runtime.service.ts): every 60 s and on a calendar_sync wake-up.
|
||||||
// full Prisma message and stack.
|
// The loop logs a failed pass via loggableError. Once `signal` aborts
|
||||||
@Cron(CronExpression.EVERY_MINUTE)
|
// (worker shutdown), no further entry is claimed; the entry in progress
|
||||||
async scheduledRun(): Promise<void> {
|
// is finished, since a cut-off one waits for the stale-claim window.
|
||||||
try {
|
async processOutbox(signal?: AbortSignal): Promise<void> {
|
||||||
await this.processOutbox();
|
if (signal?.aborted) {
|
||||||
} catch (error: unknown) {
|
return;
|
||||||
const { message, stack } = loggableError(error);
|
|
||||||
this.logger.error(`Calendar sync tick failed: ${message}`, stack);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async processOutbox(): Promise<void> {
|
|
||||||
const settings = await this.settingsService.get();
|
const settings = await this.settingsService.get();
|
||||||
if (
|
if (
|
||||||
!settings.enabled ||
|
!settings.enabled ||
|
||||||
@@ -232,15 +204,18 @@ export class CalendarSyncService implements OnModuleDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Several workers may run at once - one cron tick per pod, plus "Jetzt
|
// Several workers may run at once - one loop per worker pod. Each entry
|
||||||
// synchronisieren". Each entry is claimed right before it is written,
|
// is claimed right before it is written, so exactly one of them talks
|
||||||
// so exactly one of them talks to Graph about it.
|
// to Graph about it.
|
||||||
const candidates = await this.prisma.calendarSyncOutboxEntry.findMany({
|
const candidates = await this.prisma.calendarSyncOutboxEntry.findMany({
|
||||||
where: { status: 'PENDING', ...claimableNow() },
|
where: { status: 'PENDING', ...claimableNow() },
|
||||||
orderBy: { updatedAt: 'asc' },
|
orderBy: { updatedAt: 'asc' },
|
||||||
take: BATCH_SIZE,
|
take: BATCH_SIZE,
|
||||||
});
|
});
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const claimed = await this.claim(candidate);
|
const claimed = await this.claim(candidate);
|
||||||
if (claimed) {
|
if (claimed) {
|
||||||
await this.processEntry(claimed, settings.groupId);
|
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
|
// "Jetzt synchronisieren": gives FAILED entries a fresh set of attempts -
|
||||||
// attempts - besides editing the source record the documented way out of
|
// besides editing the source record the documented way out of FAILED -
|
||||||
// FAILED. A FAILED row is never claimed (settle() clears the claim with
|
// and wakes the worker, which runs the pass. The API never calls Graph
|
||||||
// it), so this cannot pull a row from under a running worker. The regular
|
// for it, so the request returns at once. A FAILED row is never claimed
|
||||||
// cron tick leaves FAILED alone.
|
// (settle() clears the claim with it), so this cannot pull a row from
|
||||||
async runNow(): Promise<void> {
|
// 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({
|
await this.prisma.calendarSyncOutboxEntry.updateMany({
|
||||||
where: { status: 'FAILED' },
|
where: { status: 'FAILED' },
|
||||||
data: { status: 'PENDING', attempts: 0, lastError: null },
|
data: { status: 'PENDING', attempts: 0, lastError: null },
|
||||||
});
|
});
|
||||||
await this.processOutbox();
|
await notifyWorker(this.prisma, WAKE_CHANNELS.calendarSync);
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compare-and-set on the row as it was read: the update only matches while
|
// 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,
|
entry.sourceId,
|
||||||
);
|
);
|
||||||
if (!input) {
|
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;
|
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. */
|
/** The row exactly as claimed: still ours, PENDING, nothing enqueued since. */
|
||||||
private unchangedSinceClaim(
|
private unchangedSinceClaim(
|
||||||
entry: ClaimedEntry,
|
entry: ClaimedEntry,
|
||||||
@@ -452,7 +428,12 @@ export class CalendarSyncService implements OnModuleDestroy {
|
|||||||
const task = await this.prisma.task.findUnique({
|
const task = await this.prisma.task.findUnique({
|
||||||
where: { id: sourceId },
|
where: { id: sourceId },
|
||||||
});
|
});
|
||||||
if (!task || !task.dueDate) {
|
if (
|
||||||
|
!task ||
|
||||||
|
!task.dueDate ||
|
||||||
|
task.status === 'DONE' ||
|
||||||
|
task.status === 'CANCELLED'
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return { title: task.title, date: task.dueDate };
|
return { title: task.title, date: task.dueDate };
|
||||||
@@ -534,7 +515,8 @@ export class CalendarSyncService implements OnModuleDestroy {
|
|||||||
this.prisma.calendarSyncOutboxEntry.count({
|
this.prisma.calendarSyncOutboxEntry.count({
|
||||||
where: { status: 'SYNCED' },
|
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({
|
this.prisma.calendarSyncOutboxEntry.count({
|
||||||
where: { claimedAt: { gte: new Date(Date.now() - STALE_CLAIM_MS) } },
|
where: { claimedAt: { gte: new Date(Date.now() - STALE_CLAIM_MS) } },
|
||||||
}),
|
}),
|
||||||
@@ -546,7 +528,7 @@ export class CalendarSyncService implements OnModuleDestroy {
|
|||||||
pendingCount,
|
pendingCount,
|
||||||
failedCount,
|
failedCount,
|
||||||
syncedCount,
|
syncedCount,
|
||||||
running: this.activeRun !== null || heldCount > 0,
|
running: heldCount > 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { Module } from '@nestjs/common';
|
||||||
import { AutomationModule } from '../automation/automation.module';
|
|
||||||
import { ColoniesController } from './colonies.controller';
|
import { ColoniesController } from './colonies.controller';
|
||||||
import { ColoniesService } from './colonies.service';
|
import { ColoniesService } from './colonies.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AutomationModule],
|
|
||||||
controllers: [ColoniesController],
|
controllers: [ColoniesController],
|
||||||
providers: [ColoniesService],
|
providers: [ColoniesService],
|
||||||
exports: [ColoniesService],
|
exports: [ColoniesService],
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { ColoniesService } from './colonies.service';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
import { AutomationService } from '../automation/automation.service';
|
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||||
|
|
||||||
describe('ColoniesService', () => {
|
describe('ColoniesService', () => {
|
||||||
let service: ColoniesService;
|
let service: ColoniesService;
|
||||||
let tx: {
|
let tx: {
|
||||||
|
$queryRaw: jest.Mock;
|
||||||
colony: { create: jest.Mock; update: jest.Mock; findUnique: jest.Mock };
|
colony: { create: jest.Mock; update: jest.Mock; findUnique: jest.Mock };
|
||||||
colonyApiaryHistory: { create: jest.Mock };
|
colonyApiaryHistory: { create: jest.Mock };
|
||||||
colonyLineage: { create: jest.Mock };
|
colonyLineage: { create: jest.Mock };
|
||||||
@@ -27,6 +28,7 @@ describe('ColoniesService', () => {
|
|||||||
$transaction: jest.Mock;
|
$transaction: jest.Mock;
|
||||||
};
|
};
|
||||||
let auditLog: { record: jest.Mock };
|
let auditLog: { record: jest.Mock };
|
||||||
|
let events: { record: jest.Mock; recordAll: jest.Mock };
|
||||||
|
|
||||||
const parentColony = {
|
const parentColony = {
|
||||||
id: 'colony-1',
|
id: 'colony-1',
|
||||||
@@ -38,6 +40,7 @@ describe('ColoniesService', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tx = {
|
tx = {
|
||||||
|
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||||
colony: { create: jest.fn(), update: jest.fn(), findUnique: jest.fn() },
|
colony: { create: jest.fn(), update: jest.fn(), findUnique: jest.fn() },
|
||||||
colonyApiaryHistory: { create: jest.fn() },
|
colonyApiaryHistory: { create: jest.fn() },
|
||||||
colonyLineage: { create: jest.fn() },
|
colonyLineage: { create: jest.fn() },
|
||||||
@@ -62,19 +65,30 @@ describe('ColoniesService', () => {
|
|||||||
callback(tx),
|
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) };
|
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||||
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||||
const automation = {
|
events = {
|
||||||
evaluateColonyHealth: jest.fn().mockResolvedValue(undefined),
|
record: jest.fn().mockResolvedValue(undefined),
|
||||||
|
recordAll: jest.fn().mockResolvedValue(0),
|
||||||
};
|
};
|
||||||
service = new ColoniesService(
|
service = new ColoniesService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
auditLog as unknown as AuditLogService,
|
auditLog as unknown as AuditLogService,
|
||||||
webhooks as unknown as WebhooksService,
|
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 () => {
|
it('findById throws NotFoundException when missing', async () => {
|
||||||
prisma.colony.findUnique.mockResolvedValue(null);
|
prisma.colony.findUnique.mockResolvedValue(null);
|
||||||
await expect(service.findById('missing')).rejects.toBeInstanceOf(
|
await expect(service.findById('missing')).rejects.toBeInstanceOf(
|
||||||
@@ -137,7 +151,11 @@ describe('ColoniesService', () => {
|
|||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
currentQueenId: 'queen-2',
|
currentQueenId: 'queen-2',
|
||||||
});
|
});
|
||||||
tx.colony.update.mockResolvedValue({});
|
tx.colony.update.mockResolvedValue({
|
||||||
|
id: 'colony-3',
|
||||||
|
status: 'MERGED',
|
||||||
|
healthStatus: null,
|
||||||
|
});
|
||||||
tx.colonyLineage.create.mockResolvedValue({});
|
tx.colonyLineage.create.mockResolvedValue({});
|
||||||
|
|
||||||
await service.merge(
|
await service.merge(
|
||||||
@@ -242,6 +260,7 @@ describe('ColoniesService', () => {
|
|||||||
expect(result.status).toBe('DISSOLVED');
|
expect(result.status).toBe('DISSOLVED');
|
||||||
expect(auditLog.record).toHaveBeenCalledWith(
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ action: 'DEACTIVATE' }),
|
expect.objectContaining({ action: 'DEACTIVATE' }),
|
||||||
|
tx,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -320,7 +339,7 @@ describe('ColoniesService', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
service.reactivate('colony-1', 'user-1'),
|
service.reactivate('colony-1', 'user-1'),
|
||||||
).rejects.toBeInstanceOf(ConflictException);
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
expect(prisma.colony.update).not.toHaveBeenCalled();
|
expect(tx.colony.update).not.toHaveBeenCalled();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -330,7 +349,7 @@ describe('ColoniesService', () => {
|
|||||||
status: 'DISSOLVED',
|
status: 'DISSOLVED',
|
||||||
deactivatedAt: new Date(),
|
deactivatedAt: new Date(),
|
||||||
});
|
});
|
||||||
prisma.colony.update.mockResolvedValue({
|
tx.colony.update.mockResolvedValue({
|
||||||
...parentColony,
|
...parentColony,
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
deactivatedAt: null,
|
deactivatedAt: null,
|
||||||
@@ -339,13 +358,15 @@ describe('ColoniesService', () => {
|
|||||||
const result = await service.reactivate('colony-1', 'user-1');
|
const result = await service.reactivate('colony-1', 'user-1');
|
||||||
|
|
||||||
expect(result.status).toBe('ACTIVE');
|
expect(result.status).toBe('ACTIVE');
|
||||||
expect(prisma.colony.update).toHaveBeenCalledWith({
|
expect(tx.colony.update).toHaveBeenCalledWith({
|
||||||
where: { id: 'colony-1' },
|
where: { id: 'colony-1' },
|
||||||
data: { status: 'ACTIVE', deactivatedAt: null },
|
data: { status: 'ACTIVE', deactivatedAt: null },
|
||||||
});
|
});
|
||||||
expect(auditLog.record).toHaveBeenCalledWith(
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ action: 'UPDATE' }),
|
expect.objectContaining({ action: 'UPDATE' }),
|
||||||
|
tx,
|
||||||
);
|
);
|
||||||
|
expect(transitionTypes()).toEqual(['ColonyStatusChanged']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('writes an apiary-history entry when an edit corrects the apiary', async () => {
|
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();
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { WebhooksService } from '../webhooks/webhooks.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 { Colony, ColonyStatus } from '../../generated/prisma';
|
||||||
import { CreateColonyDto } from './dto/create-colony.dto';
|
import { CreateColonyDto } from './dto/create-colony.dto';
|
||||||
import { UpdateColonyDto } from './dto/update-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 { SplitColonyDto } from './dto/split-colony.dto';
|
||||||
import { MergeColonyDto } from './dto/merge-colony.dto';
|
import { MergeColonyDto } from './dto/merge-colony.dto';
|
||||||
import { isLivingColonyStatus } from './colony-status';
|
import { isLivingColonyStatus } from './colony-status';
|
||||||
|
import { lockRowsForUpdate } from '../prisma/row-lock';
|
||||||
|
import { colonyTransitionEvents, lockColony } from './colony-events';
|
||||||
|
|
||||||
export interface ColonyFilter {
|
export interface ColonyFilter {
|
||||||
status?: ColonyStatus;
|
status?: ColonyStatus;
|
||||||
@@ -27,7 +29,7 @@ export class ColoniesService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
private readonly webhooks: WebhooksService,
|
private readonly webhooks: WebhooksService,
|
||||||
private readonly automation: AutomationService,
|
private readonly events: DomainEventsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(filter: ColonyFilter = {}): Promise<Colony[]> {
|
async findAll(filter: ColonyFilter = {}): Promise<Colony[]> {
|
||||||
@@ -77,14 +79,22 @@ export class ColoniesService {
|
|||||||
|
|
||||||
async create(dto: CreateColonyDto, actingUserId: string): Promise<Colony> {
|
async create(dto: CreateColonyDto, actingUserId: string): Promise<Colony> {
|
||||||
await this.assertApiaryActive(dto.apiaryId);
|
await this.assertApiaryActive(dto.apiaryId);
|
||||||
const colony = await this.prisma.colony.create({ data: { ...dto } });
|
const colony = await this.prisma.$transaction(async (tx) => {
|
||||||
await this.auditLog.record({
|
const created = await tx.colony.create({ data: { ...dto } });
|
||||||
|
await this.auditLog.record(
|
||||||
|
{
|
||||||
entityType: 'Colony',
|
entityType: 'Colony',
|
||||||
entityId: colony.id,
|
entityId: created.id,
|
||||||
action: 'CREATE',
|
action: 'CREATE',
|
||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
afterData: colony,
|
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);
|
await this.webhooks.dispatch('colony.created', colony);
|
||||||
return colony;
|
return colony;
|
||||||
}
|
}
|
||||||
@@ -94,14 +104,16 @@ export class ColoniesService {
|
|||||||
dto: UpdateColonyDto,
|
dto: UpdateColonyDto,
|
||||||
actingUserId: string,
|
actingUserId: string,
|
||||||
): Promise<Colony> {
|
): Promise<Colony> {
|
||||||
const before = await this.findById(id);
|
// 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 =
|
const movesApiary =
|
||||||
dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId;
|
dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId;
|
||||||
if (movesApiary) {
|
if (movesApiary) {
|
||||||
await this.assertApiaryActive(dto.apiaryId!);
|
await this.assertApiaryActive(dto.apiaryId!);
|
||||||
}
|
}
|
||||||
|
|
||||||
const colony = await this.prisma.$transaction(async (tx) => {
|
|
||||||
const updated = await tx.colony.update({
|
const updated = await tx.colony.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { ...dto },
|
data: { ...dto },
|
||||||
@@ -119,17 +131,23 @@ export class ColoniesService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return updated;
|
await this.events.recordAll(
|
||||||
});
|
tx,
|
||||||
await this.auditLog.record({
|
colonyTransitionEvents(before, updated, actingUserId),
|
||||||
|
);
|
||||||
|
await this.auditLog.record(
|
||||||
|
{
|
||||||
entityType: 'Colony',
|
entityType: 'Colony',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
action: 'UPDATE',
|
action: 'UPDATE',
|
||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
beforeData: before,
|
beforeData: before,
|
||||||
afterData: colony,
|
afterData: updated,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
});
|
});
|
||||||
await this.automation.evaluateColonyHealth(before, colony);
|
|
||||||
return colony;
|
return colony;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,10 +156,11 @@ export class ColoniesService {
|
|||||||
dto: MoveColonyDto,
|
dto: MoveColonyDto,
|
||||||
actingUserId: string,
|
actingUserId: string,
|
||||||
): Promise<Colony> {
|
): Promise<Colony> {
|
||||||
const before = await this.findById(id);
|
this.assertActive(await this.findById(id));
|
||||||
this.assertActive(before);
|
|
||||||
await this.assertApiaryActive(dto.apiaryId);
|
await this.assertApiaryActive(dto.apiaryId);
|
||||||
const colony = await this.prisma.$transaction(async (tx) => {
|
const colony = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const before = await lockColony(tx, id);
|
||||||
|
this.assertActive(before);
|
||||||
const updated = await tx.colony.update({
|
const updated = await tx.colony.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { apiaryId: dto.apiaryId },
|
data: { apiaryId: dto.apiaryId },
|
||||||
@@ -154,15 +173,18 @@ export class ColoniesService {
|
|||||||
notes: dto.notes,
|
notes: dto.notes,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return updated;
|
await this.auditLog.record(
|
||||||
});
|
{
|
||||||
await this.auditLog.record({
|
|
||||||
entityType: 'Colony',
|
entityType: 'Colony',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
action: 'UPDATE',
|
action: 'UPDATE',
|
||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
beforeData: before,
|
beforeData: before,
|
||||||
afterData: colony,
|
afterData: updated,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
});
|
});
|
||||||
return colony;
|
return colony;
|
||||||
}
|
}
|
||||||
@@ -192,14 +214,17 @@ export class ColoniesService {
|
|||||||
notes: dto.notes,
|
notes: dto.notes,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return created;
|
await this.auditLog.record(
|
||||||
});
|
{
|
||||||
await this.auditLog.record({
|
|
||||||
entityType: 'Colony',
|
entityType: 'Colony',
|
||||||
entityId: child.id,
|
entityId: created.id,
|
||||||
action: 'CREATE',
|
action: 'CREATE',
|
||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
afterData: { ...child, splitFromColonyId: parent.id },
|
afterData: { ...created, splitFromColonyId: parent.id },
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return created;
|
||||||
});
|
});
|
||||||
return child;
|
return child;
|
||||||
}
|
}
|
||||||
@@ -222,6 +247,9 @@ export class ColoniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
await this.prisma.$transaction(async (tx) => {
|
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) {
|
for (const mergedColonyId of dto.mergedColonyIds) {
|
||||||
const merged = await tx.colony.findUnique({
|
const merged = await tx.colony.findUnique({
|
||||||
where: { id: mergedColonyId },
|
where: { id: mergedColonyId },
|
||||||
@@ -238,7 +266,7 @@ export class ColoniesService {
|
|||||||
// The survivor keeps its own queen; merged-away queens are simply
|
// The survivor keeps its own queen; merged-away queens are simply
|
||||||
// released, not auto-transferred (which colony's queen "wins" a
|
// released, not auto-transferred (which colony's queen "wins" a
|
||||||
// merge is a beekeeper decision, not one to guess here).
|
// merge is a beekeeper decision, not one to guess here).
|
||||||
await tx.colony.update({
|
const mergedAway = await tx.colony.update({
|
||||||
where: { id: mergedColonyId },
|
where: { id: mergedColonyId },
|
||||||
data: {
|
data: {
|
||||||
status: 'MERGED',
|
status: 'MERGED',
|
||||||
@@ -246,6 +274,10 @@ export class ColoniesService {
|
|||||||
currentQueenId: null,
|
currentQueenId: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await this.events.recordAll(
|
||||||
|
tx,
|
||||||
|
colonyTransitionEvents(merged, mergedAway, actingUserId),
|
||||||
|
);
|
||||||
if (merged.currentQueenId) {
|
if (merged.currentQueenId) {
|
||||||
await tx.queenColonyHistory.updateMany({
|
await tx.queenColonyHistory.updateMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -266,24 +298,28 @@ export class ColoniesService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
await this.auditLog.record(
|
||||||
await this.auditLog.record({
|
{
|
||||||
entityType: 'Colony',
|
entityType: 'Colony',
|
||||||
entityId: survivor.id,
|
entityId: survivor.id,
|
||||||
action: 'UPDATE',
|
action: 'UPDATE',
|
||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
beforeData: survivor,
|
beforeData: survivor,
|
||||||
afterData: { ...survivor, mergedColonyIds: dto.mergedColonyIds },
|
afterData: { ...survivor, mergedColonyIds: dto.mergedColonyIds },
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
return this.findById(survivor.id);
|
return this.findById(survivor.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async dissolve(id: string, actingUserId: string): Promise<Colony> {
|
async dissolve(id: string, actingUserId: string): Promise<Colony> {
|
||||||
const before = await this.findById(id);
|
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
|
// Re-dissolving would overwrite deactivatedAt and the terminal status
|
||||||
// (e.g. MERGED -> DISSOLVED), losing when and how the colony really ended.
|
// (e.g. MERGED -> DISSOLVED), losing when and how the colony really ended.
|
||||||
this.assertActive(before);
|
this.assertActive(before);
|
||||||
const colony = await this.prisma.$transaction(async (tx) => {
|
|
||||||
const updated = await tx.colony.update({
|
const updated = await tx.colony.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
@@ -302,39 +338,56 @@ export class ColoniesService {
|
|||||||
data: { endDate: new Date() },
|
data: { endDate: new Date() },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return updated;
|
await this.events.recordAll(
|
||||||
});
|
tx,
|
||||||
await this.auditLog.record({
|
colonyTransitionEvents(before, updated, actingUserId),
|
||||||
|
);
|
||||||
|
await this.auditLog.record(
|
||||||
|
{
|
||||||
entityType: 'Colony',
|
entityType: 'Colony',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
action: 'DEACTIVATE',
|
action: 'DEACTIVATE',
|
||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
beforeData: before,
|
beforeData: before,
|
||||||
afterData: colony,
|
afterData: updated,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
});
|
});
|
||||||
return colony;
|
return colony;
|
||||||
}
|
}
|
||||||
|
|
||||||
async reactivate(id: string, actingUserId: string): Promise<Colony> {
|
async reactivate(id: string, actingUserId: string): Promise<Colony> {
|
||||||
const before = await this.findById(id);
|
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
|
// Only an ended colony can be brought back. A weak or queenless one
|
||||||
// still exists - reactivating it would just overwrite its condition.
|
// still exists - reactivating it would just overwrite its condition.
|
||||||
if (isLivingColonyStatus(before.status)) {
|
if (isLivingColonyStatus(before.status)) {
|
||||||
throw new ConflictException('Colony is already active');
|
throw new ConflictException('Colony is already active');
|
||||||
}
|
}
|
||||||
const colony = await this.prisma.colony.update({
|
const updated = await tx.colony.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: 'ACTIVE', deactivatedAt: null },
|
data: { status: 'ACTIVE', deactivatedAt: null },
|
||||||
});
|
});
|
||||||
await this.auditLog.record({
|
await this.events.recordAll(
|
||||||
|
tx,
|
||||||
|
colonyTransitionEvents(before, updated, actingUserId),
|
||||||
|
);
|
||||||
|
await this.auditLog.record(
|
||||||
|
{
|
||||||
entityType: 'Colony',
|
entityType: 'Colony',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
action: 'UPDATE',
|
action: 'UPDATE',
|
||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
beforeData: before,
|
beforeData: before,
|
||||||
afterData: colony,
|
afterData: updated,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
});
|
});
|
||||||
return colony;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getHistory(id: string) {
|
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 { readFileSync } from 'node:fs';
|
||||||
import { Prisma } from '../../../generated/prisma';
|
import * as path from 'node:path';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
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', () => {
|
describe('IdempotencyCleanupService', () => {
|
||||||
|
let executeRaw: jest.Mock<Promise<number>, RawCall>;
|
||||||
let service: IdempotencyCleanupService;
|
let service: IdempotencyCleanupService;
|
||||||
let prisma: {
|
|
||||||
idempotencyRecord: { deleteMany: jest.Mock };
|
const statements = (): { sql: string; values: unknown[] }[] =>
|
||||||
consumedToken: { deleteMany: jest.Mock };
|
executeRaw.mock.calls.map(([strings, ...values]) => ({
|
||||||
};
|
sql: strings.join('?').replace(/\s+/g, ' '),
|
||||||
|
values,
|
||||||
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
prisma = {
|
executeRaw = jest
|
||||||
idempotencyRecord: {
|
.fn<Promise<number>, RawCall>()
|
||||||
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
|
.mockResolvedValueOnce(3)
|
||||||
},
|
.mockResolvedValueOnce(4);
|
||||||
consumedToken: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }) },
|
service = new IdempotencyCleanupService({
|
||||||
};
|
$executeRaw: executeRaw,
|
||||||
service = new IdempotencyCleanupService(prisma as unknown as PrismaService);
|
} as unknown as PrismaService);
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes records older than the retention period and expired tokens', async () => {
|
it('removes records older than the retention period and expired tokens', async () => {
|
||||||
prisma.idempotencyRecord.deleteMany.mockResolvedValue({ count: 3 });
|
await expect(service.removeExpired()).resolves.toEqual({
|
||||||
const now = new Date('2026-10-12T04:00:00.000Z');
|
idempotencyRecords: 3,
|
||||||
|
consumedTokens: 4,
|
||||||
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 } },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// The cron tick's own errors would otherwise reach the scheduler's logger
|
const [records, tokens] = statements();
|
||||||
// with the full Prisma message and stack.
|
expect(records.sql).toContain('DELETE FROM "IdempotencyRecord"');
|
||||||
it('the daily tick never throws and logs a failure without the Prisma message', async () => {
|
expect(records.sql).toContain(
|
||||||
const errorSpy = jest
|
'"createdAt" < now() - (? * interval \'1 day\')',
|
||||||
.spyOn(Logger.prototype, 'error')
|
);
|
||||||
.mockImplementation(() => undefined);
|
expect(records.values).toEqual([IDEMPOTENCY_RETENTION_DAYS]);
|
||||||
prisma.idempotencyRecord.deleteMany.mockRejectedValue(
|
expect(tokens.sql).toContain('DELETE FROM "ConsumedToken"');
|
||||||
new Prisma.PrismaClientKnownRequestError('key "Max Mustermann"', {
|
expect(tokens.sql).toContain(
|
||||||
code: 'P2024',
|
'"expiresAt" < now() - (? * interval \'1 hour\')',
|
||||||
clientVersion: '7.9.1',
|
);
|
||||||
meta: { modelName: 'IdempotencyRecord' },
|
expect(tokens.values).toEqual([CONSUMED_TOKEN_GRACE_HOURS]);
|
||||||
}),
|
});
|
||||||
|
|
||||||
|
// 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();
|
expect(source).not.toMatch(/@nestjs\/schedule|@Cron\(/);
|
||||||
|
expect('scheduledCleanup' in service).toBe(false);
|
||||||
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
|
|
||||||
expect(message).toContain('P2024');
|
|
||||||
expect(message).not.toContain('Mustermann');
|
|
||||||
expect(stack).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,46 +1,45 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { loggableError } from '../loggable-error';
|
|
||||||
|
|
||||||
// Longer than any realistic offline stretch at the apiary: an entry queued
|
// 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
|
// on the phone must still find its key when it finally syncs, otherwise the
|
||||||
// repeat would create a duplicate after all.
|
// repeat would create a duplicate after all.
|
||||||
export const IDEMPOTENCY_RETENTION_DAYS = 90;
|
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()
|
@Injectable()
|
||||||
export class IdempotencyCleanupService {
|
export class IdempotencyCleanupService {
|
||||||
private readonly logger = new Logger(IdempotencyCleanupService.name);
|
private readonly logger = new Logger(IdempotencyCleanupService.name);
|
||||||
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
// The daily tick. An error is logged here, Prisma errors by name/code/model
|
async removeExpired(): Promise<ExpiredRemovalCounts> {
|
||||||
// only (see loggableError) - left uncaught, the scheduler would log the
|
const idempotencyRecords = await this.prisma.$executeRaw`
|
||||||
// full Prisma message and stack.
|
DELETE FROM "IdempotencyRecord"
|
||||||
@Cron(CronExpression.EVERY_DAY_AT_4AM)
|
WHERE "createdAt" < now() - (${IDEMPOTENCY_RETENTION_DAYS} * interval '1 day')`;
|
||||||
async scheduledCleanup(): Promise<void> {
|
if (idempotencyRecords > 0) {
|
||||||
try {
|
this.logger.log(
|
||||||
await this.removeExpired();
|
`Removed ${idempotencyRecords} expired idempotency record(s)`,
|
||||||
} 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)`);
|
|
||||||
}
|
}
|
||||||
// Used one-time tokens only matter until the token itself expires.
|
// Used one-time tokens only matter until the token itself expires.
|
||||||
await this.prisma.consumedToken.deleteMany({
|
const consumedTokens = await this.prisma.$executeRaw`
|
||||||
where: { expiresAt: { lt: now } },
|
DELETE FROM "ConsumedToken"
|
||||||
});
|
WHERE "expiresAt" < now() - (${CONSUMED_TOKEN_GRACE_HOURS} * interval '1 hour')`;
|
||||||
return count;
|
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';
|
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
|
* 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
|
* back/forward cache or a shared proxy. Every API answer starts out no-store;
|
||||||
* it is one of the public, anonymous ones.
|
* the one anonymous origin page overrides this on its 200 only (see
|
||||||
|
* PublicController).
|
||||||
*/
|
*/
|
||||||
export function noStoreMiddleware(
|
export function noStoreMiddleware(
|
||||||
req: Request,
|
_req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction,
|
next: NextFunction,
|
||||||
): void {
|
): void {
|
||||||
if (!req.path.startsWith(CACHEABLE_PREFIX)) {
|
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
res.setHeader('Pragma', 'no-cache');
|
res.setHeader('Pragma', 'no-cache');
|
||||||
}
|
|
||||||
next();
|
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 { UpdateQueenDto } from '../../queens/dto/update-queen.dto';
|
||||||
import { UpdateRawMaterialLotDto } from '../../raw-materials/dto/update-raw-material-lot.dto';
|
import { UpdateRawMaterialLotDto } from '../../raw-materials/dto/update-raw-material-lot.dto';
|
||||||
import { UpdateRoleDto } from '../../roles/dto/update-role.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 { UpdateStockItemDto } from '../../storage/dto/update-stock-item.dto';
|
||||||
import { UpdateStorageContainerDto } from '../../storage/dto/update-storage-container.dto';
|
import { UpdateStorageContainerDto } from '../../storage/dto/update-storage-container.dto';
|
||||||
import { UpdateSwarmControlCheckDto } from '../../swarm-control/dto/update-swarm-control-check.dto';
|
import { UpdateSwarmControlCheckDto } from '../../swarm-control/dto/update-swarm-control-check.dto';
|
||||||
@@ -53,6 +54,7 @@ const contracts: Record<string, NullContract> = {
|
|||||||
'longitude',
|
'longitude',
|
||||||
'address',
|
'address',
|
||||||
'notes',
|
'notes',
|
||||||
|
'publicLocationLabel',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
UpdateAutomationRuleDto: {
|
UpdateAutomationRuleDto: {
|
||||||
@@ -152,6 +154,7 @@ const contracts: Record<string, NullContract> = {
|
|||||||
'weather',
|
'weather',
|
||||||
'temperature',
|
'temperature',
|
||||||
'colonyStrength',
|
'colonyStrength',
|
||||||
|
'broodFrames',
|
||||||
'broodAmount',
|
'broodAmount',
|
||||||
'broodPattern',
|
'broodPattern',
|
||||||
'openBroodPresent',
|
'openBroodPresent',
|
||||||
@@ -243,6 +246,13 @@ const contracts: Record<string, NullContract> = {
|
|||||||
notNull: ['key', 'name', 'permissionKeys'],
|
notNull: ['key', 'name', 'permissionKeys'],
|
||||||
clearable: ['description'],
|
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: {
|
UpdateStockItemDto: {
|
||||||
dto: UpdateStockItemDto,
|
dto: UpdateStockItemDto,
|
||||||
notNull: ['name', 'category', 'unit', 'quantity'],
|
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 { CreateDocumentDto } from './dto/create-document.dto';
|
||||||
import { UpdateDocumentDto } from './dto/update-document.dto';
|
import { UpdateDocumentDto } from './dto/update-document.dto';
|
||||||
import { DocumentsService } from './documents.service';
|
import { DocumentsService } from './documents.service';
|
||||||
|
import { Idempotent } from '../common/idempotency/idempotent.decorator';
|
||||||
|
|
||||||
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
|
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ export class DocumentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@RequirePermission('documents:upload')
|
@RequirePermission('documents:upload')
|
||||||
|
@Idempotent()
|
||||||
@Post()
|
@Post()
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
@UseInterceptors(
|
@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
Reference in new issue
Block a user